Add campaign editor script runtime and debug overlays

This commit is contained in:
Richard Zink committed 2026-11-05 12:22:59 +01:00
1 parent cd35ca8e3d
commit b06c48172b
13 files changed
+2211 -66

No files matched your search

+231 -8
View File
@@ -3,6 +3,7 @@
// Drawn as CP437 windows on top of the (paused) game world.
use crate::campaign::{missions, Progress};
use crate::campaign_doc::CampaignDoc;
use crate::cloud::{date_string, duration_string, Cloud};
use crate::game::Goal;
use crate::gfx::*;
@@ -24,6 +25,7 @@ pub enum Page {
KeyCapture { action: usize, from_title: bool },
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
Campaign, // list of the campaign missions
CampaignEditor, // editable shareable .dccamp campaign file
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
Login, // login to the savegame service (before the title)
Account, // password, logout, users (admin)
@@ -31,7 +33,7 @@ pub enum Page {
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
}
pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"];
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
// One adjustable value in the settings dialog
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -51,6 +53,11 @@ pub enum Item {
UiScale,
RenderScale,
DebugFps,
DebugFrameTime,
DebugPing,
DebugTriggerZones,
DebugCommandMarkers,
DebugPathLines,
GameSpeed,
Hints,
TouchUi,
@@ -63,7 +70,8 @@ fn tab_items(tab: usize) -> &'static [Item] {
0 => &[Language, Volume, MusicVolume, Mute, Voice],
1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
3 => &[GameSpeed, Hints, TouchUi, DebugFps],
3 => &[GameSpeed, Hints, TouchUi],
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
_ => &[],
}
}
@@ -85,6 +93,11 @@ fn item_label(it: Item) -> String {
Item::UiScale => "set.ui_scale",
Item::RenderScale => "set.render_scale",
Item::DebugFps => "set.debug_fps",
Item::DebugFrameTime => "set.debug_frame_time",
Item::DebugPing => "set.debug_ping",
Item::DebugTriggerZones => "set.debug_trigger_zones",
Item::DebugCommandMarkers => "set.debug_command_markers",
Item::DebugPathLines => "set.debug_path_lines",
Item::GameSpeed => "set.game_speed",
Item::Hints => "set.hints",
Item::TouchUi => "set.touch_ui",
@@ -127,6 +140,11 @@ fn item_value(it: Item, s: &Settings) -> String {
}
}
Item::DebugFps => onoff(s.debug_fps),
Item::DebugFrameTime => onoff(s.debug_frame_time),
Item::DebugPing => onoff(s.debug_ping),
Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
Item::DebugCommandMarkers => onoff(s.debug_command_markers),
Item::DebugPathLines => onoff(s.debug_path_lines),
Item::RenderScale => {
// the setting is for a 1080 pixel high window; show what applies to this one
let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh());
@@ -178,6 +196,11 @@ fn item_change(it: Item, s: &mut Settings, d: i32) {
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d),
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
Item::DebugFps => s.debug_fps = !s.debug_fps,
Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
Item::DebugPing => s.debug_ping = !s.debug_ping,
Item::DebugTriggerZones => s.debug_trigger_zones = !s.debug_trigger_zones,
Item::DebugCommandMarkers => s.debug_command_markers = !s.debug_command_markers,
Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines,
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
Item::Hints => s.hints = !s.hints,
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
@@ -218,6 +241,20 @@ enum Btn {
FileLoad,
NameField,
Campaign,
CampaignEditor,
CampaignNew,
CampaignOpen,
CampaignSave,
CampaignAdd,
CampaignDelete,
CampaignEditName,
CampaignEditTitle,
CampaignEditGoals,
CampaignEditNext,
CampaignEditBranches,
CampaignEditScript,
CampaignStart,
CampaignMission(usize),
Mission(usize),
StartMission,
Goals,
@@ -302,6 +339,18 @@ pub enum MenuAction {
CloudDelete(String),
EditName, // the name field was tapped (touch: ask with a browser dialog)
StartMission(usize),
CampaignNew,
CampaignOpen,
CampaignSave,
CampaignAdd,
CampaignDelete(usize),
CampaignEditName,
CampaignEditTitle,
CampaignEditGoals,
CampaignEditNext,
CampaignEditBranches,
CampaignEditScript,
CampaignStart(usize),
OpenRepo,
OpenClaudeCode,
Login(String, String),
@@ -339,6 +388,9 @@ pub struct Menu {
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
pub coop_mission: usize, // co-op campaign mission selected on the Match page
pub in_match: bool, // a match is being played; the pause menu changes its shape
pub campaign_doc: CampaignDoc,
pub campaign_selected: usize,
pub campaign_pending: bool,
confirm: Option<Btn>, // overwrite / delete waiting for a second click
scroll: usize,
rows_visible: usize,
@@ -366,6 +418,9 @@ impl Menu {
match_ui: MatchUi::default(),
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
in_match: false,
campaign_doc: CampaignDoc::default(),
campaign_selected: 0,
campaign_pending: false,
confirm: None,
scroll: 0,
rows_visible: 1,
@@ -380,13 +435,14 @@ impl Menu {
// Esc: go one level back
pub fn back(&mut self) {
self.page = match self.page {
Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 4, from_title }),
Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 5, from_title }),
Some(Page::Settings { from_title: true, .. }) => Some(Page::Title),
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
Some(Page::NewGame) => Some(Page::Title),
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Campaign) => Some(Page::Title),
Some(Page::CampaignEditor) => Some(Page::Title),
Some(Page::Login) => Some(Page::Login),
Some(Page::Account) => Some(Page::Title),
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
@@ -488,7 +544,7 @@ impl Menu {
pub fn finish_capture(&mut self) {
if let Some(Page::KeyCapture { from_title, .. }) = self.page {
self.page = Some(Page::Settings { tab: 4, from_title });
self.page = Some(Page::Settings { tab: 5, from_title });
}
}
@@ -669,6 +725,36 @@ impl Menu {
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::CampaignEditor) => {
self.page = Some(Page::CampaignEditor);
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::CampaignNew) => MenuAction::CampaignNew,
Some(Btn::CampaignOpen) => MenuAction::CampaignOpen,
Some(Btn::CampaignSave) => MenuAction::CampaignSave,
Some(Btn::CampaignAdd) => MenuAction::CampaignAdd,
Some(Btn::CampaignEditName) => MenuAction::CampaignEditName,
Some(Btn::CampaignEditTitle) => MenuAction::CampaignEditTitle,
Some(Btn::CampaignEditGoals) => MenuAction::CampaignEditGoals,
Some(Btn::CampaignEditNext) => MenuAction::CampaignEditNext,
Some(Btn::CampaignEditBranches) => MenuAction::CampaignEditBranches,
Some(Btn::CampaignEditScript) => MenuAction::CampaignEditScript,
Some(b @ Btn::CampaignDelete) => {
let i = self.campaign_selected;
if confirm == Some(b) {
MenuAction::CampaignDelete(i)
} else {
self.confirm = Some(b);
MenuAction::Consumed
}
}
Some(Btn::CampaignStart) => MenuAction::CampaignStart(self.campaign_selected),
Some(Btn::CampaignMission(i)) => {
self.campaign_selected = i;
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::Mission(i)) => {
self.page = Some(Page::Briefing { mission: i, ingame: false });
self.scroll = 0;
@@ -711,13 +797,14 @@ impl Menu {
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
Page::Campaign => self.draw_campaign(gfx),
Page::CampaignEditor => self.draw_campaign_editor(gfx),
Page::Login => self.draw_login(gfx, cloud),
Page::Account => self.draw_account(gfx, cloud),
Page::Match { .. } => self.draw_match(gfx, cloud),
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 4);
self.draw_settings(gfx, settings, 5);
self.buttons.clear();
let a = all_actions()[action];
let text = trf("set.press_key", &[action_label(a)]);
@@ -770,14 +857,15 @@ impl Menu {
items.push((Btn::Continue, tr("menu.continue")));
}
items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::CampaignEditor, tr("menu.campaign_editor")));
items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new")));
items.push((Btn::Load, tr("menu.load")));
items.push((Btn::Settings, tr("menu.settings")));
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
items.push((Btn::Standalone, tr("menu.standalone")));
}
items.push((Btn::Standalone, tr("menu.standalone")));
items.push((Btn::Bestiary, tr("menu.bestiary")));
items.push((Btn::Help, tr("menu.help")));
let w = 26.0 * CW();
@@ -928,7 +1016,7 @@ impl Menu {
tx += w + CW();
}
let top = ty + CH() * 3.5;
if tab == 4 {
if tab == 5 {
// key bindings in two columns
let acts = all_actions();
let half = (acts.len() + 1) / 2;
@@ -1148,6 +1236,141 @@ impl Menu {
self.buttons.push((br, Btn::Back));
}
// First campaign-editor step: load/save shareable missions and show their branching structure.
fn draw_campaign_editor(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = ((sw / CW()) as i32 - 2).min(72);
let rows = ((sh / CH()) as i32 - 2).min(34);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("campaign.title"), FRAME_HI);
let campaign = self.campaign_doc.clone();
let errors = campaign.validate();
let name = if campaign.name.trim().is_empty() { tr("campaign.new") } else { campaign.name.clone() };
let file = campaign.file_name();
let n = campaign.missions.len();
let inner_x = x + CW() * 2.0;
let inner_w = (cols - 4) as f32 * CW();
let head = trf("campaign.name", &[name]);
let head_w = (gfx.measure(&head, S()) + CW()).max(CW() * 8.0);
let head_rect = Rect::new(inner_x, y + CH() * 1.5, head_w, GH * S() + CH() * 0.5);
let hot_name = head_rect.contains(vec2(mx, my));
gfx.text(&head, inner_x, y + CH() * 1.5, S(), if hot_name { FRAME_HI } else { GOLD });
if !campaign.author.trim().is_empty() {
let author = trf("campaign.author", &[campaign.author.clone()]);
gfx.text(&author, inner_x + head_w + CW() * 2.0, y + CH() * 1.5, S(), DIM);
}
self.buttons.push((head_rect, Btn::CampaignEditName));
let top = if n > 0 { y + CH() * 5.0 } else { y + CH() * 3.5 };
if n > 0 {
let field_items = [(Btn::CampaignEditTitle, "T"), (Btn::CampaignEditGoals, "G"), (Btn::CampaignEditNext, "N"), (Btn::CampaignEditBranches, "B"), (Btn::CampaignEditScript, "S")];
let widths: Vec<f32> = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect();
let total_w = widths.iter().sum::<f32>() + (field_items.len() as f32 - 1.0) * CW() * 0.5;
let mut fx = x + cols as f32 * CW() - CW() * 2.0 - total_w;
for (k, (b, l)) in field_items.iter().enumerate() {
let r = Rect::new(fx, y + CH() * 2.8, widths[k], CH() * 1.8);
button(gfx, r, l, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
fx += widths[k] + CW() * 0.5;
}
}
let bottom = y + (rows - 5) as f32 * CH();
let row_h = CH() * 3.0;
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
if n == 0 {
gfx.text(&tr("campaign.empty"), inner_x, top, S(), DIM);
}
for (k, mission) in campaign.missions.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5);
let selected = k == self.campaign_selected;
let hot = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if selected { Color::new(0.32, 0.21, 0.1, 1.0) } else if hot { Color::new(0.18, 0.12, 0.08, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if selected { GOLD } else if hot { FRAME_HI } else { FRAME });
let title = if mission.title.trim().is_empty() { mission.key.clone() } else { mission.title.clone() };
let line = trf("campaign.mission_no", &[(k + 1).to_string(), title]);
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
let line: String = line.chars().take(maxc).collect();
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if selected { GOLD } else { TXT });
let next = mission.next.clone().unwrap_or_default();
let branch_count = mission.branches.len();
let detail = if branch_count > 0 {
trf("campaign.branches", &[branch_count.to_string()])
} else if !next.is_empty() {
trf("campaign.next", &[next])
} else if !mission.goals.is_empty() {
trf("campaign.goals", &[mission.goals.join(", ")])
} else {
tr("campaign.empty")
};
let script_count = mission.script.as_ref().map(|s| if s.source.trim().is_empty() { 0 } else { 1 }).unwrap_or(0);
let zone_count = mission.zones.len();
let detail = if zone_count > 0 || script_count > 0 {
format!("{} · {} · {}", detail, trf("campaign.zones", &[zone_count.to_string()]), trf("campaign.script", &[script_count.to_string()]))
} else {
detail
};
let maxc = ((r.w - CW() * 2.0) / GW.max(1.0) / (SMALL().max(1.0))) as usize;
let detail: String = detail.chars().take(maxc.max(20)).collect();
gfx.text(&detail, r.x + CW(), r.y + CH() * 1.6, SMALL(), DIM);
self.buttons.push((r, Btn::CampaignMission(k)));
}
if scroll_w > 0.0 {
let sx = inner_x + inner_w - CW() * 2.5;
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
}
let count = trf("campaign.missions", &[n.to_string()]);
gfx.text(&count, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
let file_line = format!("{} · {}", file, tr("menu.drop_hint"));
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
let file_line: String = file_line.chars().take(maxc.max(20)).collect();
gfx.text(&file_line, x + cols as f32 * CW() - gfx.measure(&file_line, SMALL()) - CW() * 2.0, bottom + CH() * 0.5, SMALL(), DIM);
if !errors.is_empty() {
let err = errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join(" | ");
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
for l in wrap(&err, maxc).iter().take(3) {
gfx.text(l, inner_x, bottom + CH() * 1.5, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
}
}
let by = y + (rows as f32 - 3.5) * CH();
let right = x + cols as f32 * CW() - CW() * 2.0;
let mut bx = inner_x;
let mut actions = vec![(Btn::CampaignNew, tr("campaign.new")), (Btn::CampaignAdd, tr("campaign.add"))];
if n > 0 {
actions.push((Btn::CampaignStart, tr("campaign.start")));
actions.push((Btn::CampaignDelete, tr("campaign.delete")));
}
actions.extend([(Btn::CampaignOpen, tr("campaign.open")), (Btn::CampaignSave, tr("campaign.save")), (Btn::Back, tr("menu.back"))]);
for (b, label) in actions {
let w = (gfx.measure(&label, S()) + CW() * 4.0).max(CW() * 6.0);
let r = Rect::new(bx, by, w, CH() * 2.0);
if r.x + r.w <= right {
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, b));
}
bx += w + CW();
}
}
// Briefing: the old imp tells the story, then the goals and what comes along
fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) {
let sw = sw();
@@ -1236,7 +1459,7 @@ impl Menu {
// scrolling on pages with lists (mouse wheel)
pub fn scrollable(&self) -> bool {
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::Briefing { .. }))
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }))
}
pub fn scroll_lines(&mut self, d: i32) {