// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). mod achievements; mod ai; mod armory; mod audio; mod badges; mod campaign; mod campaign_doc; mod cloud; mod commands; mod demo; mod editor; mod events; mod dwarves; mod fog; mod fortress; mod game; mod gfx; mod i18n; mod lobby; mod map; mod menu; mod netplay; mod personality; mod platform; mod orders; mod particles; mod props; mod research; mod rival; mod rogues; mod balance; mod talents; mod weather; mod names; mod render3d; mod rooms; mod save; #[cfg(test)] mod scenes; mod beasts; mod blocks; mod bosses; mod bridges; mod mscript; mod settings; mod siege; mod sfx; mod tracks; mod traders; mod traps; mod sound; mod surface; mod spells; mod stats; mod touch; mod ui; mod voice; mod walls; mod wilds; mod wood; mod xm; use cloud::{Cloud, CloudEvent}; use commands::Act; use game::{Game, Msg}; use gfx::Gfx; use macroquad::prelude::*; use map::{inb, mh, mw}; use menu::{Menu, MenuAction, Page}; use lobby::{MatchClient, Phase, StartInfo}; use render3d::{Cam, Overlay, R3d, disc, line3}; use settings::{bindable_keys, Action, Settings}; use sound::SoundBank; use touch::{Gesture, TouchInput}; 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 fn conf() -> macroquad::conf::Conf { macroquad::conf::Conf { miniquad_conf: miniquad::conf::Conf { window_title: "DungeonCraft".into(), window_width: 1280, window_height: 800, high_dpi: true, ..Default::default() }, draw_call_vertex_capacity: 40000, draw_call_index_capacity: 60000, ..Default::default() } } struct Input { ldown: Option<(i32, i32)>, // start of the selection rectangle rdown: Option<(f32, f32)>, // right button press position rlast: (f32, f32), rdrag: bool, mlast: Option<(f32, f32)>, // middle button drag mclick: f64, // time of the last middle click (a double click resets the view) minimap_drag: bool, // touch tlast: Option, // last position of a one-finger pan (device pixels) touch_world: bool, // the current finger started on the game world inspect: Option<(f32, f32, f64)>, // long press: show information for this point (CSS pixels) } // Tile under the pointer for a tool: digging (and the hand) aim at walls too, // building and everything else only at the floor below the pointer fn tool_tile(cam: &Cam, map: &map::Map, tool: Tool, mx: f32, my: f32) -> Option<(i32, i32)> { if matches!(tool, Tool::Dig | Tool::Hand | Tool::Steel) { return cam.pick_tile(map, mx, my).map(|(x, z, _)| (x, z)); } let p = cam.ground_point(mx, my); let (x, z) = (p.x.floor() as i32, p.y.floor() as i32); if inb(x, z) { Some((x, z)) } else { None } } fn area_tool(t: Tool) -> bool { matches!(t, Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall) } // Use the active tool on the world at (mx, my) (CSS pixels): a click, or a tap on touch screens #[allow(clippy::too_many_arguments)] fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32, my: f32, slap: bool, now: f64) { if app.game.state != 0 { return; } let tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my); let ground = app.cam.ground_point(mx, my); let (wx, wy) = (ground.x, ground.y); match (ui.tool, tile) { (Tool::Dig | Tool::Room(_) | Tool::Sell | Tool::Bridge | Tool::Steel | Tool::Drawbridge | Tool::Wall, Some(t)) => inp.ldown = Some(t), (Tool::Hand, _) => { let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); let Some(j) = j else { // a memorial stone: the hall of the fallen opens let (gx, gy) = (wx.floor() as i32, wy.floor() as i32); if map::inb(gx, gy) && app.game.memorial_at(map::idx(gx, gy)).is_some() { ui.show_overview = true; ui.ov_fallen = true; ui.ov_scroll = 0; } return; }; let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf()); if dwarf { return; } if slap { // the punch/backhand animation at the pointer starts at once, the order follows at the next step ui.hand_anim = Some((if hero { 2 } else { 1 }, now)); app.game.queue(Act::Slap { ent: id }); } else { app.game.queue(Act::Pickup { ent: id }); } } (Tool::Flag, Some((x, z))) => app.game.queue(Act::Flag { x, y: z }), (Tool::Lightning, _) => app.game.queue(Act::Lightning { x: wx, y: wy }), (Tool::Heal, _) => app.game.queue(Act::Heal { x: wx, y: wy }), (Tool::Summon, _) => app.game.queue(Act::Summon { x: wx, y: wy }), (Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }), (Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }), (Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }), (Tool::Siege(which), Some((tx, ty))) => app.game.queue(Act::Siege { which, x: tx, y: ty }), (Tool::Order(0), _) => { let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id); app.game.queue(Act::Attack { ent, x: wx, y: wy }); } (Tool::Order(_), Some((x, z))) => app.game.queue(Act::Guard { x, y: z }), _ => {} } } // Finish a selection rectangle at the tile under (mx, my) fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) { let Some((ex, ey)) = tool_tile(&app.cam, &app.game.map, tool, mx, my) else { return }; let (sx, sy) = start; let r = commands::Rect::new(sx, sy, ex, ey); match tool { Tool::Dig => app.game.queue(Act::Dig(r)), Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }), Tool::Sell => app.game.queue(Act::Sell(r)), Tool::Bridge => app.game.queue(Act::Bridge(r)), Tool::Drawbridge => app.game.queue(Act::Drawbridge(r)), Tool::Wall => app.game.queue(Act::Wall(r)), Tool::Steel => app.game.queue(Act::Steel(r)), _ => {} } } fn new_game(seed: u32, s: &Settings) -> (Game, Cam) { let mut g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty); if s.surface { // the eastern half above ground: forest, paths, cave mouths g.add_surface(seed, 0.5, 2 + (s.map_h / 64) as u32); g.init_fog(); } g.set_events(s.events); let (cx, cy) = g.map.core; let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); (g, cam) } // a campaign mission with what the last mission handed over fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) { let g = campaign::start_mission(i, progress.carry_into(i)); let (cx, cy) = g.map.core; let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); (g, cam) } fn prompt_line(title: &str, initial: &str, max: usize) -> Option { 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 sanitize_campaign_key(value: &str) -> String { value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect() } // the briefing of an editor mission (None: it has no text) fn briefing_of(doc: &campaign_doc::CampaignDoc, i: usize) -> Option<(String, String)> { let m = doc.missions.get(i)?; (!m.story.trim().is_empty()).then(|| (m.title.clone(), m.story.clone())) } // a mission of an editor campaign (its map is painted, or rolled from the run's seed) fn custom_mission_game(doc: &campaign_doc::CampaignDoc, i: usize, run_seed: u32) -> (Game, Cam) { let g = campaign::start_editor_mission(&doc.missions[i], i, run_seed); 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, Option) { 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) { platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default()); } // progress of editor campaigns played in co-op, kept by the host: campaign name -> progress const COOP_PROGRESS_KEY: &str = "dc.coop_editor_progress"; pub(crate) fn coop_progress_all() -> std::collections::BTreeMap { platform::storage_get(COOP_PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() } // where an interrupted co-op run of this campaign goes on: mission, run seed, carry pub(crate) fn coop_resume_of(doc: &campaign_doc::CampaignDoc) -> Option<(usize, u32, Vec<(u8, campaign::Carry)>)> { let p = coop_progress_all().remove(&doc.name)?; let next = p.next.filter(|n| doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission))?; Some((next, p.run_seed, p.carry)) } fn store_coop_progress(name: &str, p: &campaign_doc::CampaignProgress) { let mut all = coop_progress_all(); all.insert(name.to_string(), p.clone()); platform::storage_set(COOP_PROGRESS_KEY, &serde_json::to_string(&all).unwrap_or_default()); } // a co-op run through an editor campaign: the mission being played, the run's seed, what came // along into it, and - once it is over - where the host goes next and what everybody takes along struct CoopRun { doc: campaign_doc::CampaignDoc, mission: usize, run_seed: u32, carry_in: Vec<(u8, campaign::Carry)>, next: Option, carry_out: Vec<(u8, campaign::Carry)>, sent: bool, // the host asked the relay for the next mission } impl CoopRun { // the mission is over: the host decides where the run goes on (the branch of the reached goal, // or the same mission again after a defeat) and stores the progress fn finish(&mut self, g: &Game) { let won = g.state == 1; if won { let (next, choice) = self.doc.next_mission_choice(self.mission, |goal| g.match_goal_done(goal)); self.next = next.filter(|n| self.doc.missions.get(*n).map_or(false, campaign::is_editor_coop_mission)); self.carry_out = campaign::coop_carry_from(g); if g.local_side == 0 { let mut p = coop_progress_all().remove(&self.doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&self.doc)); p.run_seed = self.run_seed; p.mark_done(self.mission, self.doc.missions.len(), choice); // the run goes on with the next mission (or is over) p.next = self.next; p.carry = self.carry_out.clone(); store_coop_progress(&self.doc.name, &p); } } else { self.next = Some(self.mission); self.carry_out = self.carry_in.clone(); } } } // login, account and standalone actions of the menu fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut App, now: f64) { match a { MenuAction::Login(name, pw) => { if name.is_empty() || pw.is_empty() { menu.error = Some(i18n::tr("login.err_invalid")); } else if cloud.login(&name, &pw) { menu.error = Some(i18n::tr("saves.wait")); } } MenuAction::Logout => { cloud.logout(); menu.open_login(); menu.error = None; } MenuAction::OpenAccount => { if cloud.session.as_ref().map_or(false, |s| s.admin) { cloud.load_users(); } } MenuAction::ChangePassword(old, new) => { if new.chars().count() < 4 { menu.error = Some(i18n::tr("login.err_invalid")); } else { cloud.change_password(&old, &new); } } MenuAction::CreateUser(name, pw) => { if name.is_empty() || pw.chars().count() < 4 { menu.error = Some(i18n::tr("login.err_invalid")); } else { cloud.create_user(&name, &pw); } } MenuAction::DeleteUser(name) => { cloud.delete_user(&name); } MenuAction::DownloadStandalone => { platform::fetch_download("standalone/DungeonCraft.html", "DungeonCraft.html"); app.set_status(i18n::tr("standalone.running"), now); } MenuAction::EditField(i) => { // touch screens have no keyboard in the canvas: the browser asks if ui::touch_ui() { let secret = matches!((menu.page, i), (Some(Page::Login), 1) | (Some(Page::Account), 0 | 1 | 3)); let title = i18n::tr(if secret { "login.password" } else { "login.name" }); if let Some(t) = platform::prompt(&title, if secret { "" } else { &menu.fields[i] }) { menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(40).collect(); } } } MenuAction::OpenMatch => { if !app.connect_match(cloud) { menu.error = Some(i18n::tr("net.status_offline")); } } MenuAction::MatchJoin(code) => app.client.join(&code), MenuAction::MatchCreate => { // the map size decides the number of master seats let n = app.settings.match_size; app.client.create_land(lobby::seats_for(n), n, n, app.settings.difficulty, app.settings.surface, app.settings.events) } MenuAction::MatchCoopCreate(mission) => { let all = campaign::missions(); if let Some(d) = all.get(mission) { app.client.create_coop(mission, &[0, 2, 3, 4], d.size.0, d.size.1, app.settings.difficulty); } else { menu.error = Some(i18n::tr("net.status_closed")); } } MenuAction::MatchCoopEditorCreate(mission) => { let doc = menu.coop_doc().clone(); if doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) { app.client.create_coop_doc(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty); } else { menu.error = Some(i18n::tr("net.coop_editor_none")); } } MenuAction::MatchCoopSolo(doc, mission) => { // the open lobby connection is not needed: the game runs on this computer alone if app.start_offline_coop(doc, mission, None, Vec::new()) { menu.page = None; menu.error = None; app.set_status(i18n::tr("net.coop_solo_started"), now); } else { menu.error = Some(i18n::tr("net.coop_editor_none")); } } MenuAction::MatchCoopResume(doc) => { // with the relay: a room that goes on with the run; without it: offline with an AI partner match coop_resume_of(&doc) { Some((mission, seed, carry)) if app.client.connected => { app.client.create_coop_doc_resumed(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty, Some((seed, &carry))); } Some((mission, seed, carry)) => { if app.start_offline_coop(Some(doc), mission, Some(seed), carry) { menu.page = None; menu.error = None; app.set_status(i18n::tr("net.coop_solo_started"), now); } else { menu.error = Some(i18n::tr("net.coop_editor_none")); } } None => menu.error = Some(i18n::tr("net.coop_editor_none")), } } MenuAction::MatchTakeSide(side) => app.client.take_side(side), MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai), MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team), MenuAction::MatchReady(ready) => app.client.ready(ready), MenuAction::MatchStart => app.client.start(), MenuAction::MatchResume => app.client.resume(), MenuAction::MatchAbort => { app.client.abort(); app.game.coop = false; app.match_mode = false; app.coop = false; app.coop_editor = false; } MenuAction::MatchLeave => { app.client.leave(); app.game.coop = false; app.match_mode = false; app.coop = false; app.coop_editor = false; } MenuAction::MatchChat(text) => app.client.chat(&text, false), MenuAction::MatchEditField(i) => { if ui::touch_ui() { let title = i18n::tr(if i == 0 { "net.join_hint" } else { "net.chat" }); if let Some(t) = platform::prompt(&title, &menu.fields[i]) { menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(if i == 0 { 16 } else { 80 }).collect(); } } } _ => {} } } fn url_encode(s: &str) -> String { let mut out = String::new(); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' => out.push(b as char), _ => out.push_str(&format!("%{b:02X}")), } } out } fn next_seed(seed: u32) -> u32 { seed.wrapping_mul(1103515245).wrapping_add(12345) | 1 } fn apply_settings(s: &Settings, r3d: &mut R3d) { i18n::set_language(&s.language); ui::set_ui_factor(s.ui_factor); r3d.outlines = s.outlines; r3d.set_wall_detail(s.wall_subdivisions()); r3d.max_lights = s.max_lights as usize; r3d.pixel_rows = if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows as u32 } else { 360 }; audio::set_volumes(s.music_volume, s.volume, s.muted); } // Chooses the music for the current situation. Each category rotates through its tracks; // build and title music switch to the next track after two loops. struct MusicDirector { next: std::collections::HashMap, // category -> next index within the category current: Option, } impl MusicDirector { fn new() -> MusicDirector { MusicDirector { next: std::collections::HashMap::new(), current: None } } fn pick(&mut self, c: audio::Category) -> usize { let list = audio::tracks_in(c); let k = self.next.entry(c as u8).or_insert(0); let t = list[*k % list.len()]; *k += 1; t } // `chosen`: a track picked in the music player; it replaces the calm music fn update(&mut self, g: &Game, title: bool, cam: (f32, f32), chosen: Option) { use audio::Category as C; let alive = |f: &dyn Fn(&game::Ent) -> bool| g.ents.iter().any(|e| e.alive && f(e)); let near_room = |k: map::Kind| { let (cx, cz) = (cam.0 as i32, cam.1 as i32); let mut n = 0; for y in cz - 5..=cz + 5 { for x in cx - 5..=cx + 5 { if g.map.get(x, y).map_or(false, |t| t.kind == k) { n += 1; } } } n >= 4 }; // the camera looks at the land above ground: the music of the surface let above = { let (cx, cz) = (cam.0 as i32, cam.1 as i32); let mut n = 0; for y in (cz - 4..=cz + 4).step_by(2) { for x in (cx - 4..=cx + 4).step_by(2) { if map::inb(x, y) && g.map.sky(map::idx(x, y)) { n += 1; } } } n >= 13 }; let want = if title { C::Title } else if g.state == 1 { C::Victory } else if g.state == 2 { C::Defeat } else if alive(&|e| e.active_hero() && e.kind == game::EK::Paladin && e.guard) { C::BossWarden } else if alive(&|e| e.active_hero() && e.kind == game::EK::Paladin) { C::BossPaladin } else if g.time - g.gate_hit_t < 12.0 || g.gate_defenders_alive() { C::Assault } else if alive(&|e| e.active_hero() && !e.guard) { C::Battle } else if near_room(map::Kind::Torture) { C::Torture } else if near_room(map::Kind::Temple) { C::Temple } else { C::Build }; let want = match want { C::Battle if above => C::SurfaceBattle, C::Build | C::Torture | C::Temple if above => C::Surface, w => w, }; let calm = matches!(want, C::Build | C::Title | C::Surface | C::Torture | C::Temple); if let (true, Some(t)) = (calm, chosen) { if audio::current_track() != Some(t) { let fade = if self.current.map_or(false, |c| matches!(c, C::Victory | C::Defeat)) { 1.0 } else { 2.5 }; audio::play_music(t, fade); } self.current = Some(want); return; } let rotate = matches!(want, C::Build | C::Title | C::Surface) && self.current == Some(want) && audio::music_loops() >= 2; // after the chosen track was let go, the director picks again let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| x.1 != want)) && calm && self.current == Some(want); if self.current != Some(want) || rotate || foreign || audio::current_track().is_none() { let before = self.current; self.current = Some(want); let t = self.pick(want); // between the dungeon and the surface the music changes slowly let fade = if matches!(want, C::Victory | C::Defeat) { 1.0 } else if matches!((before, want), (Some(C::Surface | C::SurfaceBattle), _) | (_, C::Surface | C::SurfaceBattle)) { 4.0 } else { 2.5 }; audio::play_music(t, fade); } } } 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; } // "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); format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, h, mi) } // suggested name for a new savegame on the server: "Wave 3 - 2026-11-03 14:20" fn default_save_name(g: &Game) -> String { i18n::trf("saves.default_name", &[(g.wave + 1).to_string(), cloud::date_string(miniquad::date::now() as i64)]) } struct App { game: Game, cam: Cam, settings: Settings, seed: u32, status: Option<(String, f64)>, recorded: bool, // the end of the current mission has been recorded in the campaign progress client: MatchClient, ws_id: i32, match_mode: bool, coop: bool, coop_editor: bool, // the co-op match plays a mission of an editor campaign coop_run: Option, // ... and this is where the run through that campaign stands offline: bool, // a co-op game played without the server: the other seat is an AI partner tutorial: bool, // the tutorial mission is being played (its end leads back to the title) fps: f32, renderer: String, frame_ms: f32, frame_hist: [f32; FRAME_HIST], frame_hist_pos: usize, editor: Option, // the campaign editor (takes the whole frame while open) editor_testing: bool, // the editor waits while one of its missions is played editor_test_only: bool, // only that mission (test play); else the campaign goes on editor_public_pending: bool, // the public campaign list still has to be fetched editor_loading: Option, // server id of the own campaign being loaded pending_briefing: Option<(String, String)>, // an editor mission began: show its briefing custom_campaign: Option, custom_progress: Option, custom_mission_index: Option, custom_next_index: Option, } impl App { fn set_status(&mut self, text: String, now: f64) { self.status = Some((text, now)); } // Load a savegame (only the game state; the settings stay as they are). The camera looks at // the heart (screenshot scenes bring their own camera). fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) { match save::from_bytes(bytes) { Ok(l) => { self.game = l.game; match l.camera { Some(c) => self.cam = c, None => { let (cx, cy) = self.game.map.core; self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); } } self.cam.snap(); apply_game_settings(&mut self.game, &self.settings); self.game.coop = false; self.coop = false; r3d.invalidate(); menu.page = None; ui.show_help = false; ui.log.clear(); ui.log.push((Msg { key: "save.loaded", args: vec![] }, now)); self.status = None; } Err(key) => self.set_status(i18n::tr(key), now), } } fn autosave_json(&self) -> String { save::to_json(&self.game) } fn connect_match(&mut self, cloud: &Cloud) -> bool { if self.ws_id > 0 || self.client.connected { return true; } let name = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_else(|| "Guest".into()); let token = cloud.session.as_ref().map(|s| s.token.clone()).unwrap_or_default(); let mut url = format!("/api/netplay/ws?name={}", url_encode(&name)); if !token.is_empty() { url.push_str("&token="); url.push_str(&url_encode(&token)); } self.ws_id = platform::ws_connect(&url); if self.ws_id <= 0 { return false; } self.client.on_connect(&name); true } fn poll_match(&mut self, now: f64) { if self.ws_id <= 0 { if self.client.connected { self.client.on_disconnect(); } return; } while let Some(msg) = platform::ws_recv(self.ws_id) { 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; } let state = platform::ws_state(self.ws_id); if state == 0 || state == 1 { while let Some(text) = self.client.outbox.pop_front() { if !platform::ws_send(self.ws_id, &text) { self.client.outbox.push_front(text); break; } } return; } platform::ws_close(self.ws_id); self.ws_id = 0; self.match_mode = false; self.client.on_disconnect(); } fn leave_match(&mut self) { if !self.match_mode && !self.game.in_match() { return; } self.client.leave(); if self.ws_id > 0 { while let Some(text) = self.client.outbox.pop_front() { if !platform::ws_send(self.ws_id, &text) { self.client.outbox.push_front(text); break; } } platform::ws_close(self.ws_id); self.ws_id = 0; } self.game.end_match(); self.game.coop = false; self.match_mode = false; self.coop = false; self.coop_editor = false; self.offline = false; self.client.on_disconnect(); } // A co-op mission without the server: built like a match start with the player on side 0 and an // AI partner on side 2, then the world runs on this computer alone (no turns, no relay). // `doc` is an editor campaign (None: a built-in co-op mission); `run_seed`/`carry` continue a run. fn start_offline_coop(&mut self, doc: Option, mission: usize, run_seed: Option, carry: Vec<(u8, campaign::Carry)>) -> bool { let size = match &doc { Some(d) => d.missions.get(mission).map(|m| m.size), None => campaign::missions().get(mission).map(|m| m.size), }; let Some((map_w, map_h)) = size else { return false }; let next = run_seed.is_some(); let seed = run_seed.unwrap_or_else(|| next_seed(self.seed)); let editor = doc.is_some(); let info = StartInfo { seed, map_w, map_h, difficulty: self.settings.difficulty, sides: vec![0, 2], local_side: Some(0), mission: Some(mission), ai: vec![2], doc, editor, teams: Vec::new(), next, carry, surface: false, events: false }; if !self.start_match_game(info) { return false; } self.game.end_match(); self.offline = true; self.recorded = false; true } // the shared part of a co-op start (built-in or editor mission): the masters, AI seats, settings fn start_coop_game(&mut self, mut game: Game, info: &StartInfo) -> bool { let local = info.local_side.unwrap_or(0); game.coop = true; if !game.start_match(&info.sides, local) { return false; } game.ai_difficulty = info.difficulty; game.sync_ai_sides(&info.ai, info.difficulty); // what the masters bring from the last mission of an editor campaign run (same on every client) campaign::apply_coop_carry(&mut game, &info.carry); 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); self.match_mode = true; self.coop = true; self.seed = info.seed.wrapping_mul(31) | 1; true } fn start_match_game(&mut self, info: StartInfo) -> bool { self.coop_editor = false; self.coop_run = None; if !info.sides.contains(&0) { return false; } if info.editor { // a co-op mission of an editor campaign: every client builds it from the same file and // the room's shared seed (random maps and block scripts roll from it) let (Some(doc), Some(mission)) = (info.doc.as_ref(), info.mission) else { return false }; let Some(mission_doc) = doc.missions.get(mission) else { return false }; if !campaign::is_editor_coop_mission(mission_doc) { return false; } let game = campaign::start_editor_coop_mission(mission_doc, mission, info.seed); if !self.start_coop_game(game, &info) { return false; } self.coop_editor = true; self.recorded = false; self.pending_briefing = briefing_of(doc, mission); // the host keeps the progress of the run (a new run starts with the room's seed) and // where it stands, so an interrupted run can go on later if info.local_side == Some(0) { let mut p = coop_progress_all().remove(&doc.name).unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(doc)); p.ensure(doc.missions.len()); p.run_seed = info.seed; p.next = Some(mission); p.carry = info.carry.clone(); store_coop_progress(&doc.name, &p); } self.coop_run = Some(CoopRun { doc: doc.clone(), mission, run_seed: info.seed, carry_in: info.carry.clone(), next: None, carry_out: Vec::new(), sent: false }); return true; } if let Some(mission) = info.mission { if !campaign::is_coop_mission(mission) { return false; } let game = campaign::start_coop_mission(mission, None); return self.start_coop_game(game, &info); } if info.map_w < 72 || info.map_h < 72 { return false; } self.game = Game::new(info.seed, info.map_w, info.map_h); let local = info.local_side.unwrap_or(0); if !self.game.start_match(&info.sides, local) { return false; } if info.surface { // the same land on every client: built from the shared seed self.game.add_surface(info.seed, 0.5, 2 + (info.map_h / 64) as u32); } self.game.set_events(info.events); self.game.coop = false; self.game.set_teams(&info.teams); self.game.ai_difficulty = info.difficulty; self.game.sync_ai_sides(&info.ai, info.difficulty); 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); self.match_mode = true; self.coop = false; self.seed = info.seed.wrapping_mul(31) | 1; true } fn tick_match_game(&mut self) { if !self.match_mode || !self.game.in_match() { return; } for (_, cmds, ai) in self.client.take_turns() { let difficulty = self.game.ai_difficulty; self.game.sync_ai_sides(&ai, difficulty); self.game.receive_turn(&cmds); } for (_, turn, sum) in self.client.take_checks() { self.game.check_peer(turn, sum); } let cmds = self.game.take_orders(); if !cmds.is_empty() { self.client.send_commands(&cmds); } if let Some(turn) = self.game.checksum_due() { let sum = self.game.checksum(); self.client.send_checksum(turn, sum); } } } #[macroquad::main(conf)] async fn main() { let gfx = Gfx::new(); show_mouse(false); // the game draws its own pixel-art pointer let mut r3d = R3d::new(); let mut sounds = SoundBank::new(); let mut music = MusicDirector::new(); // lifetime statistics and achievements (browser storage, merged with the server after a login) let stored_profile: achievements::Profile = platform::storage_get(achievements::PROFILE_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); let mut tracker = achievements::Tracker::new(stored_profile); let mut want_profile = false; // fetch the server's profile when the client is free let mut profile_store_t = 0.0f32; let mut profile_upload_t = 0.0f32; let mut ui = Ui::new(); let mut menu = Menu::new(); let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable // settings: stored in the browser; on the very first start the player picks a language let stored = platform::storage_get(SETTINGS_KEY).and_then(|s| Settings::from_json(&s)); let mut settings = stored.clone().unwrap_or_default(); if stored.is_none() { match platform::storage_get("dc.lang") { Some(code) => settings.language = code, None => ui.show_langsel = true, } } apply_settings(&settings, &mut r3d); ui.show_help = false; menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some(); 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, coop_editor: false, coop_run: None, offline: false, tutorial: 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, editor: None, editor_testing: false, editor_test_only: false, editor_public_pending: false, editor_loading: None, pending_briefing: None }; menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); 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, mclick: -1.0, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut touch = TouchInput::new(); let mut auto_touch = platform::coarse_pointer(); let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance let mut menu_dragged = false; // the finger on a menu has swiped (no click when it lifts) // until the mouse has really moved its position is (0, 0): no edge scrolling before that let mut mouse_moved = false; // automatic mode: touch interface after a touch, desktop after a mouse click let mut autosave_t = 0.0f32; let mut voice = voice::Voice::new(); let mut was_briefing = false; let mut cache_t = 0.0f32; loop { let raw_dt = get_frame_time(); let dt = raw_dt.min(0.1); 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; // ---------------- the campaign editor: opened from the title menu, takes the whole frame if menu.page == Some(Page::CampaignEditor) && app.editor.is_none() { app.editor = Some(editor::Editor::new(menu.campaign_doc.clone())); menu.page = Some(Page::Title); } // an editor mission began: its briefing first if let Some(b) = app.pending_briefing.take() { menu.custom_briefing = b; menu.page = Some(Page::CustomBriefing); menu.scroll_lines(-1000); } // a played editor campaign is over: back into the editor if menu.page == Some(Page::CampaignEditor) && app.editor_testing { back_to_editor(&mut app, &mut menu, &mut r3d); } if app.editor.is_some() && !app.editor_testing { editor_frame(&mut app, &mut menu, &mut cloud, &mut r3d, &gfx, &mut ui, dt, now); ui::end_frame(); next_frame().await; continue; } // ---------------- touch or mouse: which interface is active // (with the touch interface touches are no longer turned into mouse events) simulate_mouse_with_touch(app.settings.touch_ui == 2); let gestures = if app.settings.touch_ui == 2 { Vec::new() } else { touch.update(now, ui.tool == Tool::Hand && !menu.open()) }; if !touches().is_empty() { auto_touch = true; } else if app.settings.touch_ui != 2 && (is_mouse_button_pressed(MouseButton::Left) || is_mouse_button_pressed(MouseButton::Right)) { auto_touch = false; } let touch_mode = match app.settings.touch_ui { 1 => true, 2 => false, _ => auto_touch, }; ui::set_touch(touch_mode); // pixel look: the interface on a virtual screen whose pixels are whole parts of the world's let rows = if [270, 360, 480].contains(&app.settings.pixel_rows) { app.settings.pixel_rows as u32 } else { 360 }; // the interface needs room: the smallest whole multiple of the world's rows with at least // 900 rows (360 -> 1080), on a desktop and on a touch screen alike (a phone has about // 1080 device rows in landscape; a smaller screen draws in device pixels) let need: u32 = 900; // measured on the shorter side: upright (portrait) the width gets those pixels and the // height grows with the screen, so the world and the menus keep their size. The setting // "interface size" picks the interface pixels per world pixel: large 2, medium 3, small 4 let short = match app.settings.ui_factor { k @ 2..=4 => rows * k as u32, _ => rows * need.div_ceil(rows), }; let (w, h) = (screen_width().max(1.0), screen_height().max(1.0)); let vrows = if h > w { (short as f32 * h / w).round() as u32 } else { short }; ui::set_virtual_rows(vrows); if touch_mode { // the pointer is the finger, or the position of this frame's gesture let gpos = gestures.iter().rev().find_map(|g| match *g { Gesture::Press(p) | Gesture::Tap(p) | Gesture::Release(p) | Gesture::LongPress(p) | Gesture::DoubleTap(p) => Some(p), _ => None, }); let p = touch.finger().or(gpos).unwrap_or(vec2(-1e5, -1e5)); ui::set_pointer(p.x, p.y); } let (ux, uy) = ui::mouse(); // device pixels (interface) let (mx, my) = if touch_mode { (ux / ui::dpr(), uy / ui::dpr()) } else { mouse_position() }; // CSS pixels (3D picking) // ---------------- savegame files: file picker result or dropped onto the window let mut incoming: Option> = platform::take_file(); for f in get_dropped_files() { if let Some(b) = f.bytes { incoming = Some(b); } } if let Some(bytes) = incoming { 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 match cloud.poll() { CloudEvent::None => {} CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now), CloudEvent::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now), CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), now), CloudEvent::Error(key) => { if matches!(menu.page, Some(Page::Account)) { menu.error = Some(i18n::tr(key)); } else { app.set_status(i18n::tr(key), now); } } // accounts: the login page comes before the title screen CloudEvent::LoginNeeded => { if matches!(menu.page, Some(Page::Title) | None) { menu.open_login(); menu.error = None; } } CloudEvent::LoggedIn => { want_profile = true; if menu.page == Some(Page::Login) { menu.page = Some(Page::Title); } menu.error = None; if let Some(s) = &cloud.session { app.set_status(i18n::trf("login.welcome", &[s.name.clone()]), now); } } CloudEvent::LoginFailed(key) => menu.error = Some(i18n::tr(key)), CloudEvent::PasswordChanged => { menu.fields[0].clear(); menu.fields[1].clear(); menu.error = None; app.set_status(i18n::tr("account.pw_changed"), now); } CloudEvent::UsersChanged => { menu.fields[2].clear(); menu.fields[3].clear(); menu.error = None; app.set_status(i18n::tr("account.users_changed"), now); } } // download of the standalone file match platform::fetch_download_state() { 2 => app.set_status(i18n::tr("standalone.done"), now), 3 => app.set_status(i18n::tr("standalone.failed"), now), _ => {} } // ---------------- multiplayer relay: receive messages, send queued requests, update the menu state app.poll_match(now); menu.in_match = app.match_mode || app.game.in_match(); menu.gold_allies = app.game.gold_allies(); ui.coop_end = app.coop_run.as_ref().filter(|_| app.coop_editor).map(|r| (app.game.local_side == 0, r.next.is_some(), r.sent)); if matches!(menu.page, Some(Page::Match { .. })) { menu.set_match_ui(app.client.ui()); } for (side, name, text) in app.client.take_new_chat() { ui.chat_log.push((side, name, text, now)); while ui.chat_log.len() > 100 { ui.chat_log.remove(0); } } if let Some(info) = app.client.take_start() { if app.start_match_game(info) { menu.page = None; menu.error = None; ui.log.clear(); ui.chat_log.clear(); autosave_t = 0.0; cache_t = 0.0; app.set_status(i18n::tr("net.title"), now); } else { menu.error = Some(i18n::tr("net.status_closed")); app.match_mode = false; } } // ---------------- text fields (savegame name, login, account) if menu.typing() { // Tab switches the field right away (the following characters go to the next one) let mut tab_char = false; while let Some(c) = get_char_pressed() { if c == '\t' { tab_char = true; menu.next_field(); } else { menu.type_char(c); } } if is_key_pressed(KeyCode::Tab) && !tab_char { menu.next_field(); } if is_key_pressed(KeyCode::Backspace) { menu.backspace(); } if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { if matches!(menu.page, Some(Page::Saves { .. })) { let name = menu.save_name.trim().to_string(); if cloud.save(None, &name, &app.autosave_json()) { app.set_status(i18n::tr("saves.wait"), now); } } else { let a = menu.submit(); account_action(a, &mut cloud, &mut menu, &mut app, now); } } } let (_, wheel) = mouse_wheel(); if wheel != 0.0 && ui.show_langsel { ui.langsel_wheel(if wheel > 0.0 { -1 } else { 1 }); } else if wheel != 0.0 && menu.page.is_some() { menu.wheel(if wheel > 0.0 { -1 } else { 1 }); } else if wheel != 0.0 { ui.overview_wheel(ux, uy, if wheel > 0.0 { -1 } else { 1 }); } // touch: a swipe over a menu, the language grid or the creature overview scrolls it, // one line per row height if touch_mode { for g in &gestures { match *g { Gesture::Press(_) => menu_dragged = false, Gesture::DragStart(p) if menu.page.is_some() || ui.show_langsel || ui.overview_wheel(p.x, p.y, 0) => { menu_swipe = Some((p.y, 0.0)); menu_dragged = true; } Gesture::DragMove(p) => { if let Some((last, acc)) = menu_swipe { let mut acc = acc + p.y - last; let step = ui::CH() * 2.0; while acc.abs() >= step { // finger up: the content follows it up, so the list scrolls down let d = if acc < 0.0 { 1 } else { -1 }; if ui.show_langsel { ui.langsel_wheel(d); } else if menu.page.is_some() { menu.wheel(d); } else { ui.overview_wheel(p.x, p.y, d); } acc += d as f32 * step; } menu_swipe = Some((p.y, acc)); } } Gesture::DragEnd | Gesture::Cancel => menu_swipe = None, _ => {} } } } // ---------------- key capture for the key binding dialog if let Some(action) = menu.capturing() { if is_key_pressed(KeyCode::Escape) { menu.finish_capture(); } else if let Some(k) = get_last_key_pressed() { if bindable_keys().any(|b| b == k) { app.settings.bind(action, k); apply_settings(&app.settings, &mut r3d); store_settings(&app.settings); menu.finish_capture(); } } let p = mouse_position(); draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, p, false, now); ui::end_frame(); next_frame().await; continue; } let dialog = ui.show_langsel || ui.show_help; if ui.chat_open && (menu.open() || dialog) { ui.chat_open = false; ui.chat_text.clear(); } let in_menu = menu.open(); let playing = !in_menu && !dialog && !ui.chat_open; let over_ui = ui.over_ui(ux, uy); let hover_tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my); let ground = app.cam.ground_point(mx, my); let (wx, wy) = (ground.x, ground.y); let keys = app.settings.clone(); let key = |a: Action| keys.key(a); // ---------------- Esc: chat, dialogs, menus, pause menu if is_key_pressed(KeyCode::Escape) { if ui.chat_open { ui.chat_open = false; ui.chat_text.clear(); } else if ui.show_langsel { } else if ui.show_help { ui.show_help = false; } else if in_menu { menu.back(); } else if inp.ldown.is_some() { inp.ldown = None; } else { menu.page = Some(Page::Pause); } } // ---------------- in-match chat input if ui.chat_open { while let Some(c) = get_char_pressed() { if !c.is_control() && c != '\t' && ui.chat_text.chars().count() < 80 { ui.chat_text.push(c); } } if is_key_pressed(KeyCode::Backspace) { ui.chat_text.pop(); } if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { let text: String = ui.chat_text.trim().chars().take(80).collect(); if !text.is_empty() { app.client.chat(&text, ui.chat_team); } ui.chat_text.clear(); ui.chat_open = false; } } else if playing && app.game.state == 0 && (app.match_mode || app.game.in_match()) { if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) { ui.chat_open = true; ui.chat_text.clear(); } } // ---------------- keyboard (only while playing) if playing { for i in 0..TOOLS.len() { let k = key(Action::Tool(i)); if k != KeyCode::Unknown && is_key_pressed(k) && app.game.tool_allowed(i) && app.game.tool_visible(i) { ui.tool = TOOLS[i]; inp.ldown = None; } } if is_key_pressed(key(Action::Help)) { ui.show_help = true; } if is_key_pressed(key(Action::Pause)) && app.game.state == 0 { app.game.paused = !app.game.paused; } if is_key_pressed(key(Action::Outlines)) { app.settings.outlines = !app.settings.outlines; settings_changed = true; } 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); apply_game_settings(&mut app.game, &app.settings); } } if !dialog && !menu.typing() { if is_key_pressed(key(Action::Mute)) { app.settings.muted = !app.settings.muted; settings_changed = true; } if is_key_pressed(key(Action::VolumeUp)) || is_key_pressed(KeyCode::KpAdd) { app.settings.volume = (app.settings.volume + 0.1).min(1.0); settings_changed = true; } if is_key_pressed(key(Action::VolumeDown)) || is_key_pressed(KeyCode::KpSubtract) { app.settings.volume = (app.settings.volume - 0.1).max(0.0); settings_changed = true; } } // ---------------- camera if playing { let cam = &mut app.cam; let (right, fwd) = cam.ground_axes(); let pan = cam.zoom * keys.pan_speed.max(0.25) * dt; let move_cam = |dx: f32, dz: f32, cam: &mut Cam| { let d = right * dx + fwd * dz; cam.tx += d.x; cam.tz += d.y; }; if is_key_down(key(Action::CamForward)) || is_key_down(KeyCode::Up) { move_cam(0.0, pan, cam); } if is_key_down(key(Action::CamBack)) || is_key_down(KeyCode::Down) { move_cam(0.0, -pan, cam); } if is_key_down(key(Action::CamLeft)) || is_key_down(KeyCode::Left) { move_cam(-pan, 0.0, cam); } if is_key_down(key(Action::CamRight)) || is_key_down(KeyCode::Right) { move_cam(pan, 0.0, cam); } let edge = 6.0; if !mouse_moved && mouse_delta_position() != Vec2::ZERO { mouse_moved = true; } if keys.edge_scroll && mouse_moved && !touch_mode && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { if mx < edge { move_cam(-pan, 0.0, cam); } else if mx > screen_width() - edge { move_cam(pan, 0.0, cam); } if my < edge { move_cam(0.0, pan, cam); } else if my > screen_height() - edge { move_cam(0.0, -pan, cam); } } let rot = 1.8 * dt * keys.rotate_speed; if is_key_down(key(Action::RotateLeft)) { cam.yaw += rot; } if is_key_down(key(Action::RotateRight)) { cam.yaw -= rot; } if is_key_down(key(Action::TiltUp)) { cam.pitch += rot * 0.6; } if is_key_down(key(Action::TiltDown)) { cam.pitch -= rot * 0.6; } // a double click with the middle button: the standard angle and turn (same place) if is_mouse_button_pressed(MouseButton::Middle) && !touch_mode { let now = get_time(); if now - inp.mclick < 0.35 { cam.reset_view(); inp.mclick = -1.0; } else { inp.mclick = now; } } if is_mouse_button_down(MouseButton::Middle) && !touch_mode { if let Some((lx, ly)) = inp.mlast { let inv = if keys.invert_mouse { -1.0 } else { 1.0 }; cam.yaw -= (mx - lx) * 0.008 * keys.rotate_speed * inv; cam.pitch += (my - ly) * 0.005 * keys.rotate_speed * inv; } inp.mlast = Some((mx, my)); } else { inp.mlast = None; } cam.pitch = cam.pitch.clamp(0.35, 1.45); let (_, wheel) = mouse_wheel(); if wheel != 0.0 && !over_ui { let before = cam.ground_point(mx, my); let f = 0.88f32.powf(keys.zoom_speed); cam.zoom = (cam.zoom * if wheel > 0.0 { f } else { 1.0 / f }).clamp(6.0, 45.0); let after = cam.ground_point(mx, my); cam.tx += before.x - after.x; cam.tz += before.y - after.y; } // touch: two fingers move, zoom and rotate the camera for g in &gestures { if let Gesture::Camera { center, pan, zoom, rotate } = *g { let d = ui::dpr(); let a = cam.ground_point((center.x - pan.x) / d, (center.y - pan.y) / d); let b = cam.ground_point(center.x / d, center.y / d); cam.tx -= b.x - a.x; cam.tz -= b.y - a.y; let before = cam.ground_point(center.x / d, center.y / d); cam.zoom = (cam.zoom / zoom.max(0.01)).clamp(6.0, 45.0); cam.yaw += rotate; // the scene turns with the fingers let after = cam.ground_point(center.x / d, center.y / d); cam.tx += before.x - after.x; cam.tz += before.y - after.y; } } // right button: drag = pan, click = drop if is_mouse_button_pressed(MouseButton::Right) { inp.rdown = Some((mx, my)); inp.rlast = (mx, my); inp.rdrag = false; } if let Some((sx, sy)) = inp.rdown { if is_mouse_button_down(MouseButton::Right) { if (mx - sx).abs() + (my - sy).abs() > 5.0 { inp.rdrag = true; } if inp.rdrag { let a = cam.ground_point(inp.rlast.0, inp.rlast.1); let b = cam.ground_point(mx, my); cam.tx -= b.x - a.x; cam.tz -= b.y - a.y; } inp.rlast = (mx, my); } } if is_mouse_button_released(MouseButton::Right) { if !inp.rdrag && !over_ui && app.game.state == 0 { if inp.ldown.is_some() { inp.ldown = None; } else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_) | Tool::Flag) { app.game.queue(Act::Unmark { x: wx, y: wy }); } else { app.game.queue(Act::Drop { x: wx, y: wy }); } } inp.rdown = None; } } // ---------------- left button (touch: a finger touching the interface) // (in menus and dialogs a touch clicks when the finger lifts without a swipe, so that // swiping a list scrolls it instead of picking the entry under the finger) let menu_touch = touch_mode && (in_menu || dialog); let pressed = if menu_touch { !menu_dragged && gestures.iter().any(|g| matches!(g, Gesture::Release(_))) } else if touch_mode { gestures.iter().any(|g| matches!(g, Gesture::Press(_))) } else { is_mouse_button_pressed(MouseButton::Left) }; if touch_mode && pressed { inp.touch_world = playing && !over_ui; inp.inspect = None; } if pressed { if dialog { match ui.click(ux, uy, &app.game) { UiAction::SelectLanguage(code) => { app.settings.language = code; settings_changed = true; ui.show_langsel = false; } UiAction::CloseHelp => ui.show_help = false, _ => {} } } else if in_menu { let from_title = menu.page == Some(Page::Title); match menu.click(ux, uy, &mut app.settings, &cloud) { MenuAction::SettingsChanged => settings_changed = true, MenuAction::Resume => {} MenuAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }), MenuAction::NewGame => { app.seed = next_seed(app.seed); (app.game, app.cam) = new_game(app.seed, &app.settings); apply_game_settings(&mut app.game, &app.settings); menu.page = None; ui.log.clear(); ui.show_help = true; autosave_t = 0.0; } MenuAction::Continue => { if let Some(s) = platform::storage_get(save::AUTOSAVE_KEY) { app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now); } } // with the savegame service: list on the server, otherwise savegame files MenuAction::Load if cloud.available() => { menu.open_saves(false, from_title, String::new()); cloud.refresh(); } MenuAction::Save if cloud.available() => { menu.open_saves(true, false, default_save_name(&app.game)); cloud.refresh(); } MenuAction::Load | MenuAction::FileLoad => { platform::open_file(); app.set_status(i18n::tr("save.choose"), now); } MenuAction::Save | MenuAction::FileSave => { let name = save_file_name(); platform::download(&name, app.autosave_json().as_bytes()); app.set_status(i18n::trf("save.saved", &[platform::saved_as(&name)]), now); } MenuAction::CloudSave(id, name) => { if cloud.save(id.as_deref(), &name, &app.autosave_json()) { app.set_status(i18n::tr("saves.wait"), now); } } MenuAction::CloudLoad(id) => { if cloud.load(&id) { app.set_status(i18n::tr("saves.wait"), now); } } MenuAction::CloudDelete(id) => { cloud.delete(&id); } MenuAction::StartMission(i) => { (app.game, app.cam) = mission_game(i, &menu.progress); apply_game_settings(&mut app.game, &app.settings); app.game.coop = false; app.coop = false; app.recorded = false; menu.page = None; ui.log.clear(); 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", &[platform::saved_as(&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 => {} 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 _ = mission_doc; let progress = campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc); (app.game, app.cam) = custom_mission_game(&menu.campaign_doc, i, progress.run_seed); app.pending_briefing = briefing_of(&menu.campaign_doc, i); apply_game_settings(&mut app.game, &app.settings); app.game.coop = false; app.coop = false; app.recorded = false; app.custom_campaign = Some(menu.campaign_doc.clone()); app.custom_progress = Some(progress); 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::Tutorial => { let doc = editor::templates::tutorial_campaign(); let progress = campaign_doc::CampaignProgress::for_doc(&doc); (app.game, app.cam) = custom_mission_game(&doc, 0, progress.run_seed); app.pending_briefing = briefing_of(&doc, 0); apply_game_settings(&mut app.game, &app.settings); app.game.coop = false; app.coop = false; app.recorded = false; app.custom_campaign = Some(doc); app.custom_progress = Some(progress); app.custom_mission_index = Some(0); app.tutorial = true; menu.page = None; ui.log.clear(); ui.show_help = false; autosave_t = 0.0; } MenuAction::OpenRepo => platform::open_url(menu::REPO_URL), MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL), MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL), a @ (MenuAction::Login(..) | MenuAction::Logout | MenuAction::OpenAccount | MenuAction::ChangePassword(..) | MenuAction::CreateUser(..) | MenuAction::DeleteUser(..) | MenuAction::DownloadStandalone | MenuAction::EditField(..) | MenuAction::OpenMatch | MenuAction::MatchJoin(..) | MenuAction::MatchCreate | MenuAction::MatchCoopCreate(..) | MenuAction::MatchTakeSide(..) | MenuAction::MatchSetAi(..) | MenuAction::MatchSetTeam(..) | MenuAction::MatchReady(..) | MenuAction::MatchStart | MenuAction::MatchResume | MenuAction::MatchAbort | MenuAction::MatchLeave | MenuAction::MatchCoopEditorCreate(..) | MenuAction::MatchCoopSolo(..) | MenuAction::MatchCoopResume(..) | MenuAction::MatchChat(..) | MenuAction::MatchEditField(..)) => account_action(a, &mut cloud, &mut menu, &mut app, now), MenuAction::EditName => { if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) { menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect(); } } MenuAction::Help => ui.show_help = true, MenuAction::BackToEditor => back_to_editor(&mut app, &mut menu, &mut r3d), MenuAction::MainMenu if app.editor_testing => back_to_editor(&mut app, &mut menu, &mut r3d), MenuAction::MainMenu => { app.tutorial = false; if app.match_mode || app.game.in_match() { app.leave_match(); } else if app.game.state == 0 { platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json()); menu.has_autosave = true; } menu.page = Some(Page::Title); app.seed = next_seed(app.seed); (app.game, app.cam) = Game::new_demo(app.seed); } MenuAction::None | MenuAction::Consumed => {} } } else { match ui.click(ux, uy, &app.game) { UiAction::SelectTool(t) => { ui.tool = t; app.game.sfx_ui(game::Sfx::Click); } UiAction::MinimapJump(x, y) => { app.cam.tx = x; app.cam.tz = y; inp.minimap_drag = true; } UiAction::NewGame => { app.seed = next_seed(app.seed); (app.game, app.cam) = new_game(app.seed, &app.settings); 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 | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d), // a co-op mission of an editor campaign is over: the host starts the next one (or the // same one again) for everybody; the others wait for it UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => { if app.offline { // no relay: the next mission of the run starts here at once let run = app.coop_run.take().expect("a co-op run"); let next = run.next.unwrap_or(run.mission); if app.start_offline_coop(Some(run.doc), next, Some(run.run_seed), run.carry_out) { ui.log.clear(); autosave_t = 0.0; } } else if let Some(run) = app.coop_run.as_mut() { if let Some(next) = run.next { app.client.next_mission(next, run.run_seed, &run.carry_out); run.sent = true; } } } // a built-in co-op mission without the server: the next one after a victory, else again UiAction::EndNext if app.offline && !app.coop_editor => { let i = app.game.mission.as_ref().map_or(0, |m| m.index); let next = if app.game.state == 1 { campaign::next_coop_mission(i, 1) } else { i }; if next <= i && app.game.state == 1 { app.leave_match(); menu.page = Some(Page::Title); app.seed = next_seed(app.seed); (app.game, app.cam) = Game::new_demo(app.seed); } else if app.start_offline_coop(None, next, None, Vec::new()) { ui.log.clear(); autosave_t = 0.0; } } UiAction::EndCampaign if app.offline && !app.coop_editor => { app.leave_match(); menu.page = Some(Page::Title); app.seed = next_seed(app.seed); (app.game, app.cam) = Game::new_demo(app.seed); } UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {} // ... or the run is over (or the player leaves): the match ends, back to the lobby UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => { app.leave_match(); app.seed = next_seed(app.seed); (app.game, app.cam) = Game::new_demo(app.seed); menu.open_match(true); if !app.connect_match(&cloud) { menu.error = Some(i18n::tr("net.status_offline")); } } UiAction::EndNext | UiAction::EndCampaign if app.tutorial && app.game.state == 1 => { app.tutorial = false; app.custom_campaign = None; app.custom_progress = None; app.custom_mission_index = None; menu.page = Some(Page::Title); app.seed = next_seed(app.seed); (app.game, app.cam) = Game::new_demo(app.seed); } UiAction::EndNext => { 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() { (app.game, app.cam) = custom_mission_game(&campaign, next, progress.as_ref().map_or(1, |p| p.run_seed)); app.pending_briefing = briefing_of(&campaign, next); apply_game_settings(&mut app.game, &app.settings); 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() { (app.game, app.cam) = custom_mission_game(&campaign, i, progress.as_ref().map_or(1, |p| p.run_seed)); app.pending_briefing = briefing_of(&campaign, i); apply_game_settings(&mut app.game, &app.settings); 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); apply_game_settings(&mut app.game, &app.settings); app.recorded = false; ui.log.clear(); } } } UiAction::EndCampaign => menu.page = Some(Page::Campaign), UiAction::Briefing => { if let Some(i) = app.game.mission.as_ref().map(|m| m.index) { menu.open_briefing(i); } } UiAction::OpenMenu => menu.page = Some(Page::Pause), UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }), UiAction::LearnTalent(branch) => app.game.queue(Act::Talent { branch }), UiAction::Locked => { let locked = ui.last_locked.unwrap_or(usize::MAX); app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]); app.game.sfx_ui(game::Sfx::Error); } UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} UiAction::ChatSetTeam(team) => ui.chat_team = team, UiAction::ChatSend => { let text: String = ui.chat_text.trim().chars().take(80).collect(); if !text.is_empty() { app.client.chat(&text, ui.chat_team); } ui.chat_text.clear(); ui.chat_open = false; } UiAction::None => { if touch_mode { // touch: area tools start their rectangle, everything else acts on the tap if area_tool(ui.tool) && app.game.state == 0 { inp.ldown = hover_tile; } } else { let slap = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift); world_action(&mut app, &mut ui, &r3d, &mut inp, mx, my, slap, now); } } } } } let held = if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) }; if inp.minimap_drag { if held && ui.minimap_contains(ux, uy) { let (x, y) = ui.minimap_world(ux, uy); app.cam.tx = x; app.cam.tz = y; } else if !held { inp.minimap_drag = false; } } // ---------------- touch gestures on the game world if touch_mode { let d = ui::dpr(); for g in &gestures { match *g { Gesture::Cancel => { inp.ldown = None; inp.tlast = None; } Gesture::Tap(p) | Gesture::DoubleTap(p) if playing && !ui.over_ui(p.x, p.y) => { if !area_tool(ui.tool) { let slap = matches!(g, Gesture::DoubleTap(_)); world_action(&mut app, &mut ui, &r3d, &mut inp, p.x / d, p.y / d, slap, now); } } Gesture::LongPress(p) if playing && inp.touch_world => { inp.ldown = None; if ui.tool == Tool::Hand && !app.game.hand.is_empty() { let gp = app.cam.ground_point(p.x / d, p.y / d); app.game.queue(Act::Drop { x: gp.x, y: gp.y }); } else { inp.inspect = Some((p.x / d, p.y / d, now)); } } Gesture::DragStart(p) if playing && inp.touch_world && inp.ldown.is_none() => inp.tlast = Some(p), Gesture::DragMove(p) => { if let Some(l) = inp.tlast { let a = app.cam.ground_point(l.x / d, l.y / d); let b = app.cam.ground_point(p.x / d, p.y / d); app.cam.tx -= b.x - a.x; app.cam.tz -= b.y - a.y; inp.tlast = Some(p); } } Gesture::Release(p) => { inp.tlast = None; if let Some(start) = inp.ldown.take() { if playing && inp.touch_world { finish_rect(&mut app, ui.tool, start, p.x / d, p.y / d); } } } _ => {} } } if inp.inspect.map_or(false, |(_, _, t)| now - t > 6.0) { inp.inspect = None; } } if !touch_mode && is_mouse_button_released(MouseButton::Left) { if let Some(start) = inp.ldown.take() { if playing { finish_rect(&mut app, ui.tool, start, mx, my); } } } app.cam.tx = app.cam.tx.clamp(0.0, mw() as f32); app.cam.tz = app.cam.tz.clamp(0.0, mh() as f32); app.cam.rx = app.cam.rx.clamp(0.0, mw() as f32); app.cam.rz = app.cam.rz.clamp(0.0, mh() as f32); // a mission script asked the camera to look somewhere if let Some(p) = app.game.script.as_mut().and_then(|s| s.camera.take()) { app.cam.tx = p.0; app.cam.tz = p.1; } app.cam.update_render(dt); if settings_changed { apply_settings(&app.settings, &mut r3d); store_settings(&app.settings); apply_game_settings(&mut app.game, &app.settings); } // ---------------- update (single-player worlds stand still while menus or dialogs are open; // matches keep running in the background, the bridge battle behind the title menu goes on) if app.game.demo.is_some() && !playing { app.game.update(dt); app.cam.yaw += dt * 0.03; app.game.events.retain(|ev| ev.pos.is_none()); } else if app.game.in_match() { app.game.update(dt * app.settings.game_speed.max(1.0)); app.tick_match_game(); } else if playing { // the world runs in fixed steps: the speed setting only feeds more time into them app.game.update(dt * app.settings.game_speed.max(1.0)); // autosave: snapshot for leaving the tab + regular autosave if app.game.state == 0 && !app.match_mode { cache_t += dt; autosave_t += dt; if cache_t >= AUTOSAVE_CACHE_EVERY { cache_t = 0.0; platform::cache_on_leave(save::AUTOSAVE_KEY, &app.autosave_json()); } if autosave_t >= AUTOSAVE_EVERY { autosave_t = 0.0; platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json()); menu.has_autosave = true; } } } app.game.smooth_display(dt); // winter: snow falls on the land above ground near the camera if render3d::season() == 2 && !app.game.map.sky.is_empty() && app.game.parts.len() < 3000 { let (cx, cz) = (app.cam.tx, app.cam.tz); for _ in 0..3 { let x = cx + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8; let z = cz + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8; if inb(x as i32, z as i32) && app.game.map.sky(map::idx(x as i32, z as i32)) { app.game.parts.push(particles::Part { kind: particles::EMBER, x, y: z, h: 4.0, vx: rand::gen_range(-0.2f32, 0.2), vy: 0.0, vh: -0.6, t: 0.0, max: 6.0, col: particles::abgr(245, 248, 255), size: 0.04 }); } } } let hear = app.cam.zoom * 1.2; let (right, _) = app.cam.ground_axes(); for ev in app.game.events.drain(..) { sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); } sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now); // a won mission goes into the campaign progress (with the carry-over); a co-op mission of an // editor campaign keeps no progress (its index is not a built-in mission) if app.coop_editor { if app.game.state != 0 && !app.recorded { app.recorded = true; if let Some(run) = app.coop_run.as_mut() { run.finish(&app.game); } } } else if app.coop { // a built-in co-op mission is no step of the single-player campaign: no progress } else 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 { 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; } } } } menu.mission = app.game.mission.as_ref().map(|m| m.index); let on_title = menu.page == Some(Page::Title) || matches!( menu.page, Some(Page::Settings { from_title: true, .. }) | 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) | Some(Page::Bestiary { from_title: true, .. }) | Some(Page::Match { from_title: true }) | Some(Page::Achievements { from_title: true }) ); music.update(&app.game, on_title, (app.cam.rx, app.cam.rz), audio::track_by_id(&app.settings.music_track)); // ---------------- achievements: count the game, unlock, store and share the profile { let ctx = achievements::Context { coop: app.coop, editor_test: app.editor_testing }; let unix = miniquad::date::now() as u64; let counting = !on_title && (playing || app.game.state != 0); for id in if counting { tracker.update(&app.game, &ctx, dt, unix) } else { Vec::new() } { let (name, _) = achievements::texts(&id); app.game.msgs.push(game::Msg { key: "msg.achievement", args: vec![name] }); audio::play_sfx(game::Sfx::Victory.index(), 0.8, 0.0); } if let Some(json) = cloud.profile_loaded.take() { if let Ok(p) = serde_json::from_str::(&json) { tracker.profile.merge(&p); tracker.profile.check_tiers(unix); tracker.dirty = true; tracker.upload = tracker.profile != p; } } if want_profile && cloud.session.is_some() && cloud.load_profile() { want_profile = false; } profile_store_t += dt; profile_upload_t += dt; if tracker.dirty && (profile_store_t > 10.0 || tracker.upload) { profile_store_t = 0.0; tracker.dirty = false; platform::storage_set(achievements::PROFILE_KEY, &serde_json::to_string(&tracker.profile).unwrap_or_default()); } if tracker.upload && cloud.session.is_some() && profile_upload_t > 30.0 && !want_profile { if cloud.save_profile(&serde_json::to_string(&tracker.profile).unwrap_or_default()) { tracker.upload = false; profile_upload_t = 0.0; } } if matches!(menu.page, Some(Page::Achievements { .. })) { menu.profile = tracker.profile.clone(); } } for m in app.game.msgs.drain(..) { if app.settings.voice && voice::speaks(m.key) { voice.say(&i18n::trf(m.key, &m.args), now, 12); } ui.log.push((m, now)); } // a briefing opens: Crookfinger tells the story let briefing = matches!(menu.page, Some(Page::Briefing { .. })); if briefing && !was_briefing && app.settings.voice { if let Some(Page::Briefing { mission, .. }) = menu.page { let key = campaign::missions().get(mission).map(|d| format!("camp.{}.story", d.key)).unwrap_or_default(); voice.say(&i18n::tr(&key), now, 22); } } if !briefing && was_briefing { voice.stop(); } was_briefing = briefing; voice.update(now, if app.settings.muted { 0.0 } else { app.settings.volume }); draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now); ui::end_frame(); next_frame().await } } #[allow(clippy::too_many_arguments)] // pointer: CSS pixels of the mouse or finger; touch: hover highlight and information only // while a finger is down or for a long-pressed point fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, pointer: (f32, f32), touch: bool, now: f64) { let (mx, my) = match (touch, inp.inspect) { (true, Some((x, y, _))) if inp.ldown.is_none() => (x, y), _ => pointer, }; let (ux, uy) = (mx * ui::dpr(), my * ui::dpr()); let blocked = menu.open() || ui.show_help || ui.show_langsel; let over_ui = blocked || ui.over_ui(ux, uy); let hover_tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my); let ground = app.cam.ground_point(mx, my); clear_background(Color::from_rgba(4, 3, 5, 255)); let (color, radius) = match ui.tool { Tool::Dig => { // the dig selection in the master's colour (like the marks) let c = render3d::master_rgb(app.game.local_side); (Color::new(c.x, c.y, c.z, 1.0), None) } Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None), Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None), Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None), Tool::Flag => (Color::new(1.0, 0.3, 0.3, 1.0), None), Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))), Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))), Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None), Tool::Bridge | Tool::Drawbridge => (Color::new(0.9, 0.7, 0.4, 1.0), None), Tool::Wall => (Color::new(0.8, 0.4, 1.0, 1.0), None), Tool::Siege(_) => (Color::new(0.9, 0.7, 0.4, 1.0), None), Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None), Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None), Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None), Tool::Spell(sp) => (Color::new(0.8, 0.55, 1.0, 1.0), Some((ground.x, ground.y, sp.radius().max(0.6)))), Tool::Order(_) => (Color::new(1.0, 0.3, 0.3, 1.0), None), }; let ov = Overlay { hover: if over_ui { None } else { hover_tile.filter(|&(x, z)| inb(x, z)) }, rect: inp.ldown.and_then(|(sx, sy)| hover_tile.map(|(ex, ey)| (sx, sy, ex.clamp(0, mw() as i32 - 1), ey.clamp(0, mh() as i32 - 1)))), color, radius: if over_ui { None } else { radius }, 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 || app.editor_testing { draw_debug_3d(app, now); } // planned walls of this master: a pulsing frame where the wall will rise let pulse = 0.55 + 0.25 * ((now * 3.0).sin() as f32); let me = app.game.local_side; for &(i, side) in app.game.wall_plans.iter().filter(|p| p.1 == me).take(200) { let (x, z) = map::xy(i); let (x0, z0, x1, z1) = (x as f32 + 0.08, z as f32 + 0.08, x as f32 + 0.92, z as f32 + 0.92); let c = Color::new(0.75, 0.45, 1.0, pulse); let _ = side; for h in [0.02, 0.9] { line3(vec3(x0, h, z0), vec3(x1, h, z0), 0.04, c); line3(vec3(x1, h, z0), vec3(x1, h, z1), 0.04, c); line3(vec3(x1, h, z1), vec3(x0, h, z1), 0.04, c); line3(vec3(x0, h, z1), vec3(x0, h, z0), 0.04, c); } for (a, b) in [(x0, z0), (x1, z0), (x1, z1), (x0, z1)] { line3(vec3(a, 0.02, b), vec3(a, 0.9, b), 0.04, c); } } 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) | Some(Page::Bestiary { from_title: true, .. }) | Some(Page::Match { from_title: true }) | Some(Page::Achievements { from_title: true }) ); if !title_screen { let info = if over_ui || (touch && inp.inspect.is_none()) { Vec::new() } else { let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false); hover_info(&app.game, ent, hover_tile) }; ui.draw(&app.game, gfx, &app.cam, &app.settings, &info, now); } else { ui.rects.clear(); } if app.status.as_ref().map_or(false, |(_, t)| now - t > 4.0) { app.status = None; } menu.draw(gfx, &app.settings, app.status.as_ref().map(|(s, _)| s.as_str()), cloud); if !menu.open() || ui.show_help || ui.show_langsel { ui.draw_dialogs(&app.game, gfx); } if !touch { ui.draw_cursor(gfx, &app.game, now, blocked); } if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping { draw_debug_fps(app, gfx); } if app.editor_testing && !menu.open() { draw_script_debug(app, gfx); } } // test play from the editor: which trigger fired when, and the variables fn draw_script_debug(app: &App, gfx: &Gfx) { let Some(s) = app.game.script.as_ref() else { return }; let lines: Vec<(String, Color)> = std::iter::once((i18n::tr("ed.debug_title"), ui::FRAME_HI)) .chain(s.vars.iter().map(|(k, v)| (format!("{} = {}", k, v), ui::GOLD))) .chain(s.log.iter().rev().take(10).map(|(t, n)| (format!("{:>5.0}s {}", t, n), if n.starts_with('!') { ui::RED } else { ui::TXT }))) .collect(); let w = ui::CW() * 30.0; let x = ui::sw() - w - ui::CW(); // below the hover info at the top right let y = ui::CH() * 13.0; draw_rectangle(x, y, w, ui::CH() * (lines.len() as f32 + 1.0), Color::new(0.04, 0.03, 0.03, 0.85)); for (k, (l, c)) in lines.iter().enumerate() { let l: String = l.chars().take(29).collect(); gfx.text(&l, x + ui::CW() * 0.5, y + ui::CH() * (0.5 + k as f32), ui::SMALL(), *c); } } // a test play ends (pause menu, mission over): back into the editor fn back_to_editor(app: &mut App, menu: &mut Menu, r3d: &mut R3d) { app.editor_testing = false; menu.editor_test = false; menu.page = Some(Page::Title); app.custom_campaign = None; app.custom_progress = None; app.custom_mission_index = None; app.custom_next_index = None; r3d.invalidate(); } // one frame of the campaign editor and what it asks for #[allow(clippy::too_many_arguments)] fn editor_frame(app: &mut App, menu: &mut Menu, cloud: &mut Cloud, r3d: &mut R3d, gfx: &Gfx, ui: &mut Ui, dt: f32, now: f64) { let Some(mut ed) = app.editor.take() else { return }; // the savegame service: campaign lists, loads and saves if let CloudEvent::Error(key) = cloud.poll() { ed.set_status(i18n::tr(key)); } if app.editor_public_pending && cloud.refresh_public_campaigns() { app.editor_public_pending = false; } ed.own_list = cloud.campaigns.iter().map(|c| (c.id.clone(), c.name.clone())).collect(); ed.public_list = cloud.public_campaigns.iter().map(|c| (c.owner.clone(), c.id.clone(), c.name.clone())).collect(); if let Some(info) = cloud.take_saved_campaign() { ed.server_id = Some(info.id.clone()); ed.published = info.published; ed.set_status(i18n::tr("ed.saved_server")); } if let Some(text) = cloud.take_loaded_campaign() { match campaign_doc::CampaignDoc::load(&text) { Ok(doc) => { let id = app.editor_loading.take(); ed.published = id.as_ref().and_then(|i| cloud.campaigns.iter().find(|c| c.id == *i)).map_or(false, |c| c.published); ed.load(doc, id); ed.set_status(i18n::tr("ed.loaded")); } Err(e) => ed.set_status(e), } } // a campaign file picked or dropped onto the window let mut incoming = platform::take_file(); for f in get_dropped_files() { if let Some(b) = f.bytes { incoming = Some(b); } } if let Some(bytes) = incoming { match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) { Ok(doc) => { ed.load(doc, None); ed.set_status(i18n::tr("ed.loaded")); } Err(e) => ed.set_status(e), } } // the map editor's map has to be the active one (map size for tiles and drawing) if let Some(g) = ed.active_map() { g.map.activate(); } let act = ed.frame(gfx, r3d, dt, now); // the game draws its own pointer (the system one is hidden) ui::begin_ui(); ui.draw_cursor(gfx, &app.game, now, true); match act { editor::EdAction::None => {} editor::EdAction::Close => { menu.campaign_doc = ed.doc.clone(); (app.game, app.cam) = Game::new_demo(app.seed); apply_game_settings(&mut app.game, &app.settings); r3d.invalidate(); menu.page = Some(Page::Title); return; // the editor is gone } editor::EdAction::Test(_) | editor::EdAction::Play => { let (i, only) = match act { editor::EdAction::Test(i) => (i, true), _ => (0, false), }; let progress = campaign_doc::CampaignProgress::for_doc(&ed.doc); (app.game, app.cam) = custom_mission_game(&ed.doc, i, progress.run_seed); app.pending_briefing = briefing_of(&ed.doc, i); apply_game_settings(&mut app.game, &app.settings); app.game.coop = false; app.coop = false; app.recorded = false; app.custom_campaign = Some(ed.doc.clone()); app.custom_progress = Some(progress); app.custom_mission_index = Some(i); app.custom_next_index = None; app.editor_testing = true; app.editor_test_only = only; menu.editor_test = true; menu.page = None; ui.log.clear(); ui.show_help = false; r3d.invalidate(); } editor::EdAction::SaveFile => { let name = ed.doc.file_name(); platform::download(&name, ed.doc.save().as_bytes()); ed.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)])); } editor::EdAction::OpenFile => platform::open_file(), editor::EdAction::ServerSave => { if !cloud.available() { ed.set_status(i18n::tr("ed.no_server")); } else if !cloud.save_campaign(ed.server_id.as_deref(), &ed.doc.name, &serde_json::to_string(&ed.doc).unwrap_or_default()) { ed.set_status(i18n::tr("ed.busy")); } } editor::EdAction::ServerRefresh => { if cloud.available() { cloud.refresh_campaigns(); app.editor_public_pending = true; } } editor::EdAction::ServerLoad(id) => { if cloud.load_campaign(&id) { app.editor_loading = Some(id); } } editor::EdAction::PublicLoad(owner, id) => { if cloud.load_public_campaign(&owner, &id) { app.editor_loading = None; } } editor::EdAction::Publish(on) => match ed.server_id.clone() { Some(id) => { cloud.publish_campaign(&id, on); } None => ed.set_status(i18n::tr("ed.save_first")), }, } app.editor = Some(ed); } fn draw_debug_fps(app: &App, gfx: &Gfx) { ui::begin_ui(); let scale = ui::SMALL().max(1.0); 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 { 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_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 || app.editor_testing { 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) }