From fb7dab36836b260cb0ed6b610c417526d40e19a8 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 2 Nov 2026 23:08:33 +0100 Subject: [PATCH] Title screen, pause menu, settings dialog and savegames - title screen (continue/new/load/settings/help) and Esc pause menu - settings: language & sound, camera, graphics, game, rebindable keys; stored in localStorage and in every savegame (always applied on load) - savegames as JSON (.dcsave): download, load via file picker or drag & drop - autosave to localStorage every 2 minutes and when the tab is hidden/closed - configurable wall detail, light count, interface size, game speed, hints - audio behind a cargo feature so native tests run (cargo test --no-default-features) - savegame round-trip test Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 98 +++++++ Cargo.toml | 9 +- DESIGN.md | 9 +- README.md | 13 +- lang/de.lang | 69 +++++ lang/en.lang | 69 +++++ src/game.rs | 18 +- src/main.rs | 674 +++++++++++++++++++++++++++++++---------------- src/map.rs | 29 +- src/menu.rs | 434 ++++++++++++++++++++++++++++++ src/platform.rs | 44 +++- src/render3d.rs | 29 +- src/save.rs | 90 +++++++ src/settings.rs | 209 +++++++++++++++ src/sound.rs | 22 +- src/ui.rs | 212 ++++++++------- www/dc_plugin.js | 59 ++++- 17 files changed, 1742 insertions(+), 345 deletions(-) create mode 100644 src/menu.rs create mode 100644 src/save.rs create mode 100644 src/settings.rs diff --git a/Cargo.lock b/Cargo.lock index 130a8d7..3506807 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,8 @@ name = "dungeoncraft" version = "0.1.0" dependencies = [ "macroquad", + "serde", + "serde_json", ] [[package]] @@ -186,6 +188,12 @@ dependencies = [ "png", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lewton" version = "0.9.4" @@ -245,6 +253,12 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "miniquad" version = "0.4.11" @@ -323,6 +337,15 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + [[package]] name = "quad-alsa-sys" version = "0.3.2" @@ -351,6 +374,58 @@ dependencies = [ "winapi", ] +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -366,6 +441,17 @@ dependencies = [ "maybe-uninit", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -375,6 +461,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + [[package]] name = "winapi" version = "0.3.9" @@ -402,3 +494,9 @@ name = "zlib-rs" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 0700999..ae32529 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,14 @@ version = "0.1.0" edition = "2021" [dependencies] -macroquad = { version = "0.4", features = ["audio"] } +macroquad = { version = "0.4" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[features] +default = ["audio"] +# macroquad audio (needs libasound natively); disable for native tests: cargo test --no-default-features +audio = ["macroquad/audio"] [profile.release] opt-level = 3 diff --git a/DESIGN.md b/DESIGN.md index 859d371..da9797f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -29,9 +29,14 @@ An original dungeon management game (genre: dungeon building & defence) that run - Spells: 0 lightning, X heal, V summon imp - Torches: placed automatically when ground is claimed; foreign areas are dimly visible +## Menus, savegames, settings +- **Title screen** (Continue, New Game, Load, Settings, Help) and **pause menu** on Esc (Resume, Save, Load, Settings, Help, Main Menu) +- Savegames as **JSON** (`*.dcsave`, format tag + version, ~300 KB): **save = download**, **load = file picker or drag & drop** onto the game window +- **Autosave** in the browser (localStorage) every 2 minutes and when the tab is hidden/closed; "Continue" on the title screen +- Settings: language + sound, camera (rotation/zoom speed, edge scrolling, invert mouse), graphics (tile outlines, wall detail, light sources, interface size), game (speed 1×/2×, hints), **key bindings** (conflicts swap keys; Esc and arrow keys are fixed) +- Settings are stored in the browser **and** in every savegame; loading a savegame **always applies** its settings + ## Planned -- Menu: save (download `*.dcsave`), load (file picker + drag & drop), autosave in the browser (localStorage) -- Settings: language + sound, camera, graphics, game, key bindings – stored in the browser **and** in the savegame - Music: own **XM/MOD replayer in Rust**, shared mixer for music + sound effects (streamed via a JS audio plugin, replaces macroquad audio) - Soundtrack (composed as `.xm`, editable in MilkyTracker): title/menu, several calm build tracks, battle, victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece** diff --git a/README.md b/README.md index ffb59de..2554b1d 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,17 @@ python3 -m http.server 8080 -d www # open http://localhost:8080 ### Testing the game logic -The game logic (`src/game.rs`, `src/map.rs`) does not depend on macroquad and contains a headless simulation test. +The game logic (`src/game.rs`, `src/map.rs`) does not depend on macroquad. The tests (a headless simulation of whole games and a savegame round trip) run natively without the audio feature: + +```sh +cargo test --release --no-default-features +``` ## Controls | Input | Action | |---|---| +| Esc | Pause menu (save, load, settings) | | Left click / drag | Use the active tool (dig and rooms: drag a rectangle) | | Right click | Drop a unit from your hand | | Right drag, WASD, arrows, screen edge | Move the camera | @@ -51,6 +56,12 @@ The game logic (`src/game.rs`, `src/map.rs`) does not depend on macroquad and co | G | Tile outlines on/off | | H | Help | +All keys except Esc and the arrow keys can be changed in Settings → Keys. + +## Savegames + +Use *Save Game* in the pause menu to download a `.dcsave` file (JSON). Load it again via *Load Game* or by dropping the file onto the game window. The game also autosaves into the browser every two minutes and when the tab is closed; *Continue* on the title screen resumes from there. + ## Adding a language 1. Copy `lang/en.lang` to `lang/.lang` and translate the texts (placeholders `{0}`, `{1}` stay as they are). diff --git a/lang/de.lang b/lang/de.lang index 18bd081..d19b5b8 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -197,3 +197,72 @@ tile.marked = zum Graben markiert # ---- Sprachauswahl langsel.title = Sprache langsel.hint = Klicke eine Sprache an. Du kannst sie später in den Einstellungen ändern. + +# ---- Menüs +menu.subtitle = Graben. Bauen. Verteidigen. Erobern. +menu.continue = Fortsetzen +menu.new = Neues Spiel +menu.load = Spiel laden +menu.save = Spiel speichern +menu.settings = Einstellungen +menu.help = Hilfe +menu.resume = Weiterspielen +menu.main = Hauptmenü +menu.back = Zurück +menu.paused = Pause +menu.drop_hint = Tipp: Du kannst eine .dcsave-Datei auch einfach aufs Spielfenster ziehen, um sie zu laden. + +# ---- Einstellungen +set.tab.sound = Sprache & Ton +set.tab.camera = Kamera +set.tab.graphics = Grafik +set.tab.game = Spiel +set.tab.keys = Tasten +set.language = Sprache +set.volume = Effekt-Lautstärke +set.music = Musik-Lautstärke +set.mute = Stumm +set.rotate_speed = Drehgeschwindigkeit +set.zoom_speed = Zoomgeschwindigkeit +set.edge_scroll = Bildschirmrand-Scrollen +set.invert = Mausdrehung umkehren +set.outlines = Kachel-Umrisse +set.wall_detail = Wanddetail +set.lights = Lichtquellen +set.ui_scale = Oberflächengröße +set.game_speed = Spieltempo +set.hints = Hinweise +set.keys_hint = Aktion anklicken und Taste drücken. Bereits belegte Tasten werden getauscht. +set.press_key = Taste drücken für: {0} +set.esc_cancel = Esc bricht ab +set.reset_keys = Tasten zurücksetzen +val.on = An +val.off = Aus +val.low = Niedrig +val.medium = Mittel +val.high = Hoch +val.normal = Normal +val.large = Groß + +# ---- Namen der Tastenbelegung +key.cam_forward = Kamera vor +key.cam_back = Kamera zurück +key.cam_left = Kamera links +key.cam_right = Kamera rechts +key.rotate_left = Links drehen +key.rotate_right = Rechts drehen +key.tilt_up = Neigen hoch +key.tilt_down = Neigen runter +key.pause = Pause +key.mute = Ton an/aus +key.volume_up = Lauter +key.volume_down = Leiser +key.outlines = Kachel-Umrisse +key.help = Hilfe + +# ---- Spielstände +save.saved = Spiel gespeichert: {0} +save.loaded = Spiel geladen. +save.err_invalid = Diese Datei ist kein gültiger DungeonCraft-Spielstand. +save.err_version = Dieser Spielstand stammt aus einer neueren DungeonCraft-Version. +save.choose = Spielstand-Datei auswählen ... diff --git a/lang/en.lang b/lang/en.lang index 46610a0..96df502 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -198,3 +198,72 @@ tile.marked = marked for digging # ---- language selection langsel.title = Language langsel.hint = Click a language. You can change it later in the settings. + +# ---- menus +menu.subtitle = Dig. Build. Defend. Conquer. +menu.continue = Continue +menu.new = New Game +menu.load = Load Game +menu.save = Save Game +menu.settings = Settings +menu.help = Help +menu.resume = Resume +menu.main = Main Menu +menu.back = Back +menu.paused = Paused +menu.drop_hint = Tip: you can also drop a .dcsave file onto the game window to load it. + +# ---- settings +set.tab.sound = Language & Sound +set.tab.camera = Camera +set.tab.graphics = Graphics +set.tab.game = Game +set.tab.keys = Keys +set.language = Language +set.volume = Sound volume +set.music = Music volume +set.mute = Mute +set.rotate_speed = Rotation speed +set.zoom_speed = Zoom speed +set.edge_scroll = Screen edge scrolling +set.invert = Invert mouse rotation +set.outlines = Tile outlines +set.wall_detail = Wall detail +set.lights = Light sources +set.ui_scale = Interface size +set.game_speed = Game speed +set.hints = Hints +set.keys_hint = Click an action and press a key. Keys already in use are swapped. +set.press_key = Press a key for: {0} +set.esc_cancel = Esc cancels +set.reset_keys = Reset keys +val.on = On +val.off = Off +val.low = Low +val.medium = Medium +val.high = High +val.normal = Normal +val.large = Large + +# ---- key binding names +key.cam_forward = Camera forward +key.cam_back = Camera back +key.cam_left = Camera left +key.cam_right = Camera right +key.rotate_left = Rotate left +key.rotate_right = Rotate right +key.tilt_up = Tilt up +key.tilt_down = Tilt down +key.pause = Pause +key.mute = Sound on/off +key.volume_up = Volume up +key.volume_down = Volume down +key.outlines = Tile outlines +key.help = Help + +# ---- savegames +save.saved = Game saved: {0} +save.loaded = Game loaded. +save.err_invalid = This file is not a valid DungeonCraft savegame. +save.err_version = This savegame comes from a newer version of DungeonCraft. +save.choose = Choose a savegame file ... diff --git a/src/game.rs b/src/game.rs index 1b4ddda..a35e80c 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,6 +1,7 @@ // Game logic: units, AI, economy, hero waves. use crate::map::*; +use serde::{Deserialize, Serialize}; pub const CORE_HP: f32 = 2000.0; pub const GATE_HP: f32 = 3000.0; @@ -71,7 +72,7 @@ pub struct SfxEvent { pub const MANA_MAX: f32 = 400.0; pub const CARRY_MAX: i32 = 300; -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum EK { Worker, Kobold, @@ -112,7 +113,7 @@ pub fn st(k: EK) -> St { } } -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Job { None, Dig(usize), @@ -129,7 +130,7 @@ pub enum Job { Rally, } -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub struct Ent { pub id: u32, pub kind: EK, @@ -217,6 +218,7 @@ pub struct Fx { pub col: u32, } +#[derive(Serialize, Deserialize)] pub struct Game { pub map: Map, pub ents: Vec, @@ -231,7 +233,9 @@ pub struct Game { pub warned: bool, pub portal_timer: f32, pub payday: f32, + #[serde(skip)] pub fx: Vec, + #[serde(skip)] pub msgs: Vec, pub state: u8, // 0 running, 1 victory, 2 defeat pub paused: bool, @@ -241,14 +245,17 @@ pub struct Game { pub gate_hit_t: f32, pub gate_stage: u32, // defender thresholds triggered (0..3) pub flag: Option, + #[serde(skip)] pub events: Vec, pub last_wave_value: f32, pub torches: Vec, + #[serde(skip)] + pub hints: bool, // show hint messages (setting) hint_t: [f32; 16], } // torch on a wall face: wall tile + direction (index into DIR4) towards the open side -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Torch { pub wall: usize, pub dir: usize, @@ -315,6 +322,7 @@ impl Game { events: Vec::new(), last_wave_value: 0.0, torches: Vec::new(), + hints: true, hint_t: [-1000.0; 16], }; let (cx, cy) = g.map.core; @@ -391,7 +399,7 @@ impl Game { } fn hint(&mut self, slot: usize, key: &'static str) { - if self.time - self.hint_t[slot] > 30.0 { + if self.hints && self.time - self.hint_t[slot] > 30.0 { self.hint_t[slot] = self.time; self.msg(key, vec![]); } diff --git a/src/main.rs b/src/main.rs index cdb8a0a..af8fb2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,19 +4,28 @@ mod game; mod gfx; mod i18n; mod map; +mod menu; mod platform; mod render3d; +mod save; +mod settings; mod sound; mod ui; -use game::Game; +use game::{Game, Msg}; use gfx::Gfx; use macroquad::prelude::*; use map::{idx, inb, MH, MW}; +use menu::{Menu, MenuAction, Page}; use render3d::{Cam, Overlay, R3d}; +use settings::{bindable_keys, Action, Settings}; use sound::SoundBank; use ui::{hover_info, Tool, Ui, UiAction, TOOLS}; +const SETTINGS_KEY: &str = "dc.settings"; +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 { @@ -52,215 +61,389 @@ fn next_seed(seed: u32) -> u32 { seed.wrapping_mul(1103515245).wrapping_add(12345) | 1 } +fn apply_settings(s: &Settings, r3d: &mut R3d, sounds: &mut SoundBank) { + i18n::set_language(&s.language); + ui::set_ui_scale(s.ui_scale as f32); + r3d.outlines = s.outlines; + r3d.set_wall_detail(s.wall_subdivisions()); + r3d.max_lights = s.max_lights as usize; + sounds.volume = s.volume; + sounds.muted = s.muted; +} + +fn store_settings(s: &Settings) { + platform::storage_set(SETTINGS_KEY, &s.to_json()); +} + +// "dungeoncraft_2026-11-02_2145.dcsave" (UTC) +fn save_file_name() -> String { + let secs = miniquad::date::now() as i64; + let days = secs.div_euclid(86400); + let rem = secs.rem_euclid(86400); + // civil date from days since 1970-01-01 (Howard Hinnant's algorithm) + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; + format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, rem / 3600, rem % 3600 / 60) +} + +struct App { + game: Game, + cam: Cam, + settings: Settings, + seed: u32, + status: Option<(String, f64)>, +} + +impl App { + fn set_status(&mut self, text: String, now: f64) { + self.status = Some((text, now)); + } + + // Load a savegame (settings in the savegame are always applied) + fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, sounds: &mut SoundBank, now: f64) { + match save::from_bytes(bytes) { + Ok(l) => { + self.game = l.game; + if let Some(c) = l.camera { + self.cam = c; + } + if let Some(s) = l.settings { + self.settings = s; + apply_settings(&self.settings, r3d, sounds); + store_settings(&self.settings); + } + self.game.hints = self.settings.hints; + r3d.invalidate(); + menu.page = None; + ui.show_help = false; + ui.log.clear(); + ui.log.push((Msg { key: "save.loaded", args: vec![] }, now)); + self.status = None; + } + Err(key) => self.set_status(i18n::tr(key), now), + } + } + + fn autosave_json(&self) -> String { + save::to_json(&self.game, &self.cam, &self.settings) + } +} + #[macroquad::main(conf)] async fn main() { let gfx = Gfx::new(); let mut r3d = R3d::new(); let mut sounds = SoundBank::new(); sounds.generate_all().await; - let mut seed = (miniquad::date::now() * 1000.0) as u32 | 1; - let (mut game, mut cam) = new_game(seed); let mut ui = Ui::new(); - // language: stored choice, otherwise ask on first start - match platform::storage_get("dc.lang") { - Some(code) if i18n::set_language(&code) => {} - _ => ui.show_langsel = true, + let mut menu = Menu::new(); + + // 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, &mut sounds); + 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; + let (game, cam) = new_game(seed); + let mut app = App { game, cam, settings, seed, status: None }; + app.game.hints = app.settings.hints; let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false }; + let mut autosave_t = 0.0f32; + let mut cache_t = 0.0f32; loop { let dt = get_frame_time().min(0.1); let now = get_time(); let (mx, my) = mouse_position(); + let mut settings_changed = false; + + // ---------------- savegame files: file picker result or dropped onto the window + let mut incoming: Option> = platform::take_file(); + for f in get_dropped_files() { + if let Some(b) = f.bytes { + incoming = Some(b); + } + } + if let Some(bytes) = incoming { + app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, &mut sounds, now); + } + + // ---------------- 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, &mut sounds); + store_settings(&app.settings); + menu.finish_capture(); + } + } + draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &inp, now); + next_frame().await; + continue; + } + + let dialog = ui.show_langsel || ui.show_help; + let in_menu = menu.open(); + let playing = !in_menu && !dialog; let over_ui = ui.over_ui(mx, my); - let picked = cam.pick_tile(&game.map, mx, my); + let picked = app.cam.pick_tile(&app.game.map, mx, my); let hover_tile = picked.map(|(x, z, _)| (x, z)); - let ground = cam.ground_point(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); - // ---------------- keyboard - let tool_keys = [ - KeyCode::Key1, - KeyCode::Key2, - KeyCode::Key3, - KeyCode::Key4, - KeyCode::Key5, - KeyCode::Key6, - KeyCode::Key7, - KeyCode::Key8, - KeyCode::Key9, - KeyCode::Key0, - KeyCode::X, - KeyCode::V, - ]; - for (i, k) in tool_keys.iter().enumerate() { - if is_key_pressed(*k) { - ui.tool = TOOLS[i]; - inp.ldown = None; - } - } - if is_key_pressed(KeyCode::H) || (is_key_pressed(KeyCode::Escape) && ui.show_help) { - ui.show_help = !ui.show_help; - } + // ---------------- Esc: dialogs, menus, pause menu if is_key_pressed(KeyCode::Escape) { - inp.ldown = None; - } - if (is_key_pressed(KeyCode::P) || is_key_pressed(KeyCode::Space)) && game.state == 0 { - game.paused = !game.paused; - } - if is_key_pressed(KeyCode::G) { - r3d.outlines = !r3d.outlines; - } - if is_key_pressed(KeyCode::M) { - sounds.muted = !sounds.muted; - } - if is_key_pressed(KeyCode::Equal) || is_key_pressed(KeyCode::KpAdd) { - sounds.change_volume(0.1); - } - if is_key_pressed(KeyCode::Minus) || is_key_pressed(KeyCode::KpSubtract) { - sounds.change_volume(-0.1); - } - if is_key_pressed(KeyCode::N) && game.state != 0 { - seed = next_seed(seed); - (game, cam) = new_game(seed); - ui.show_help = false; + 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); + } } - // ---------------- camera: pan - let (right, fwd) = cam.ground_axes(); - let pan = cam.zoom * 0.9 * 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(KeyCode::W) || is_key_down(KeyCode::Up) { - move_cam(0.0, pan, &mut cam); - } - if is_key_down(KeyCode::S) || is_key_down(KeyCode::Down) { - move_cam(0.0, -pan, &mut cam); - } - if is_key_down(KeyCode::A) || is_key_down(KeyCode::Left) { - move_cam(-pan, 0.0, &mut cam); - } - if is_key_down(KeyCode::D) || is_key_down(KeyCode::Right) { - move_cam(pan, 0.0, &mut cam); - } - let edge = 6.0; - if !ui.show_help && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { - if mx < edge { - move_cam(-pan, 0.0, &mut cam); - } else if mx > screen_width() - edge { - move_cam(pan, 0.0, &mut cam); - } - if my < edge { - move_cam(0.0, pan, &mut cam); - } else if my > screen_height() - edge { - move_cam(0.0, -pan, &mut cam); - } - } - // ---------------- camera: rotate / tilt - let rot_speed = 1.8 * dt; - if is_key_down(KeyCode::Q) { - cam.yaw += rot_speed; - } - if is_key_down(KeyCode::E) { - cam.yaw -= rot_speed; - } - if is_key_down(KeyCode::R) { - cam.pitch += rot_speed * 0.6; - } - if is_key_down(KeyCode::F) { - cam.pitch -= rot_speed * 0.6; - } - if is_mouse_button_down(MouseButton::Middle) { - if let Some((lx, ly)) = inp.mlast { - cam.yaw -= (mx - lx) * 0.008; - cam.pitch += (my - ly) * 0.005; - } - inp.mlast = Some((mx, my)); - } else { - inp.mlast = None; - } - cam.pitch = cam.pitch.clamp(0.35, 1.45); - // ---------------- camera: zoom around the mouse - let (_, wheel) = mouse_wheel(); - if wheel != 0.0 && !over_ui { - let before = cam.ground_point(mx, my); - cam.zoom = (cam.zoom * if wheel > 0.0 { 0.88 } else { 1.0 / 0.88 }).clamp(6.0, 45.0); - let after = cam.ground_point(mx, my); - 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 && game.state == 0 { - if inp.ldown.is_some() { + // ---------------- keyboard (only while playing) + if playing { + for i in 0..TOOLS.len() { + if is_key_pressed(key(Action::Tool(i))) { + ui.tool = TOOLS[i]; inp.ldown = None; - } else { - game.drop(wx, wy); } } - inp.rdown = 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.game.hints = app.settings.hints; + } + } + if !dialog { + 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 * 0.9 * 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 keys.edge_scroll && mx >= 0.0 && my >= 0.0 && mx < screen_width() && my < screen_height() { + if mx < edge { + move_cam(-pan, 0.0, cam); + } else if mx > screen_width() - edge { + move_cam(pan, 0.0, cam); + } + if my < edge { + move_cam(0.0, pan, cam); + } else if my > screen_height() - edge { + move_cam(0.0, -pan, cam); + } + } + let rot = 1.8 * dt * keys.rotate_speed; + if is_key_down(key(Action::RotateLeft)) { + cam.yaw += rot; + } + if is_key_down(key(Action::RotateRight)) { + cam.yaw -= rot; + } + if is_key_down(key(Action::TiltUp)) { + cam.pitch += rot * 0.6; + } + if is_key_down(key(Action::TiltDown)) { + cam.pitch -= rot * 0.6; + } + if is_mouse_button_down(MouseButton::Middle) { + 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; + } + // 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 { + app.game.drop(wx, wy); + } + } + inp.rdown = None; + } } // ---------------- left button if is_mouse_button_pressed(MouseButton::Left) { - match ui.click(mx, my, &game) { - UiAction::SelectTool(t) => { - ui.tool = t; - game.sfx_ui(game::Sfx::Click); + if dialog { + match ui.click(mx, my, &app.game) { + UiAction::SelectLanguage(code) => { + app.settings.language = code; + settings_changed = true; + ui.show_langsel = false; + } + UiAction::CloseHelp => ui.show_help = false, + _ => {} } - UiAction::MinimapJump(x, y) => { - cam.tx = x; - cam.tz = y; - inp.minimap_drag = true; + } else if in_menu { + match menu.click(mx, my, &mut app.settings) { + MenuAction::SettingsChanged => settings_changed = true, + MenuAction::Resume => {} + MenuAction::NewGame => { + app.seed = next_seed(app.seed); + (app.game, app.cam) = new_game(app.seed); + app.game.hints = app.settings.hints; + 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, &mut sounds, now); + } + } + MenuAction::Load => { + platform::open_file(); + app.set_status(i18n::tr("save.choose"), now); + } + MenuAction::Save => { + let name = save_file_name(); + platform::download(&name, app.autosave_json().as_bytes()); + app.set_status(i18n::trf("save.saved", &[name]), now); + } + MenuAction::Help => ui.show_help = true, + MenuAction::MainMenu => { + if app.game.state == 0 { + platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json()); + menu.has_autosave = true; + } + menu.page = Some(Page::Title); + } + MenuAction::None | MenuAction::Consumed => {} } - UiAction::CloseHelp => ui.show_help = false, - UiAction::NewGame => { - seed = next_seed(seed); - (game, cam) = new_game(seed); - ui.show_help = false; - } - UiAction::SelectLanguage(code) => { - i18n::set_language(&code); - platform::storage_set("dc.lang", &code); - ui.show_langsel = false; - game.sfx_ui(game::Sfx::Click); - } - UiAction::Consumed => {} - UiAction::None => { - if game.state == 0 { - match (ui.tool, hover_tile) { - (Tool::Dig | Tool::Room(_) | Tool::Sell, Some(t)) => inp.ldown = Some(t), - (Tool::Hand, _) => { - let j = r3d.pick_ent(&game, &cam, mx, my, true); - game.pickup(j); + } else { + match ui.click(mx, my, &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.game.hints = app.settings.hints; + } + UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} + UiAction::None => { + if app.game.state == 0 { + match (ui.tool, hover_tile) { + (Tool::Dig | Tool::Room(_) | Tool::Sell, Some(t)) => inp.ldown = Some(t), + (Tool::Hand, _) => { + let j = r3d.pick_ent(&app.game, &app.cam, mx, my, true); + app.game.pickup(j); + } + (Tool::Flag, Some((x, z))) => app.game.set_flag(x, z), + (Tool::Lightning, _) => app.game.lightning(wx, wy), + (Tool::Heal, _) => app.game.heal(wx, wy), + (Tool::Summon, _) => app.game.summon(wx, wy), + _ => {} } - (Tool::Flag, Some((x, z))) => game.set_flag(x, z), - (Tool::Lightning, _) => game.lightning(wx, wy), - (Tool::Heal, _) => game.heal(wx, wy), - (Tool::Summon, _) => game.summon(wx, wy), - _ => {} } } } @@ -269,68 +452,115 @@ async fn main() { if inp.minimap_drag { if is_mouse_button_down(MouseButton::Left) && ui.minimap_contains(mx, my) { let (x, y) = ui.minimap_world(mx, my); - cam.tx = x; - cam.tz = y; + app.cam.tx = x; + app.cam.tz = y; } else if !is_mouse_button_down(MouseButton::Left) { inp.minimap_drag = false; } } if is_mouse_button_released(MouseButton::Left) { if let Some((sx, sy)) = inp.ldown.take() { - if let Some((ex, ey)) = hover_tile { + if let (true, Some((ex, ey))) = (playing, hover_tile) { match ui.tool { Tool::Dig => { - let on = !game.map.t[idx(sx, sy)].marked; - game.mark_rect(sx, sy, ex, ey, on); + let on = !app.game.map.t[idx(sx, sy)].marked; + app.game.mark_rect(sx, sy, ex, ey, on); } - Tool::Room(r) => game.build_rect(r, sx, sy, ex, ey), - Tool::Sell => game.sell_rect(sx, sy, ex, ey), + Tool::Room(r) => app.game.build_rect(r, sx, sy, ex, ey), + Tool::Sell => app.game.sell_rect(sx, sy, ex, ey), _ => {} } } } } - cam.tx = cam.tx.clamp(0.0, MW as f32); - cam.tz = cam.tz.clamp(0.0, MH as f32); + app.cam.tx = app.cam.tx.clamp(0.0, MW as f32); + app.cam.tz = app.cam.tz.clamp(0.0, MH as f32); - // ---------------- update - game.update(dt); - let hear = cam.zoom * 1.2; - for ev in game.events.drain(..) { - sounds.play(ev, vec2(cam.tx, cam.tz), hear, now); + if settings_changed { + apply_settings(&app.settings, &mut r3d, &mut sounds); + store_settings(&app.settings); + app.game.hints = app.settings.hints; } - for m in game.msgs.drain(..) { + + // ---------------- update (the world stands still while menus or dialogs are open) + if playing { + for _ in 0..(app.settings.game_speed.round().max(1.0) as usize) { + app.game.update(dt); + } + // autosave: snapshot for leaving the tab + regular autosave + if app.game.state == 0 { + 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; + } + } + } + let hear = app.cam.zoom * 1.2; + for ev in app.game.events.drain(..) { + sounds.play(ev, vec2(app.cam.tx, app.cam.tz), hear, now); + } + for m in app.game.msgs.drain(..) { ui.log.push((m, now)); } - // ---------------- draw - clear_background(Color::from_rgba(4, 3, 5, 255)); - let (color, radius) = match ui.tool { - Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None), - Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None), - Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None), - Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None), - Tool::Flag => (Color::new(1.0, 0.3, 0.3, 1.0), None), - Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((wx, wy, 1.6))), - Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((wx, wy, 2.55))), - Tool::Summon => (Color::new(0.8, 0.5, 1.0, 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(&game, &gfx, &cam, now, &ov); - let info = if over_ui { - Vec::new() - } else { - let ent = r3d.pick_ent(&game, &cam, mx, my, false); - hover_info(&game, ent, hover_tile) - }; - ui.draw(&game, &gfx, &cam, &sounds, &info, now); - + draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &inp, now); next_frame().await } } + +#[allow(clippy::too_many_arguments)] +fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, sounds: &SoundBank, inp: &Input, now: f64) { + let (mx, my) = mouse_position(); + let blocked = menu.open() || ui.show_help || ui.show_langsel; + let over_ui = blocked || ui.over_ui(mx, my); + let hover_tile = app.cam.pick_tile(&app.game.map, mx, my).map(|(x, z, _)| (x, z)); + let ground = app.cam.ground_point(mx, my); + clear_background(Color::from_rgba(4, 3, 5, 255)); + let (color, radius) = match ui.tool { + Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None), + Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None), + Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None), + Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None), + Tool::Flag => (Color::new(1.0, 0.3, 0.3, 1.0), None), + Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))), + Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))), + Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None), + }; + 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); + let title_screen = matches!( + menu.page, + Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) + ); + if !title_screen { + let info = if over_ui { + 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, sounds, &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())); + if !menu.open() || ui.show_help || ui.show_langsel { + ui.draw_dialogs(&app.game, gfx); + } +} diff --git a/src/map.rs b/src/map.rs index 5234beb..c82a951 100644 --- a/src/map.rs +++ b/src/map.rs @@ -1,5 +1,6 @@ // Map, tiles, generation and path finding. +use serde::{Deserialize, Serialize}; use std::cmp::Reverse; use std::collections::BinaryHeap; @@ -7,7 +8,7 @@ pub const MW: usize = 64; pub const MH: usize = 64; pub const N: usize = MW * MH; -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Kind { Rock, Earth, @@ -23,24 +24,34 @@ pub enum Kind { Gate, } -#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Owner { None, Player, Hero, } -#[derive(Clone, Copy)] +// Short serde names keep savegames small (the map has 4096 tiles) +#[derive(Clone, Copy, Serialize, Deserialize)] pub struct Tile { + #[serde(rename = "k")] pub kind: Kind, + #[serde(rename = "o")] pub owner: Owner, - pub hp: f32, // dig resistance - pub gold: i32, // gold left (gold vein) + #[serde(rename = "h")] + pub hp: f32, // dig resistance + #[serde(rename = "g")] + pub gold: i32, // gold left (gold vein) + #[serde(rename = "m")] pub marked: bool, // marked for digging + #[serde(rename = "r")] pub reserved: u32, // entity id + 1 working on this tile - pub food: f32, // mushrooms in the mushroom garden - pub bed: u32, // entity id + 1 sleeping here - pub var: u8, // texture variant + #[serde(rename = "f")] + pub food: f32, // mushrooms in the mushroom garden + #[serde(rename = "b")] + pub bed: u32, // entity id + 1 sleeping here + #[serde(rename = "v")] + pub var: u8, // texture variant } impl Tile { @@ -74,6 +85,7 @@ pub fn inb(x: i32, y: i32) -> bool { pub const DIR4: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)]; pub const DIR8: [(i32, i32); 8] = [(0, -1), (1, 0), (0, 1), (-1, 0), (1, -1), (1, 1), (-1, 1), (-1, -1)]; +#[derive(Serialize, Deserialize)] pub struct Rng(pub u32); impl Rng { pub fn next(&mut self) -> u32 { @@ -95,6 +107,7 @@ impl Rng { } } +#[derive(Serialize, Deserialize)] pub struct Map { pub t: Vec, pub core: (i32, i32), // top-left corner of the core (3x3) diff --git a/src/menu.rs b/src/menu.rs new file mode 100644 index 0000000..d405f35 --- /dev/null +++ b/src/menu.rs @@ -0,0 +1,434 @@ +// Menus: title screen, pause menu and the settings dialog (tabs, values, key bindings). +// Drawn as CP437 windows on top of the (paused) game world. + +use crate::gfx::*; +use crate::i18n::{languages, tr, trf}; +use crate::settings::{all_actions, key_name, Action, Settings}; +use crate::ui::{tool_info, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, TOOLS, TXT}; +use macroquad::prelude::*; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Page { + Title, + Pause, + Settings { tab: usize, from_title: bool }, + KeyCapture { action: usize, from_title: bool }, +} + +pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"]; + +// One adjustable value in the settings dialog +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Item { + Language, + Volume, + MusicVolume, + Mute, + RotateSpeed, + ZoomSpeed, + EdgeScroll, + InvertMouse, + Outlines, + WallDetail, + Lights, + UiScale, + GameSpeed, + Hints, +} + +fn tab_items(tab: usize) -> &'static [Item] { + use Item::*; + match tab { + 0 => &[Language, Volume, MusicVolume, Mute], + 1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse], + 2 => &[Outlines, WallDetail, Lights, UiScale], + 3 => &[GameSpeed, Hints], + _ => &[], + } +} + +fn item_label(it: Item) -> String { + tr(match it { + Item::Language => "set.language", + Item::Volume => "set.volume", + Item::MusicVolume => "set.music", + Item::Mute => "set.mute", + Item::RotateSpeed => "set.rotate_speed", + Item::ZoomSpeed => "set.zoom_speed", + Item::EdgeScroll => "set.edge_scroll", + Item::InvertMouse => "set.invert", + Item::Outlines => "set.outlines", + Item::WallDetail => "set.wall_detail", + Item::Lights => "set.lights", + Item::UiScale => "set.ui_scale", + Item::GameSpeed => "set.game_speed", + Item::Hints => "set.hints", + }) +} + +fn onoff(b: bool) -> String { + tr(if b { "val.on" } else { "val.off" }) +} + +fn pct(v: f32) -> String { + format!("{}%", (v * 100.0).round() as i32) +} + +fn item_value(it: Item, s: &Settings) -> String { + match it { + Item::Language => languages().into_iter().find(|(c, _)| *c == s.language).map(|(_, n)| n).unwrap_or_else(|| s.language.clone()), + Item::Volume => pct(s.volume), + Item::MusicVolume => pct(s.music_volume), + Item::Mute => onoff(s.muted), + Item::RotateSpeed => pct(s.rotate_speed), + Item::ZoomSpeed => pct(s.zoom_speed), + Item::EdgeScroll => onoff(s.edge_scroll), + Item::InvertMouse => onoff(s.invert_mouse), + Item::Outlines => onoff(s.outlines), + Item::WallDetail => tr(match s.wall_detail { + 1 => "val.low", + 3 => "val.high", + _ => "val.medium", + }), + Item::Lights => s.max_lights.to_string(), + Item::UiScale => tr(if s.ui_scale >= 3 { "val.large" } else { "val.normal" }), + Item::GameSpeed => format!("{}x", s.game_speed as i32), + Item::Hints => onoff(s.hints), + } +} + +fn cycle(opts: &[T], cur: T, d: i32) -> T { + let i = opts.iter().position(|&o| o == cur).unwrap_or(0) as i32; + opts[(i + d).rem_euclid(opts.len() as i32) as usize] +} + +fn step(v: f32, d: i32, lo: f32, hi: f32, st: f32) -> f32 { + ((v + d as f32 * st) * 100.0).round().clamp(lo * 100.0, hi * 100.0) / 100.0 +} + +// Change a value; d = -1 / +1 (toggles flip either way) +fn item_change(it: Item, s: &mut Settings, d: i32) { + match it { + Item::Language => { + let codes: Vec = languages().into_iter().map(|(c, _)| c).collect(); + let i = codes.iter().position(|c| *c == s.language).unwrap_or(0) as i32; + s.language = codes[(i + d).rem_euclid(codes.len() as i32) as usize].clone(); + } + Item::Volume => s.volume = step(s.volume, d, 0.0, 1.0, 0.1), + Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1), + Item::Mute => s.muted = !s.muted, + Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25), + Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25), + Item::EdgeScroll => s.edge_scroll = !s.edge_scroll, + Item::InvertMouse => s.invert_mouse = !s.invert_mouse, + Item::Outlines => s.outlines = !s.outlines, + Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d), + Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d), + Item::UiScale => s.ui_scale = cycle(&[2u8, 3], s.ui_scale, d), + Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), + Item::Hints => s.hints = !s.hints, + } +} + +pub fn action_label(a: Action) -> String { + match a { + Action::Tool(i) => tool_info(TOOLS[i]).name, + other => tr(&format!("key.{}", other.id())), + } +} + +#[derive(Clone, Copy, PartialEq, Debug)] +enum Btn { + Continue, + Resume, + New, + Load, + Save, + Settings, + Help, + MainMenu, + Back, + Tab(usize), + Change(usize, i32), + Key(usize), + ResetKeys, +} + +pub enum MenuAction { + None, + Consumed, + Continue, // load the autosave + Resume, + NewGame, + Load, + Save, + Help, + MainMenu, + SettingsChanged, +} + +pub struct Menu { + pub page: Option, + pub has_autosave: bool, + buttons: Vec<(Rect, Btn)>, +} + +fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) { + draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent }); + let s = S(); + gfx.text(label, r.x + (r.w - gfx.measure(label, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT }); +} + +impl Menu { + pub fn new() -> Menu { + Menu { page: Some(Page::Title), has_autosave: false, buttons: Vec::new() } + } + + pub fn open(&self) -> bool { + self.page.is_some() + } + + // Esc: go one level back + pub fn back(&mut self) { + self.page = match self.page { + Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 4, from_title }), + Some(Page::Settings { from_title: true, .. }) => Some(Page::Title), + Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause), + Some(Page::Pause) => None, + Some(Page::Title) => Some(Page::Title), + None => Some(Page::Pause), + }; + } + + pub fn capturing(&self) -> Option { + match self.page { + Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(), + _ => None, + } + } + + pub fn finish_capture(&mut self) { + if let Some(Page::KeyCapture { from_title, .. }) = self.page { + self.page = Some(Page::Settings { tab: 4, from_title }); + } + } + + pub fn click(&mut self, x: f32, y: f32, settings: &mut Settings) -> MenuAction { + if self.page.is_none() { + return MenuAction::None; + } + let hit = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(_, b)| *b); + let from_title = matches!(self.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })); + match hit { + None => MenuAction::Consumed, + Some(Btn::Continue) => MenuAction::Continue, + Some(Btn::Resume) => { + self.page = None; + MenuAction::Resume + } + Some(Btn::New) => MenuAction::NewGame, + Some(Btn::Load) => MenuAction::Load, + Some(Btn::Save) => MenuAction::Save, + Some(Btn::Help) => MenuAction::Help, + Some(Btn::MainMenu) => MenuAction::MainMenu, + Some(Btn::Settings) => { + self.page = Some(Page::Settings { tab: 0, from_title }); + MenuAction::Consumed + } + Some(Btn::Back) => { + self.back(); + MenuAction::Consumed + } + Some(Btn::Tab(t)) => { + self.page = Some(Page::Settings { tab: t, from_title }); + MenuAction::Consumed + } + Some(Btn::Change(i, d)) => { + if let Some(Page::Settings { tab, .. }) = self.page { + if let Some(&it) = tab_items(tab).get(i) { + item_change(it, settings, d); + return MenuAction::SettingsChanged; + } + } + MenuAction::Consumed + } + Some(Btn::Key(a)) => { + self.page = Some(Page::KeyCapture { action: a, from_title }); + MenuAction::Consumed + } + Some(Btn::ResetKeys) => { + settings.reset_keys(); + MenuAction::SettingsChanged + } + } + } + + pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>) { + self.buttons.clear(); + let Some(page) = self.page else { return }; + let sw = screen_width(); + let sh = screen_height(); + draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 })); + match page { + Page::Title => self.draw_title(gfx), + Page::Pause => self.draw_pause(gfx), + Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab), + Page::KeyCapture { action, .. } => { + self.draw_settings(gfx, settings, 4); + self.buttons.clear(); + let a = all_actions()[action]; + let text = trf("set.press_key", &[action_label(a)]); + let cols = (text.chars().count() as i32 + 8).max(36); + let x = (sw - cols as f32 * CW()) * 0.5; + let y = sh * 0.4; + gfx.window(x, y, cols, 5, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.98)); + gfx.text(&text, x + CW() * 4.0, y + CH() * 1.5, S(), TXT); + let esc = tr("set.esc_cancel"); + gfx.text(&esc, x + CW() * 4.0, y + CH() * 3.0, S(), DIM); + } + } + if let Some(st) = status { + let w = gfx.measure(st, S()) + CW() * 4.0; + let x = (sw - w) * 0.5; + let y = sh - CH() * 9.0; + draw_rectangle(x, y, w, CH() * 2.0, Color::new(0.05, 0.03, 0.04, 0.95)); + draw_rectangle_lines(x, y, w, CH() * 2.0, 2.0, GOLD); + gfx.text(st, x + CW() * 2.0, y + CH() * 0.5, S(), GOLD); + } + } + + fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) { + let (mx, my) = mouse_position(); + for (k, (b, label)) in items.iter().enumerate() { + let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.2); + button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, *b)); + } + } + + fn draw_title(&mut self, gfx: &Gfx) { + let sw = screen_width(); + let sh = screen_height(); + let big = (S() * 4.0).min((sw / (GW * 14.0)).floor()).max(S()); + let title = "DungeonCraft"; + let ty = sh * 0.12; + gfx.text(title, (sw - gfx.measure(title, big)) * 0.5 + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8)); + gfx.text(title, (sw - gfx.measure(title, big)) * 0.5, ty, big, FRAME_HI); + let sub = tr("menu.subtitle"); + gfx.text(&sub, (sw - gfx.measure(&sub, S())) * 0.5, ty + GH * big + CH(), S(), TXT); + let mut items = Vec::new(); + if self.has_autosave { + items.push((Btn::Continue, tr("menu.continue"))); + } + items.push((Btn::New, tr("menu.new"))); + items.push((Btn::Load, tr("menu.load"))); + items.push((Btn::Settings, tr("menu.settings"))); + items.push((Btn::Help, tr("menu.help"))); + let w = 26.0 * CW(); + let y = ty + GH * big + CH() * 4.0; + self.column(gfx, (sw - w) * 0.5, y, w, &items); + let hint = tr("menu.drop_hint"); + gfx.text(&hint, (sw - gfx.measure(&hint, (S() / 2.0).max(1.0))) * 0.5, sh - CH() * 7.5, (S() / 2.0).max(1.0), DIM); + let credit = tr("help.font"); + gfx.text(&credit, (sw - gfx.measure(&credit, (S() / 2.0).max(1.0))) * 0.5, sh - CH() * 6.5, (S() / 2.0).max(1.0), DIM); + } + + fn draw_pause(&mut self, gfx: &Gfx) { + let sw = screen_width(); + let sh = screen_height(); + let items = vec![ + (Btn::Resume, tr("menu.resume")), + (Btn::Save, tr("menu.save")), + (Btn::Load, tr("menu.load")), + (Btn::Settings, tr("menu.settings")), + (Btn::Help, tr("menu.help")), + (Btn::MainMenu, tr("menu.main")), + ]; + let cols = 32; + let rows = items.len() as i32 * 3 + 4; + let x = (sw - cols as f32 * CW()) * 0.5; + let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI); + let w = (cols - 6) as f32 * CW(); + self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items); + } + + fn draw_settings(&mut self, gfx: &Gfx, s: &Settings, tab: usize) { + let sw = screen_width(); + let sh = screen_height(); + let (mx, my) = mouse_position(); + let cols = ((sw / CW()) as i32 - 2).min(66); + let rows = ((sh / CH()) as i32 - 2).min(28); + let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); + let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.settings"), FRAME_HI); + // tabs + let mut tx = x + CW() * 2.0; + let ty = y + CH() * 1.5; + for (i, key) in TABS.iter().enumerate() { + let label = tr(key); + let w = gfx.measure(&label, S()) + CW() * 2.0; + let r = Rect::new(tx, ty, w, CH() * 2.0); + let hot = r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if i == tab { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); + draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if i == tab || hot { FRAME_HI } else { FRAME }); + gfx.text(&label, r.x + CW(), r.y + CH() * 0.5, S(), if i == tab { FRAME_HI } else { TXT }); + self.buttons.push((r, Btn::Tab(i))); + tx += w + CW(); + } + let top = ty + CH() * 3.5; + if tab == 4 { + // key bindings in two columns + let acts = all_actions(); + let half = (acts.len() + 1) / 2; + let colw = (cols as f32 - 6.0) * CW() * 0.5; + let rowh = CH() * 1.35; + for (k, a) in acts.iter().enumerate() { + let c = k / half; + let r = k % half; + let bx = x + CW() * 2.0 + c as f32 * (colw + CW() * 2.0); + let by = top + r as f32 * rowh; + let rect = Rect::new(bx, by - 2.0, colw, rowh - 2.0); + let hot = rect.contains(vec2(mx, my)); + if hot { + draw_rectangle(rect.x, rect.y, rect.w, rect.h, Color::new(0.25, 0.16, 0.08, 1.0)); + } + let key = format!("[{}]", key_name(s.key(*a))); + let maxc = ((colw - gfx.measure(&key, S()) - CW()) / CW()).max(4.0) as usize; + let label: String = action_label(*a).chars().take(maxc).collect(); + gfx.text(&label, bx + CW() * 0.5, by, S(), TXT); + gfx.text(&key, bx + colw - gfx.measure(&key, S()) - CW() * 0.5, by, S(), GOLD); + self.buttons.push((rect, Btn::Key(k))); + } + let hint = tr("set.keys_hint"); + gfx.text(&hint, x + CW() * 2.0, top + half as f32 * rowh + CH() * 0.5, (S() / 2.0).max(1.0), DIM); + let rw = gfx.measure(&tr("set.reset_keys"), S()) + CW() * 4.0; + let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.2); + button(gfx, rr, &tr("set.reset_keys"), rr.contains(vec2(mx, my)), FRAME); + self.buttons.push((rr, Btn::ResetKeys)); + } else { + for (i, &it) in tab_items(tab).iter().enumerate() { + let by = top + i as f32 * CH() * 2.5; + gfx.text(&item_label(it), x + CW() * 3.0, by + CH() * 0.4, S(), TXT); + // value with < > buttons + let vw = 18.0 * CW(); + let vx = x + cols as f32 * CW() - vw - CW() * 3.0; + let left = Rect::new(vx, by, CW() * 2.5, CH() * 1.8); + let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 1.8); + button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); + button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); + let val = item_value(it, s); + gfx.text(&val, vx + (vw - gfx.measure(&val, S())) * 0.5, by + CH() * 0.4, S(), GOLD); + let mid = Rect::new(vx + CW() * 2.5, by, vw - CW() * 5.0, CH() * 1.8); + self.buttons.push((left, Btn::Change(i, -1))); + self.buttons.push((right, Btn::Change(i, 1))); + self.buttons.push((mid, Btn::Change(i, 1))); + } + } + let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0; + let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.2); + button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME); + self.buttons.push((br, Btn::Back)); + } +} diff --git a/src/platform.rs b/src/platform.rs index 6874fe7..65c1f28 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -1,5 +1,6 @@ // Browser integration via our own miniquad JS plugin (www/dc_plugin.js): -// persistent key/value storage (localStorage). Native builds use an in-memory fallback. +// persistent key/value storage (localStorage), a cache that is written to storage when the +// page is hidden or closed, file download and file upload. Native builds use simple fallbacks. #[cfg(target_arch = "wasm32")] mod imp { @@ -7,6 +8,11 @@ mod imp { fn dc_storage_get(key: *const u8, key_len: u32) -> i32; fn dc_storage_take(dst: *mut u8); fn dc_storage_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); + fn dc_cache_set(key: *const u8, key_len: u32, val: *const u8, val_len: u32); + fn dc_download(name: *const u8, name_len: u32, data: *const u8, data_len: u32); + fn dc_open_file(); + fn dc_file_poll() -> i32; + fn dc_file_take(dst: *mut u8); } pub fn storage_get(key: &str) -> Option { @@ -24,6 +30,32 @@ mod imp { pub fn storage_set(key: &str, val: &str) { unsafe { dc_storage_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) } } + + // value is written to storage by JS when the page gets hidden or closed + pub fn cache_on_leave(key: &str, val: &str) { + unsafe { dc_cache_set(key.as_ptr(), key.len() as u32, val.as_ptr(), val.len() as u32) } + } + + pub fn download(name: &str, data: &[u8]) { + unsafe { dc_download(name.as_ptr(), name.len() as u32, data.as_ptr(), data.len() as u32) } + } + + // opens the browser file picker; poll with take_file() + pub fn open_file() { + unsafe { dc_open_file() } + } + + pub fn take_file() -> Option> { + unsafe { + let n = dc_file_poll(); + if n < 0 { + return None; + } + let mut buf = vec![0u8; n as usize]; + dc_file_take(buf.as_mut_ptr()); + Some(buf) + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -39,6 +71,16 @@ mod imp { pub fn storage_set(key: &str, val: &str) { STORE.with(|s| s.borrow_mut().insert(key.to_string(), val.to_string())); } + pub fn cache_on_leave(key: &str, val: &str) { + storage_set(key, val); + } + pub fn download(name: &str, data: &[u8]) { + let _ = std::fs::write(name, data); + } + pub fn open_file() {} + pub fn take_file() -> Option> { + None + } } pub use imp::*; diff --git a/src/render3d.rs b/src/render3d.rs index 8f5066c..36ef737 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -8,6 +8,7 @@ use crate::map::*; use macroquad::miniquad::{BlendFactor, BlendState, BlendValue, Comparison, Equation, PipelineParams, UniformDesc, UniformType}; use macroquad::models::{Mesh, Vertex}; use macroquad::prelude::*; +use serde::{Deserialize, Serialize}; pub const WALL_H: f32 = 0.95; const CHUNK: i32 = 16; @@ -15,11 +16,11 @@ const NCH: i32 = MW as i32 / CHUNK; pub const MAX_LIGHTS: usize = 24; const ATLAS_W: f32 = 256.0; const ATLAS_H: f32 = 80.0; -const WALL_SUB: usize = 3; // subdivisions per wall face edge const DISP_AMP: f32 = 0.11; // wall surface irregularity // ------------------------------------------------------------------ camera +#[derive(Clone, Serialize, Deserialize)] pub struct Cam { pub tx: f32, // look-at target on the ground (world x) pub tz: f32, // look-at target on the ground (world z = map y) @@ -411,7 +412,7 @@ fn edge_pts(a: Vec3, b: Vec3, n: usize) -> Vec { .collect() } -fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: &Texture2D) -> (Option, Option, Option) { +fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: &Texture2D, wall_sub: usize) -> (Option, Option, Option) { let mut mb = MB::new(); let mut ov = MB::new(); let mut ln = MB::new(); @@ -485,7 +486,7 @@ fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: [dark; 4], [Vec3::Y; 4], ); - let n = WALL_SUB; + let n = wall_sub; let top_amb = amb_at(amb, xf + 0.5, zf + 0.5) * 0.95 + 0.1; build_grid( &mut mb, @@ -615,6 +616,8 @@ pub struct R3d { glow: Material, chunks: Vec, pub outlines: bool, // tile outlines on/off + wall_sub: usize, // wall face subdivisions (detail) + pub max_lights: usize, } fn rgba(u: u32) -> Color { @@ -645,7 +648,21 @@ fn chunk_sig(map: &Map, cx: i32, cz: i32, fill_bucket: u8) -> u64 { impl R3d { pub fn new() -> R3d { let chunks = (0..NCH * NCH).map(|_| Chunk { sig: 0, mesh: None, overlay: None, lines: None }).collect(); - R3d { lit: lit_material(), glow: glow_material(), chunks, outlines: true } + R3d { lit: lit_material(), glow: glow_material(), chunks, outlines: true, wall_sub: 3, max_lights: MAX_LIGHTS } + } + + // Changing the wall detail rebuilds all chunks + pub fn set_wall_detail(&mut self, sub: usize) { + if sub != self.wall_sub { + self.wall_sub = sub; + self.invalidate(); + } + } + + pub fn invalidate(&mut self) { + for c in self.chunks.iter_mut() { + c.sig = 0; + } } fn update_chunks(&mut self, g: &Game, gfx: &Gfx) { @@ -658,7 +675,7 @@ impl R3d { let ch = &mut self.chunks[(cz * NCH + cx) as usize]; if ch.sig != sig { let a = amb.get_or_insert_with(|| ambient_field(&g.map)); - let (m, o, l) = build_chunk(&g.map, a, cx, cz, bucket as f32 / 6.0, &gfx.atlas); + let (m, o, l) = build_chunk(&g.map, a, cx, cz, bucket as f32 / 6.0, &gfx.atlas, self.wall_sub); ch.mesh = m; ch.overlay = o; ch.lines = l; @@ -714,7 +731,7 @@ impl R3d { } } l.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); - l.truncate(MAX_LIGHTS); + l.truncate(self.max_lights.min(MAX_LIGHTS)); let mut pos = vec![Vec4::ZERO; MAX_LIGHTS]; let mut colr = vec![Vec4::ZERO; MAX_LIGHTS]; for (k, (_, p, c)) in l.iter().enumerate() { diff --git a/src/save.rs b/src/save.rs new file mode 100644 index 0000000..411719d --- /dev/null +++ b/src/save.rs @@ -0,0 +1,90 @@ +// Savegames: JSON with format tag and version, containing the game state, the camera +// and the player's settings (settings are always applied when a savegame is loaded). + +use crate::game::Game; +use crate::render3d::Cam; +use crate::settings::Settings; +use serde::{Deserialize, Serialize}; + +pub const FORMAT: &str = "dungeoncraft-save"; +pub const VERSION: u32 = 1; +pub const AUTOSAVE_KEY: &str = "dc.autosave"; + +#[derive(Serialize)] +struct SaveOut<'a> { + format: &'a str, + version: u32, + game: &'a Game, + camera: &'a Cam, + settings: &'a Settings, +} + +#[derive(Deserialize)] +struct SaveIn { + format: String, + version: u32, + game: Game, + camera: Option, + settings: Option, +} + +pub struct Loaded { + pub game: Game, + pub camera: Option, + pub settings: Option, +} + +pub fn to_json(game: &Game, camera: &Cam, settings: &Settings) -> String { + serde_json::to_string(&SaveOut { format: FORMAT, version: VERSION, game, camera, settings }).unwrap_or_default() +} + +// Err contains a language key describing the problem +pub fn from_bytes(data: &[u8]) -> Result { + let s: SaveIn = serde_json::from_slice(data).map_err(|_| "save.err_invalid")?; + if s.format != FORMAT { + return Err("save.err_invalid"); + } + if s.version > VERSION { + return Err("save.err_version"); + } + let mut game = s.game; + game.recount(); + Ok(Loaded { game, camera: s.camera, settings: s.settings }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_roundtrip() { + let mut g = Game::new(42); + let (cx, cy) = g.map.core; + g.mark_rect(cx - 5, cy - 5, cx + 8, cy + 8, true); + for _ in 0..3000 { + g.update(0.05); + } + let cam = Cam::new(10.0, 20.0); + let mut settings = Settings::default(); + settings.language = "de".into(); + let json = to_json(&g, &cam, &settings); + println!("savegame size: {} KB", json.len() / 1024); + let l = from_bytes(json.as_bytes()).expect("load"); + assert_eq!(l.game.time, g.time); + assert_eq!(l.game.ents.len(), g.ents.len()); + assert_eq!(l.game.gold, g.gold); + assert!(l.game.map.t.iter().zip(g.map.t.iter()).all(|(a, b)| a.kind == b.kind && a.owner == b.owner && a.marked == b.marked)); + assert_eq!(l.settings.unwrap().language, "de"); + assert_eq!(l.camera.unwrap().tx, 10.0); + // the loaded game keeps running identically + let mut a = l.game; + let mut b = g; + for _ in 0..2000 { + a.update(0.05); + b.update(0.05); + } + assert_eq!(a.gold, b.gold); + assert_eq!(a.ents.len(), b.ents.len()); + assert!(from_bytes(b"{\"format\":\"x\"}").is_err()); + } +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..2e6fcf9 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,209 @@ +// Player settings: stored in the browser (localStorage "dc.settings") and in every savegame. + +use macroquad::prelude::KeyCode; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +// Rebindable keyboard actions. Esc (menu) and the arrow keys (camera) are fixed. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Action { + Tool(usize), // 0..12, same order as ui::TOOLS + CamForward, + CamBack, + CamLeft, + CamRight, + RotateLeft, + RotateRight, + TiltUp, + TiltDown, + Pause, + Mute, + VolumeUp, + VolumeDown, + Outlines, + Help, +} + +pub fn all_actions() -> Vec { + let mut v: Vec = (0..12).map(Action::Tool).collect(); + v.extend_from_slice(&[ + Action::CamForward, + Action::CamBack, + Action::CamLeft, + Action::CamRight, + Action::RotateLeft, + Action::RotateRight, + Action::TiltUp, + Action::TiltDown, + Action::Pause, + Action::Mute, + Action::VolumeUp, + Action::VolumeDown, + Action::Outlines, + Action::Help, + ]); + v +} + +impl Action { + // stable id used in settings files and as language key suffix (key.) + pub fn id(self) -> String { + match self { + Action::Tool(i) => format!("tool{}", i), + Action::CamForward => "cam_forward".into(), + Action::CamBack => "cam_back".into(), + Action::CamLeft => "cam_left".into(), + Action::CamRight => "cam_right".into(), + Action::RotateLeft => "rotate_left".into(), + Action::RotateRight => "rotate_right".into(), + Action::TiltUp => "tilt_up".into(), + Action::TiltDown => "tilt_down".into(), + Action::Pause => "pause".into(), + Action::Mute => "mute".into(), + Action::VolumeUp => "volume_up".into(), + Action::VolumeDown => "volume_down".into(), + Action::Outlines => "outlines".into(), + Action::Help => "help".into(), + } + } + + fn default_key(self) -> KeyCode { + use KeyCode::*; + match self { + Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V][i.min(11)], + Action::CamForward => W, + Action::CamBack => S, + Action::CamLeft => A, + Action::CamRight => D, + Action::RotateLeft => Q, + Action::RotateRight => E, + Action::TiltUp => R, + Action::TiltDown => F, + Action::Pause => P, + Action::Mute => M, + Action::VolumeUp => Equal, + Action::VolumeDown => Minus, + Action::Outlines => G, + Action::Help => H, + } + } +} + +// Keys that can be bound, with their display names +const KEY_NAMES: &[(KeyCode, &str)] = &[ + (KeyCode::A, "A"), (KeyCode::B, "B"), (KeyCode::C, "C"), (KeyCode::D, "D"), (KeyCode::E, "E"), (KeyCode::F, "F"), + (KeyCode::G, "G"), (KeyCode::H, "H"), (KeyCode::I, "I"), (KeyCode::J, "J"), (KeyCode::K, "K"), (KeyCode::L, "L"), + (KeyCode::M, "M"), (KeyCode::N, "N"), (KeyCode::O, "O"), (KeyCode::P, "P"), (KeyCode::Q, "Q"), (KeyCode::R, "R"), + (KeyCode::S, "S"), (KeyCode::T, "T"), (KeyCode::U, "U"), (KeyCode::V, "V"), (KeyCode::W, "W"), (KeyCode::X, "X"), + (KeyCode::Y, "Y"), (KeyCode::Z, "Z"), + (KeyCode::Key0, "0"), (KeyCode::Key1, "1"), (KeyCode::Key2, "2"), (KeyCode::Key3, "3"), (KeyCode::Key4, "4"), + (KeyCode::Key5, "5"), (KeyCode::Key6, "6"), (KeyCode::Key7, "7"), (KeyCode::Key8, "8"), (KeyCode::Key9, "9"), + (KeyCode::Space, "Space"), (KeyCode::Minus, "-"), (KeyCode::Equal, "="), (KeyCode::Comma, ","), (KeyCode::Period, "."), + (KeyCode::Slash, "/"), (KeyCode::Semicolon, ";"), (KeyCode::Apostrophe, "'"), (KeyCode::LeftBracket, "["), + (KeyCode::RightBracket, "]"), (KeyCode::Backslash, "\\"), (KeyCode::Tab, "Tab"), (KeyCode::Backspace, "Backspace"), + (KeyCode::Enter, "Enter"), (KeyCode::Insert, "Insert"), (KeyCode::Delete, "Delete"), (KeyCode::Home, "Home"), + (KeyCode::End, "End"), (KeyCode::PageUp, "PageUp"), (KeyCode::PageDown, "PageDown"), + (KeyCode::F1, "F1"), (KeyCode::F2, "F2"), (KeyCode::F3, "F3"), (KeyCode::F4, "F4"), (KeyCode::F6, "F6"), + (KeyCode::F7, "F7"), (KeyCode::F8, "F8"), (KeyCode::F9, "F9"), (KeyCode::F10, "F10"), + (KeyCode::Kp0, "Num0"), (KeyCode::Kp1, "Num1"), (KeyCode::Kp2, "Num2"), (KeyCode::Kp3, "Num3"), (KeyCode::Kp4, "Num4"), + (KeyCode::Kp5, "Num5"), (KeyCode::Kp6, "Num6"), (KeyCode::Kp7, "Num7"), (KeyCode::Kp8, "Num8"), (KeyCode::Kp9, "Num9"), + (KeyCode::KpAdd, "Num+"), (KeyCode::KpSubtract, "Num-"), (KeyCode::KpMultiply, "Num*"), (KeyCode::KpDivide, "Num/"), + (KeyCode::LeftShift, "Shift"), (KeyCode::LeftControl, "Ctrl"), (KeyCode::LeftAlt, "Alt"), +]; + +pub fn key_name(k: KeyCode) -> String { + KEY_NAMES.iter().find(|(c, _)| *c == k).map(|(_, n)| n.to_string()).unwrap_or_else(|| "?".into()) +} + +fn key_from_name(n: &str) -> Option { + KEY_NAMES.iter().find(|(_, name)| *name == n).map(|(c, _)| *c) +} + +// All bindable keys (for the key capture dialog) +pub fn bindable_keys() -> impl Iterator { + KEY_NAMES.iter().map(|(k, _)| *k) +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Settings { + // language & sound + pub language: String, + pub volume: f32, + pub music_volume: f32, + pub muted: bool, + // camera + pub rotate_speed: f32, + pub zoom_speed: f32, + pub edge_scroll: bool, + pub invert_mouse: bool, + // graphics + pub outlines: bool, + pub wall_detail: u8, // 1 low, 2 medium, 3 high + pub max_lights: u8, // 8, 16 or 24 + pub ui_scale: u8, // font scale 2 (normal) or 3 (large) + // game + pub game_speed: f32, // 1 or 2 + pub hints: bool, + // key bindings: action id -> key name + pub keys: BTreeMap, +} + +impl Default for Settings { + fn default() -> Settings { + Settings { + language: "en".into(), + volume: 0.7, + music_volume: 0.6, + muted: false, + rotate_speed: 1.0, + zoom_speed: 1.0, + edge_scroll: true, + invert_mouse: false, + outlines: true, + wall_detail: 2, + max_lights: 24, + ui_scale: 2, + game_speed: 1.0, + hints: true, + keys: BTreeMap::new(), + } + } +} + +impl Settings { + pub fn key(&self, a: Action) -> KeyCode { + self.keys.get(&a.id()).and_then(|n| key_from_name(n)).unwrap_or_else(|| a.default_key()) + } + + // Bind a key; if another action already uses it, the two actions swap keys + pub fn bind(&mut self, a: Action, k: KeyCode) { + let old = self.key(a); + for other in all_actions() { + if other != a && self.key(other) == k { + self.keys.insert(other.id(), key_name(old)); + } + } + self.keys.insert(a.id(), key_name(k)); + } + + pub fn reset_keys(&mut self) { + self.keys.clear(); + } + + pub fn wall_subdivisions(&self) -> usize { + match self.wall_detail { + 1 => 2, + 3 => 4, + _ => 3, + } + } + + pub fn to_json(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } + + pub fn from_json(s: &str) -> Option { + serde_json::from_str(s).ok() + } +} diff --git a/src/sound.rs b/src/sound.rs index 15bad9b..1f40723 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -3,7 +3,25 @@ // Events without a registered sound stay silent. use crate::game::{Sfx, SfxEvent, SFX_ALL}; +#[cfg(feature = "audio")] use macroquad::audio::{load_sound_from_bytes, play_sound, PlaySoundParams, Sound}; +#[cfg(not(feature = "audio"))] +use stub::*; + +// Silent stand-in when the "audio" feature is off (native tests) +#[cfg(not(feature = "audio"))] +mod stub { + #[derive(Clone)] + pub struct Sound; + pub struct PlaySoundParams { + pub looped: bool, + pub volume: f32, + } + pub async fn load_sound_from_bytes(_: &[u8]) -> Result { + Err(()) + } + pub fn play_sound(_: &Sound, _: PlaySoundParams) {} +} use macroquad::prelude::*; pub struct SoundBank { @@ -33,10 +51,6 @@ impl SoundBank { // procedurally generated sounds will be registered here pub async fn generate_all(&mut self) {} - pub fn change_volume(&mut self, d: f32) { - self.volume = (self.volume + d).clamp(0.0, 1.0); - } - // minimum gap between two identical sounds (avoids a wall of noise) fn min_gap(sfx: Sfx) -> f64 { match sfx { diff --git a/src/ui.rs b/src/ui.rs index 78485bf..407385f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -6,12 +6,34 @@ use crate::gfx::*; use crate::i18n::{languages, tr, trf}; use crate::map::*; use crate::render3d::{minimap_color, Cam}; +use crate::settings::{key_name, Action, Settings}; use crate::sound::SoundBank; use macroquad::prelude::*; -pub const S: f32 = 2.0; // UI font scale -pub const CW: f32 = GW * S; // character width in px -pub const CH: f32 = GH * S; // character height in px +// UI font scale (setting: 2 = normal, 3 = large); character cell size follows it +thread_local! { + static UI_SCALE: std::cell::Cell = std::cell::Cell::new(2.0); +} +pub fn set_ui_scale(s: f32) { + UI_SCALE.with(|c| c.set(s)); +} +#[allow(non_snake_case)] +pub fn S() -> f32 { + UI_SCALE.with(|c| c.get()) +} +#[allow(non_snake_case)] +pub fn CW() -> f32 { + GW * S() +} +#[allow(non_snake_case)] +pub fn CH() -> f32 { + GH * S() +} +// factor relative to the normal scale, for pixel sizes such as buttons and icons +#[allow(non_snake_case)] +pub fn K() -> f32 { + S() / 2.0 +} pub const FRAME: Color = Color::new(0.62, 0.52, 0.36, 1.0); pub const FRAME_HI: Color = Color::new(0.95, 0.8, 0.4, 1.0); @@ -51,7 +73,6 @@ pub const TOOLS: [Tool; 12] = [ Tool::Summon, ]; -pub const TOOL_KEYS: [&str; 12] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "X", "V"]; pub struct ToolInfo { pub label: String, @@ -216,42 +237,43 @@ impl Ui { UiAction::None } - pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, snd: &SoundBank, info: &[(String, Color)], now: f64) { + pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, snd: &SoundBank, settings: &Settings, info: &[(String, Color)], now: f64) { self.rects.clear(); + let keys: Vec = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect(); let sw = screen_width(); let sh = screen_height(); let (mx, my) = mouse_position(); // ---------------- top bar - let cols = (sw / CW).ceil() as i32; - gfx.window(0.0, 0.0, cols, 3, S, false, FRAME, BG); - self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH)); - let y = CH; - let mut x = CW * 2.0; + let cols = (sw / CW()).ceil() as i32; + gfx.window(0.0, 0.0, cols, 3, S(), false, FRAME, BG); + self.rects.push(Rect::new(0.0, 0.0, sw, 3.0 * CH())); + let y = CH(); + let mut x = CW() * 2.0; let seg = |label: &str, lc: Color, value: &str, vc: Color, x: &mut f32| { - gfx.text(label, *x, y, S, lc); - *x += gfx.measure(label, S) + CW; - gfx.text(value, *x, y, S, vc); - *x += gfx.measure(value, S) + CW * 2.0; + gfx.text(label, *x, y, S(), lc); + *x += gfx.measure(label, S()) + CW(); + gfx.text(value, *x, y, S(), vc); + *x += gfx.measure(value, S()) + CW() * 2.0; }; seg(&tr("hud.gold"), GOLD, &format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT, &mut x); let mana = tr("hud.mana"); - gfx.text(&mana, x, y, S, MANA); - x += gfx.measure(&mana, S) + CW * 0.5; + gfx.text(&mana, x, y, S(), MANA); + x += gfx.measure(&mana, S()) + CW() * 0.5; bar(x, y + 3.0, 70.0, 10.0, g.mana / MANA_MAX, MANA); - x += 70.0 + CW * 0.5; - gfx.text(&format!("{}", g.mana as i32), x, y, S, TXT); - x += CW * 5.0; + x += 70.0 + CW() * 0.5; + gfx.text(&format!("{}", g.mana as i32), x, y, S(), TXT); + x += CW() * 5.0; let core = tr("hud.core"); - gfx.text(&core, x, y, S, RED); - x += gfx.measure(&core, S) + CW * 0.5; + gfx.text(&core, x, y, S(), RED); + x += gfx.measure(&core, S()) + CW() * 0.5; bar(x, y + 3.0, 70.0, 10.0, g.core_hp / CORE_HP, RED); - x += 70.0 + CW * 1.5; + x += 70.0 + CW() * 1.5; let gate = tr("hud.gate"); - gfx.text(&gate, x, y, S, BLUE); - x += gfx.measure(&gate, S) + CW * 0.5; + gfx.text(&gate, x, y, S(), BLUE); + x += gfx.measure(&gate, S()) + CW() * 0.5; bar(x, y + 3.0, 70.0, 10.0, g.gate_hp / GATE_HP, BLUE); - x += 70.0 + CW * 1.5; + x += 70.0 + CW() * 1.5; let cap = g.counts[1].min(30); seg(&tr("hud.creatures"), TXT, &format!("{}/{}", g.creature_count(), cap), TXT, &mut x); seg(&tr("hud.workers"), TXT, &format!("{}", g.worker_count()), TXT, &mut x); @@ -259,20 +281,23 @@ impl Ui { seg(&trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, &mmss(g.wave_timer), wave_col, &mut x); seg(&tr("hud.army"), TXT, &format!("{}", g.army_value() as i32), TXT, &mut x); let vol = if snd.muted { tr("hud.off") } else { format!("{}%", (snd.volume * 100.0).round() as i32) }; - seg("♪", DIM, &vol, DIM, &mut x); + // volume only if it still fits into the bar + if x + gfx.measure(&format!("♪ {}", vol), S()) < sw - CW() * 2.0 { + seg("♪", DIM, &vol, DIM, &mut x); + } // ---------------- toolbar let tb_rows = 5; - let tb_y = sh - tb_rows as f32 * CH; - gfx.window(0.0, tb_y, cols, tb_rows, S, false, FRAME, BG); - self.rects.push(Rect::new(0.0, tb_y, sw, tb_rows as f32 * CH)); + let tb_y = sh - tb_rows as f32 * CH(); + gfx.window(0.0, tb_y, cols, tb_rows, S(), false, FRAME, BG); + self.rects.push(Rect::new(0.0, tb_y, sw, tb_rows as f32 * CH())); self.tool_rects.clear(); - let bw = 80.0; + let bw = 80.0 * K(); let mut hover_tool = None; for (i, &t) in TOOLS.iter().enumerate() { - let bx = CW + i as f32 * (bw + 4.0); - let by = tb_y + CH * 0.75; - let r = Rect::new(bx, by, bw, CH * 3.5); + let bx = CW() + i as f32 * (bw + 4.0 * K()); + let by = tb_y + CH() * 0.75; + let r = Rect::new(bx, by, bw, CH() * 3.5); self.tool_rects.push(r); let hovered = r.contains(vec2(mx, my)); if hovered { @@ -282,19 +307,21 @@ impl Ui { draw_rectangle(r.x, r.y, r.w, r.h, if sel { Color::new(0.35, 0.22, 0.1, 1.0) } else if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if sel { FRAME_HI } else { Color::new(0.35, 0.28, 0.2, 1.0) }); let ti = tool_info(t); - gfx.draw_cell(ti.icon, bx + (bw - 32.0) * 0.5, by + 4.0, 32.0, 32.0, WHITE); - gfx.text(TOOL_KEYS[i], bx + 3.0, by + 3.0, 1.0, if sel { FRAME_HI } else { DIM }); - let lw = gfx.measure(&ti.label, 1.0); - gfx.text(&ti.label, bx + (bw - lw) * 0.5, by + 42.0, 1.0, if sel { FRAME_HI } else { TXT }); + let icon = 32.0 * K(); + let small = (S() / 2.0).floor().max(1.0); + gfx.draw_cell(ti.icon, bx + (bw - icon) * 0.5, by + 4.0 * K(), icon, icon, WHITE); + gfx.text(&keys[i], bx + 3.0, by + 3.0, small, if sel { FRAME_HI } else { DIM }); + let lw = gfx.measure(&ti.label, small); + gfx.text(&ti.label, bx + (bw - lw) * 0.5, by + 42.0 * K(), small, if sel { FRAME_HI } else { TXT }); } // hand contents - let hx = CW + TOOLS.len() as f32 * (bw + 4.0) + CW; + let hx = CW() + TOOLS.len() as f32 * (bw + 4.0 * K()) + CW(); if hx + 120.0 < sw - 200.0 { - gfx.text(&tr("hud.hand"), hx, tb_y + CH, S, TXT); + gfx.text(&tr("hud.hand"), hx, tb_y + CH(), S(), TXT); for (k, id) in g.hand.iter().enumerate() { if let Some(e) = g.ents.iter().find(|e| e.id == *id) { let sx = hx + (k % 4) as f32 * 26.0; - let sy = tb_y + CH * 2.2 + (k / 4) as f32 * 24.0; + let sy = tb_y + CH() * 2.2 + (k / 4) as f32 * 24.0; draw_texture_ex( &gfx.sprites, sx, @@ -305,18 +332,18 @@ impl Ui { } } if g.hand.is_empty() { - gfx.text(&tr("hud.empty"), hx, tb_y + CH * 2.5, 1.0, DIM); + gfx.text(&tr("hud.empty"), hx, tb_y + CH() * 2.5, 1.0, DIM); } } // ---------------- minimap let mm_cols = 16; let mm_rows = 12; - let mm_w = mm_cols as f32 * CW; - let mm_h = mm_rows as f32 * CH; + let mm_w = mm_cols as f32 * CW(); + let mm_h = mm_rows as f32 * CH(); let mm_x = sw - mm_w; let mm_y = tb_y - mm_h; - gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S, false, FRAME, BG, &tr("ui.map"), FRAME_HI); + gfx.window_titled(mm_x, mm_y, mm_cols, mm_rows, S(), false, FRAME, BG, &tr("ui.map"), FRAME_HI); self.rects.push(Rect::new(mm_x, mm_y, mm_w, mm_h)); if now - self.minimap_t > 0.25 { self.minimap_t = now; @@ -330,9 +357,9 @@ impl Ui { } self.minimap_tex.update(&self.minimap_img); } - let map_px = (mm_w - CW * 2.0).min(mm_h - CH * 2.0); + let map_px = (mm_w - CW() * 2.0).min(mm_h - CH() * 2.0); let mrx = mm_x + (mm_w - map_px) * 0.5; - let mry = mm_y + CH + (mm_h - CH * 2.0 - map_px) * 0.5; + let mry = mm_y + CH() + (mm_h - CH() * 2.0 - map_px) * 0.5; self.minimap_rect = Rect::new(mrx, mry, map_px, map_px); draw_texture_ex(&self.minimap_tex, mrx, mry, WHITE, DrawTextureParams { dest_size: Some(vec2(map_px, map_px)), ..Default::default() }); let ms = map_px / MW as f32; @@ -366,18 +393,18 @@ impl Ui { } } let irows = lines.len() as i32 + 2; - let ix = sw - icols as f32 * CW; - let iy = 3.0 * CH; - gfx.window_titled(ix, iy, icols, irows, S, false, FRAME, BG, &info[0].0, info[0].1); + let ix = sw - icols as f32 * CW(); + let iy = 3.0 * CH(); + gfx.window_titled(ix, iy, icols, irows, S(), false, FRAME, BG, &info[0].0, info[0].1); for (k, (l, c)) in lines.iter().enumerate() { - gfx.text(l, ix + CW * 2.0, iy + CH * (k as f32 + 1.0), S, *c); + gfx.text(l, ix + CW() * 2.0, iy + CH() * (k as f32 + 1.0), S(), *c); } } // ---------------- messages (translated at display time, so a language switch applies at once) let recent: Vec<&(Msg, f64)> = self.log.iter().filter(|(_, t)| now - t < 14.0).collect(); if !recent.is_empty() { - let mcols = (((sw - mm_w) / CW) as i32 - 2).clamp(20, 70); + let mcols = (((sw - mm_w) / CW()) as i32 - 2).clamp(20, 70); let mut lines: Vec<(String, f32)> = Vec::new(); for (m, t) in recent.iter().rev().take(5).rev() { let a = (1.0 - ((now - t) as f32 - 11.0) / 3.0).clamp(0.2, 1.0); @@ -386,11 +413,11 @@ impl Ui { } } let mrows = lines.len() as i32 + 2; - let my0 = tb_y - mrows as f32 * CH; - gfx.window(0.0, my0, mcols, mrows, S, false, FRAME, Color::new(0.06, 0.04, 0.05, 0.75)); - self.rects.push(Rect::new(0.0, my0, mcols as f32 * CW, mrows as f32 * CH)); + let my0 = tb_y - mrows as f32 * CH(); + gfx.window(0.0, my0, mcols, mrows, S(), false, FRAME, Color::new(0.06, 0.04, 0.05, 0.75)); + self.rects.push(Rect::new(0.0, my0, mcols as f32 * CW(), mrows as f32 * CH())); for (k, (l, a)) in lines.iter().enumerate() { - gfx.text(l, CW * 2.0, my0 + CH * (k as f32 + 1.0), S, Color::new(TXT.r, TXT.g, TXT.b, *a)); + gfx.text(l, CW() * 2.0, my0 + CH() * (k as f32 + 1.0), S(), Color::new(TXT.r, TXT.g, TXT.b, *a)); } } if self.log.len() > 100 { @@ -407,17 +434,22 @@ impl Ui { if !cost.is_empty() { lines.push((trf("ui.cost", &[cost]), GOLD)); } - lines.push((trf("ui.key", &[TOOL_KEYS[i].to_string()]), DIM)); + lines.push((trf("ui.key", &[keys[i].clone()]), DIM)); let rows = lines.len() as i32 + 2; - let tx = (mx - tcols as f32 * CW * 0.5).clamp(0.0, sw - tcols as f32 * CW); - let ty = tb_y - rows as f32 * CH - 4.0; - gfx.window_titled(tx, ty, tcols as i32, rows, S, false, FRAME_HI, BG, &ti.name, FRAME_HI); + let tx = (mx - tcols as f32 * CW() * 0.5).clamp(0.0, sw - tcols as f32 * CW()); + let ty = tb_y - rows as f32 * CH() - 4.0; + gfx.window_titled(tx, ty, tcols as i32, rows, S(), false, FRAME_HI, BG, &ti.name, FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { - gfx.text(l, tx + CW * 2.0, ty + CH * (k as f32 + 1.0), S, *c); + gfx.text(l, tx + CW() * 2.0, ty + CH() * (k as f32 + 1.0), S(), *c); } } - // ---------------- dialogs + } + + // Dialogs on top of everything (help, language selection, end screen, pause) + pub fn draw_dialogs(&mut self, g: &Game, gfx: &Gfx) { + let sw = screen_width(); + let sh = screen_height(); if self.show_langsel { self.draw_langsel(gfx); } else if g.state != 0 { @@ -426,13 +458,13 @@ impl Ui { self.draw_help(gfx); } else if g.paused { let w = 24; - let x = (sw - w as f32 * CW) * 0.5; + let x = (sw - w as f32 * CW()) * 0.5; let y = sh * 0.35; - gfx.window(x, y, w, 5, S, true, FRAME_HI, BG); + gfx.window(x, y, w, 5, S(), true, FRAME_HI, BG); let t = tr("ui.pause"); - gfx.text(&t, (sw - gfx.measure(&t, 3.0)) * 0.5, y + CH, 3.0, FRAME_HI); + gfx.text(&t, (sw - gfx.measure(&t, 3.0)) * 0.5, y + CH(), 3.0, FRAME_HI); let t2 = tr("ui.pause_hint"); - gfx.text(&t2, (sw - gfx.measure(&t2, S)) * 0.5, y + CH * 3.0, S, DIM); + gfx.text(&t2, (sw - gfx.measure(&t2, S())) * 0.5, y + CH() * 3.0, S(), DIM); } } @@ -464,11 +496,11 @@ impl Ui { let rows = lines.len() as i32 + 4; let sw = screen_width(); let sh = screen_height(); - let x = ((sw - cols as f32 * CW) * 0.5).max(0.0); - let y = ((sh - rows as f32 * CH) * 0.5).max(0.0); - gfx.window_titled(x, y, cols, rows, S, true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), "DungeonCraft", FRAME_HI); + let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); + let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), "DungeonCraft", FRAME_HI); for (k, (l, c)) in lines.iter().enumerate() { - gfx.text(l, x + CW * 3.0, y + CH * (k as f32 + 2.0), S, *c); + gfx.text(l, x + CW() * 3.0, y + CH() * (k as f32 + 2.0), S(), *c); } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } @@ -481,28 +513,28 @@ impl Ui { let langs = languages(); let cols = 50; let rows = 6 + langs.len() as i32 * 3; - let x = ((sw - cols as f32 * CW) * 0.5).max(0.0); - let y = ((sh - rows as f32 * CH) * 0.5).max(0.0); + let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); + let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); // the title shows all language names, since no language is chosen yet let title = langs.iter().map(|(c, _)| crate::i18n::tr_in(c, "langsel.title")).collect::>().join(" / "); - gfx.window_titled(x, y, cols, rows, S, true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); + gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); self.lang_rects.clear(); let (mx, my) = mouse_position(); for (k, (code, name)) in langs.iter().enumerate() { - let bw = 24.0 * CW; + let bw = 24.0 * CW(); let bx = (sw - bw) * 0.5; - let by = y + CH * (2.0 + k as f32 * 3.0); - let r = Rect::new(bx, by, bw, CH * 2.0); + let by = y + CH() * (2.0 + k as f32 * 3.0); + let r = Rect::new(bx, by, bw, CH() * 2.0); let hov = r.contains(vec2(mx, my)); draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hov { FRAME_HI } else { FRAME }); - gfx.text(name, bx + (bw - gfx.measure(name, S)) * 0.5, by + CH * 0.5, S, TXT); + gfx.text(name, bx + (bw - gfx.measure(name, S())) * 0.5, by + CH() * 0.5, S(), TXT); self.lang_rects.push((r, code.clone())); } - let hint_y = y + CH * (2.0 + langs.len() as f32 * 3.0 + 0.5); + let hint_y = y + CH() * (2.0 + langs.len() as f32 * 3.0 + 0.5); for (k, (c, _)) in langs.iter().enumerate() { let h = crate::i18n::tr_in(c, "langsel.hint"); - gfx.text(&h, x + CW * 2.0, hint_y + CH * k as f32, 1.0, DIM); + gfx.text(&h, x + CW() * 2.0, hint_y + CH() * k as f32, 1.0, DIM); } self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } @@ -514,27 +546,27 @@ impl Ui { let win = g.state == 1; let cols = 44; let rows = 11; - let x = (sw - cols as f32 * CW) * 0.5; - let y = (sh - rows as f32 * CH) * 0.5; + let x = (sw - cols as f32 * CW()) * 0.5; + let y = (sh - rows as f32 * CH()) * 0.5; let col = if win { GOLD } else { RED }; - gfx.window(x, y, cols, rows, S, true, col, Color::new(0.05, 0.03, 0.04, 0.97)); + gfx.window(x, y, cols, rows, S(), true, col, Color::new(0.05, 0.03, 0.04, 0.97)); let title = tr(if win { "end.victory" } else { "end.defeat" }); - gfx.text(&title, (sw - gfx.measure(&title, 4.0)) * 0.5, y + CH * 1.5, 4.0, col); + gfx.text(&title, (sw - gfx.measure(&title, 4.0)) * 0.5, y + CH() * 1.5, 4.0, col); let sub = tr(if win { "end.victory_sub" } else { "end.defeat_sub" }); - gfx.text(&sub, (sw - gfx.measure(&sub, S)) * 0.5, y + CH * 4.5, S, TXT); + gfx.text(&sub, (sw - gfx.measure(&sub, S())) * 0.5, y + CH() * 4.5, S(), TXT); let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]); - gfx.text(&st, (sw - gfx.measure(&st, S)) * 0.5, y + CH * 6.0, S, DIM); + gfx.text(&st, (sw - gfx.measure(&st, S())) * 0.5, y + CH() * 6.0, S(), DIM); let b = format!(" {} ", tr("end.new")); let b = b.as_str(); - let bw = gfx.measure(b, S) + CW * 2.0; + let bw = gfx.measure(b, S()) + CW() * 2.0; let bx = (sw - bw) * 0.5; - let by = y + CH * 7.75; - self.end_button = Rect::new(bx, by, bw, CH * 2.0); + let by = y + CH() * 7.75; + self.end_button = Rect::new(bx, by, bw, CH() * 2.0); let (mx, my) = mouse_position(); let hov = self.end_button.contains(vec2(mx, my)); - draw_rectangle(bx, by, bw, CH * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); - draw_rectangle_lines(bx, by, bw, CH * 2.0, 2.0, col); - gfx.text(b, bx + CW, by + CH * 0.5, S, TXT); + draw_rectangle(bx, by, bw, CH() * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) }); + draw_rectangle_lines(bx, by, bw, CH() * 2.0, 2.0, col); + gfx.text(b, bx + CW(), by + CH() * 0.5, S(), TXT); self.rects.push(Rect::new(0.0, 0.0, sw, sh)); } } diff --git a/www/dc_plugin.js b/www/dc_plugin.js index fc00e08..4ef122c 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -1,7 +1,9 @@ -// DungeonCraft browser plugin for miniquad: localStorage access for the Rust code. +// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave. (function () { "use strict"; - var pending = null; + var pending = null; // value handed over by dc_storage_get + var file = null; // bytes of a file chosen via the file picker + var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed function bytes(ptr, len) { return new Uint8Array(wasm_memory.buffer, ptr, len); @@ -9,23 +11,70 @@ function str(ptr, len) { return new TextDecoder().decode(bytes(ptr, len)); } + function flushCache() { + for (var k in cache) { + try { window.localStorage.setItem(k, cache[k]); } catch (e) { } + } + } + window.addEventListener("pagehide", flushCache); + document.addEventListener("visibilitychange", function () { + if (document.visibilityState === "hidden") flushCache(); + }); + + // hidden for loading savegames + var input = document.createElement("input"); + input.type = "file"; + input.accept = ".dcsave,.json,application/json"; + input.style.display = "none"; + input.addEventListener("change", function () { + var f = input.files && input.files[0]; + if (!f) return; + f.arrayBuffer().then(function (buf) { file = new Uint8Array(buf); }); + input.value = ""; + }); + document.addEventListener("DOMContentLoaded", function () { document.body.appendChild(input); }); function register(importObject) { - importObject.env.dc_storage_get = function (kp, kl) { + var env = importObject.env; + env.dc_storage_get = function (kp, kl) { var v = null; try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { } if (v === null) { pending = null; return -1; } pending = new TextEncoder().encode(v); return pending.length; }; - importObject.env.dc_storage_take = function (ptr) { + env.dc_storage_take = function (ptr) { if (pending === null) return; bytes(ptr, pending.length).set(pending); pending = null; }; - importObject.env.dc_storage_set = function (kp, kl, vp, vl) { + env.dc_storage_set = function (kp, kl, vp, vl) { try { window.localStorage.setItem(str(kp, kl), str(vp, vl)); } catch (e) { } }; + env.dc_cache_set = function (kp, kl, vp, vl) { + cache[str(kp, kl)] = str(vp, vl); + }; + env.dc_download = function (np, nl, dp, dl) { + var blob = new Blob([bytes(dp, dl).slice()], { type: "application/json" }); + var a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = str(np, nl); + document.body.appendChild(a); + a.click(); + setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000); + }; + env.dc_open_file = function () { + file = null; + input.click(); + }; + env.dc_file_poll = function () { + return file === null ? -1 : file.length; + }; + env.dc_file_take = function (ptr) { + if (file === null) return; + bytes(ptr, file.length).set(file); + file = null; + }; } miniquad_add_plugin({ register_plugin: register, version: 1, name: "dungeoncraft" });