Files
dungeoncraft/src/main.rs
T

2511 lines
113 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
mod achievements;
mod ai;
mod armory;
mod audio;
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 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
minimap_drag: bool,
// touch
tlast: Option<Vec2>, // 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 { 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<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 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<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) {
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<String, campaign_doc::CampaignProgress> {
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<usize>,
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 => app.client.create_land(&[0, 2, 3, 4], app.settings.map_w, app.settings.map_h, 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<u8, usize>, // category -> next index within the category
current: Option<audio::Category>,
}
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<usize>) {
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<CoopRun>, // ... 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<editor::Editor>, // 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<String>, // server id of the own campaign being loaded
pending_briefing: Option<(String, String)>, // an editor mission began: show its briefing
custom_campaign: Option<campaign_doc::CampaignDoc>,
custom_progress: Option<campaign_doc::CampaignProgress>,
custom_mission_index: Option<usize>,
custom_next_index: Option<usize>,
}
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<campaign_doc::CampaignDoc>, mission: usize, run_seed: Option<u32>, 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, 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 on a desktop (360 -> 1080) and 540 on a touch screen (360 -> 720)
let need: u32 = if touch_mode { 540 } else { 900 };
ui::set_virtual_rows(rows * need.div_ceil(rows));
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<Vec<u8>> = 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;
}
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(_)) {
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", &[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", &[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::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);
}
// 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::<achievements::Profile>(&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 => (Color::new(1.0, 0.85, 0.3, 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 => platform::download(&ed.doc.file_name(), ed.doc.save().as_bytes()),
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)
}