2730 lines
123 KiB
Rust
2730 lines
123 KiB
Rust
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||
// Windows release builds are a window program: no console opens beside the game
|
||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||
|
||
mod achievements;
|
||
mod ai;
|
||
mod armory;
|
||
mod audio;
|
||
mod badges;
|
||
mod campaign;
|
||
mod campaign_doc;
|
||
mod cloud;
|
||
mod commands;
|
||
mod demo;
|
||
mod demo_map;
|
||
mod editor;
|
||
mod events;
|
||
mod dwarves;
|
||
mod fog;
|
||
mod fortress;
|
||
mod game;
|
||
mod gfx;
|
||
mod holiday;
|
||
mod i18n;
|
||
mod lobby;
|
||
mod map;
|
||
mod menu;
|
||
mod netplay;
|
||
mod personality;
|
||
mod platform;
|
||
mod portraits;
|
||
mod orders;
|
||
mod particles;
|
||
mod props;
|
||
mod research;
|
||
mod rival;
|
||
mod rogues;
|
||
mod balance;
|
||
mod talents;
|
||
mod weather;
|
||
mod names;
|
||
mod render3d;
|
||
mod rooms;
|
||
mod save;
|
||
#[cfg(test)]
|
||
mod scenes;
|
||
mod beasts;
|
||
mod blocks;
|
||
mod bosses;
|
||
mod bridges;
|
||
mod mscript;
|
||
mod settings;
|
||
mod siege;
|
||
mod sfx;
|
||
mod tracks;
|
||
mod traders;
|
||
mod traps;
|
||
mod sound;
|
||
mod surface;
|
||
mod spells;
|
||
mod stats;
|
||
mod touch;
|
||
mod ui;
|
||
mod voice;
|
||
mod walls;
|
||
mod wilds;
|
||
mod wood;
|
||
mod xm;
|
||
|
||
use cloud::{Cloud, CloudEvent};
|
||
use commands::Act;
|
||
use game::{Game, Msg};
|
||
use gfx::Gfx;
|
||
use macroquad::prelude::*;
|
||
use map::{inb, mh, mw};
|
||
use menu::{Menu, MenuAction, Page};
|
||
use lobby::{MatchClient, Phase, StartInfo};
|
||
use render3d::{Cam, Overlay, R3d, disc, line3};
|
||
use settings::{bindable_keys, Action, Settings};
|
||
use sound::SoundBank;
|
||
use touch::{Gesture, TouchInput};
|
||
use ui::{chat_color, hover_info, Tool, Ui, UiAction, TOOLS};
|
||
|
||
const SETTINGS_KEY: &str = "dc.settings";
|
||
const FRAME_HIST: usize = 64;
|
||
const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves
|
||
const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left
|
||
|
||
fn conf() -> macroquad::conf::Conf {
|
||
macroquad::conf::Conf {
|
||
miniquad_conf: miniquad::conf::Conf {
|
||
window_title: "DungeonCraft".into(),
|
||
window_width: 1280,
|
||
window_height: 800,
|
||
high_dpi: true,
|
||
..Default::default()
|
||
},
|
||
draw_call_vertex_capacity: 40000,
|
||
draw_call_index_capacity: 60000,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
struct Input {
|
||
ldown: Option<(i32, i32)>, // start of the selection rectangle
|
||
rdown: Option<(f32, f32)>, // right button press position
|
||
rlast: (f32, f32),
|
||
rdrag: bool,
|
||
mlast: Option<(f32, f32)>, // middle button drag
|
||
mclick: f64, // time of the last middle click (a double click resets the view)
|
||
minimap_drag: bool,
|
||
// touch
|
||
tlast: Option<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 {
|
||
// a memorial stone: the hall of the fallen opens
|
||
let (gx, gy) = (wx.floor() as i32, wy.floor() as i32);
|
||
if map::inb(gx, gy) && app.game.memorial_at(map::idx(gx, gy)).is_some() {
|
||
ui.show_overview = true;
|
||
ui.ov_fallen = true;
|
||
ui.ov_scroll = 0;
|
||
}
|
||
return;
|
||
};
|
||
let (id, hero, dwarf) = (app.game.ents[j].id, app.game.ents[j].hero, app.game.ents[j].dwarf());
|
||
if dwarf {
|
||
return;
|
||
}
|
||
if slap {
|
||
// the punch/backhand animation at the pointer starts at once, the order follows at the next step
|
||
ui.hand_anim = Some((if hero { 2 } else { 1 }, now));
|
||
app.game.queue(Act::Slap { ent: id });
|
||
} else {
|
||
app.game.queue(Act::Pickup { ent: id });
|
||
}
|
||
}
|
||
(Tool::Flag, Some((x, z))) => app.game.queue(Act::Flag { x, y: z }),
|
||
(Tool::Lightning, _) => app.game.queue(Act::Lightning { x: wx, y: wy }),
|
||
(Tool::Heal, _) => app.game.queue(Act::Heal { x: wx, y: wy }),
|
||
(Tool::Summon, _) => app.game.queue(Act::Summon { x: wx, y: wy }),
|
||
(Tool::Tower(k), Some((x, z))) => app.game.queue(Act::Tower { kind: k, x, y: z }),
|
||
(Tool::Trap(k), Some((x, z))) => app.game.queue(Act::Trap { kind: k, x, y: z }),
|
||
(Tool::Spell(sp), _) => app.game.queue(Act::Cast { spell: sp, x: wx, y: wy }),
|
||
(Tool::Siege(which), Some((tx, ty))) => app.game.queue(Act::Siege { which, x: tx, y: ty }),
|
||
(Tool::Order(0), _) => {
|
||
let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||
let ent = j.filter(|&j| j < app.game.ents.len()).map(|j| app.game.ents[j].id);
|
||
app.game.queue(Act::Attack { ent, x: wx, y: wy });
|
||
}
|
||
(Tool::Order(_), Some((x, z))) => app.game.queue(Act::Guard { x, y: z }),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
// Finish a selection rectangle at the tile under (mx, my)
|
||
fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
|
||
let Some((ex, ey)) = tool_tile(&app.cam, &app.game.map, tool, mx, my) else { return };
|
||
let (sx, sy) = start;
|
||
let r = commands::Rect::new(sx, sy, ex, ey);
|
||
match tool {
|
||
Tool::Dig => app.game.queue(Act::Dig(r)),
|
||
Tool::Room(room) => app.game.queue(Act::Room { room: room as u8, r }),
|
||
Tool::Sell => app.game.queue(Act::Sell(r)),
|
||
Tool::Bridge => app.game.queue(Act::Bridge(r)),
|
||
Tool::Drawbridge => app.game.queue(Act::Drawbridge(r)),
|
||
Tool::Wall => app.game.queue(Act::Wall(r)),
|
||
Tool::Steel => app.game.queue(Act::Steel(r)),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
|
||
let mut g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
|
||
if s.surface {
|
||
// the eastern half above ground: forest, paths, cave mouths
|
||
g.add_surface(seed, 0.5, 2 + (s.map_h / 64) as u32);
|
||
g.init_fog();
|
||
}
|
||
g.set_events(s.events);
|
||
let (cx, cy) = g.map.core;
|
||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||
(g, cam)
|
||
}
|
||
|
||
// a campaign mission with what the last mission handed over
|
||
fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) {
|
||
let g = campaign::start_mission(i, progress.carry_into(i));
|
||
let (cx, cy) = g.map.core;
|
||
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
|
||
(g, cam)
|
||
}
|
||
|
||
fn prompt_line(title: &str, initial: &str, max: usize) -> Option<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 => {
|
||
// the map size decides the number of master seats
|
||
let n = app.settings.match_size;
|
||
app.client.create_land(lobby::seats_for(n), n, n, app.settings.difficulty, app.settings.surface, app.settings.events)
|
||
}
|
||
MenuAction::MatchCoopCreate(mission) => {
|
||
let all = campaign::missions();
|
||
if let Some(d) = all.get(mission) {
|
||
app.client.create_coop(mission, &[0, 2, 3, 4], d.size.0, d.size.1, app.settings.difficulty);
|
||
} else {
|
||
menu.error = Some(i18n::tr("net.status_closed"));
|
||
}
|
||
}
|
||
MenuAction::MatchCoopEditorCreate(mission) => {
|
||
let doc = menu.coop_doc().clone();
|
||
if doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) {
|
||
app.client.create_coop_doc(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty);
|
||
} else {
|
||
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||
}
|
||
}
|
||
MenuAction::MatchCoopSolo(doc, mission) => {
|
||
// the open lobby connection is not needed: the game runs on this computer alone
|
||
if app.start_offline_coop(doc, mission, None, Vec::new()) {
|
||
menu.page = None;
|
||
menu.error = None;
|
||
app.set_status(i18n::tr("net.coop_solo_started"), now);
|
||
} else {
|
||
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||
}
|
||
}
|
||
MenuAction::MatchCoopResume(doc) => {
|
||
// with the relay: a room that goes on with the run; without it: offline with an AI partner
|
||
match coop_resume_of(&doc) {
|
||
Some((mission, seed, carry)) if app.client.connected => {
|
||
app.client.create_coop_doc_resumed(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty, Some((seed, &carry)));
|
||
}
|
||
Some((mission, seed, carry)) => {
|
||
if app.start_offline_coop(Some(doc), mission, Some(seed), carry) {
|
||
menu.page = None;
|
||
menu.error = None;
|
||
app.set_status(i18n::tr("net.coop_solo_started"), now);
|
||
} else {
|
||
menu.error = Some(i18n::tr("net.coop_editor_none"));
|
||
}
|
||
}
|
||
None => menu.error = Some(i18n::tr("net.coop_editor_none")),
|
||
}
|
||
}
|
||
MenuAction::MatchTakeSide(side) => app.client.take_side(side),
|
||
MenuAction::MatchSetAi(side, ai) => app.client.set_ai(side, ai),
|
||
MenuAction::MatchSetTeam(side, team, own) => app.client.set_team(if own { None } else { Some(side) }, team),
|
||
MenuAction::MatchReady(ready) => app.client.ready(ready),
|
||
MenuAction::MatchStart => app.client.start(),
|
||
MenuAction::MatchResume => app.client.resume(),
|
||
MenuAction::MatchAbort => {
|
||
app.client.abort();
|
||
app.game.coop = false;
|
||
app.match_mode = false;
|
||
app.coop = false;
|
||
app.coop_editor = false;
|
||
}
|
||
MenuAction::MatchLeave => {
|
||
app.client.leave();
|
||
app.game.coop = false;
|
||
app.match_mode = false;
|
||
app.coop = false;
|
||
app.coop_editor = false;
|
||
}
|
||
MenuAction::MatchChat(text) => app.client.chat(&text, false),
|
||
MenuAction::MatchEditField(i) => {
|
||
if ui::touch_ui() {
|
||
let title = i18n::tr(if i == 0 { "net.join_hint" } else { "net.chat" });
|
||
if let Some(t) = platform::prompt(&title, &menu.fields[i]) {
|
||
menu.fields[i] = t.chars().filter(|c| !c.is_control()).take(if i == 0 { 16 } else { 80 }).collect();
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn url_encode(s: &str) -> String {
|
||
let mut out = String::new();
|
||
for b in s.bytes() {
|
||
match b {
|
||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' => out.push(b as char),
|
||
_ => out.push_str(&format!("%{b:02X}")),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn next_seed(seed: u32) -> u32 {
|
||
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
|
||
}
|
||
|
||
fn apply_settings(s: &Settings, r3d: &mut R3d) {
|
||
i18n::set_language(&s.language);
|
||
r3d.outlines = s.outlines;
|
||
r3d.set_wall_detail(s.wall_subdivisions());
|
||
r3d.max_lights = s.max_lights as usize;
|
||
r3d.pixel_rows = settings::pixel_rows(s.pixel_rows);
|
||
holiday::set_override(s.debug_holiday);
|
||
gfx::set_high_res_ui(r3d.pixel_rows >= settings::HIGH_RES_ROWS 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>,
|
||
build_picks: usize, // build tracks picked so far (during a holiday every fourth is its piece)
|
||
}
|
||
|
||
impl MusicDirector {
|
||
fn new() -> MusicDirector {
|
||
MusicDirector { next: std::collections::HashMap::new(), current: None, build_picks: 0 }
|
||
}
|
||
|
||
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
|
||
// (a holiday's piece belongs to the title and build music)
|
||
let holiday = holiday::current();
|
||
let fits = |c: C| c == want || (matches!(want, C::Title | C::Build) && audio::holiday_music(holiday) == Some(c));
|
||
let foreign = chosen.is_none() && audio::current_track().map_or(false, |t| crate::tracks::TRACKS.get(t).map_or(false, |x| !fits(x.1))) && 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(audio::holiday_category(want, holiday, self.build_picks));
|
||
if want == C::Build {
|
||
self.build_picks += 1;
|
||
}
|
||
// 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, mclick: -1.0, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
|
||
let mut touch = TouchInput::new();
|
||
let mut auto_touch = platform::coarse_pointer();
|
||
let mut menu_swipe: Option<(f32, f32)> = None; // a swipe over a menu: last finger y, unused distance
|
||
let mut menu_dragged = false; // the finger on a menu has swiped (no click when it lifts)
|
||
// until the mouse has really moved its position is (0, 0): no edge scrolling before that
|
||
let mut mouse_moved = false; // automatic mode: touch interface after a touch, desktop after a mouse click
|
||
let mut autosave_t = 0.0f32;
|
||
let mut voice = voice::Voice::new();
|
||
let mut was_briefing = false;
|
||
let mut cache_t = 0.0f32;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
audio::native::start();
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let mut shot = ShotPlan::from_env();
|
||
loop {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
audio::native::pump();
|
||
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 = settings::pixel_rows(app.settings.pixel_rows);
|
||
// the interface needs room: the smallest whole multiple of the world's rows with at least
|
||
// 900 rows (270 -> 1080), on a desktop and on a touch screen alike (a phone has about
|
||
// 1080 device rows in landscape; a smaller screen draws in device pixels)
|
||
let need: u32 = 900;
|
||
// measured on the shorter side: upright (portrait) the width gets those pixels and the
|
||
// height grows with the screen, so the world and the menus keep their size. The setting
|
||
// "interface size" picks the interface rows in percent of the standard (100% = 1080 rows);
|
||
// on a screen with fewer rows the interface stays at the screen's rows
|
||
let short = match app.settings.ui_size {
|
||
0 => rows * need.div_ceil(rows),
|
||
p => settings::ui_size_rows(p).min((screen_width().min(screen_height()) * screen_dpi_scale()).max(1.0) as u32),
|
||
};
|
||
let (w, h) = (screen_width().max(1.0), screen_height().max(1.0));
|
||
let vrows = if h > w { (short as f32 * h / w).round() as u32 } else { short };
|
||
ui::set_virtual_rows(vrows);
|
||
if touch_mode {
|
||
// the pointer is the finger, or the position of this frame's gesture
|
||
let gpos = gestures.iter().rev().find_map(|g| match *g {
|
||
Gesture::Press(p) | Gesture::Tap(p) | Gesture::Release(p) | Gesture::LongPress(p) | Gesture::DoubleTap(p) => Some(p),
|
||
_ => None,
|
||
});
|
||
let p = touch.finger().or(gpos).unwrap_or(vec2(-1e5, -1e5));
|
||
ui::set_pointer(p.x, p.y);
|
||
}
|
||
let (ux, uy) = ui::mouse(); // device pixels (interface)
|
||
let (mx, my) = if touch_mode { (ux / ui::dpr(), uy / ui::dpr()) } else { mouse_position() }; // CSS pixels (3D picking)
|
||
|
||
// ---------------- savegame files: file picker result or dropped onto the window
|
||
let mut incoming: Option<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;
|
||
}
|
||
// a double click with the middle button: the standard angle and turn (same place)
|
||
if is_mouse_button_pressed(MouseButton::Middle) && !touch_mode {
|
||
let now = get_time();
|
||
if now - inp.mclick < 0.35 {
|
||
cam.reset_view();
|
||
inp.mclick = -1.0;
|
||
} else {
|
||
inp.mclick = now;
|
||
}
|
||
}
|
||
if is_mouse_button_down(MouseButton::Middle) && !touch_mode {
|
||
if let Some((lx, ly)) = inp.mlast {
|
||
let inv = if keys.invert_mouse { -1.0 } else { 1.0 };
|
||
cam.yaw -= (mx - lx) * 0.008 * keys.rotate_speed * inv;
|
||
cam.pitch += (my - ly) * 0.005 * keys.rotate_speed * inv;
|
||
}
|
||
inp.mlast = Some((mx, my));
|
||
} else {
|
||
inp.mlast = None;
|
||
}
|
||
cam.pitch = cam.pitch.clamp(0.35, 1.45);
|
||
let (_, wheel) = mouse_wheel();
|
||
if wheel != 0.0 && !over_ui {
|
||
let before = cam.ground_point(mx, my);
|
||
let f = 0.88f32.powf(keys.zoom_speed);
|
||
cam.zoom = (cam.zoom * if wheel > 0.0 { f } else { 1.0 / f }).clamp(6.0, 45.0);
|
||
let after = cam.ground_point(mx, my);
|
||
cam.tx += before.x - after.x;
|
||
cam.tz += before.y - after.y;
|
||
}
|
||
// touch: two fingers move, zoom and rotate the camera
|
||
for g in &gestures {
|
||
if let Gesture::Camera { center, pan, zoom, rotate } = *g {
|
||
let d = ui::dpr();
|
||
let a = cam.ground_point((center.x - pan.x) / d, (center.y - pan.y) / d);
|
||
let b = cam.ground_point(center.x / d, center.y / d);
|
||
cam.tx -= b.x - a.x;
|
||
cam.tz -= b.y - a.y;
|
||
let before = cam.ground_point(center.x / d, center.y / d);
|
||
cam.zoom = (cam.zoom / zoom.max(0.01)).clamp(6.0, 45.0);
|
||
cam.yaw += rotate; // the scene turns with the fingers
|
||
let after = cam.ground_point(center.x / d, center.y / d);
|
||
cam.tx += before.x - after.x;
|
||
cam.tz += before.y - after.y;
|
||
}
|
||
}
|
||
// right button: drag = pan, click = drop
|
||
if is_mouse_button_pressed(MouseButton::Right) {
|
||
inp.rdown = Some((mx, my));
|
||
inp.rlast = (mx, my);
|
||
inp.rdrag = false;
|
||
}
|
||
if let Some((sx, sy)) = inp.rdown {
|
||
if is_mouse_button_down(MouseButton::Right) {
|
||
if (mx - sx).abs() + (my - sy).abs() > 5.0 {
|
||
inp.rdrag = true;
|
||
}
|
||
if inp.rdrag {
|
||
let a = cam.ground_point(inp.rlast.0, inp.rlast.1);
|
||
let b = cam.ground_point(mx, my);
|
||
cam.tx -= b.x - a.x;
|
||
cam.tz -= b.y - a.y;
|
||
}
|
||
inp.rlast = (mx, my);
|
||
}
|
||
}
|
||
if is_mouse_button_released(MouseButton::Right) {
|
||
if !inp.rdrag && !over_ui && app.game.state == 0 {
|
||
if inp.ldown.is_some() {
|
||
inp.ldown = None;
|
||
} else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_) | Tool::Flag) {
|
||
app.game.queue(Act::Unmark { x: wx, y: wy });
|
||
} else {
|
||
app.game.queue(Act::Drop { x: wx, y: wy });
|
||
}
|
||
}
|
||
inp.rdown = None;
|
||
}
|
||
}
|
||
|
||
// ---------------- left button (touch: a finger touching the interface)
|
||
// (in menus and dialogs a touch clicks when the finger lifts without a swipe, so that
|
||
// swiping a list scrolls it instead of picking the entry under the finger)
|
||
let menu_touch = touch_mode && (in_menu || dialog);
|
||
let pressed = if menu_touch {
|
||
!menu_dragged && gestures.iter().any(|g| matches!(g, Gesture::Release(_)))
|
||
} else if touch_mode {
|
||
gestures.iter().any(|g| matches!(g, Gesture::Press(_)))
|
||
} else {
|
||
is_mouse_button_pressed(MouseButton::Left)
|
||
};
|
||
if touch_mode && pressed {
|
||
inp.touch_world = playing && !over_ui;
|
||
inp.inspect = None;
|
||
}
|
||
if pressed {
|
||
if dialog {
|
||
match ui.click(ux, uy, &app.game) {
|
||
UiAction::SelectLanguage(code) => {
|
||
app.settings.language = code;
|
||
settings_changed = true;
|
||
ui.show_langsel = false;
|
||
}
|
||
UiAction::CloseHelp => ui.show_help = false,
|
||
_ => {}
|
||
}
|
||
} else if in_menu {
|
||
let from_title = menu.page == Some(Page::Title);
|
||
match menu.click(ux, uy, &mut app.settings, &cloud) {
|
||
MenuAction::SettingsChanged => settings_changed = true,
|
||
MenuAction::Resume => {}
|
||
MenuAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
|
||
MenuAction::NewGame => {
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = true;
|
||
autosave_t = 0.0;
|
||
}
|
||
MenuAction::Continue => {
|
||
if let Some(s) = platform::storage_get(save::AUTOSAVE_KEY) {
|
||
app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now);
|
||
}
|
||
}
|
||
// with the savegame service: list on the server, otherwise savegame files
|
||
MenuAction::Load if cloud.available() => {
|
||
menu.open_saves(false, from_title, String::new());
|
||
cloud.refresh();
|
||
}
|
||
MenuAction::Save if cloud.available() => {
|
||
menu.open_saves(true, false, default_save_name(&app.game));
|
||
cloud.refresh();
|
||
}
|
||
MenuAction::Load | MenuAction::FileLoad => {
|
||
platform::open_file();
|
||
app.set_status(i18n::tr("save.choose"), now);
|
||
}
|
||
MenuAction::Save | MenuAction::FileSave => {
|
||
let name = save_file_name();
|
||
platform::download(&name, app.autosave_json().as_bytes());
|
||
app.set_status(i18n::trf("save.saved", &[platform::saved_as(&name)]), now);
|
||
}
|
||
MenuAction::CloudSave(id, name) => {
|
||
if cloud.save(id.as_deref(), &name, &app.autosave_json()) {
|
||
app.set_status(i18n::tr("saves.wait"), now);
|
||
}
|
||
}
|
||
MenuAction::CloudLoad(id) => {
|
||
if cloud.load(&id) {
|
||
app.set_status(i18n::tr("saves.wait"), now);
|
||
}
|
||
}
|
||
MenuAction::CloudDelete(id) => {
|
||
cloud.delete(&id);
|
||
}
|
||
MenuAction::StartMission(i) => {
|
||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = false;
|
||
autosave_t = 0.0;
|
||
}
|
||
MenuAction::CampaignNew => {
|
||
menu.campaign_doc = campaign_doc::CampaignDoc::default();
|
||
menu.campaign_doc.add_mission();
|
||
menu.campaign_selected = 0;
|
||
menu.page = Some(Page::CampaignEditor);
|
||
app.set_status(i18n::tr("campaign.new"), now);
|
||
}
|
||
MenuAction::CampaignAdd => {
|
||
menu.campaign_doc.add_mission();
|
||
menu.campaign_selected = menu.campaign_doc.missions.len().saturating_sub(1);
|
||
menu.page = Some(Page::CampaignEditor);
|
||
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
|
||
}
|
||
MenuAction::CampaignOpen => {
|
||
menu.campaign_pending = true;
|
||
platform::open_file();
|
||
app.set_status(i18n::tr("campaign.choose"), now);
|
||
}
|
||
MenuAction::CampaignSave => {
|
||
let name = menu.campaign_doc.file_name();
|
||
platform::download(&name, menu.campaign_doc.save().as_bytes());
|
||
app.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]), now);
|
||
}
|
||
MenuAction::CampaignEditName => {
|
||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) {
|
||
menu.campaign_doc.name = value;
|
||
app.set_status(i18n::tr("campaign.updated"), now);
|
||
}
|
||
}
|
||
MenuAction::CampaignEditTitle => {
|
||
let i = menu.campaign_selected;
|
||
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.title.clone()) {
|
||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_title"), &initial, 80) {
|
||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||
mission.title = value;
|
||
}
|
||
app.set_status(i18n::tr("campaign.updated"), now);
|
||
}
|
||
}
|
||
}
|
||
MenuAction::CampaignEditGoals => {
|
||
let i = menu.campaign_selected;
|
||
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.goals.join(", ")) {
|
||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_goals"), &initial, 120) {
|
||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||
mission.goals = campaign_doc::parse_list(&value);
|
||
}
|
||
app.set_status(i18n::tr("campaign.updated"), now);
|
||
}
|
||
}
|
||
}
|
||
MenuAction::CampaignEditNext => {
|
||
let i = menu.campaign_selected;
|
||
let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.next.clone()).unwrap_or_default();
|
||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_next"), &initial, 48) {
|
||
let key = sanitize_campaign_key(&value);
|
||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||
mission.next = Some(key);
|
||
}
|
||
app.set_status(i18n::tr("campaign.updated"), now);
|
||
}
|
||
}
|
||
MenuAction::CampaignEditBranches => {
|
||
let i = menu.campaign_selected;
|
||
if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| campaign_doc::branches_to_text(&m.branches)) {
|
||
if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_branches"), &initial, 180) {
|
||
if let Some(mission) = menu.campaign_doc.missions.get_mut(i) {
|
||
mission.branches = campaign_doc::parse_branches(&value);
|
||
}
|
||
app.set_status(i18n::tr("campaign.updated"), now);
|
||
}
|
||
}
|
||
}
|
||
MenuAction::CampaignEditScript => {}
|
||
MenuAction::CampaignDelete(i) => {
|
||
if i < menu.campaign_doc.missions.len() {
|
||
menu.campaign_doc.missions.remove(i);
|
||
menu.campaign_selected = menu.campaign_selected.min(menu.campaign_doc.missions.len().saturating_sub(1));
|
||
if i == app.custom_mission_index.unwrap_or(usize::MAX) {
|
||
app.custom_campaign = None;
|
||
app.custom_progress = None;
|
||
app.custom_mission_index = None;
|
||
app.custom_next_index = None;
|
||
}
|
||
app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now);
|
||
}
|
||
}
|
||
MenuAction::CampaignStart(i) => {
|
||
if let Some(mission_doc) = menu.campaign_doc.missions.get(i) {
|
||
if menu.campaign_doc.validate().is_empty() {
|
||
let _ = mission_doc;
|
||
let progress = campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc);
|
||
(app.game, app.cam) = custom_mission_game(&menu.campaign_doc, i, progress.run_seed);
|
||
app.pending_briefing = briefing_of(&menu.campaign_doc, i);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
app.custom_campaign = Some(menu.campaign_doc.clone());
|
||
app.custom_progress = Some(progress);
|
||
app.custom_mission_index = Some(i);
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = false;
|
||
autosave_t = 0.0;
|
||
} else {
|
||
app.set_status(i18n::tr("campaign.invalid"), now);
|
||
}
|
||
}
|
||
}
|
||
MenuAction::Tutorial => {
|
||
let doc = editor::templates::tutorial_campaign();
|
||
let progress = campaign_doc::CampaignProgress::for_doc(&doc);
|
||
(app.game, app.cam) = custom_mission_game(&doc, 0, progress.run_seed);
|
||
app.pending_briefing = briefing_of(&doc, 0);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
app.custom_campaign = Some(doc);
|
||
app.custom_progress = Some(progress);
|
||
app.custom_mission_index = Some(0);
|
||
app.tutorial = true;
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = false;
|
||
autosave_t = 0.0;
|
||
}
|
||
MenuAction::DemoMap => {
|
||
// debug: four allied masters with every room and creature against an
|
||
// endless gate (never autosaved, counts for no achievement)
|
||
if app.match_mode || app.game.in_match() {
|
||
app.leave_match();
|
||
}
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo_map(app.seed);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
app.tutorial = false;
|
||
app.custom_campaign = None;
|
||
app.custom_progress = None;
|
||
app.custom_mission_index = None;
|
||
app.custom_next_index = None;
|
||
menu.page = None;
|
||
r3d.invalidate();
|
||
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 && app.game.demo_map.is_none() {
|
||
platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json());
|
||
menu.has_autosave = true;
|
||
}
|
||
menu.page = Some(Page::Title);
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
}
|
||
MenuAction::None | MenuAction::Consumed => {}
|
||
}
|
||
} else {
|
||
match ui.click(ux, uy, &app.game) {
|
||
UiAction::SelectTool(t) => {
|
||
ui.tool = t;
|
||
app.game.sfx_ui(game::Sfx::Click);
|
||
}
|
||
UiAction::MinimapJump(x, y) => {
|
||
app.cam.tx = x;
|
||
app.cam.tz = y;
|
||
inp.minimap_drag = true;
|
||
}
|
||
UiAction::NewGame => {
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = new_game(app.seed, &app.settings);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
}
|
||
// end of a mission: next mission (won) or try again (lost)
|
||
UiAction::EndNext | UiAction::EndCampaign if app.editor_testing && app.editor_test_only => back_to_editor(&mut app, &mut menu, &mut r3d),
|
||
// a co-op mission of an editor campaign is over: the host starts the next one (or the
|
||
// same one again) for everybody; the others wait for it
|
||
UiAction::EndNext if app.coop_editor && app.game.local_side == 0 && app.coop_run.as_ref().map_or(false, |r| r.next.is_some() && !r.sent) => {
|
||
if app.offline {
|
||
// no relay: the next mission of the run starts here at once
|
||
let run = app.coop_run.take().expect("a co-op run");
|
||
let next = run.next.unwrap_or(run.mission);
|
||
if app.start_offline_coop(Some(run.doc), next, Some(run.run_seed), run.carry_out) {
|
||
ui.log.clear();
|
||
autosave_t = 0.0;
|
||
}
|
||
} else if let Some(run) = app.coop_run.as_mut() {
|
||
if let Some(next) = run.next {
|
||
app.client.next_mission(next, run.run_seed, &run.carry_out);
|
||
run.sent = true;
|
||
}
|
||
}
|
||
}
|
||
// a built-in co-op mission without the server: the next one after a victory, else again
|
||
UiAction::EndNext if app.offline && !app.coop_editor => {
|
||
let i = app.game.mission.as_ref().map_or(0, |m| m.index);
|
||
let next = if app.game.state == 1 { campaign::next_coop_mission(i, 1) } else { i };
|
||
if next <= i && app.game.state == 1 {
|
||
app.leave_match();
|
||
menu.page = Some(Page::Title);
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
} else if app.start_offline_coop(None, next, None, Vec::new()) {
|
||
ui.log.clear();
|
||
autosave_t = 0.0;
|
||
}
|
||
}
|
||
UiAction::EndCampaign if app.offline && !app.coop_editor => {
|
||
app.leave_match();
|
||
menu.page = Some(Page::Title);
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
}
|
||
UiAction::EndNext if app.coop_editor && app.coop_run.as_ref().map_or(false, |r| r.next.is_some()) => {}
|
||
// ... or the run is over (or the player leaves): the match ends, back to the lobby
|
||
UiAction::EndNext | UiAction::EndCampaign if app.coop_editor => {
|
||
app.leave_match();
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
menu.open_match(true);
|
||
if !app.connect_match(&cloud) {
|
||
menu.error = Some(i18n::tr("net.status_offline"));
|
||
}
|
||
}
|
||
UiAction::EndNext | UiAction::EndCampaign if app.tutorial && app.game.state == 1 => {
|
||
app.tutorial = false;
|
||
app.custom_campaign = None;
|
||
app.custom_progress = None;
|
||
app.custom_mission_index = None;
|
||
menu.page = Some(Page::Title);
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
}
|
||
UiAction::EndNext => {
|
||
if app.custom_campaign.is_some() {
|
||
let campaign = app.custom_campaign.clone();
|
||
let progress = app.custom_progress.clone();
|
||
if app.game.state == 1 {
|
||
if let Some(next) = app.custom_next_index {
|
||
if let Some(campaign) = campaign {
|
||
if next < campaign.missions.len() {
|
||
(app.game, app.cam) = custom_mission_game(&campaign, next, progress.as_ref().map_or(1, |p| p.run_seed));
|
||
app.pending_briefing = briefing_of(&campaign, next);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
|
||
app.custom_campaign = Some(campaign);
|
||
app.custom_progress = Some(progress);
|
||
app.custom_mission_index = Some(next);
|
||
app.custom_next_index = None;
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = false;
|
||
autosave_t = 0.0;
|
||
} else {
|
||
menu.page = Some(Page::CampaignEditor);
|
||
}
|
||
}
|
||
} else {
|
||
menu.page = Some(Page::CampaignEditor);
|
||
}
|
||
} else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) {
|
||
if i < campaign.missions.len() {
|
||
(app.game, app.cam) = custom_mission_game(&campaign, i, progress.as_ref().map_or(1, |p| p.run_seed));
|
||
app.pending_briefing = briefing_of(&campaign, i);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign));
|
||
app.custom_campaign = Some(campaign);
|
||
app.custom_progress = Some(progress);
|
||
app.custom_mission_index = Some(i);
|
||
app.custom_next_index = None;
|
||
ui.log.clear();
|
||
}
|
||
}
|
||
} else if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||
if app.game.state == 1 {
|
||
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
|
||
} else {
|
||
(app.game, app.cam) = mission_game(i, &menu.progress);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.recorded = false;
|
||
ui.log.clear();
|
||
}
|
||
}
|
||
}
|
||
UiAction::EndCampaign => menu.page = Some(Page::Campaign),
|
||
UiAction::Briefing => {
|
||
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
|
||
menu.open_briefing(i);
|
||
}
|
||
}
|
||
UiAction::OpenMenu => menu.page = Some(Page::Pause),
|
||
UiAction::SendGold(to, amount) => app.game.queue(Act::SendGold { to, amount }),
|
||
UiAction::LearnTalent(branch) => app.game.queue(Act::Talent { branch }),
|
||
UiAction::Locked => {
|
||
let locked = ui.last_locked.unwrap_or(usize::MAX);
|
||
app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]);
|
||
app.game.sfx_ui(game::Sfx::Error);
|
||
}
|
||
UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {}
|
||
UiAction::ChatSetTeam(team) => ui.chat_team = team,
|
||
UiAction::ChatSend => {
|
||
let text: String = ui.chat_text.trim().chars().take(80).collect();
|
||
if !text.is_empty() {
|
||
app.client.chat(&text, ui.chat_team);
|
||
}
|
||
ui.chat_text.clear();
|
||
ui.chat_open = false;
|
||
}
|
||
UiAction::None => {
|
||
if touch_mode {
|
||
// touch: area tools start their rectangle, everything else acts on the tap
|
||
if area_tool(ui.tool) && app.game.state == 0 {
|
||
inp.ldown = hover_tile;
|
||
}
|
||
} else {
|
||
let slap = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);
|
||
world_action(&mut app, &mut ui, &r3d, &mut inp, mx, my, slap, now);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let held = if touch_mode { touch.finger().is_some() } else { is_mouse_button_down(MouseButton::Left) };
|
||
if inp.minimap_drag {
|
||
if held && ui.minimap_contains(ux, uy) {
|
||
let (x, y) = ui.minimap_world(ux, uy);
|
||
app.cam.tx = x;
|
||
app.cam.tz = y;
|
||
} else if !held {
|
||
inp.minimap_drag = false;
|
||
}
|
||
}
|
||
|
||
// ---------------- touch gestures on the game world
|
||
if touch_mode {
|
||
let d = ui::dpr();
|
||
for g in &gestures {
|
||
match *g {
|
||
Gesture::Cancel => {
|
||
inp.ldown = None;
|
||
inp.tlast = None;
|
||
}
|
||
Gesture::Tap(p) | Gesture::DoubleTap(p) if playing && !ui.over_ui(p.x, p.y) => {
|
||
if !area_tool(ui.tool) {
|
||
let slap = matches!(g, Gesture::DoubleTap(_));
|
||
world_action(&mut app, &mut ui, &r3d, &mut inp, p.x / d, p.y / d, slap, now);
|
||
}
|
||
}
|
||
Gesture::LongPress(p) if playing && inp.touch_world => {
|
||
inp.ldown = None;
|
||
if ui.tool == Tool::Hand && !app.game.hand.is_empty() {
|
||
let gp = app.cam.ground_point(p.x / d, p.y / d);
|
||
app.game.queue(Act::Drop { x: gp.x, y: gp.y });
|
||
} else {
|
||
inp.inspect = Some((p.x / d, p.y / d, now));
|
||
}
|
||
}
|
||
Gesture::DragStart(p) if playing && inp.touch_world && inp.ldown.is_none() => inp.tlast = Some(p),
|
||
Gesture::DragMove(p) => {
|
||
if let Some(l) = inp.tlast {
|
||
let a = app.cam.ground_point(l.x / d, l.y / d);
|
||
let b = app.cam.ground_point(p.x / d, p.y / d);
|
||
app.cam.tx -= b.x - a.x;
|
||
app.cam.tz -= b.y - a.y;
|
||
inp.tlast = Some(p);
|
||
}
|
||
}
|
||
Gesture::Release(p) => {
|
||
inp.tlast = None;
|
||
if let Some(start) = inp.ldown.take() {
|
||
if playing && inp.touch_world {
|
||
finish_rect(&mut app, ui.tool, start, p.x / d, p.y / d);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if inp.inspect.map_or(false, |(_, _, t)| now - t > 6.0) {
|
||
inp.inspect = None;
|
||
}
|
||
}
|
||
if !touch_mode && is_mouse_button_released(MouseButton::Left) {
|
||
if let Some(start) = inp.ldown.take() {
|
||
if playing {
|
||
finish_rect(&mut app, ui.tool, start, mx, my);
|
||
}
|
||
}
|
||
}
|
||
app.cam.tx = app.cam.tx.clamp(0.0, mw() as f32);
|
||
app.cam.tz = app.cam.tz.clamp(0.0, mh() as f32);
|
||
app.cam.rx = app.cam.rx.clamp(0.0, mw() as f32);
|
||
app.cam.rz = app.cam.rz.clamp(0.0, mh() as f32);
|
||
// a mission script asked the camera to look somewhere
|
||
if let Some(p) = app.game.script.as_mut().and_then(|s| s.camera.take()) {
|
||
app.cam.tx = p.0;
|
||
app.cam.tz = p.1;
|
||
}
|
||
app.cam.update_render(dt);
|
||
|
||
if settings_changed {
|
||
apply_settings(&app.settings, &mut r3d);
|
||
store_settings(&app.settings);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
}
|
||
|
||
// ---------------- update (single-player worlds stand still while menus or dialogs are open;
|
||
// matches keep running in the background, the bridge battle behind the title menu goes on)
|
||
if app.game.demo.is_some() && !playing {
|
||
app.game.update(dt);
|
||
app.cam.yaw += dt * 0.03;
|
||
app.game.events.retain(|ev| ev.pos.is_none());
|
||
} else if app.game.in_match() {
|
||
app.game.update(dt * app.settings.game_speed.max(1.0));
|
||
app.tick_match_game();
|
||
} else if playing {
|
||
// the world runs in fixed steps: the speed setting only feeds more time into them
|
||
app.game.update(dt * app.settings.game_speed.max(1.0));
|
||
// autosave: snapshot for leaving the tab + regular autosave
|
||
// (the debug demo map never replaces the autosave)
|
||
if app.game.state == 0 && !app.match_mode && app.game.demo_map.is_none() {
|
||
cache_t += dt;
|
||
autosave_t += dt;
|
||
if cache_t >= AUTOSAVE_CACHE_EVERY {
|
||
cache_t = 0.0;
|
||
platform::cache_on_leave(save::AUTOSAVE_KEY, &app.autosave_json());
|
||
}
|
||
if autosave_t >= AUTOSAVE_EVERY {
|
||
autosave_t = 0.0;
|
||
platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json());
|
||
menu.has_autosave = true;
|
||
}
|
||
}
|
||
}
|
||
app.game.smooth_display(dt);
|
||
// winter: snow falls on the land above ground near the camera
|
||
if render3d::season() == 2 && !app.game.map.sky.is_empty() && app.game.parts.len() < 3000 {
|
||
let (cx, cz) = (app.cam.tx, app.cam.tz);
|
||
for _ in 0..3 {
|
||
let x = cx + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8;
|
||
let z = cz + (rand::gen_range(-1.0f32, 1.0)) * app.cam.zoom * 0.8;
|
||
if inb(x as i32, z as i32) && app.game.map.sky(map::idx(x as i32, z as i32)) {
|
||
app.game.parts.push(particles::Part { kind: particles::EMBER, x, y: z, h: 4.0, vx: rand::gen_range(-0.2f32, 0.2), vy: 0.0, vh: -0.6, t: 0.0, max: 6.0, col: particles::abgr(245, 248, 255), size: 0.04 });
|
||
}
|
||
}
|
||
}
|
||
let hear = app.cam.zoom * 1.2;
|
||
let (right, _) = app.cam.ground_axes();
|
||
for ev in app.game.events.drain(..) {
|
||
sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now);
|
||
}
|
||
sounds.ambience(&app.game, (app.cam.rx, app.cam.rz), now);
|
||
sounds.hearts(&app.game, vec2(app.cam.rx, app.cam.rz), right, 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 });
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let shot_now = shot.as_mut().and_then(|p| p.before_frame(&mut app, &mut ui, &mut menu, &mut r3d, now));
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let draw_t0 = std::time::Instant::now();
|
||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, (mx, my), touch_mode, now);
|
||
ui::end_frame();
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(p) = shot.as_mut() {
|
||
// the self test reports the time drawing takes (without waiting for the screen)
|
||
p.frames += 1;
|
||
p.draw_ms += draw_t0.elapsed().as_secs_f64() * 1000.0;
|
||
if now - p.report_t >= 2.0 {
|
||
println!("self test: {:.1} s, {} frames, drawing {:.2} ms per frame, {} units, {} particles", now - p.started.unwrap_or(now), p.frames, p.draw_ms / p.frames.max(1) as f64, app.game.ents.len(), app.game.parts.len());
|
||
p.frames = 0;
|
||
p.draw_ms = 0.0;
|
||
p.report_t = now;
|
||
}
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if let Some(path) = shot_now {
|
||
shot_capture(&path);
|
||
}
|
||
next_frame().await
|
||
}
|
||
}
|
||
|
||
// ---- self test and screenshots (desktop): DC_SHOT=demo or a savegame file starts it right
|
||
// away, DC_SHOT_STYLE=hd|pixel picks the graphics style (not saved), DC_SHOT_AFTER seconds later
|
||
// (default 10) the screen goes to DC_SHOT_OUT (default /tmp/dc-shot.png) and the game ends;
|
||
// DC_SHOT_ZOOM sets the camera zoom, DC_SHOT_YAW its turn, DC_SHOT_AT=x,z where it looks;
|
||
// DC_SHOT_FF=seconds runs the game that long at once before (a screen that is off gives few frames)
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
struct ShotPlan {
|
||
load: String,
|
||
out: String,
|
||
after: f64,
|
||
style: Option<u16>,
|
||
zoom: Option<f32>,
|
||
yaw: Option<f32>,
|
||
at: Option<(f32, f32)>,
|
||
started: Option<f64>,
|
||
frames: u64,
|
||
draw_ms: f64,
|
||
report_t: f64,
|
||
ff: f32,
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
impl ShotPlan {
|
||
fn from_env() -> Option<ShotPlan> {
|
||
let load = std::env::var("DC_SHOT").ok()?;
|
||
let num = |k: &str| std::env::var(k).ok().and_then(|v| v.parse::<f32>().ok());
|
||
Some(ShotPlan {
|
||
load,
|
||
out: std::env::var("DC_SHOT_OUT").unwrap_or_else(|_| "/tmp/dc-shot.png".into()),
|
||
after: num("DC_SHOT_AFTER").unwrap_or(10.0) as f64,
|
||
style: std::env::var("DC_SHOT_STYLE").ok().map(|v| if v == "pixel" { settings::PIXEL_ART_ROWS } else { settings::HIGH_RES_ROWS }),
|
||
zoom: num("DC_SHOT_ZOOM"),
|
||
yaw: num("DC_SHOT_YAW"),
|
||
at: std::env::var("DC_SHOT_AT").ok().and_then(|v| {
|
||
let (a, b) = v.split_once(',')?;
|
||
Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
|
||
}),
|
||
started: None,
|
||
frames: 0,
|
||
draw_ms: 0.0,
|
||
report_t: 0.0,
|
||
ff: num("DC_SHOT_FF").unwrap_or(0.0),
|
||
})
|
||
}
|
||
|
||
// starts the scene on the first frame; returns the file to write once the time has come
|
||
fn before_frame(&mut self, app: &mut App, ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) -> Option<String> {
|
||
match self.started {
|
||
None => {
|
||
if let Some(rows) = self.style {
|
||
app.settings.pixel_rows = rows;
|
||
}
|
||
app.settings.hints = false;
|
||
apply_settings(&app.settings, r3d);
|
||
if self.load == "demo" {
|
||
app.seed = next_seed(app.seed);
|
||
(app.game, app.cam) = Game::new_demo_map(app.seed);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
r3d.invalidate();
|
||
} else if let Ok(bytes) = std::fs::read(&self.load) {
|
||
app.load_bytes(&bytes, ui, menu, r3d, now);
|
||
}
|
||
menu.page = None;
|
||
ui.show_help = false;
|
||
// fast forward: the game's own steps, without drawing
|
||
let mut left = self.ff;
|
||
while left > 0.0 {
|
||
app.game.update(0.05);
|
||
left -= 0.05;
|
||
}
|
||
if let Some(z) = self.zoom {
|
||
app.cam.zoom = z;
|
||
}
|
||
if let Some(y) = self.yaw {
|
||
app.cam.yaw = y;
|
||
}
|
||
if let Some((x, z)) = self.at {
|
||
(app.cam.tx, app.cam.tz, app.cam.rx, app.cam.rz) = (x, z, x, z);
|
||
}
|
||
self.started = Some(now);
|
||
None
|
||
}
|
||
Some(t0) if now - t0 >= self.after => Some(self.out.clone()),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// writes the screen to a PNG file and ends the game
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn shot_capture(path: &str) {
|
||
let img = get_screen_data();
|
||
let (w, h) = (img.width as u32, img.height as u32);
|
||
let mut out = Image::gen_image_color(w as u16, h as u16, BLACK);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let mut p = img.get_pixel(x, y);
|
||
p.a = 1.0;
|
||
out.set_pixel(x, y, p);
|
||
}
|
||
}
|
||
out.export_png(path);
|
||
std::process::exit(0);
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
// pointer: CSS pixels of the mouse or finger; touch: hover highlight and information only
|
||
// while a finger is down or for a long-pressed point
|
||
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, pointer: (f32, f32), touch: bool, now: f64) {
|
||
let (mx, my) = match (touch, inp.inspect) {
|
||
(true, Some((x, y, _))) if inp.ldown.is_none() => (x, y),
|
||
_ => pointer,
|
||
};
|
||
let (ux, uy) = (mx * ui::dpr(), my * ui::dpr());
|
||
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
||
let over_ui = blocked || ui.over_ui(ux, uy);
|
||
let hover_tile = tool_tile(&app.cam, &app.game.map, ui.tool, mx, my);
|
||
let ground = app.cam.ground_point(mx, my);
|
||
clear_background(Color::from_rgba(4, 3, 5, 255));
|
||
let (color, radius) = match ui.tool {
|
||
Tool::Dig => {
|
||
// the dig selection in the master's colour (like the marks)
|
||
let c = render3d::master_rgb(app.game.local_side);
|
||
(Color::new(c.x, c.y, c.z, 1.0), None)
|
||
}
|
||
Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None),
|
||
Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None),
|
||
Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None),
|
||
Tool::Flag => (Color::new(1.0, 0.3, 0.3, 1.0), None),
|
||
Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))),
|
||
Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))),
|
||
Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None),
|
||
Tool::Bridge | Tool::Drawbridge => (Color::new(0.9, 0.7, 0.4, 1.0), None),
|
||
Tool::Wall => (Color::new(0.8, 0.4, 1.0, 1.0), None),
|
||
Tool::Siege(_) => (Color::new(0.9, 0.7, 0.4, 1.0), None),
|
||
Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None),
|
||
Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None),
|
||
Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None),
|
||
Tool::Spell(sp) => (Color::new(0.8, 0.55, 1.0, 1.0), Some((ground.x, ground.y, sp.radius().max(0.6)))),
|
||
Tool::Order(_) => (Color::new(1.0, 0.3, 0.3, 1.0), None),
|
||
};
|
||
let ov = Overlay {
|
||
hover: if over_ui { None } else { hover_tile.filter(|&(x, z)| inb(x, z)) },
|
||
rect: inp.ldown.and_then(|(sx, sy)| hover_tile.map(|(ex, ey)| (sx, sy, ex.clamp(0, mw() as i32 - 1), ey.clamp(0, mh() as i32 - 1)))),
|
||
color,
|
||
radius: if over_ui { None } else { radius },
|
||
high: ui.tool == Tool::Dig,
|
||
};
|
||
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
|
||
if app.settings.debug_trigger_zones || app.settings.debug_path_lines || app.editor_testing {
|
||
draw_debug_3d(app, now);
|
||
}
|
||
// planned walls of this master: a pulsing frame where the wall will rise
|
||
let pulse = 0.55 + 0.25 * ((now * 3.0).sin() as f32);
|
||
let me = app.game.local_side;
|
||
for &(i, side) in app.game.wall_plans.iter().filter(|p| p.1 == me).take(200) {
|
||
let (x, z) = map::xy(i);
|
||
let (x0, z0, x1, z1) = (x as f32 + 0.08, z as f32 + 0.08, x as f32 + 0.92, z as f32 + 0.92);
|
||
let c = Color::new(0.75, 0.45, 1.0, pulse);
|
||
let _ = side;
|
||
for h in [0.02, 0.9] {
|
||
line3(vec3(x0, h, z0), vec3(x1, h, z0), 0.04, c);
|
||
line3(vec3(x1, h, z0), vec3(x1, h, z1), 0.04, c);
|
||
line3(vec3(x1, h, z1), vec3(x0, h, z1), 0.04, c);
|
||
line3(vec3(x0, h, z1), vec3(x0, h, z0), 0.04, c);
|
||
}
|
||
for (a, b) in [(x0, z0), (x1, z0), (x1, z1), (x0, z1)] {
|
||
line3(vec3(a, 0.02, b), vec3(a, 0.9, b), 0.04, c);
|
||
}
|
||
}
|
||
let title_screen = matches!(
|
||
menu.page,
|
||
Some(Page::Title)
|
||
| Some(Page::Settings { from_title: true, .. })
|
||
| Some(Page::KeyCapture { from_title: true, .. })
|
||
| Some(Page::Campaign)
|
||
| Some(Page::CampaignEditor)
|
||
| Some(Page::Briefing { ingame: false, .. })
|
||
| Some(Page::Login)
|
||
| Some(Page::Account)
|
||
| Some(Page::Bestiary { from_title: true, .. })
|
||
| Some(Page::Match { from_title: true })
|
||
| Some(Page::Achievements { from_title: true })
|
||
);
|
||
if !title_screen {
|
||
let info = if over_ui || (touch && inp.inspect.is_none()) {
|
||
Vec::new()
|
||
} else {
|
||
let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false);
|
||
hover_info(&app.game, ent, hover_tile)
|
||
};
|
||
ui.draw(&app.game, gfx, &app.cam, &app.settings, &info, now);
|
||
} else {
|
||
ui.rects.clear();
|
||
}
|
||
if app.status.as_ref().map_or(false, |(_, t)| now - t > 4.0) {
|
||
app.status = None;
|
||
}
|
||
menu.draw(gfx, &app.settings, app.status.as_ref().map(|(s, _)| s.as_str()), cloud);
|
||
if !menu.open() || ui.show_help || ui.show_langsel {
|
||
ui.draw_dialogs(&app.game, gfx);
|
||
}
|
||
if !touch {
|
||
ui.draw_cursor(gfx, &app.game, now, blocked);
|
||
}
|
||
if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping {
|
||
draw_debug_fps(app, gfx);
|
||
}
|
||
if app.editor_testing && !menu.open() {
|
||
draw_script_debug(app, gfx);
|
||
}
|
||
}
|
||
|
||
// test play from the editor: which trigger fired when, and the variables
|
||
fn draw_script_debug(app: &App, gfx: &Gfx) {
|
||
let Some(s) = app.game.script.as_ref() else { return };
|
||
let lines: Vec<(String, Color)> = std::iter::once((i18n::tr("ed.debug_title"), ui::FRAME_HI))
|
||
.chain(s.vars.iter().map(|(k, v)| (format!("{} = {}", k, v), ui::GOLD)))
|
||
.chain(s.log.iter().rev().take(10).map(|(t, n)| (format!("{:>5.0}s {}", t, n), if n.starts_with('!') { ui::RED } else { ui::TXT })))
|
||
.collect();
|
||
let w = ui::CW() * 30.0;
|
||
let x = ui::sw() - w - ui::CW();
|
||
// below the hover info at the top right
|
||
let y = ui::CH() * 13.0;
|
||
draw_rectangle(x, y, w, ui::CH() * (lines.len() as f32 + 1.0), Color::new(0.04, 0.03, 0.03, 0.85));
|
||
for (k, (l, c)) in lines.iter().enumerate() {
|
||
let l: String = l.chars().take(29).collect();
|
||
gfx.text(&l, x + ui::CW() * 0.5, y + ui::CH() * (0.5 + k as f32), ui::SMALL(), *c);
|
||
}
|
||
}
|
||
|
||
// a test play ends (pause menu, mission over): back into the editor
|
||
fn back_to_editor(app: &mut App, menu: &mut Menu, r3d: &mut R3d) {
|
||
app.editor_testing = false;
|
||
menu.editor_test = false;
|
||
menu.page = Some(Page::Title);
|
||
app.custom_campaign = None;
|
||
app.custom_progress = None;
|
||
app.custom_mission_index = None;
|
||
app.custom_next_index = None;
|
||
r3d.invalidate();
|
||
}
|
||
|
||
// one frame of the campaign editor and what it asks for
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn editor_frame(app: &mut App, menu: &mut Menu, cloud: &mut Cloud, r3d: &mut R3d, gfx: &Gfx, ui: &mut Ui, dt: f32, now: f64) {
|
||
let Some(mut ed) = app.editor.take() else { return };
|
||
// the savegame service: campaign lists, loads and saves
|
||
if let CloudEvent::Error(key) = cloud.poll() {
|
||
ed.set_status(i18n::tr(key));
|
||
}
|
||
if app.editor_public_pending && cloud.refresh_public_campaigns() {
|
||
app.editor_public_pending = false;
|
||
}
|
||
ed.own_list = cloud.campaigns.iter().map(|c| (c.id.clone(), c.name.clone())).collect();
|
||
ed.public_list = cloud.public_campaigns.iter().map(|c| (c.owner.clone(), c.id.clone(), c.name.clone())).collect();
|
||
if let Some(info) = cloud.take_saved_campaign() {
|
||
ed.server_id = Some(info.id.clone());
|
||
ed.published = info.published;
|
||
ed.set_status(i18n::tr("ed.saved_server"));
|
||
}
|
||
if let Some(text) = cloud.take_loaded_campaign() {
|
||
match campaign_doc::CampaignDoc::load(&text) {
|
||
Ok(doc) => {
|
||
let id = app.editor_loading.take();
|
||
ed.published = id.as_ref().and_then(|i| cloud.campaigns.iter().find(|c| c.id == *i)).map_or(false, |c| c.published);
|
||
ed.load(doc, id);
|
||
ed.set_status(i18n::tr("ed.loaded"));
|
||
}
|
||
Err(e) => ed.set_status(e),
|
||
}
|
||
}
|
||
// a campaign file picked or dropped onto the window
|
||
let mut incoming = platform::take_file();
|
||
for f in get_dropped_files() {
|
||
if let Some(b) = f.bytes {
|
||
incoming = Some(b);
|
||
}
|
||
}
|
||
if let Some(bytes) = incoming {
|
||
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
|
||
Ok(doc) => {
|
||
ed.load(doc, None);
|
||
ed.set_status(i18n::tr("ed.loaded"));
|
||
}
|
||
Err(e) => ed.set_status(e),
|
||
}
|
||
}
|
||
// the map editor's map has to be the active one (map size for tiles and drawing)
|
||
if let Some(g) = ed.active_map() {
|
||
g.map.activate();
|
||
}
|
||
let act = ed.frame(gfx, r3d, dt, now);
|
||
// the game draws its own pointer (the system one is hidden)
|
||
ui::begin_ui();
|
||
ui.draw_cursor(gfx, &app.game, now, true);
|
||
match act {
|
||
editor::EdAction::None => {}
|
||
editor::EdAction::Close => {
|
||
menu.campaign_doc = ed.doc.clone();
|
||
(app.game, app.cam) = Game::new_demo(app.seed);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
r3d.invalidate();
|
||
menu.page = Some(Page::Title);
|
||
return; // the editor is gone
|
||
}
|
||
editor::EdAction::Test(_) | editor::EdAction::Play => {
|
||
let (i, only) = match act {
|
||
editor::EdAction::Test(i) => (i, true),
|
||
_ => (0, false),
|
||
};
|
||
let progress = campaign_doc::CampaignProgress::for_doc(&ed.doc);
|
||
(app.game, app.cam) = custom_mission_game(&ed.doc, i, progress.run_seed);
|
||
app.pending_briefing = briefing_of(&ed.doc, i);
|
||
apply_game_settings(&mut app.game, &app.settings);
|
||
app.game.coop = false;
|
||
app.coop = false;
|
||
app.recorded = false;
|
||
app.custom_campaign = Some(ed.doc.clone());
|
||
app.custom_progress = Some(progress);
|
||
app.custom_mission_index = Some(i);
|
||
app.custom_next_index = None;
|
||
app.editor_testing = true;
|
||
app.editor_test_only = only;
|
||
menu.editor_test = true;
|
||
menu.page = None;
|
||
ui.log.clear();
|
||
ui.show_help = false;
|
||
r3d.invalidate();
|
||
}
|
||
editor::EdAction::SaveFile => {
|
||
let name = ed.doc.file_name();
|
||
platform::download(&name, ed.doc.save().as_bytes());
|
||
ed.set_status(i18n::trf("campaign.saved", &[platform::saved_as(&name)]));
|
||
}
|
||
editor::EdAction::OpenFile => platform::open_file(),
|
||
editor::EdAction::ServerSave => {
|
||
if !cloud.available() {
|
||
ed.set_status(i18n::tr("ed.no_server"));
|
||
} else if !cloud.save_campaign(ed.server_id.as_deref(), &ed.doc.name, &serde_json::to_string(&ed.doc).unwrap_or_default()) {
|
||
ed.set_status(i18n::tr("ed.busy"));
|
||
}
|
||
}
|
||
editor::EdAction::ServerRefresh => {
|
||
if cloud.available() {
|
||
cloud.refresh_campaigns();
|
||
app.editor_public_pending = true;
|
||
}
|
||
}
|
||
editor::EdAction::ServerLoad(id) => {
|
||
if cloud.load_campaign(&id) {
|
||
app.editor_loading = Some(id);
|
||
}
|
||
}
|
||
editor::EdAction::PublicLoad(owner, id) => {
|
||
if cloud.load_public_campaign(&owner, &id) {
|
||
app.editor_loading = None;
|
||
}
|
||
}
|
||
editor::EdAction::Publish(on) => match ed.server_id.clone() {
|
||
Some(id) => {
|
||
cloud.publish_campaign(&id, on);
|
||
}
|
||
None => ed.set_status(i18n::tr("ed.save_first")),
|
||
},
|
||
}
|
||
app.editor = Some(ed);
|
||
}
|
||
|
||
fn draw_debug_fps(app: &App, gfx: &Gfx) {
|
||
ui::begin_ui();
|
||
let scale = ui::SMALL().max(1.0);
|
||
let text_h = scale * 10.0 + 4.0;
|
||
|
||
if app.settings.debug_fps {
|
||
let fps = app.fps.max(0.0).round() as i32;
|
||
let renderer = {
|
||
let r = app.renderer.trim();
|
||
if r.is_empty() {
|
||
if cfg!(target_arch = "wasm32") {
|
||
"WebGL"
|
||
} else {
|
||
"OpenGL"
|
||
}
|
||
} else {
|
||
r
|
||
}
|
||
};
|
||
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||
let head: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
|
||
let head_w = gfx.measure(&head, scale);
|
||
let head_x = (ui::sw() - head_w - 2.0).max(2.0);
|
||
draw_rectangle(head_x - 2.0, 0.0, head_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||
gfx.text(&head, head_x, 2.0, scale, GOLD);
|
||
}
|
||
|
||
if app.settings.debug_frame_time {
|
||
let ms = app.frame_ms.max(0.0);
|
||
let graph_w = (ui::sw() * 0.42).clamp(80.0, 260.0).min(ui::sw() - 4.0).max(40.0);
|
||
let graph_h = (scale * 30.0).clamp(20.0, 52.0);
|
||
let frame_y = (ui::sh() - text_h - 2.0).max(12.0);
|
||
let graph_y = (frame_y - graph_h - 6.0).max(12.0);
|
||
let graph_x = (ui::sw() - graph_w - 2.0).max(2.0);
|
||
|
||
let mut max_ms = ms.max(16.7);
|
||
for v in app.frame_hist.iter() {
|
||
max_ms = max_ms.max(*v);
|
||
}
|
||
let max_ms = max_ms.max(16.7).max(ms * 1.3).min(100.0);
|
||
|
||
draw_rectangle(graph_x - 2.0, graph_y - 2.0, graph_w + 4.0, graph_h + 4.0, Color::from_rgba(0, 0, 0, 170));
|
||
let bar_w = graph_w / FRAME_HIST as f32;
|
||
for k in 0..FRAME_HIST {
|
||
let v = app.frame_hist[(app.frame_hist_pos + k) % FRAME_HIST];
|
||
if v <= 0.0 {
|
||
continue;
|
||
}
|
||
let h = graph_h * (v / max_ms).clamp(0.0, 1.0).max(0.08);
|
||
let col = if v <= 16.7 {
|
||
Color::from_rgba(90, 220, 120, 235)
|
||
} else if v <= 33.3 {
|
||
Color::from_rgba(240, 210, 100, 235)
|
||
} else {
|
||
Color::from_rgba(235, 90, 90, 235)
|
||
};
|
||
draw_rectangle(graph_x + k as f32 * bar_w, graph_y + graph_h - h, bar_w.max(1.0), h, col);
|
||
}
|
||
draw_rectangle(graph_x, graph_y + graph_h, graph_w, 1.0, Color::from_rgba(255, 255, 255, 50));
|
||
|
||
let frame_line = format!("{:.1} ms", ms);
|
||
let frame_w = gfx.measure(&frame_line, scale);
|
||
let frame_x = (ui::sw() - frame_w - 2.0).max(2.0);
|
||
draw_rectangle(frame_x - 2.0, frame_y - 2.0, frame_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||
gfx.text(&frame_line, frame_x, frame_y, scale, GOLD);
|
||
}
|
||
|
||
if app.settings.debug_ping {
|
||
let ping = if app.client.connected { format!("{:.0} ms", app.client.ping_ms) } else { "no match".into() };
|
||
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
|
||
let line: String = format!("ping {}", ping).chars().take(max_cols).collect();
|
||
let line_w = gfx.measure(&line, scale);
|
||
draw_rectangle(0.0, ui::sh() - text_h - 2.0, line_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
|
||
let color = if !app.client.connected {
|
||
ui::DIM
|
||
} else if app.client.ping_ms <= 120.0 {
|
||
ui::GREEN
|
||
} else if app.client.ping_ms <= 300.0 {
|
||
Color::from_rgba(240, 210, 100, 255)
|
||
} else {
|
||
ui::RED
|
||
};
|
||
gfx.text(&line, 2.0, ui::sh() - text_h, scale, color);
|
||
}
|
||
}
|
||
|
||
fn draw_debug_3d(app: &App, now: f64) {
|
||
let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32);
|
||
|
||
if app.settings.debug_trigger_zones || app.editor_testing {
|
||
if let Some(campaign) = app.custom_campaign.as_ref() {
|
||
if let Some(index) = app.custom_mission_index {
|
||
if let Some(mission) = campaign.missions.get(index) {
|
||
let done = app.custom_progress.as_ref().map(|p| p.is_done(index)).unwrap_or(false);
|
||
let zone_pulse = if done { pulse * 0.45 } else { pulse };
|
||
for zone in mission.zones.iter().take(80) {
|
||
draw_debug_zone(zone, app.game.map.core, zone_pulse);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if app.settings.debug_path_lines {
|
||
let mut drawn = 0usize;
|
||
for e in app.game.ents.iter() {
|
||
if !e.alive || e.held || e.hero || e.side != app.game.local_side || e.path.is_empty() {
|
||
continue;
|
||
}
|
||
if drawn >= 120 {
|
||
break;
|
||
}
|
||
drawn += 1;
|
||
let color = chat_color(Some(e.side), 0.55);
|
||
let mut prev = vec3(e.disp_x, 0.075, e.disp_y);
|
||
for tile in e.path.iter().take(12) {
|
||
let (tx, ty) = map::xy(*tile);
|
||
let p = vec3(tx as f32 + 0.5, 0.055, ty as f32 + 0.5);
|
||
line3(prev, p, 0.04, color);
|
||
prev = p;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn draw_debug_zone(zone: &campaign_doc::ZoneDoc, core: (i32, i32), pulse: f32) {
|
||
let c = debug_zone_color(&zone.name);
|
||
let color = Color::new(c.r, c.g, c.b, pulse);
|
||
let at = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5));
|
||
let shape = zone.shape.as_str();
|
||
|
||
match shape {
|
||
"rect" | "rectangle" | "square" => {
|
||
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||
let a = vec3(from.0 + 0.5, 0.05, from.1 + 0.5);
|
||
let b = vec3(to.0 + 0.5, 0.05, to.1 + 0.5);
|
||
line3(vec3(a.x, 0.05, a.y), vec3(b.x, 0.05, a.y), 0.05, color);
|
||
line3(vec3(b.x, 0.05, a.y), vec3(b.x, 0.05, b.y), 0.05, color);
|
||
line3(vec3(b.x, 0.05, b.y), vec3(a.x, 0.05, b.y), 0.05, color);
|
||
line3(vec3(a.x, 0.05, b.y), vec3(a.x, 0.05, a.y), 0.05, color);
|
||
}
|
||
}
|
||
"line" | "path" => {
|
||
if let (Some(from), Some(to)) = (zone.from, zone.to) {
|
||
let w = zone.width.unwrap_or(1.0).max(0.2);
|
||
line3(vec3(from.0 + 0.5, 0.05, from.1 + 0.5), vec3(to.0 + 0.5, 0.05, to.1 + 0.5), w * 0.05, color);
|
||
}
|
||
}
|
||
"tiles" => {
|
||
if let Some(tiles) = zone.tiles.as_ref() {
|
||
for (x, y) in tiles.iter().take(200) {
|
||
disc(vec3(*x as f32 + 0.5, 0.045, *y as f32 + 0.5), 0.45, color, 12);
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
let r = zone.radius.unwrap_or(2.0);
|
||
disc(vec3(at.0 + 0.5, 0.04, at.1 + 0.5), r, color, 48);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn debug_zone_color(name: &str) -> Color {
|
||
let mut h: u64 = 2166136261;
|
||
for b in name.bytes() {
|
||
h ^= b as u64;
|
||
h = h.wrapping_mul(1099511628211);
|
||
}
|
||
Color::from_rgba(80 + ((h % 176) as u8), 90 + (((h >> 8) % 166) as u8), 120 + (((h >> 16) % 136) as u8), 255)
|
||
}
|