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

+649 -41
View File
@@ -2,6 +2,7 @@
mod audio;
mod campaign;
mod campaign_doc;
mod cloud;
mod commands;
mod demo;
@@ -25,6 +26,7 @@ mod rival;
mod render3d;
mod rooms;
mod save;
mod script;
mod settings;
mod sfx;
mod tracks;
@@ -45,13 +47,14 @@ use macroquad::prelude::*;
use map::{inb, mh, mw};
use menu::{Menu, MenuAction, Page};
use lobby::{MatchClient, Phase, StartInfo};
use render3d::{Cam, Overlay, R3d};
use render3d::{Cam, Overlay, R3d, disc, line3};
use settings::{bindable_keys, Action, Settings};
use sound::SoundBank;
use touch::{Gesture, TouchInput};
use ui::{hover_info, Tool, Ui, UiAction, TOOLS};
use ui::{chat_color, hover_info, Tool, Ui, UiAction, TOOLS};
const SETTINGS_KEY: &str = "dc.settings";
const FRAME_HIST: usize = 64;
const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves
const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left
@@ -175,6 +178,38 @@ fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) {
(g, cam)
}
fn prompt_line(title: &str, initial: &str, max: usize) -> Option<String> {
let value = platform::prompt(title, initial)?;
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
let clean = clean.trim().to_string();
if clean.is_empty() {
None
} else {
Some(clean)
}
}
fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option<String> {
let value = platform::prompt(title, initial)?;
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
Some(clean.trim().to_string())
}
fn sanitize_campaign_key(value: &str) -> String {
value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect()
}
fn custom_mission_game(def: &campaign::MissionDef, i: usize) -> (Game, Cam) {
let g = campaign::start_mission_doc(def, i, None);
let (cx, cy) = g.map.core;
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
(g, cam)
}
fn custom_next_choice(campaign: &campaign_doc::CampaignDoc, i: usize, g: &Game) -> (Option<usize>, Option<String>) {
campaign.next_mission_choice(i, |goal| g.goal_done(goal))
}
const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one
fn store_progress(p: &campaign::Progress) {
@@ -372,6 +407,197 @@ fn store_settings(s: &Settings) {
platform::storage_set(SETTINGS_KEY, &s.to_json());
}
fn apply_game_settings(g: &mut Game, s: &Settings) {
g.hints = s.hints;
g.debug_markers = s.debug_command_markers;
}
fn start_custom_script(g: &mut Game, doc: &campaign_doc::MissionDoc) {
let Some(script) = doc.script.as_ref() else {
return;
};
if script.source.trim().is_empty() {
return;
}
if !script.language.is_empty() && !script.language.eq_ignore_ascii_case("rhai") {
g.msg("msg.script_language", vec![script.language.clone()]);
return;
}
match script::run_start(&script.source) {
Ok(m) => {
for message in m.messages() {
g.msg("msg.script", vec![message]);
}
if let Some(objective) = m.objective() {
g.msg("msg.script_objective", vec![objective]);
}
for action in m.actions() {
apply_script_action(g, doc, action);
}
}
Err(error) => {
g.msg("msg.script_error", vec![error]);
}
}
}
fn validate_script_source(doc: &campaign_doc::MissionDoc, source: &str) -> Result<(), String> {
match script::run_start(source) {
Ok(m) => {
for action in m.actions() {
match action {
script::ScriptAction::Spawn { kind, .. } => {
if script::parse_spawn_kind(&kind).is_none() {
return Err(format!("unknown creature: {kind}"));
}
}
script::ScriptAction::SpawnInZone { zone, kind, .. } => {
if script::parse_spawn_kind(&kind).is_none() {
return Err(format!("unknown creature: {kind}"));
}
if !doc.zones.iter().any(|z| z.name == zone) {
return Err(format!("unknown zone: {zone}"));
}
}
script::ScriptAction::Wave { .. } => {}
}
}
Ok(())
}
Err(error) => Err(error),
}
}
fn apply_script_action(g: &mut Game, doc: &campaign_doc::MissionDoc, action: script::ScriptAction) {
match action {
script::ScriptAction::Spawn { side, kind, x, y, count, level } => {
let Some(kind) = script::parse_spawn_kind(&kind) else {
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
return;
};
spawn_script_units(g, side, kind, &vec![(x, y); count.max(0) as usize], level);
}
script::ScriptAction::SpawnInZone { zone, kind, side, count, level } => {
let Some(kind) = script::parse_spawn_kind(&kind) else {
g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]);
return;
};
let Some(zone_doc) = doc.zones.iter().find(|z| z.name == zone) else {
g.msg("msg.script_error", vec![format!("unknown zone: {zone}")]);
return;
};
let positions = zone_spawn_positions(g, zone_doc, count);
if positions.is_empty() {
g.msg("msg.script_error", vec![format!("no usable spawn position in zone: {zone}")]);
return;
}
spawn_script_units(g, side, kind, &positions, level);
}
script::ScriptAction::Wave { count } => {
apply_script_wave(g, count);
}
}
}
fn apply_script_wave(g: &mut Game, count: i32) {
let count = count.max(0) as u32;
if count == 0 {
return;
}
if let Some(m) = g.mission.as_mut() {
m.waves = true;
if m.wave_limit != 0 {
m.wave_limit = m.wave_limit.max(g.wave.saturating_add(count));
}
}
g.wave_timer = 0.0;
}
fn spawn_script_units(g: &mut Game, side: i32, kind: game::EK, positions: &[(f32, f32)], level: i32) {
let positions: Vec<(f32, f32)> = positions.iter().copied().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).collect();
if positions.is_empty() {
return;
}
let old_side = g.me_side;
match side {
0 => g.me_side = 0,
1 => g.me_side = 1,
2 | 3 | 4 => g.me_side = side as u8,
_ => {
g.msg("msg.script_error", vec![format!("unknown script side: {side}")]);
return;
}
}
for (x, y) in positions {
g.spawn(kind, side == 1, x, y, level.max(1) as u32);
}
g.me_side = old_side;
}
fn valid_script_spawn_pos(_g: &Game, x: f32, y: f32) -> bool {
x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < mw() as f32 && y < mh() as f32 && inb(x as i32, y as i32)
}
fn zone_spawn_positions(g: &Game, zone: &campaign_doc::ZoneDoc, count: i32) -> Vec<(f32, f32)> {
let count = count.clamp(1, 10) as usize;
let core = g.map.core;
let center = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
let mut out = Vec::new();
match zone.shape.as_str() {
"rect" | "rectangle" | "square" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let x0 = from.0.min(to.0);
let x1 = from.0.max(to.0);
let y0 = from.1.min(to.1);
let y1 = from.1.max(to.1);
let cols = ((count as f32).sqrt().ceil()) as usize;
for k in 0..count {
let r = k / cols;
let c = k % cols;
let tx = if cols > 1 { c as f32 / (cols as f32 - 1.0) } else { 0.5 };
let rows = ((count as f32) / cols as f32).ceil() as usize;
let ty = if rows > 1 { r as f32 / (rows as f32 - 1.0) } else { 0.5 };
out.push((x0 + 0.5 + tx * (x1 - x0), y0 + 0.5 + ty * (y1 - y0)));
}
}
}
"line" | "path" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let x0 = from.0 + 0.5;
let y0 = from.1 + 0.5;
let x1 = to.0 + 0.5;
let y1 = to.1 + 0.5;
for k in 0..count {
let t = (k as f32 + 0.5) / count as f32;
out.push((x0 + t * (x1 - x0), y0 + t * (y1 - y0)));
}
}
}
"tiles" => {
if let Some(tiles) = zone.tiles.as_ref() {
for (x, y) in tiles.iter().take(count) {
out.push((*x as f32 + 0.5, *y as f32 + 0.5));
}
}
}
_ => {
let radius = zone.radius.unwrap_or(2.0).max(0.0);
let cx = center.0 + 0.5;
let cy = center.1 + 0.5;
if count == 1 || radius <= 0.5 {
out.push((cx, cy));
} else {
let r = radius.min(radius.max(0.5) * 0.75);
for k in 0..count {
let angle = (k as f32) * std::f32::consts::TAU / count as f32;
out.push((cx + angle.cos() * r, cy + angle.sin() * r));
}
}
}
}
out.into_iter().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).take(count).collect()
}
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
fn save_file_name() -> String {
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
@@ -396,6 +622,13 @@ struct App {
coop: bool,
fps: f32,
renderer: String,
frame_ms: f32,
frame_hist: [f32; FRAME_HIST],
frame_hist_pos: usize,
custom_campaign: Option<campaign_doc::CampaignDoc>,
custom_progress: Option<campaign_doc::CampaignProgress>,
custom_mission_index: Option<usize>,
custom_next_index: Option<usize>,
}
impl App {
@@ -417,7 +650,7 @@ impl App {
apply_settings(&self.settings, r3d);
store_settings(&self.settings);
}
self.game.hints = self.settings.hints;
apply_game_settings(&mut self.game, &self.settings);
self.game.coop = false;
self.coop = false;
r3d.invalidate();
@@ -454,7 +687,7 @@ impl App {
true
}
fn poll_match(&mut self) {
fn poll_match(&mut self, now: f64) {
if self.ws_id <= 0 {
if self.client.connected {
self.client.on_disconnect();
@@ -462,8 +695,9 @@ impl App {
return;
}
while let Some(msg) = platform::ws_recv(self.ws_id) {
self.client.feed(&msg);
self.client.feed_at(&msg, now);
}
self.client.tick_ping(now);
if self.client.phase() == Phase::Closed && self.game.in_match() {
self.game.end_match();
self.match_mode = false;
@@ -522,7 +756,7 @@ impl App {
}
game.ai_difficulty = info.difficulty;
game.sync_ai_sides(&info.ai, info.difficulty);
game.hints = self.settings.hints;
apply_game_settings(&mut game, &self.settings);
self.game = game;
let (cx, cy) = self.game.map.core;
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
@@ -542,7 +776,7 @@ impl App {
self.game.coop = false;
self.game.ai_difficulty = info.difficulty;
self.game.sync_ai_sides(&info.ai, info.difficulty);
self.game.hints = self.settings.hints;
apply_game_settings(&mut self.game, &self.settings);
self.game.init_fog();
let (cx, cy) = self.game.map.core;
self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
@@ -602,9 +836,9 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
// behind the title menu: the endless bridge battle
let (game, cam) = Game::new_demo(seed);
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer() };
let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
let mut touch = TouchInput::new();
let mut auto_touch = platform::coarse_pointer();
@@ -621,6 +855,10 @@ async fn main() {
let now = get_time();
let fps_now = 1.0 / raw_dt.max(0.001);
app.fps = if app.fps <= 0.0 { fps_now } else { app.fps + (fps_now - app.fps) * 0.08 };
let raw_ms = raw_dt * 1000.0;
app.frame_ms = if app.frame_ms <= 0.0 { raw_ms } else { app.frame_ms + (raw_ms - app.frame_ms) * 0.08 };
app.frame_hist[app.frame_hist_pos] = raw_ms;
app.frame_hist_pos = (app.frame_hist_pos + 1) % FRAME_HIST;
let mut settings_changed = false;
// ---------------- touch or mouse: which interface is active
@@ -658,7 +896,25 @@ async fn main() {
}
}
if let Some(bytes) = incoming {
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
if menu.campaign_pending {
menu.campaign_pending = false;
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
Ok(campaign) => {
let errors = campaign.validate();
if errors.is_empty() {
app.set_status(i18n::trf("campaign.loaded", &[campaign.name.clone()]), now);
menu.campaign_doc = campaign;
menu.campaign_selected = 0;
menu.page = Some(Page::CampaignEditor);
} else {
app.set_status(i18n::trf("campaign.invalid", &[errors.join(" | ")]), now);
}
}
Err(e) => app.set_status(i18n::trf("campaign.invalid", &[e]), now),
}
} else {
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
}
}
// ---------------- savegame service: finished requests
@@ -712,7 +968,7 @@ async fn main() {
}
// ---------------- multiplayer relay: receive messages, send queued requests, update the menu state
app.poll_match();
app.poll_match(now);
menu.in_match = app.match_mode || app.game.in_match();
if matches!(menu.page, Some(Page::Match { .. })) {
menu.set_match_ui(app.client.ui());
@@ -871,7 +1127,7 @@ async fn main() {
if is_key_pressed(KeyCode::N) && app.game.state != 0 {
app.seed = next_seed(app.seed);
(app.game, app.cam) = new_game(app.seed, &app.settings);
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
}
}
if !dialog && !menu.typing() {
@@ -1035,7 +1291,7 @@ async fn main() {
MenuAction::NewGame => {
app.seed = next_seed(app.seed);
(app.game, app.cam) = new_game(app.seed, &app.settings);
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
menu.page = None;
ui.log.clear();
ui.show_help = true;
@@ -1079,7 +1335,7 @@ async fn main() {
}
MenuAction::StartMission(i) => {
(app.game, app.cam) = mission_game(i, &menu.progress);
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
app.game.coop = false;
app.coop = false;
app.recorded = false;
@@ -1088,6 +1344,140 @@ async fn main() {
ui.show_help = false;
autosave_t = 0.0;
}
MenuAction::CampaignNew => {
menu.campaign_doc = campaign_doc::CampaignDoc::default();
menu.campaign_doc.add_mission();
menu.campaign_selected = 0;
menu.page = Some(Page::CampaignEditor);
app.set_status(i18n::tr("campaign.new"), now);
}
MenuAction::CampaignAdd => {
menu.campaign_doc.add_mission();
menu.campaign_selected = menu.campaign_doc.missions.len().saturating_sub(1);
menu.page = Some(Page::CampaignEditor);
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
}
MenuAction::CampaignOpen => {
menu.campaign_pending = true;
platform::open_file();
app.set_status(i18n::tr("campaign.choose"), now);
}
MenuAction::CampaignSave => {
let name = menu.campaign_doc.file_name();
platform::download(&name, menu.campaign_doc.save().as_bytes());
app.set_status(i18n::trf("campaign.saved", &[name]), now);
}
MenuAction::CampaignEditName => {
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
menu.campaign_doc.name = value;
app.set_status(i18n::tr("campaign.updated"), now);
}
}
MenuAction::CampaignEditTitle => {
let i = menu.campaign_selected;
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.title.clone()) {
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_title"), &initial, 80) {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.title = value;
}
app.set_status(i18n::tr("campaign.updated"), now);
}
}
}
MenuAction::CampaignEditGoals => {
let i = menu.campaign_selected;
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.goals.join(", ")) {
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_goals"), &initial, 120) {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.goals = campaign_doc::parse_list(&value);
}
app.set_status(i18n::tr("campaign.updated"), now);
}
}
}
MenuAction::CampaignEditNext => {
let i = menu.campaign_selected;
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.next.clone()).unwrap_or_default();
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_next"), &initial, 48) {
let key = sanitize_campaign_key(&value);
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.next = Some(key);
}
app.set_status(i18n::tr("campaign.updated"), now);
}
}
MenuAction::CampaignEditBranches => {
let i = menu.campaign_selected;
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| campaign_doc::branches_to_text(&m.branches)) {
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_branches"), &initial, 180) {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.branches = campaign_doc::parse_branches(&value);
}
app.set_status(i18n::tr("campaign.updated"), now);
}
}
}
MenuAction::CampaignEditScript => {
let i = menu.campaign_selected;
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.script.as_ref()).map(|s| s.source.clone()).unwrap_or_default();
if let Some(source) = prompt_optional_line(&i18n::tr("campaign.prompt_script"), &initial, 600) {
if source.trim().is_empty() {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.script = None;
}
app.set_status(i18n::tr("campaign.updated"), now);
} else if let Some(mission) = menu.campaign_doc.missions.get(i).cloned() {
match validate_script_source(&mission, &source) {
Ok(()) => {
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
mission.script = Some(campaign_doc::ScriptDoc { language: "rhai".into(), source });
}
app.set_status(i18n::tr("campaign.updated"), now);
}
Err(error) => {
app.set_status(i18n::trf("msg.script_error", &vec![error]), now);
}
}
} else {
app.set_status(i18n::tr("campaign.error_no_mission"), now);
}
}
}
MenuAction::CampaignDelete(i) => {
if i < menu.campaign_doc.missions.len() {
menu.campaign_doc.missions.remove(i);
menu.campaign_selected = menu.campaign_selected.min(menu.campaign_doc.missions.len().saturating_sub(1));
if i == app.custom_mission_index.unwrap_or(usize::MAX) {
app.custom_campaign = None;
app.custom_progress = None;
app.custom_mission_index = None;
app.custom_next_index = None;
}
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
}
}
MenuAction::CampaignStart(i) => {
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
if menu.campaign_doc.validate().is_empty() {
let def = campaign::mission_def_from_doc(mission_doc);
(app.game, app.cam) = custom_mission_game(&def, i);
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, mission_doc);
app.game.coop = false;
app.coop = false;
app.recorded = false;
app.custom_campaign = Some(menu.campaign_doc.clone());
app.custom_progress = Some(campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc));
app.custom_mission_index = Some(i);
menu.page = None;
ui.log.clear();
ui.show_help = false;
autosave_t = 0.0;
} else {
app.set_status(i18n::tr("campaign.invalid"), now);
}
}
}
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
a @ (MenuAction::Login(..)
@@ -1144,18 +1534,65 @@ async fn main() {
UiAction::NewGame => {
app.seed = next_seed(app.seed);
(app.game, app.cam) = new_game(app.seed, &app.settings);
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
app.game.coop = false;
app.coop = false;
}
// end of a mission: next mission (won) or try again (lost)
UiAction::EndNext => {
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
if app.custom_campaign.is_some() {
let campaign = app.custom_campaign.clone();
let progress = app.custom_progress.clone();
if app.game.state == 1 {
if let Some(next) = app.custom_next_index {
if let Some(campaign) = campaign {
if next < campaign.missions.len() {
let def = campaign::mission_def_from_doc(&campaign.missions[next]);
(app.game, app.cam) = custom_mission_game(&def, next);
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, &campaign.missions[next]);
app.game.coop = false;
app.coop = false;
app.recorded = false;
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
app.custom_campaign = Some(campaign);
app.custom_progress = Some(progress);
app.custom_mission_index = Some(next);
app.custom_next_index = None;
menu.page = None;
ui.log.clear();
ui.show_help = false;
autosave_t = 0.0;
} else {
menu.page = Some(Page::CampaignEditor);
}
}
} else {
menu.page = Some(Page::CampaignEditor);
}
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
if i < campaign.missions.len() {
let def = campaign::mission_def_from_doc(&campaign.missions[i]);
(app.game, app.cam) = custom_mission_game(&def, i);
apply_game_settings(&mut app.game, &app.settings);
start_custom_script(&mut app.game, &campaign.missions[i]);
app.game.coop = false;
app.coop = false;
app.recorded = false;
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
app.custom_campaign = Some(campaign);
app.custom_progress = Some(progress);
app.custom_mission_index = Some(i);
app.custom_next_index = None;
ui.log.clear();
}
}
} else if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
if app.game.state == 1 {
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
} else {
(app.game, app.cam) = mission_game(i, &menu.progress);
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
app.recorded = false;
ui.log.clear();
}
@@ -1273,7 +1710,7 @@ async fn main() {
if settings_changed {
apply_settings(&app.settings, &mut r3d);
store_settings(&app.settings);
app.game.hints = app.settings.hints;
apply_game_settings(&mut app.game, &app.settings);
}
// ---------------- update (single-player worlds stand still while menus or dialogs are open;
@@ -1310,12 +1747,32 @@ async fn main() {
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
}
// a won mission goes into the campaign progress (with the carry-over)
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 {
app.recorded = true;
if app.custom_campaign.is_none() {
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 {
app.recorded = true;
if app.game.state == 1 {
menu.progress.set_done(i, campaign::carry_from(&app.game));
store_progress(&menu.progress);
}
}
}
} else if app.game.state != 0 && !app.recorded {
app.recorded = true;
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
app.custom_mission_index = Some(i);
if app.game.state == 1 {
menu.progress.set_done(i, campaign::carry_from(&app.game));
store_progress(&menu.progress);
let next_info = match &app.custom_campaign {
Some(campaign) => Some(custom_next_choice(campaign, i, &app.game)),
None => None,
};
let mission_count = app.custom_campaign.as_ref().map(|c| c.missions.len()).unwrap_or(0);
if let Some((next_i, choice)) = next_info {
if let Some(progress) = &mut app.custom_progress {
progress.mark_done(i, mission_count, choice);
}
app.custom_next_index = next_i;
}
}
}
}
@@ -1327,6 +1784,7 @@ async fn main() {
| Some(Page::KeyCapture { from_title: true, .. })
| Some(Page::NewGame)
| Some(Page::Campaign)
| Some(Page::CampaignEditor)
| Some(Page::Briefing { ingame: false, .. })
| Some(Page::Login)
| Some(Page::Account)
@@ -1396,12 +1854,16 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
high: ui.tool == Tool::Dig,
};
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
if app.settings.debug_trigger_zones || app.settings.debug_path_lines {
draw_debug_3d(app, now);
}
let title_screen = matches!(
menu.page,
Some(Page::Title)
| Some(Page::Settings { from_title: true, .. })
| Some(Page::KeyCapture { from_title: true, .. })
| Some(Page::Campaign)
| Some(Page::CampaignEditor)
| Some(Page::Briefing { ingame: false, .. })
| Some(Page::Login)
| Some(Page::Account)
@@ -1428,7 +1890,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
if !touch {
ui.draw_cursor(gfx, &app.game, now, blocked);
}
if app.settings.debug_fps {
if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping {
draw_debug_fps(app, gfx);
}
}
@@ -1436,24 +1898,170 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
fn draw_debug_fps(app: &App, gfx: &Gfx) {
ui::begin_ui();
let scale = ui::SMALL().max(1.0);
let fps = app.fps.max(0.0).round() as i32;
let renderer = {
let r = app.renderer.trim();
if r.is_empty() {
if cfg!(target_arch = "wasm32") {
"WebGL"
let text_h = scale * 10.0 + 4.0;
if app.settings.debug_fps {
let fps = app.fps.max(0.0).round() as i32;
let renderer = {
let r = app.renderer.trim();
if r.is_empty() {
if cfg!(target_arch = "wasm32") {
"WebGL"
} else {
"OpenGL"
}
} else {
"OpenGL"
r
}
} else {
r
};
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
let head: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
let head_w = gfx.measure(&head, scale);
let head_x = (ui::sw() - head_w - 2.0).max(2.0);
draw_rectangle(head_x - 2.0, 0.0, head_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
gfx.text(&head, head_x, 2.0, scale, GOLD);
}
if app.settings.debug_frame_time {
let ms = app.frame_ms.max(0.0);
let graph_w = (ui::sw() * 0.42).clamp(80.0, 260.0).min(ui::sw() - 4.0).max(40.0);
let graph_h = (scale * 30.0).clamp(20.0, 52.0);
let frame_y = (ui::sh() - text_h - 2.0).max(12.0);
let graph_y = (frame_y - graph_h - 6.0).max(12.0);
let graph_x = (ui::sw() - graph_w - 2.0).max(2.0);
let mut max_ms = ms.max(16.7);
for v in app.frame_hist.iter() {
max_ms = max_ms.max(*v);
}
};
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
let line: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
let w = gfx.measure(&line, scale);
let x = (ui::sw() - w - 2.0).max(2.0);
let y = 2.0;
draw_rectangle(x - 2.0, y - 2.0, w + 4.0, scale * 10.0 + 4.0, Color::from_rgba(0, 0, 0, 150));
gfx.text(&line, x, y, scale, GOLD);
let max_ms = max_ms.max(16.7).max(ms * 1.3).min(100.0);
draw_rectangle(graph_x - 2.0, graph_y - 2.0, graph_w + 4.0, graph_h + 4.0, Color::from_rgba(0, 0, 0, 170));
let bar_w = graph_w / FRAME_HIST as f32;
for k in 0..FRAME_HIST {
let v = app.frame_hist[(app.frame_hist_pos + k) % FRAME_HIST];
if v <= 0.0 {
continue;
}
let h = graph_h * (v / max_ms).clamp(0.0, 1.0).max(0.08);
let col = if v <= 16.7 {
Color::from_rgba(90, 220, 120, 235)
} else if v <= 33.3 {
Color::from_rgba(240, 210, 100, 235)
} else {
Color::from_rgba(235, 90, 90, 235)
};
draw_rectangle(graph_x + k as f32 * bar_w, graph_y + graph_h - h, bar_w.max(1.0), h, col);
}
draw_rectangle(graph_x, graph_y + graph_h, graph_w, 1.0, Color::from_rgba(255, 255, 255, 50));
let frame_line = format!("{:.1} ms", ms);
let frame_w = gfx.measure(&frame_line, scale);
let frame_x = (ui::sw() - frame_w - 2.0).max(2.0);
draw_rectangle(frame_x - 2.0, frame_y - 2.0, frame_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
gfx.text(&frame_line, frame_x, frame_y, scale, GOLD);
}
if app.settings.debug_ping {
let ping = if app.client.connected { format!("{:.0} ms", app.client.ping_ms) } else { "no match".into() };
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
let line: String = format!("ping {}", ping).chars().take(max_cols).collect();
let line_w = gfx.measure(&line, scale);
draw_rectangle(0.0, ui::sh() - text_h - 2.0, line_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
let color = if !app.client.connected {
ui::DIM
} else if app.client.ping_ms <= 120.0 {
ui::GREEN
} else if app.client.ping_ms <= 300.0 {
Color::from_rgba(240, 210, 100, 255)
} else {
ui::RED
};
gfx.text(&line, 2.0, ui::sh() - text_h, scale, color);
}
}
fn draw_debug_3d(app: &App, now: f64) {
let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32);
if app.settings.debug_trigger_zones {
if let Some(campaign) = app.custom_campaign.as_ref() {
if let Some(index) = app.custom_mission_index {
if let Some(mission) = campaign.missions.get(index) {
let done = app.custom_progress.as_ref().map(|p| p.is_done(index)).unwrap_or(false);
let zone_pulse = if done { pulse * 0.45 } else { pulse };
for zone in mission.zones.iter().take(80) {
draw_debug_zone(zone, app.game.map.core, zone_pulse);
}
}
}
}
}
if app.settings.debug_path_lines {
let mut drawn = 0usize;
for e in app.game.ents.iter() {
if !e.alive || e.held || e.hero || e.side != app.game.local_side || e.path.is_empty() {
continue;
}
if drawn >= 120 {
break;
}
drawn += 1;
let color = chat_color(Some(e.side), 0.55);
let mut prev = vec3(e.disp_x, 0.075, e.disp_y);
for tile in e.path.iter().take(12) {
let (tx, ty) = map::xy(*tile);
let p = vec3(tx as f32 + 0.5, 0.055, ty as f32 + 0.5);
line3(prev, p, 0.04, color);
prev = p;
}
}
}
}
fn draw_debug_zone(zone: &campaign_doc::ZoneDoc, core: (i32, i32), pulse: f32) {
let c = debug_zone_color(&zone.name);
let color = Color::new(c.r, c.g, c.b, pulse);
let at = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
let shape = zone.shape.as_str();
match shape {
"rect" | "rectangle" | "square" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let a = vec3(from.0 + 0.5, 0.05, from.1 + 0.5);
let b = vec3(to.0 + 0.5, 0.05, to.1 + 0.5);
line3(vec3(a.x, 0.05, a.y), vec3(b.x, 0.05, a.y), 0.05, color);
line3(vec3(b.x, 0.05, a.y), vec3(b.x, 0.05, b.y), 0.05, color);
line3(vec3(b.x, 0.05, b.y), vec3(a.x, 0.05, b.y), 0.05, color);
line3(vec3(a.x, 0.05, b.y), vec3(a.x, 0.05, a.y), 0.05, color);
}
}
"line" | "path" => {
if let (Some(from), Some(to)) = (zone.from, zone.to) {
let w = zone.width.unwrap_or(1.0).max(0.2);
line3(vec3(from.0 + 0.5, 0.05, from.1 + 0.5), vec3(to.0 + 0.5, 0.05, to.1 + 0.5), w * 0.05, color);
}
}
"tiles" => {
if let Some(tiles) = zone.tiles.as_ref() {
for (x, y) in tiles.iter().take(200) {
disc(vec3(*x as f32 + 0.5, 0.045, *y as f32 + 0.5), 0.45, color, 12);
}
}
}
_ => {
let r = zone.radius.unwrap_or(2.0);
disc(vec3(at.0 + 0.5, 0.04, at.1 + 0.5), r, color, 48);
}
}
}
fn debug_zone_color(name: &str) -> Color {
let mut h: u64 = 2166136261;
for b in name.bytes() {
h ^= b as u64;
h = h.wrapping_mul(1099511628211);
}
Color::from_rgba(80 + ((h % 176) as u8), 90 + (((h >> 8) % 166) as u8), 120 + (((h >> 16) % 136) as u8), 255)
}