Files
dungeoncraft/src/main.rs
T
2026-11-05 17:53:51 +01:00

2142 lines
93 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 audio;
mod campaign;
mod campaign_doc;
mod cloud;
mod commands;
mod demo;
mod editor;
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;
mod blocks;
mod mscript;
mod settings;
mod sfx;
mod tracks;
mod traps;
mod sound;
mod spells;
mod touch;
mod ui;
mod voice;
mod wilds;
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)
}
// 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, 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::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::Steel => app.game.queue(Act::Steel(r)),
_ => {}
}
}
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
let g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
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());
}
// 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(&[0, 2, 3, 4], app.settings.map_w, app.settings.map_h, app.settings.difficulty),
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) => {
if menu.campaign_doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) {
app.client.create_coop_doc(&menu.campaign_doc, mission, &[0, 2, 3, 4], app.settings.difficulty);
} else {
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::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.render_div = s.render_scale.clamp(1, 16) as u32;
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
}
fn update(&mut self, g: &Game, title: bool, cam: (f32, f32)) {
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
};
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 rotate = matches!(want, C::Build | C::Title) && self.current == Some(want) && audio::music_loops() >= 2;
if self.current != Some(want) || rotate || audio::current_track().is_none() {
self.current = Some(want);
let t = self.pick(want);
let fade = if matches!(want, C::Victory | C::Defeat) { 1.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
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 (settings in the savegame are always applied)
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;
if let Some(c) = l.camera {
self.cam = c;
self.cam.snap();
}
if let Some(s) = l.settings {
self.settings = s;
apply_settings(&self.settings, r3d);
store_settings(&self.settings);
}
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, &self.cam, &self.settings)
}
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.client.on_disconnect();
}
// 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);
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;
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.pending_briefing = briefing_of(doc, mission);
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;
}
self.game.coop = false;
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();
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, 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);
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);
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 => {
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();
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);
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::CancelOrders);
} 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::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::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::MatchReady(..)
| MenuAction::MatchStart
| MenuAction::MatchResume
| MenuAction::MatchAbort
| MenuAction::MatchLeave
| MenuAction::MatchCoopEditorCreate(..)
| 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 => {
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 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 => {
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::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);
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 {
app.recorded = app.game.state != 0;
} 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, .. })
);
music.update(&app.game, on_title, (app.cam.rx, app.cam.rz));
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);
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 => (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);
}
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, .. })
);
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);
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)
}