From b06c48172b7efc38379c85031695409d74ff6066 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 12:22:59 +0100 Subject: [PATCH] Add campaign editor script runtime and debug overlays --- Cargo.lock | 321 ++++++++++++++++++++- Cargo.toml | 1 + lang/de.lang | 43 ++- lang/en.lang | 43 ++- src/campaign.rs | 52 +++- src/campaign_doc.rs | 569 ++++++++++++++++++++++++++++++++++++ src/commands.rs | 32 +- src/game.rs | 13 +- src/main.rs | 690 +++++++++++++++++++++++++++++++++++++++++--- src/menu.rs | 239 ++++++++++++++- src/render3d.rs | 5 +- src/script.rs | 246 ++++++++++++++++ src/settings.rs | 23 +- 13 files changed, 2211 insertions(+), 66 deletions(-) create mode 100644 src/campaign_doc.rs create mode 100644 src/script.rs diff --git a/Cargo.lock b/Cargo.lock index a422ae2..187e071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,19 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -26,6 +39,18 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytemuck" version = "1.25.2" @@ -38,6 +63,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -50,6 +84,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "core_maths" version = "0.1.1" @@ -68,11 +135,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "dungeoncraft" version = "0.1.0" dependencies = [ "macroquad", + "rhai", "serde", "serde_json", ] @@ -119,6 +193,41 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "glam" version = "0.27.0" @@ -155,6 +264,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.191" @@ -258,19 +378,40 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "png" version = "0.17.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crc32fast", "fdeflate", "flate2", "miniz_oxide 0.8.9", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -295,6 +436,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rhai" +version = "1.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3925b784c0bee6542637e425ed7511a63257bf11b7ecf51ffd845e2d24d9163" +dependencies = [ + "ahash", + "bitflags 2.13.2", + "compact_str", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d9661edb4a4b8145b017582d634c5eb88ff1a5284a5aa701f83a28f0657a826" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "serde" version = "1.0.229" @@ -322,7 +498,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -344,6 +520,46 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -355,6 +571,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thin-vec" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a4b9ba8738cb4a4f399d37e266becfd475e75eb73425b87a05a2f2039ba63e" + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -370,6 +601,72 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -392,6 +689,26 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 9e8e3f8..32d104a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ license = "MIT" [dependencies] macroquad = { version = "0.4" } +rhai = { version = "1.27.0", default-features = false, features = ["std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/lang/de.lang b/lang/de.lang index 1387489..6e0a1cd 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -219,6 +219,7 @@ set.tab.sound = Sprache & Ton set.tab.camera = Kamera set.tab.graphics = Grafik set.tab.game = Spiel +set.tab.debug = Debug set.tab.keys = Tasten set.language = Sprache set.volume = Effekt-Lautstärke @@ -339,7 +340,12 @@ dir.nw = Nordwesten val.auto = Auto ({0}x) set.render_scale = 3D-Auflösung -set.debug_fps = FPS-Anzeige +set.debug_fps = FPS anzeigen +set.debug_frame_time = Framezeit anzeigen +set.debug_ping = Ping anzeigen +set.debug_trigger_zones = Triggerzonen anzeigen +set.debug_command_markers = Debug-Befehlsmarker anzeigen +set.debug_path_lines = Pfadlinien anzeigen hint.pickup_prisoner_where = Gefangene können nur auf eigenem Gebiet aufgehoben werden. hint.punch_where = Die Hand kann Helden nur auf eigenem Gebiet schlagen. @@ -598,6 +604,36 @@ account.users_changed = Benutzer aktualisiert. standalone.running = Die Standalone-Version wird vorbereitet ... standalone.done = DungeonCraft.html heruntergeladen: In jedem Browser öffnen, kein Server nötig. Deine heruntergeladenen Spielstände lassen sich dort auch laden. standalone.failed = Die Standalone-Version konnte nicht heruntergeladen werden. +menu.campaign_editor = Kampagneneditor +campaign.title = Kampagneneditor +campaign.new = Neu +campaign.open = Öffnen +campaign.save = Speichern +campaign.add = Add +campaign.delete = Entf +campaign.start = Start +campaign.choose = Kampagnendatei auswählen. +campaign.loaded = Kampagne geladen: {0} +campaign.saved = Kampagne gespeichert: {0} +campaign.invalid = Fehler in der Kampagnendatei: {0} +campaign.empty = Keine Missionen. +campaign.missions = Missionen: {0} +campaign.name = Kampagne: {0} +campaign.author = Autor: {0} +campaign.mission_no = {0}. {1} +campaign.next = Nächste: {0} +campaign.branches = Verzweigungen: {0} +campaign.goals = Ziele: {0} +campaign.zones = Zonen: {0} +campaign.script = Skript: {0} +campaign.prompt_name = Kampagnenname +campaign.prompt_title = Missionstitel +campaign.prompt_goals = Ziele, mit Komma getrennt +campaign.prompt_next = Nächster Missionsname +campaign.prompt_branches = Verzweigungen, mit Semikolon getrennt: id:goal:next:prio:title +campaign.prompt_script = Rhai-Skript, z.B. fn on_start(m) { m.log("..."); m.objective("..."); m.spawn_at("HEROES","Knight",20,20,3,2); } +campaign.updated = Geändert. +campaign.error_no_mission = Keine Mission ausgewählt. job.register = Meldet sich am Portal job.make_bed = Richtet sein Bett ein job.fetch_wage = Holt sich seinen Lohn @@ -842,6 +878,11 @@ research.done = Hier gibt es nichts mehr zu erforschen. msg.researched = Erforscht: {0}! Jetzt im Baumenü. msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten daran. +msg.script = {0} +msg.script_objective = Ziel: {0} +msg.script_error = Skriptfehler: {0} +msg.script_language = Unbekannte Missionssprache: {0} + # spells hint.cavein_where = Ein Einsturz braucht freien Boden (keine Räume) unter dem Zeiger. hint.spell_own = Wirke ihn auf deine eigenen Kreaturen. diff --git a/lang/en.lang b/lang/en.lang index 2332e72..01a1ebd 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -220,6 +220,7 @@ set.tab.sound = Language & Sound set.tab.camera = Camera set.tab.graphics = Graphics set.tab.game = Game +set.tab.debug = Debug set.tab.keys = Keys set.language = Language set.volume = Sound volume @@ -340,7 +341,12 @@ dir.nw = north-west val.auto = Auto ({0}x) set.render_scale = 3D resolution -set.debug_fps = FPS debug +set.debug_fps = Show FPS +set.debug_frame_time = Show frame time +set.debug_ping = Show ping +set.debug_trigger_zones = Show trigger zones +set.debug_command_markers = Show debug command markers +set.debug_path_lines = Show path lines hint.pickup_prisoner_where = Prisoners can only be picked up on your own territory. hint.punch_where = The hand can only punch heroes on your own territory. @@ -599,6 +605,36 @@ account.users_changed = Users updated. standalone.running = Preparing the standalone version ... standalone.done = DungeonCraft.html downloaded: open it in any browser, no server needed. Your downloaded savegames load there too. standalone.failed = The standalone version could not be downloaded. +menu.campaign_editor = Campaign Editor +campaign.title = Campaign Editor +campaign.new = New +campaign.open = Open +campaign.save = Save +campaign.add = Add +campaign.delete = Del +campaign.start = Start +campaign.choose = Choose a campaign file. +campaign.loaded = Campaign loaded: {0} +campaign.saved = Campaign saved: {0} +campaign.invalid = Campaign file error: {0} +campaign.empty = No missions. +campaign.missions = Missions: {0} +campaign.name = Campaign: {0} +campaign.author = Author: {0} +campaign.mission_no = {0}. {1} +campaign.next = Next: {0} +campaign.branches = Branches: {0} +campaign.goals = Goals: {0} +campaign.zones = Zones: {0} +campaign.script = Script: {0} +campaign.prompt_name = Campaign name +campaign.prompt_title = Mission title +campaign.prompt_goals = Goals, comma separated +campaign.prompt_next = Next mission key +campaign.prompt_branches = Branches, semicolon separated: id:goal:next:prio:title +campaign.prompt_script = Rhai script, e.g. fn on_start(m) { m.log("..."); m.objective("..."); m.spawn_at("HEROES","Knight",20,20,3,2); } +campaign.updated = Updated. +campaign.error_no_mission = No mission selected. job.register = Reporting at the portal job.make_bed = Setting up its bed job.fetch_wage = Fetching its wage @@ -843,6 +879,11 @@ research.done = Nothing left to research here. msg.researched = Researched: {0}! It is in the build menu now. msg.tool_research = Not researched yet - creatures studying in the library work on it. +msg.script = {0} +msg.script_objective = Objective: {0} +msg.script_error = Script error: {0} +msg.script_language = Unsupported mission script language: {0} + # spells hint.cavein_where = A cave-in needs open floor (not rooms) under the pointer. hint.spell_own = Cast it on your own creatures. diff --git a/src/campaign.rs b/src/campaign.rs index ea3ed72..ee3e24b 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -29,7 +29,7 @@ pub struct FortDef { } pub struct MissionDef { - pub key: &'static str, + pub key: String, pub size: (usize, usize), pub seed: u32, pub gold: f32, @@ -145,9 +145,9 @@ impl FortDef { } } -fn base(key: &'static str, size: (usize, usize), seed: u32, goals: Vec) -> MissionDef { +fn base(key: &str, size: (usize, usize), seed: u32, goals: Vec) -> MissionDef { MissionDef { - key, + key: key.to_string(), size, seed, gold: 3000.0, @@ -169,6 +169,21 @@ fn base(key: &'static str, size: (usize, usize), seed: u32, goals: Vec) -> } } +// Converts one campaign editor mission into the same shape the campaign generator uses. +pub fn mission_def_from_doc(d: &crate::campaign_doc::MissionDoc) -> MissionDef { + let mut out = base(&d.key, d.size, d.seed, crate::campaign_doc::parse_goals(&d.goals)); + out.gold = d.gold; + out.portals = d.portals; + out.treasures = d.treasures; + out.waves = d.waves; + out.wave_limit = d.wave_limit; + out.first_wave = d.first_wave; + out.gate = d.waves; + out.gate_defenders = d.waves; + out.time_limit = d.time_limit; + out +} + // Rivers are at least four tiles wide: a ranged attack just reaches across. pub fn missions() -> Vec { let mut v = Vec::new(); @@ -487,6 +502,16 @@ pub fn start_coop_mission(i: usize, carry: Option<&Carry>) -> Game { fn start_mission_mode(i: usize, carry: Option<&Carry>, coop: bool) -> Game { let all = missions(); let d = &all[i.min(all.len() - 1)]; + start_mission_def(d, i, carry, coop) +} + +// A mission generated from the campaign editor file: same start pipeline, but without the +// built-in campaign table and without language-file story keys for the title. +pub fn start_mission_doc(d: &MissionDef, i: usize, carry: Option<&Carry>) -> Game { + start_mission_def(d, i, carry, false) +} + +fn start_mission_def(d: &MissionDef, i: usize, carry: Option<&Carry>, coop: bool) -> Game { let (w, h) = d.size; let opts = GenOpts { portals: Some(d.portals), treasures: Some(d.treasures), gate: d.gate, core_y: None }; let map = Map::generate_with(d.seed, w, h, &opts); @@ -600,6 +625,27 @@ pub fn carry_from(g: &Game) -> Carry { mod tests { use super::*; + #[test] + fn a_custom_editor_mission_can_be_started() { + let doc = crate::campaign_doc::MissionDoc { + key: "custom01".into(), + title: "Custom".into(), + size: (72, 72), + seed: 9991, + gold: 3500.0, + goals: vec!["KillAll".into()], + ..Default::default() + }; + let def = mission_def_from_doc(&doc); + assert_eq!(def.key, "custom01"); + assert_eq!(def.goals, vec![Goal::KillAll]); + + let g = start_mission_doc(&def, 0, None); + let m = g.mission.as_ref().expect("the custom mission has mission state"); + assert_eq!(m.goals, vec![Goal::KillAll]); + assert_eq!(g.gold, 3500.0); + } + #[test] fn all_missions_build() { for (i, d) in missions().iter().enumerate() { diff --git a/src/campaign_doc.rs b/src/campaign_doc.rs new file mode 100644 index 0000000..462c4cd --- /dev/null +++ b/src/campaign_doc.rs @@ -0,0 +1,569 @@ +// Data model for shareable campaign files (.dccamp). +// This is the first editor layer: JSON missions, branch goals, scripts and trigger zones. +// The gameplay-side campaign start for custom missions comes in a later step. + +use crate::game::Goal; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign"; +pub const CAMPAIGN_VERSION: u32 = 1; +pub const CAMPAIGN_EXTENSION: &str = "dccamp"; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct CampaignProgress { + pub done: Vec, + pub completed_choice: Vec>, +} + +impl CampaignProgress { + pub fn for_doc(doc: &CampaignDoc) -> CampaignProgress { + let n = doc.missions.len(); + CampaignProgress { + done: vec![false; n], + completed_choice: vec![None; n], + } + } + + pub fn ensure(&mut self, n: usize) { + self.done.resize(n, false); + self.completed_choice.resize(n, None); + } + + pub fn is_done(&self, i: usize) -> bool { + self.done.get(i).copied().unwrap_or(false) + } + + pub fn mark_done(&mut self, i: usize, n: usize, choice: Option) { + self.ensure(n); + if let Some(d) = self.done.get_mut(i) { + *d = true; + } + if let Some(c) = self.completed_choice.get_mut(i) { + *c = choice; + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CampaignDoc { + #[serde(default = "default_format")] + pub format: String, + #[serde(default = "default_version")] + pub version: u32, + #[serde(default)] + pub name: String, + #[serde(default)] + pub author: String, + #[serde(default)] + pub missions: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MissionDoc { + #[serde(default)] + pub key: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub story: String, + #[serde(default = "default_size")] + pub size: (usize, usize), + #[serde(default)] + pub seed: u32, + #[serde(default = "default_gold")] + pub gold: f32, + #[serde(default = "default_portals")] + pub portals: usize, + #[serde(default)] + pub treasures: usize, + #[serde(default)] + pub goals: Vec, + #[serde(default = "default_false")] + pub waves: bool, + #[serde(default)] + pub wave_limit: u32, + #[serde(default = "default_first_wave")] + pub first_wave: f32, + #[serde(default)] + pub time_limit: f32, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub branches: Vec, + #[serde(default)] + pub zones: Vec, + #[serde(default)] + pub script: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchDoc { + #[serde(default)] + pub id: String, + #[serde(default)] + pub goal: String, + #[serde(default)] + pub title: String, + #[serde(default)] + pub next: String, + #[serde(default)] + pub priority: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ZoneDoc { + #[serde(default)] + pub name: String, + #[serde(default = "default_shape")] + pub shape: String, + #[serde(default)] + pub at: Option<(f32, f32)>, + #[serde(default)] + pub radius: Option, + #[serde(default)] + pub tiles: Option>, + #[serde(default)] + pub from: Option<(f32, f32)>, + #[serde(default)] + pub to: Option<(f32, f32)>, + #[serde(default)] + pub width: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ScriptDoc { + #[serde(default = "default_script_language")] + pub language: String, + #[serde(default)] + pub source: String, +} + +impl Default for CampaignDoc { + fn default() -> Self { + CampaignDoc { + format: default_format(), + version: default_version(), + name: "Neue Kampagne".into(), + author: String::new(), + missions: Vec::new(), + } + } +} + +impl Default for MissionDoc { + fn default() -> Self { + MissionDoc { + key: String::new(), + title: String::new(), + story: String::new(), + size: default_size(), + seed: 0, + gold: default_gold(), + portals: default_portals(), + treasures: 0, + goals: Vec::new(), + waves: false, + wave_limit: 0, + first_wave: default_first_wave(), + time_limit: 0.0, + next: None, + branches: Vec::new(), + zones: Vec::new(), + script: None, + } + } +} + +impl CampaignDoc { + pub fn load(text: &str) -> Result { + serde_json::from_str::(text).map_err(|e| format!("invalid campaign file: {e}")) + } + + pub fn save(&self) -> String { + serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into()) + } + + pub fn add_mission(&mut self) -> &mut MissionDoc { + let n = self.missions.len() + 1; + self.missions.push(MissionDoc { + key: format!("m{n:02}"), + title: format!("Mission {n}"), + seed: (n as u32) * 101, + goals: vec!["Shards".into()], + ..Default::default() + }); + self.missions.last_mut().expect("the new mission is last") + } + + pub fn file_name(&self) -> String { + let base: String = self + .name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') + .collect(); + let base = if base.is_empty() { "campaign".to_string() } else { base }; + format!("{}.{}", base, CAMPAIGN_EXTENSION) + } + + pub fn validate(&self) -> Vec { + let mut errors = Vec::new(); + let mut keys = HashSet::new(); + + if self.missions.is_empty() { + errors.push("campaign has no missions".into()); + } + + for mission in &self.missions { + if !valid_key(&mission.key) { + errors.push(format!("invalid mission key: {:?}", mission.key)); + } else if !keys.insert(mission.key.clone()) { + errors.push(format!("duplicate mission key: {}", mission.key)); + } + + if mission.title.trim().is_empty() { + errors.push(format!("mission {} has no title", mission.key)); + } + + if mission.size.0 < 32 || mission.size.1 < 32 || mission.size.0 > 512 || mission.size.1 > 512 { + errors.push(format!("mission {} has an out-of-range size: {}x{}", mission.key, mission.size.0, mission.size.1)); + } + + for raw in &mission.goals { + if parse_goal(raw).is_none() { + errors.push(format!("mission {} has an unknown goal: {}", mission.key, raw)); + } + } + + if let Some(next) = &mission.next { + if !valid_key(next) { + errors.push(format!("mission {} has an invalid next mission: {:?}", mission.key, next)); + } + } + + if mission.next.as_deref().map_or(false, |next| !self.missions.iter().any(|m| m.key == next)) { + errors.push(format!("mission {} has an unknown next mission: {}", mission.key, mission.next.clone().unwrap_or_default())); + } + + for branch in &mission.branches { + if !valid_key(&branch.id) { + errors.push(format!("mission {} has an invalid branch id: {:?}", mission.key, branch.id)); + } + + if parse_goal(&branch.goal).is_none() { + errors.push(format!("mission {} branch {} has an unknown goal: {}", mission.key, branch.id, branch.goal)); + } + + if !valid_key(&branch.next) { + errors.push(format!("mission {} branch {} has an invalid next mission: {:?}", mission.key, branch.id, branch.next)); + } else if !self.missions.iter().any(|m| m.key == branch.next) { + errors.push(format!("mission {} branch {} points to an unknown mission: {}", mission.key, branch.id, branch.next)); + } + } + } + + for mission in &self.missions { + if let Some(next) = &mission.next { + if !self.missions.iter().any(|m| m.key == *next) { + errors.push(format!("mission {} next mission does not exist: {}", mission.key, next)); + } + } + } + + errors + } + + pub fn next_mission_choice(&self, i: usize, goal_done: F) -> (Option, Option) + where + F: Fn(Goal) -> bool, + { + let Some(mission) = self.missions.get(i) else { + return (None, None); + }; + + let mut matched: Vec<&BranchDoc> = mission + .branches + .iter() + .filter(|b| match parse_goal(&b.goal) { + Some(g) => goal_done(g), + None => false, + }) + .collect(); + matched.sort_by(|a, b| b.priority.unwrap_or(0).cmp(&a.priority.unwrap_or(0))); + + if let Some(branch) = matched.first() { + return (self.missions.iter().position(|m| m.key == branch.next), Some(branch.id.clone())); + } + + let main_done = !mission.goals.is_empty() + && mission + .goals + .iter() + .filter_map(|g| parse_goal(g)) + .all(|g| goal_done(g)); + + if main_done { + if let Some(next) = &mission.next { + return (self.missions.iter().position(|m| m.key == *next), None); + } + } + + if let Some(next) = &mission.next { + return (self.missions.iter().position(|m| m.key == *next), None); + } + + if mission.branches.is_empty() && i + 1 < self.missions.len() { + return (Some(i + 1), None); + } + + (None, None) + } +} + +pub fn parse_list(text: &str) -> Vec { + text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() +} + +pub fn parse_branches(text: &str) -> Vec { + text.split(';') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| { + let parts: Vec<&str> = s.split(':').map(|p| p.trim()).collect(); + BranchDoc { + id: parts.first().copied().unwrap_or_default().to_string(), + goal: parts.get(1).copied().unwrap_or_default().to_string(), + next: parts.get(2).copied().unwrap_or_default().to_string(), + priority: parts.get(3).and_then(|p| p.parse::().ok()), + title: parts.get(4).copied().unwrap_or_default().to_string(), + } + }) + .collect() +} + +pub fn branches_to_text(branches: &[BranchDoc]) -> String { + branches + .iter() + .map(|b| { + let mut line = format!("{}:{}:{}", b.id, b.goal, b.next); + if b.priority.is_some() || !b.title.is_empty() { + line.push(':'); + line.push_str(&b.priority.unwrap_or(0).to_string()); + } + if !b.title.is_empty() { + line.push(':'); + line.push_str(&b.title); + } + line + }) + .collect::>() + .join("; ") +} + +pub fn parse_goals(raws: &[String]) -> Vec { + raws.iter().filter_map(|r| parse_goal(r)).collect() +} + +pub fn parse_goal(raw: &str) -> Option { + let s = raw.trim(); + match s { + "Shards" => return Some(Goal::Shards), + "Heart" => return Some(Goal::Heart), + "KillAll" => return Some(Goal::KillAll), + "Towers" => return Some(Goal::Towers), + "Rivals" => return Some(Goal::Rivals), + _ => {} + } + + if let Some(n) = number_after_prefix(s, "Loot") { + return Some(Goal::Loot(n)); + } + if let Some(n) = number_after_prefix(s, "Survive") { + return Some(Goal::Survive(n)); + } + if let Some(n) = number_after_prefix(s, "Springs") { + return Some(Goal::Springs(n)); + } + if let Some(n) = number_after_prefix(s, "Freed") { + return Some(Goal::Freed(n)); + } + + None +} + +fn number_after_prefix(s: &str, prefix: &str) -> Option { + let rest = s.strip_prefix(prefix)?; + if !rest.starts_with('(') || !rest.ends_with(')') { + return None; + } + rest[1..rest.len() - 1].trim().parse::().ok() +} + +fn valid_key(s: &str) -> bool { + !s.is_empty() && s.len() <= 48 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn default_format() -> String { + CAMPAIGN_FORMAT.into() +} + +fn default_version() -> u32 { + CAMPAIGN_VERSION +} + +fn default_size() -> (usize, usize) { + (96, 80) +} + +fn default_gold() -> f32 { + 3000.0 +} + +fn default_portals() -> usize { + 1 +} + +fn default_first_wave() -> f32 { + 300.0 +} + +fn default_false() -> bool { + false +} + +fn default_shape() -> String { + "circle".into() +} + +fn default_script_language() -> String { + "rhai".into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_campaign_file_reads_missions_branches_and_scripts() { + let text = r#"{ + "format": "dungeoncraft.campaign", + "version": 1, + "name": "Meine Kampagne", + "author": "Tester", + "missions": [ + { + "key": "my01", + "title": "Der erste Weg", + "size": [96, 80], + "seed": 123, + "goals": ["Shards"], + "branches": [ + { "id": "kill_path", "goal": "KillAll", "next": "my02a" }, + { "id": "loot_path", "goal": "Loot(3000)", "next": "my02b", "priority": 2 } + ], + "script": { "language": "rhai", "source": "fn on_start(m) { m.log('hi'); }" } + }, + { "key": "my02a", "title": "A", "size": [96, 80] }, + { "key": "my02b", "title": "B", "size": [96, 80] } + ] + }"#; + + let campaign = CampaignDoc::load(text).expect("the campaign file parses"); + assert_eq!(campaign.name, "Meine Kampagne"); + assert_eq!(campaign.missions[0].branches.len(), 2); + assert_eq!(campaign.missions[0].branches[1].priority, Some(2)); + assert_eq!(parse_goal(campaign.missions[0].branches[1].goal.as_str()), Some(Goal::Loot(3000))); + assert!(campaign.validate().is_empty()); + } + + #[test] + fn branch_goals_choose_the_next_mission() { + let campaign = CampaignDoc { + name: "Branches".into(), + missions: vec![ + MissionDoc { + key: "m01".into(), + title: "Start".into(), + branches: vec![BranchDoc { id: "kill".into(), goal: "KillAll".into(), next: "m02a".into(), priority: Some(1), ..Default::default() }], + ..Default::default() + }, + MissionDoc { key: "m02a".into(), title: "A".into(), ..Default::default() }, + ], + ..Default::default() + }; + + let (next, choice) = campaign.next_mission_choice(0, |goal| goal == Goal::KillAll); + assert_eq!(next, Some(1)); + assert_eq!(choice.as_deref(), Some("kill")); + } + + #[test] + fn higher_priority_branch_wins() { + let campaign = CampaignDoc { + missions: vec![ + MissionDoc { + key: "m01".into(), + title: "Start".into(), + branches: vec![ + BranchDoc { id: "low".into(), goal: "Shards".into(), next: "m02low".into(), priority: Some(1), ..Default::default() }, + BranchDoc { id: "high".into(), goal: "Shards".into(), next: "m02high".into(), priority: Some(2), ..Default::default() }, + ], + ..Default::default() + }, + MissionDoc { key: "m02low".into(), title: "Low".into(), ..Default::default() }, + MissionDoc { key: "m02high".into(), title: "High".into(), ..Default::default() }, + ], + ..Default::default() + }; + + let (next, choice) = campaign.next_mission_choice(0, |goal| goal == Goal::Shards); + assert_eq!(next, Some(2)); + assert_eq!(choice.as_deref(), Some("high")); + } + + #[test] + fn a_new_campaign_starts_with_one_playable_mission() { + let mut campaign = CampaignDoc::default(); + campaign.add_mission(); + assert_eq!(campaign.missions.len(), 1); + assert_eq!(campaign.missions[0].key, "m01"); + assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::Shards)); + assert!(campaign.validate().is_empty()); + } + + #[test] + fn branch_text_round_trips_branch_objects() { + let branches = parse_branches("kill:KillAll:m02a:1:Kill path; loot:Loot(3000):m02b:2:Plunder path"); + assert_eq!(branches.len(), 2); + assert_eq!(branches[0].id, "kill"); + assert_eq!(branches[0].priority, Some(1)); + assert_eq!(branches[0].title, "Kill path"); + assert_eq!(parse_goal(branches[1].goal.as_str()), Some(Goal::Loot(3000))); + + let text = branches_to_text(&branches); + assert_eq!(parse_branches(&text), branches); + } + + #[test] + fn a_bad_branch_next_is_reported() { + let campaign = CampaignDoc { + missions: vec![MissionDoc { + key: "my01".into(), + title: "Mission".into(), + branches: vec![BranchDoc { id: "bad".into(), goal: "Shards".into(), next: "missing".into(), ..Default::default() }], + ..Default::default() + }], + ..Default::default() + }; + + let errors = campaign.validate(); + assert!(errors.iter().any(|e| e.contains("points to an unknown mission"))); + } + + #[test] + fn numbered_goals_parse_into_real_game_goals() { + assert_eq!(parse_goal("Survive(4)"), Some(Goal::Survive(4))); + assert_eq!(parse_goal("Springs(2)"), Some(Goal::Springs(2))); + assert_eq!(parse_goal("Freed(1)"), Some(Goal::Freed(1))); + } +} \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs index d0e211b..ac014ac 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -159,7 +159,14 @@ impl Game { Act::Guard { x, y } => (point(x as f32, y as f32), MARK_GUARD, 10.0), Act::CancelOrders => (point(self.map.core.0 as f32, self.map.core.1 as f32), MARK_CANCEL, 4.0), }; - self.add_mark(x, y, kind, side, dur); + let fixed = matches!(act, Act::Guard { .. } | Act::Attack { .. } | Act::Flag { .. }); + let dur = match act { + Act::Guard { .. } => 120.0, + Act::Attack { .. } => 20.0, + Act::Flag { .. } => 120.0, + _ => dur, + }; + self.add_mark(x, y, kind, side, dur, fixed); } fn run_act(&mut self, act: Act) { @@ -226,6 +233,7 @@ mod tests { #[test] fn a_queued_command_is_applied_at_the_next_step() { let mut g = game(7); + g.debug_markers = true; let (cx, cy) = g.map.core; let (x, y) = (cx + 6, cy); // outside the treasury ring: rock that can be marked g.queue(Act::Dig(Rect::new(x, y, x + 1, y + 1))); @@ -239,14 +247,30 @@ mod tests { #[test] fn commands_leave_visible_markers() { let mut g = game(12); + g.debug_markers = true; let (cx, cy) = g.map.core; g.queue(Act::Dig(Rect::new(cx + 6, cy, cx + 7, cy + 1))); g.queue(Act::Guard { x: cx, y: cy }); g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 }); g.update(STEP); - assert!(g.marks.iter().any(|m| m.kind == MARK_DIG && m.side == 0), "dig marker"); - assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.side == 0), "guard marker"); - assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.side == 0), "attack marker"); + assert!(g.marks.iter().any(|m| m.kind == MARK_DIG && m.side == 0 && !m.fixed), "dig debug marker"); + assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.side == 0 && m.fixed), "fixed guard flag"); + assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.side == 0 && m.fixed), "fixed attack flag"); + } + + #[test] + fn fixed_order_flags_do_not_need_the_debug_marker_setting() { + let mut g = game(13); + g.debug_markers = false; + let (cx, cy) = g.map.core; + g.queue(Act::Dig(Rect::new(cx + 6, cy, cx + 7, cy + 1))); + g.queue(Act::Guard { x: cx, y: cy }); + g.queue(Act::Attack { ent: None, x: (cx + 20) as f32, y: (cy + 20) as f32 }); + g.queue(Act::Flag { x: cx, y: cy }); + assert!(!g.marks.iter().any(|m| m.kind == MARK_DIG), "dig stays hidden without debug markers"); + assert!(g.marks.iter().any(|m| m.kind == MARK_GUARD && m.fixed), "guard is fixed"); + assert!(g.marks.iter().any(|m| m.kind == MARK_ATTACK && m.fixed), "attack is fixed"); + assert!(g.marks.iter().any(|m| m.kind == MARK_FLAG && m.fixed), "gather flag is fixed"); } #[test] diff --git a/src/game.rs b/src/game.rs index 78fdd4d..175e507 100644 --- a/src/game.rs +++ b/src/game.rs @@ -616,6 +616,8 @@ pub struct Game { pub marks: Vec, // short-lived markers for commands given by the masters #[serde(skip)] pub hints: bool, // show hint messages (setting) + #[serde(skip)] + pub debug_markers: bool, // show debug-only command markers #[serde(skip, default = "hint_times")] hint_t: [f32; 32], // last time each hint slot was shown } @@ -646,6 +648,8 @@ pub struct Mark { pub kind: u8, pub side: u8, pub until: u64, + #[serde(default)] + pub fixed: bool, // guard/attack/gather order flags are shown without the debug setting } fn hint_times() -> [f32; 32] { @@ -815,6 +819,7 @@ impl Game { entry_history: Vec::new(), planned_entries: Vec::new(), hints: true, + debug_markers: false, me: Owner::Player, me_side: 0, local_side: 0, @@ -3841,19 +3846,19 @@ impl Game { } } - pub fn add_mark(&mut self, x: f32, y: f32, kind: u8, side: u8, duration: f32) { - if duration <= 0.0 || !x.is_finite() || !y.is_finite() { + pub fn add_mark(&mut self, x: f32, y: f32, kind: u8, side: u8, duration: f32, fixed: bool) { + if (!fixed && !self.debug_markers) || duration <= 0.0 || !x.is_finite() || !y.is_finite() { return; } let until = self.sim_steps + (duration / STEP).ceil() as u64; - if let Some(m) = self.marks.iter_mut().find(|m| (m.x - x).abs() < 0.75 && (m.y - y).abs() < 0.75 && m.kind == kind && m.side == side) { + if let Some(m) = self.marks.iter_mut().find(|m| (m.x - x).abs() < 0.75 && (m.y - y).abs() < 0.75 && m.kind == kind && m.side == side && m.fixed == fixed) { m.until = m.until.max(until); return; } if self.marks.len() >= 80 { self.marks.remove(0); } - self.marks.push(Mark { x, y, kind, side, until }); + self.marks.push(Mark { x, y, kind, side, until, fixed }); } fn creature_choose(&mut self, e: &mut Ent) { diff --git a/src/main.rs b/src/main.rs index 7c9565b..fa3c3b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod audio; mod campaign; +mod campaign_doc; mod cloud; mod commands; mod demo; @@ -25,6 +26,7 @@ mod rival; mod render3d; mod rooms; mod save; +mod script; mod settings; mod sfx; mod tracks; @@ -45,13 +47,14 @@ use macroquad::prelude::*; use map::{inb, mh, mw}; use menu::{Menu, MenuAction, Page}; use lobby::{MatchClient, Phase, StartInfo}; -use render3d::{Cam, Overlay, R3d}; +use render3d::{Cam, Overlay, R3d, disc, line3}; use settings::{bindable_keys, Action, Settings}; use sound::SoundBank; use touch::{Gesture, TouchInput}; -use ui::{hover_info, Tool, Ui, UiAction, TOOLS}; +use ui::{chat_color, hover_info, Tool, Ui, UiAction, TOOLS}; const SETTINGS_KEY: &str = "dc.settings"; +const FRAME_HIST: usize = 64; const AUTOSAVE_EVERY: f32 = 120.0; // seconds of play between autosaves const AUTOSAVE_CACHE_EVERY: f32 = 15.0; // snapshot kept in JS, written when the tab is left @@ -175,6 +178,38 @@ fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) { (g, cam) } +fn prompt_line(title: &str, initial: &str, max: usize) -> Option { + let value = platform::prompt(title, initial)?; + let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect(); + let clean = clean.trim().to_string(); + if clean.is_empty() { + None + } else { + Some(clean) + } +} + +fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option { + let value = platform::prompt(title, initial)?; + let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect(); + Some(clean.trim().to_string()) +} + +fn sanitize_campaign_key(value: &str) -> String { + value.trim().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-').take(48).collect() +} + +fn custom_mission_game(def: &campaign::MissionDef, i: usize) -> (Game, Cam) { + let g = campaign::start_mission_doc(def, i, None); + let (cx, cy) = g.map.core; + let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); + (g, cam) +} + +fn custom_next_choice(campaign: &campaign_doc::CampaignDoc, i: usize, g: &Game) -> (Option, Option) { + campaign.next_mission_choice(i, |goal| g.goal_done(goal)) +} + const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one fn store_progress(p: &campaign::Progress) { @@ -372,6 +407,197 @@ fn store_settings(s: &Settings) { platform::storage_set(SETTINGS_KEY, &s.to_json()); } +fn apply_game_settings(g: &mut Game, s: &Settings) { + g.hints = s.hints; + g.debug_markers = s.debug_command_markers; +} + +fn start_custom_script(g: &mut Game, doc: &campaign_doc::MissionDoc) { + let Some(script) = doc.script.as_ref() else { + return; + }; + if script.source.trim().is_empty() { + return; + } + if !script.language.is_empty() && !script.language.eq_ignore_ascii_case("rhai") { + g.msg("msg.script_language", vec![script.language.clone()]); + return; + } + match script::run_start(&script.source) { + Ok(m) => { + for message in m.messages() { + g.msg("msg.script", vec![message]); + } + if let Some(objective) = m.objective() { + g.msg("msg.script_objective", vec![objective]); + } + for action in m.actions() { + apply_script_action(g, doc, action); + } + } + Err(error) => { + g.msg("msg.script_error", vec![error]); + } + } +} + +fn validate_script_source(doc: &campaign_doc::MissionDoc, source: &str) -> Result<(), String> { + match script::run_start(source) { + Ok(m) => { + for action in m.actions() { + match action { + script::ScriptAction::Spawn { kind, .. } => { + if script::parse_spawn_kind(&kind).is_none() { + return Err(format!("unknown creature: {kind}")); + } + } + script::ScriptAction::SpawnInZone { zone, kind, .. } => { + if script::parse_spawn_kind(&kind).is_none() { + return Err(format!("unknown creature: {kind}")); + } + if !doc.zones.iter().any(|z| z.name == zone) { + return Err(format!("unknown zone: {zone}")); + } + } + script::ScriptAction::Wave { .. } => {} + } + } + Ok(()) + } + Err(error) => Err(error), + } +} + +fn apply_script_action(g: &mut Game, doc: &campaign_doc::MissionDoc, action: script::ScriptAction) { + match action { + script::ScriptAction::Spawn { side, kind, x, y, count, level } => { + let Some(kind) = script::parse_spawn_kind(&kind) else { + g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]); + return; + }; + spawn_script_units(g, side, kind, &vec![(x, y); count.max(0) as usize], level); + } + script::ScriptAction::SpawnInZone { zone, kind, side, count, level } => { + let Some(kind) = script::parse_spawn_kind(&kind) else { + g.msg("msg.script_error", vec![format!("unknown creature: {kind}")]); + return; + }; + let Some(zone_doc) = doc.zones.iter().find(|z| z.name == zone) else { + g.msg("msg.script_error", vec![format!("unknown zone: {zone}")]); + return; + }; + let positions = zone_spawn_positions(g, zone_doc, count); + if positions.is_empty() { + g.msg("msg.script_error", vec![format!("no usable spawn position in zone: {zone}")]); + return; + } + spawn_script_units(g, side, kind, &positions, level); + } + script::ScriptAction::Wave { count } => { + apply_script_wave(g, count); + } + } +} + +fn apply_script_wave(g: &mut Game, count: i32) { + let count = count.max(0) as u32; + if count == 0 { + return; + } + if let Some(m) = g.mission.as_mut() { + m.waves = true; + if m.wave_limit != 0 { + m.wave_limit = m.wave_limit.max(g.wave.saturating_add(count)); + } + } + g.wave_timer = 0.0; +} + +fn spawn_script_units(g: &mut Game, side: i32, kind: game::EK, positions: &[(f32, f32)], level: i32) { + let positions: Vec<(f32, f32)> = positions.iter().copied().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).collect(); + if positions.is_empty() { + return; + } + let old_side = g.me_side; + match side { + 0 => g.me_side = 0, + 1 => g.me_side = 1, + 2 | 3 | 4 => g.me_side = side as u8, + _ => { + g.msg("msg.script_error", vec![format!("unknown script side: {side}")]); + return; + } + } + for (x, y) in positions { + g.spawn(kind, side == 1, x, y, level.max(1) as u32); + } + g.me_side = old_side; +} + +fn valid_script_spawn_pos(_g: &Game, x: f32, y: f32) -> bool { + x.is_finite() && y.is_finite() && x >= 0.0 && y >= 0.0 && x < mw() as f32 && y < mh() as f32 && inb(x as i32, y as i32) +} + +fn zone_spawn_positions(g: &Game, zone: &campaign_doc::ZoneDoc, count: i32) -> Vec<(f32, f32)> { + let count = count.clamp(1, 10) as usize; + let core = g.map.core; + let center = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5)); + let mut out = Vec::new(); + match zone.shape.as_str() { + "rect" | "rectangle" | "square" => { + if let (Some(from), Some(to)) = (zone.from, zone.to) { + let x0 = from.0.min(to.0); + let x1 = from.0.max(to.0); + let y0 = from.1.min(to.1); + let y1 = from.1.max(to.1); + let cols = ((count as f32).sqrt().ceil()) as usize; + for k in 0..count { + let r = k / cols; + let c = k % cols; + let tx = if cols > 1 { c as f32 / (cols as f32 - 1.0) } else { 0.5 }; + let rows = ((count as f32) / cols as f32).ceil() as usize; + let ty = if rows > 1 { r as f32 / (rows as f32 - 1.0) } else { 0.5 }; + out.push((x0 + 0.5 + tx * (x1 - x0), y0 + 0.5 + ty * (y1 - y0))); + } + } + } + "line" | "path" => { + if let (Some(from), Some(to)) = (zone.from, zone.to) { + let x0 = from.0 + 0.5; + let y0 = from.1 + 0.5; + let x1 = to.0 + 0.5; + let y1 = to.1 + 0.5; + for k in 0..count { + let t = (k as f32 + 0.5) / count as f32; + out.push((x0 + t * (x1 - x0), y0 + t * (y1 - y0))); + } + } + } + "tiles" => { + if let Some(tiles) = zone.tiles.as_ref() { + for (x, y) in tiles.iter().take(count) { + out.push((*x as f32 + 0.5, *y as f32 + 0.5)); + } + } + } + _ => { + let radius = zone.radius.unwrap_or(2.0).max(0.0); + let cx = center.0 + 0.5; + let cy = center.1 + 0.5; + if count == 1 || radius <= 0.5 { + out.push((cx, cy)); + } else { + let r = radius.min(radius.max(0.5) * 0.75); + for k in 0..count { + let angle = (k as f32) * std::f32::consts::TAU / count as f32; + out.push((cx + angle.cos() * r, cy + angle.sin() * r)); + } + } + } + } + out.into_iter().filter(|(x, y)| valid_script_spawn_pos(g, *x, *y)).take(count).collect() +} + // "dungeoncraft_2026-11-02_2145.dcsave" (UTC) fn save_file_name() -> String { let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64); @@ -396,6 +622,13 @@ struct App { coop: bool, fps: f32, renderer: String, + frame_ms: f32, + frame_hist: [f32; FRAME_HIST], + frame_hist_pos: usize, + custom_campaign: Option, + custom_progress: Option, + custom_mission_index: Option, + custom_next_index: Option, } impl App { @@ -417,7 +650,7 @@ impl App { apply_settings(&self.settings, r3d); store_settings(&self.settings); } - self.game.hints = self.settings.hints; + apply_game_settings(&mut self.game, &self.settings); self.game.coop = false; self.coop = false; r3d.invalidate(); @@ -454,7 +687,7 @@ impl App { true } - fn poll_match(&mut self) { + fn poll_match(&mut self, now: f64) { if self.ws_id <= 0 { if self.client.connected { self.client.on_disconnect(); @@ -462,8 +695,9 @@ impl App { return; } while let Some(msg) = platform::ws_recv(self.ws_id) { - self.client.feed(&msg); + self.client.feed_at(&msg, now); } + self.client.tick_ping(now); if self.client.phase() == Phase::Closed && self.game.in_match() { self.game.end_match(); self.match_mode = false; @@ -522,7 +756,7 @@ impl App { } game.ai_difficulty = info.difficulty; game.sync_ai_sides(&info.ai, info.difficulty); - game.hints = self.settings.hints; + apply_game_settings(&mut game, &self.settings); self.game = game; let (cx, cy) = self.game.map.core; self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); @@ -542,7 +776,7 @@ impl App { self.game.coop = false; self.game.ai_difficulty = info.difficulty; self.game.sync_ai_sides(&info.ai, info.difficulty); - self.game.hints = self.settings.hints; + apply_game_settings(&mut self.game, &self.settings); self.game.init_fog(); let (cx, cy) = self.game.map.core; self.cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); @@ -602,9 +836,9 @@ async fn main() { let seed = (miniquad::date::now() * 1000.0) as u32 | 1; // behind the title menu: the endless bridge battle let (game, cam) = Game::new_demo(seed); - let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer() }; + let mut app = App { game, cam, settings, seed, status: None, recorded: false, client: MatchClient::new(), ws_id: 0, match_mode: false, coop: false, fps: 0.0, renderer: platform::renderer(), frame_ms: 0.0, frame_hist: [0.0; FRAME_HIST], frame_hist_pos: 0, custom_campaign: None, custom_progress: None, custom_mission_index: None, custom_next_index: None }; menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None }; let mut touch = TouchInput::new(); let mut auto_touch = platform::coarse_pointer(); @@ -621,6 +855,10 @@ async fn main() { let now = get_time(); let fps_now = 1.0 / raw_dt.max(0.001); app.fps = if app.fps <= 0.0 { fps_now } else { app.fps + (fps_now - app.fps) * 0.08 }; + let raw_ms = raw_dt * 1000.0; + app.frame_ms = if app.frame_ms <= 0.0 { raw_ms } else { app.frame_ms + (raw_ms - app.frame_ms) * 0.08 }; + app.frame_hist[app.frame_hist_pos] = raw_ms; + app.frame_hist_pos = (app.frame_hist_pos + 1) % FRAME_HIST; let mut settings_changed = false; // ---------------- touch or mouse: which interface is active @@ -658,7 +896,25 @@ async fn main() { } } if let Some(bytes) = incoming { - app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now); + if menu.campaign_pending { + menu.campaign_pending = false; + match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) { + Ok(campaign) => { + let errors = campaign.validate(); + if errors.is_empty() { + app.set_status(i18n::trf("campaign.loaded", &[campaign.name.clone()]), now); + menu.campaign_doc = campaign; + menu.campaign_selected = 0; + menu.page = Some(Page::CampaignEditor); + } else { + app.set_status(i18n::trf("campaign.invalid", &[errors.join(" | ")]), now); + } + } + Err(e) => app.set_status(i18n::trf("campaign.invalid", &[e]), now), + } + } else { + app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now); + } } // ---------------- savegame service: finished requests @@ -712,7 +968,7 @@ async fn main() { } // ---------------- multiplayer relay: receive messages, send queued requests, update the menu state - app.poll_match(); + app.poll_match(now); menu.in_match = app.match_mode || app.game.in_match(); if matches!(menu.page, Some(Page::Match { .. })) { menu.set_match_ui(app.client.ui()); @@ -871,7 +1127,7 @@ async fn main() { if is_key_pressed(KeyCode::N) && app.game.state != 0 { app.seed = next_seed(app.seed); (app.game, app.cam) = new_game(app.seed, &app.settings); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); } } if !dialog && !menu.typing() { @@ -1035,7 +1291,7 @@ async fn main() { MenuAction::NewGame => { app.seed = next_seed(app.seed); (app.game, app.cam) = new_game(app.seed, &app.settings); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); menu.page = None; ui.log.clear(); ui.show_help = true; @@ -1079,7 +1335,7 @@ async fn main() { } MenuAction::StartMission(i) => { (app.game, app.cam) = mission_game(i, &menu.progress); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); app.game.coop = false; app.coop = false; app.recorded = false; @@ -1088,6 +1344,140 @@ async fn main() { ui.show_help = false; autosave_t = 0.0; } + MenuAction::CampaignNew => { + menu.campaign_doc = campaign_doc::CampaignDoc::default(); + menu.campaign_doc.add_mission(); + menu.campaign_selected = 0; + menu.page = Some(Page::CampaignEditor); + app.set_status(i18n::tr("campaign.new"), now); + } + MenuAction::CampaignAdd => { + menu.campaign_doc.add_mission(); + menu.campaign_selected = menu.campaign_doc.missions.len().saturating_sub(1); + menu.page = Some(Page::CampaignEditor); + app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now); + } + MenuAction::CampaignOpen => { + menu.campaign_pending = true; + platform::open_file(); + app.set_status(i18n::tr("campaign.choose"), now); + } + MenuAction::CampaignSave => { + let name = menu.campaign_doc.file_name(); + platform::download(&name, menu.campaign_doc.save().as_bytes()); + app.set_status(i18n::trf("campaign.saved", &[name]), now); + } + MenuAction::CampaignEditName => { + if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_name"), &menu.campaign_doc.name, 60) { + menu.campaign_doc.name = value; + app.set_status(i18n::tr("campaign.updated"), now); + } + } + MenuAction::CampaignEditTitle => { + let i = menu.campaign_selected; + if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.title.clone()) { + if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_title"), &initial, 80) { + if let Some(mission) = menu.campaign_doc.missions.get_mut(i) { + mission.title = value; + } + app.set_status(i18n::tr("campaign.updated"), now); + } + } + } + MenuAction::CampaignEditGoals => { + let i = menu.campaign_selected; + if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| m.goals.join(", ")) { + if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_goals"), &initial, 120) { + if let Some(mission) = menu.campaign_doc.missions.get_mut(i) { + mission.goals = campaign_doc::parse_list(&value); + } + app.set_status(i18n::tr("campaign.updated"), now); + } + } + } + MenuAction::CampaignEditNext => { + let i = menu.campaign_selected; + let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.next.clone()).unwrap_or_default(); + if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_next"), &initial, 48) { + let key = sanitize_campaign_key(&value); + if let Some(mission) = menu.campaign_doc.missions.get_mut(i) { + mission.next = Some(key); + } + app.set_status(i18n::tr("campaign.updated"), now); + } + } + MenuAction::CampaignEditBranches => { + let i = menu.campaign_selected; + if let Some(initial) = menu.campaign_doc.missions.get(i).map(|m| campaign_doc::branches_to_text(&m.branches)) { + if let Some(value) = prompt_line(&i18n::tr("campaign.prompt_branches"), &initial, 180) { + if let Some(mission) = menu.campaign_doc.missions.get_mut(i) { + mission.branches = campaign_doc::parse_branches(&value); + } + app.set_status(i18n::tr("campaign.updated"), now); + } + } + } + MenuAction::CampaignEditScript => { + let i = menu.campaign_selected; + let initial = menu.campaign_doc.missions.get(i).and_then(|m| m.script.as_ref()).map(|s| s.source.clone()).unwrap_or_default(); + if let Some(source) = prompt_optional_line(&i18n::tr("campaign.prompt_script"), &initial, 600) { + if source.trim().is_empty() { + if let Some(mission) = menu.campaign_doc.missions.get_mut(i) { + mission.script = None; + } + app.set_status(i18n::tr("campaign.updated"), now); + } else if let Some(mission) = menu.campaign_doc.missions.get(i).cloned() { + match validate_script_source(&mission, &source) { + Ok(()) => { + if let Some(mission) = menu.campaign_doc.missions.get_mut(i) { + mission.script = Some(campaign_doc::ScriptDoc { language: "rhai".into(), source }); + } + app.set_status(i18n::tr("campaign.updated"), now); + } + Err(error) => { + app.set_status(i18n::trf("msg.script_error", &vec![error]), now); + } + } + } else { + app.set_status(i18n::tr("campaign.error_no_mission"), now); + } + } + } + MenuAction::CampaignDelete(i) => { + if i < menu.campaign_doc.missions.len() { + menu.campaign_doc.missions.remove(i); + menu.campaign_selected = menu.campaign_selected.min(menu.campaign_doc.missions.len().saturating_sub(1)); + if i == app.custom_mission_index.unwrap_or(usize::MAX) { + app.custom_campaign = None; + app.custom_progress = None; + app.custom_mission_index = None; + app.custom_next_index = None; + } + app.set_status(i18n::trf("campaign.missions", &[menu.campaign_doc.missions.len().to_string()]), now); + } + } + MenuAction::CampaignStart(i) => { + if let Some(mission_doc) = menu.campaign_doc.missions.get(i) { + if menu.campaign_doc.validate().is_empty() { + let def = campaign::mission_def_from_doc(mission_doc); + (app.game, app.cam) = custom_mission_game(&def, i); + apply_game_settings(&mut app.game, &app.settings); + start_custom_script(&mut app.game, mission_doc); + app.game.coop = false; + app.coop = false; + app.recorded = false; + app.custom_campaign = Some(menu.campaign_doc.clone()); + app.custom_progress = Some(campaign_doc::CampaignProgress::for_doc(&menu.campaign_doc)); + app.custom_mission_index = Some(i); + menu.page = None; + ui.log.clear(); + ui.show_help = false; + autosave_t = 0.0; + } else { + app.set_status(i18n::tr("campaign.invalid"), now); + } + } + } MenuAction::OpenRepo => platform::open_url(menu::REPO_URL), MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL), a @ (MenuAction::Login(..) @@ -1144,18 +1534,65 @@ async fn main() { UiAction::NewGame => { app.seed = next_seed(app.seed); (app.game, app.cam) = new_game(app.seed, &app.settings); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); app.game.coop = false; app.coop = false; } // end of a mission: next mission (won) or try again (lost) UiAction::EndNext => { - if let Some(i) = app.game.mission.as_ref().map(|m| m.index) { + if app.custom_campaign.is_some() { + let campaign = app.custom_campaign.clone(); + let progress = app.custom_progress.clone(); + if app.game.state == 1 { + if let Some(next) = app.custom_next_index { + if let Some(campaign) = campaign { + if next < campaign.missions.len() { + let def = campaign::mission_def_from_doc(&campaign.missions[next]); + (app.game, app.cam) = custom_mission_game(&def, next); + apply_game_settings(&mut app.game, &app.settings); + start_custom_script(&mut app.game, &campaign.missions[next]); + app.game.coop = false; + app.coop = false; + app.recorded = false; + let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign)); + app.custom_campaign = Some(campaign); + app.custom_progress = Some(progress); + app.custom_mission_index = Some(next); + app.custom_next_index = None; + menu.page = None; + ui.log.clear(); + ui.show_help = false; + autosave_t = 0.0; + } else { + menu.page = Some(Page::CampaignEditor); + } + } + } else { + menu.page = Some(Page::CampaignEditor); + } + } else if let (Some(campaign), Some(i)) = (campaign, app.custom_mission_index) { + if i < campaign.missions.len() { + let def = campaign::mission_def_from_doc(&campaign.missions[i]); + (app.game, app.cam) = custom_mission_game(&def, i); + apply_game_settings(&mut app.game, &app.settings); + start_custom_script(&mut app.game, &campaign.missions[i]); + app.game.coop = false; + app.coop = false; + app.recorded = false; + let progress = progress.unwrap_or_else(|| campaign_doc::CampaignProgress::for_doc(&campaign)); + app.custom_campaign = Some(campaign); + app.custom_progress = Some(progress); + app.custom_mission_index = Some(i); + app.custom_next_index = None; + ui.log.clear(); + } + } + } else if let Some(i) = app.game.mission.as_ref().map(|m| m.index) { if app.game.state == 1 { menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign }); } else { (app.game, app.cam) = mission_game(i, &menu.progress); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); app.recorded = false; ui.log.clear(); } @@ -1273,7 +1710,7 @@ async fn main() { if settings_changed { apply_settings(&app.settings, &mut r3d); store_settings(&app.settings); - app.game.hints = app.settings.hints; + apply_game_settings(&mut app.game, &app.settings); } // ---------------- update (single-player worlds stand still while menus or dialogs are open; @@ -1310,12 +1747,32 @@ async fn main() { sounds.play(ev, vec2(app.cam.rx, app.cam.rz), right, hear, now); } // a won mission goes into the campaign progress (with the carry-over) - if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { - if app.game.state != 0 { - app.recorded = true; + if app.custom_campaign.is_none() { + if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) { + if app.game.state != 0 { + app.recorded = true; + if app.game.state == 1 { + menu.progress.set_done(i, campaign::carry_from(&app.game)); + store_progress(&menu.progress); + } + } + } + } else if app.game.state != 0 && !app.recorded { + app.recorded = true; + if let Some(i) = app.game.mission.as_ref().map(|m| m.index) { + app.custom_mission_index = Some(i); if app.game.state == 1 { - menu.progress.set_done(i, campaign::carry_from(&app.game)); - store_progress(&menu.progress); + let next_info = match &app.custom_campaign { + Some(campaign) => Some(custom_next_choice(campaign, i, &app.game)), + None => None, + }; + let mission_count = app.custom_campaign.as_ref().map(|c| c.missions.len()).unwrap_or(0); + if let Some((next_i, choice)) = next_info { + if let Some(progress) = &mut app.custom_progress { + progress.mark_done(i, mission_count, choice); + } + app.custom_next_index = next_i; + } } } } @@ -1327,6 +1784,7 @@ async fn main() { | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame) | Some(Page::Campaign) + | Some(Page::CampaignEditor) | Some(Page::Briefing { ingame: false, .. }) | Some(Page::Login) | Some(Page::Account) @@ -1396,12 +1854,16 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M high: ui.tool == Tool::Dig, }; r3d.draw(&app.game, gfx, &app.cam, now, &ov); + if app.settings.debug_trigger_zones || app.settings.debug_path_lines { + draw_debug_3d(app, now); + } let title_screen = matches!( menu.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::Campaign) + | Some(Page::CampaignEditor) | Some(Page::Briefing { ingame: false, .. }) | Some(Page::Login) | Some(Page::Account) @@ -1428,7 +1890,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M if !touch { ui.draw_cursor(gfx, &app.game, now, blocked); } - if app.settings.debug_fps { + if app.settings.debug_fps || app.settings.debug_frame_time || app.settings.debug_ping { draw_debug_fps(app, gfx); } } @@ -1436,24 +1898,170 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M fn draw_debug_fps(app: &App, gfx: &Gfx) { ui::begin_ui(); let scale = ui::SMALL().max(1.0); - let fps = app.fps.max(0.0).round() as i32; - let renderer = { - let r = app.renderer.trim(); - if r.is_empty() { - if cfg!(target_arch = "wasm32") { - "WebGL" + let text_h = scale * 10.0 + 4.0; + + if app.settings.debug_fps { + let fps = app.fps.max(0.0).round() as i32; + let renderer = { + let r = app.renderer.trim(); + if r.is_empty() { + if cfg!(target_arch = "wasm32") { + "WebGL" + } else { + "OpenGL" + } } else { - "OpenGL" + r } - } else { - r + }; + let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize; + let head: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect(); + let head_w = gfx.measure(&head, scale); + let head_x = (ui::sw() - head_w - 2.0).max(2.0); + draw_rectangle(head_x - 2.0, 0.0, head_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170)); + gfx.text(&head, head_x, 2.0, scale, GOLD); + } + + if app.settings.debug_frame_time { + let ms = app.frame_ms.max(0.0); + let graph_w = (ui::sw() * 0.42).clamp(80.0, 260.0).min(ui::sw() - 4.0).max(40.0); + let graph_h = (scale * 30.0).clamp(20.0, 52.0); + let frame_y = (ui::sh() - text_h - 2.0).max(12.0); + let graph_y = (frame_y - graph_h - 6.0).max(12.0); + let graph_x = (ui::sw() - graph_w - 2.0).max(2.0); + + let mut max_ms = ms.max(16.7); + for v in app.frame_hist.iter() { + max_ms = max_ms.max(*v); } - }; - let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize; - let line: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect(); - let w = gfx.measure(&line, scale); - let x = (ui::sw() - w - 2.0).max(2.0); - let y = 2.0; - draw_rectangle(x - 2.0, y - 2.0, w + 4.0, scale * 10.0 + 4.0, Color::from_rgba(0, 0, 0, 150)); - gfx.text(&line, x, y, scale, GOLD); + let max_ms = max_ms.max(16.7).max(ms * 1.3).min(100.0); + + draw_rectangle(graph_x - 2.0, graph_y - 2.0, graph_w + 4.0, graph_h + 4.0, Color::from_rgba(0, 0, 0, 170)); + let bar_w = graph_w / FRAME_HIST as f32; + for k in 0..FRAME_HIST { + let v = app.frame_hist[(app.frame_hist_pos + k) % FRAME_HIST]; + if v <= 0.0 { + continue; + } + let h = graph_h * (v / max_ms).clamp(0.0, 1.0).max(0.08); + let col = if v <= 16.7 { + Color::from_rgba(90, 220, 120, 235) + } else if v <= 33.3 { + Color::from_rgba(240, 210, 100, 235) + } else { + Color::from_rgba(235, 90, 90, 235) + }; + draw_rectangle(graph_x + k as f32 * bar_w, graph_y + graph_h - h, bar_w.max(1.0), h, col); + } + draw_rectangle(graph_x, graph_y + graph_h, graph_w, 1.0, Color::from_rgba(255, 255, 255, 50)); + + let frame_line = format!("{:.1} ms", ms); + let frame_w = gfx.measure(&frame_line, scale); + let frame_x = (ui::sw() - frame_w - 2.0).max(2.0); + draw_rectangle(frame_x - 2.0, frame_y - 2.0, frame_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170)); + gfx.text(&frame_line, frame_x, frame_y, scale, GOLD); + } + + if app.settings.debug_ping { + let ping = if app.client.connected { format!("{:.0} ms", app.client.ping_ms) } else { "no match".into() }; + let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize; + let line: String = format!("ping {}", ping).chars().take(max_cols).collect(); + let line_w = gfx.measure(&line, scale); + draw_rectangle(0.0, ui::sh() - text_h - 2.0, line_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170)); + let color = if !app.client.connected { + ui::DIM + } else if app.client.ping_ms <= 120.0 { + ui::GREEN + } else if app.client.ping_ms <= 300.0 { + Color::from_rgba(240, 210, 100, 255) + } else { + ui::RED + }; + gfx.text(&line, 2.0, ui::sh() - text_h, scale, color); + } +} + +fn draw_debug_3d(app: &App, now: f64) { + let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32); + + if app.settings.debug_trigger_zones { + if let Some(campaign) = app.custom_campaign.as_ref() { + if let Some(index) = app.custom_mission_index { + if let Some(mission) = campaign.missions.get(index) { + let done = app.custom_progress.as_ref().map(|p| p.is_done(index)).unwrap_or(false); + let zone_pulse = if done { pulse * 0.45 } else { pulse }; + for zone in mission.zones.iter().take(80) { + draw_debug_zone(zone, app.game.map.core, zone_pulse); + } + } + } + } + } + + if app.settings.debug_path_lines { + let mut drawn = 0usize; + for e in app.game.ents.iter() { + if !e.alive || e.held || e.hero || e.side != app.game.local_side || e.path.is_empty() { + continue; + } + if drawn >= 120 { + break; + } + drawn += 1; + let color = chat_color(Some(e.side), 0.55); + let mut prev = vec3(e.disp_x, 0.075, e.disp_y); + for tile in e.path.iter().take(12) { + let (tx, ty) = map::xy(*tile); + let p = vec3(tx as f32 + 0.5, 0.055, ty as f32 + 0.5); + line3(prev, p, 0.04, color); + prev = p; + } + } + } +} + +fn draw_debug_zone(zone: &campaign_doc::ZoneDoc, core: (i32, i32), pulse: f32) { + let c = debug_zone_color(&zone.name); + let color = Color::new(c.r, c.g, c.b, pulse); + let at = zone.at.unwrap_or((core.0 as f32 + 1.5, core.1 as f32 + 1.5)); + let shape = zone.shape.as_str(); + + match shape { + "rect" | "rectangle" | "square" => { + if let (Some(from), Some(to)) = (zone.from, zone.to) { + let a = vec3(from.0 + 0.5, 0.05, from.1 + 0.5); + let b = vec3(to.0 + 0.5, 0.05, to.1 + 0.5); + line3(vec3(a.x, 0.05, a.y), vec3(b.x, 0.05, a.y), 0.05, color); + line3(vec3(b.x, 0.05, a.y), vec3(b.x, 0.05, b.y), 0.05, color); + line3(vec3(b.x, 0.05, b.y), vec3(a.x, 0.05, b.y), 0.05, color); + line3(vec3(a.x, 0.05, b.y), vec3(a.x, 0.05, a.y), 0.05, color); + } + } + "line" | "path" => { + if let (Some(from), Some(to)) = (zone.from, zone.to) { + let w = zone.width.unwrap_or(1.0).max(0.2); + line3(vec3(from.0 + 0.5, 0.05, from.1 + 0.5), vec3(to.0 + 0.5, 0.05, to.1 + 0.5), w * 0.05, color); + } + } + "tiles" => { + if let Some(tiles) = zone.tiles.as_ref() { + for (x, y) in tiles.iter().take(200) { + disc(vec3(*x as f32 + 0.5, 0.045, *y as f32 + 0.5), 0.45, color, 12); + } + } + } + _ => { + let r = zone.radius.unwrap_or(2.0); + disc(vec3(at.0 + 0.5, 0.04, at.1 + 0.5), r, color, 48); + } + } +} + +fn debug_zone_color(name: &str) -> Color { + let mut h: u64 = 2166136261; + for b in name.bytes() { + h ^= b as u64; + h = h.wrapping_mul(1099511628211); + } + Color::from_rgba(80 + ((h % 176) as u8), 90 + (((h >> 8) % 166) as u8), 120 + (((h >> 16) % 136) as u8), 255) } diff --git a/src/menu.rs b/src/menu.rs index 0344b8c..927ae1e 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -3,6 +3,7 @@ // Drawn as CP437 windows on top of the (paused) game world. use crate::campaign::{missions, Progress}; +use crate::campaign_doc::CampaignDoc; use crate::cloud::{date_string, duration_string, Cloud}; use crate::game::Goal; use crate::gfx::*; @@ -24,6 +25,7 @@ pub enum Page { KeyCapture { action: usize, from_title: bool }, Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode) Campaign, // list of the campaign missions + CampaignEditor, // editable shareable .dccamp campaign file Briefing { mission: usize, ingame: bool }, // story and goals of a mission Login, // login to the savegame service (before the title) Account, // password, logout, users (admin) @@ -31,7 +33,7 @@ pub enum Page { Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks } -pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"]; +pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"]; // One adjustable value in the settings dialog #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -51,6 +53,11 @@ pub enum Item { UiScale, RenderScale, DebugFps, + DebugFrameTime, + DebugPing, + DebugTriggerZones, + DebugCommandMarkers, + DebugPathLines, GameSpeed, Hints, TouchUi, @@ -63,7 +70,8 @@ fn tab_items(tab: usize) -> &'static [Item] { 0 => &[Language, Volume, MusicVolume, Mute, Voice], 1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], - 3 => &[GameSpeed, Hints, TouchUi, DebugFps], + 3 => &[GameSpeed, Hints, TouchUi], + 4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines], _ => &[], } } @@ -85,6 +93,11 @@ fn item_label(it: Item) -> String { Item::UiScale => "set.ui_scale", Item::RenderScale => "set.render_scale", Item::DebugFps => "set.debug_fps", + Item::DebugFrameTime => "set.debug_frame_time", + Item::DebugPing => "set.debug_ping", + Item::DebugTriggerZones => "set.debug_trigger_zones", + Item::DebugCommandMarkers => "set.debug_command_markers", + Item::DebugPathLines => "set.debug_path_lines", Item::GameSpeed => "set.game_speed", Item::Hints => "set.hints", Item::TouchUi => "set.touch_ui", @@ -127,6 +140,11 @@ fn item_value(it: Item, s: &Settings) -> String { } } Item::DebugFps => onoff(s.debug_fps), + Item::DebugFrameTime => onoff(s.debug_frame_time), + Item::DebugPing => onoff(s.debug_ping), + Item::DebugTriggerZones => onoff(s.debug_trigger_zones), + Item::DebugCommandMarkers => onoff(s.debug_command_markers), + Item::DebugPathLines => onoff(s.debug_path_lines), Item::RenderScale => { // the setting is for a 1080 pixel high window; show what applies to this one let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh()); @@ -178,6 +196,11 @@ fn item_change(it: Item, s: &mut Settings, d: i32) { Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d), Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::>(), s.render_scale.clamp(1, 16), d), Item::DebugFps => s.debug_fps = !s.debug_fps, + Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time, + Item::DebugPing => s.debug_ping = !s.debug_ping, + Item::DebugTriggerZones => s.debug_trigger_zones = !s.debug_trigger_zones, + Item::DebugCommandMarkers => s.debug_command_markers = !s.debug_command_markers, + Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines, Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::Hints => s.hints = !s.hints, Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d), @@ -218,6 +241,20 @@ enum Btn { FileLoad, NameField, Campaign, + CampaignEditor, + CampaignNew, + CampaignOpen, + CampaignSave, + CampaignAdd, + CampaignDelete, + CampaignEditName, + CampaignEditTitle, + CampaignEditGoals, + CampaignEditNext, + CampaignEditBranches, + CampaignEditScript, + CampaignStart, + CampaignMission(usize), Mission(usize), StartMission, Goals, @@ -302,6 +339,18 @@ pub enum MenuAction { CloudDelete(String), EditName, // the name field was tapped (touch: ask with a browser dialog) StartMission(usize), + CampaignNew, + CampaignOpen, + CampaignSave, + CampaignAdd, + CampaignDelete(usize), + CampaignEditName, + CampaignEditTitle, + CampaignEditGoals, + CampaignEditNext, + CampaignEditBranches, + CampaignEditScript, + CampaignStart(usize), OpenRepo, OpenClaudeCode, Login(String, String), @@ -339,6 +388,9 @@ pub struct Menu { pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page pub coop_mission: usize, // co-op campaign mission selected on the Match page pub in_match: bool, // a match is being played; the pause menu changes its shape + pub campaign_doc: CampaignDoc, + pub campaign_selected: usize, + pub campaign_pending: bool, confirm: Option, // overwrite / delete waiting for a second click scroll: usize, rows_visible: usize, @@ -366,6 +418,9 @@ impl Menu { match_ui: MatchUi::default(), coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), in_match: false, + campaign_doc: CampaignDoc::default(), + campaign_selected: 0, + campaign_pending: false, confirm: None, scroll: 0, rows_visible: 1, @@ -380,13 +435,14 @@ impl Menu { // 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::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 5, from_title }), Some(Page::Settings { from_title: true, .. }) => Some(Page::Title), Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause), Some(Page::NewGame) => Some(Page::Title), Some(Page::Saves { from_title: true, .. }) => Some(Page::Title), Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause), Some(Page::Campaign) => Some(Page::Title), + Some(Page::CampaignEditor) => Some(Page::Title), Some(Page::Login) => Some(Page::Login), Some(Page::Account) => Some(Page::Title), Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }), @@ -488,7 +544,7 @@ impl Menu { pub fn finish_capture(&mut self) { if let Some(Page::KeyCapture { from_title, .. }) = self.page { - self.page = Some(Page::Settings { tab: 4, from_title }); + self.page = Some(Page::Settings { tab: 5, from_title }); } } @@ -669,6 +725,36 @@ impl Menu { self.scroll = 0; MenuAction::Consumed } + Some(Btn::CampaignEditor) => { + self.page = Some(Page::CampaignEditor); + self.scroll = 0; + MenuAction::Consumed + } + Some(Btn::CampaignNew) => MenuAction::CampaignNew, + Some(Btn::CampaignOpen) => MenuAction::CampaignOpen, + Some(Btn::CampaignSave) => MenuAction::CampaignSave, + Some(Btn::CampaignAdd) => MenuAction::CampaignAdd, + Some(Btn::CampaignEditName) => MenuAction::CampaignEditName, + Some(Btn::CampaignEditTitle) => MenuAction::CampaignEditTitle, + Some(Btn::CampaignEditGoals) => MenuAction::CampaignEditGoals, + Some(Btn::CampaignEditNext) => MenuAction::CampaignEditNext, + Some(Btn::CampaignEditBranches) => MenuAction::CampaignEditBranches, + Some(Btn::CampaignEditScript) => MenuAction::CampaignEditScript, + Some(b @ Btn::CampaignDelete) => { + let i = self.campaign_selected; + if confirm == Some(b) { + MenuAction::CampaignDelete(i) + } else { + self.confirm = Some(b); + MenuAction::Consumed + } + } + Some(Btn::CampaignStart) => MenuAction::CampaignStart(self.campaign_selected), + Some(Btn::CampaignMission(i)) => { + self.campaign_selected = i; + self.scroll = 0; + MenuAction::Consumed + } Some(Btn::Mission(i)) => { self.page = Some(Page::Briefing { mission: i, ingame: false }); self.scroll = 0; @@ -711,13 +797,14 @@ impl Menu { Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab), Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save), Page::Campaign => self.draw_campaign(gfx), + Page::CampaignEditor => self.draw_campaign_editor(gfx), Page::Login => self.draw_login(gfx, cloud), Page::Account => self.draw_account(gfx, cloud), Page::Match { .. } => self.draw_match(gfx, cloud), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level), Page::KeyCapture { action, .. } => { - self.draw_settings(gfx, settings, 4); + self.draw_settings(gfx, settings, 5); self.buttons.clear(); let a = all_actions()[action]; let text = trf("set.press_key", &[action_label(a)]); @@ -770,14 +857,15 @@ impl Menu { items.push((Btn::Continue, tr("menu.continue"))); } items.push((Btn::Campaign, tr("menu.campaign"))); + items.push((Btn::CampaignEditor, tr("menu.campaign_editor"))); items.push((Btn::Match, tr("menu.multiplayer"))); items.push((Btn::New, tr("menu.new"))); items.push((Btn::Load, tr("menu.load"))); items.push((Btn::Settings, tr("menu.settings"))); if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) { items.push((Btn::Account, trf("menu.account", &[s.name.clone()]))); - items.push((Btn::Standalone, tr("menu.standalone"))); } + items.push((Btn::Standalone, tr("menu.standalone"))); items.push((Btn::Bestiary, tr("menu.bestiary"))); items.push((Btn::Help, tr("menu.help"))); let w = 26.0 * CW(); @@ -928,7 +1016,7 @@ impl Menu { tx += w + CW(); } let top = ty + CH() * 3.5; - if tab == 4 { + if tab == 5 { // key bindings in two columns let acts = all_actions(); let half = (acts.len() + 1) / 2; @@ -1148,6 +1236,141 @@ impl Menu { self.buttons.push((br, Btn::Back)); } + // First campaign-editor step: load/save shareable missions and show their branching structure. + fn draw_campaign_editor(&mut self, gfx: &Gfx) { + let sw = sw(); + let sh = sh(); + let (mx, my) = mouse(); + let cols = ((sw / CW()) as i32 - 2).min(72); + let rows = ((sh / CH()) as i32 - 2).min(34); + let x = (grid_center(sw, cols as f32 * CW())).max(0.0); + let y = (grid_center_y(sh, rows as f32 * CH())).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("campaign.title"), FRAME_HI); + + let campaign = self.campaign_doc.clone(); + let errors = campaign.validate(); + let name = if campaign.name.trim().is_empty() { tr("campaign.new") } else { campaign.name.clone() }; + let file = campaign.file_name(); + let n = campaign.missions.len(); + let inner_x = x + CW() * 2.0; + let inner_w = (cols - 4) as f32 * CW(); + let head = trf("campaign.name", &[name]); + let head_w = (gfx.measure(&head, S()) + CW()).max(CW() * 8.0); + let head_rect = Rect::new(inner_x, y + CH() * 1.5, head_w, GH * S() + CH() * 0.5); + let hot_name = head_rect.contains(vec2(mx, my)); + gfx.text(&head, inner_x, y + CH() * 1.5, S(), if hot_name { FRAME_HI } else { GOLD }); + if !campaign.author.trim().is_empty() { + let author = trf("campaign.author", &[campaign.author.clone()]); + gfx.text(&author, inner_x + head_w + CW() * 2.0, y + CH() * 1.5, S(), DIM); + } + self.buttons.push((head_rect, Btn::CampaignEditName)); + + let top = if n > 0 { y + CH() * 5.0 } else { y + CH() * 3.5 }; + if n > 0 { + let field_items = [(Btn::CampaignEditTitle, "T"), (Btn::CampaignEditGoals, "G"), (Btn::CampaignEditNext, "N"), (Btn::CampaignEditBranches, "B"), (Btn::CampaignEditScript, "S")]; + let widths: Vec = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect(); + let total_w = widths.iter().sum::() + (field_items.len() as f32 - 1.0) * CW() * 0.5; + let mut fx = x + cols as f32 * CW() - CW() * 2.0 - total_w; + for (k, (b, l)) in field_items.iter().enumerate() { + let r = Rect::new(fx, y + CH() * 2.8, widths[k], CH() * 1.8); + button(gfx, r, l, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, *b)); + fx += widths[k] + CW() * 0.5; + } + } + + let bottom = y + (rows - 5) as f32 * CH(); + let row_h = CH() * 3.0; + self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1); + self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible)); + let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 }; + + if n == 0 { + gfx.text(&tr("campaign.empty"), inner_x, top, S(), DIM); + } + + for (k, mission) in campaign.missions.iter().enumerate().skip(self.scroll).take(self.rows_visible) { + let ry = top + (k - self.scroll) as f32 * row_h; + let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5); + let selected = k == self.campaign_selected; + let hot = r.contains(vec2(mx, my)); + draw_rectangle(r.x, r.y, r.w, r.h, if selected { Color::new(0.32, 0.21, 0.1, 1.0) } else if hot { Color::new(0.18, 0.12, 0.08, 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 selected { GOLD } else if hot { FRAME_HI } else { FRAME }); + let title = if mission.title.trim().is_empty() { mission.key.clone() } else { mission.title.clone() }; + let line = trf("campaign.mission_no", &[(k + 1).to_string(), title]); + let maxc = ((r.w - CW() * 2.0) / CW()) as usize; + let line: String = line.chars().take(maxc).collect(); + gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if selected { GOLD } else { TXT }); + + let next = mission.next.clone().unwrap_or_default(); + let branch_count = mission.branches.len(); + let detail = if branch_count > 0 { + trf("campaign.branches", &[branch_count.to_string()]) + } else if !next.is_empty() { + trf("campaign.next", &[next]) + } else if !mission.goals.is_empty() { + trf("campaign.goals", &[mission.goals.join(", ")]) + } else { + tr("campaign.empty") + }; + let script_count = mission.script.as_ref().map(|s| if s.source.trim().is_empty() { 0 } else { 1 }).unwrap_or(0); + let zone_count = mission.zones.len(); + let detail = if zone_count > 0 || script_count > 0 { + format!("{} · {} · {}", detail, trf("campaign.zones", &[zone_count.to_string()]), trf("campaign.script", &[script_count.to_string()])) + } else { + detail + }; + let maxc = ((r.w - CW() * 2.0) / GW.max(1.0) / (SMALL().max(1.0))) as usize; + let detail: String = detail.chars().take(maxc.max(20)).collect(); + gfx.text(&detail, r.x + CW(), r.y + CH() * 1.6, SMALL(), DIM); + self.buttons.push((r, Btn::CampaignMission(k))); + } + + if scroll_w > 0.0 { + let sx = inner_x + inner_w - CW() * 2.5; + let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0); + let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0); + button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME); + button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME); + self.buttons.push((up, Btn::Scroll(-1))); + self.buttons.push((down, Btn::Scroll(1))); + } + + let count = trf("campaign.missions", &[n.to_string()]); + gfx.text(&count, inner_x, bottom + CH() * 0.5, SMALL(), DIM); + let file_line = format!("{} · {}", file, tr("menu.drop_hint")); + let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize; + let file_line: String = file_line.chars().take(maxc.max(20)).collect(); + gfx.text(&file_line, x + cols as f32 * CW() - gfx.measure(&file_line, SMALL()) - CW() * 2.0, bottom + CH() * 0.5, SMALL(), DIM); + + if !errors.is_empty() { + let err = errors.iter().map(|e| e.as_str()).collect::>().join(" | "); + let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize; + for l in wrap(&err, maxc).iter().take(3) { + gfx.text(l, inner_x, bottom + CH() * 1.5, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0)); + } + } + + let by = y + (rows as f32 - 3.5) * CH(); + let right = x + cols as f32 * CW() - CW() * 2.0; + let mut bx = inner_x; + let mut actions = vec![(Btn::CampaignNew, tr("campaign.new")), (Btn::CampaignAdd, tr("campaign.add"))]; + if n > 0 { + actions.push((Btn::CampaignStart, tr("campaign.start"))); + actions.push((Btn::CampaignDelete, tr("campaign.delete"))); + } + actions.extend([(Btn::CampaignOpen, tr("campaign.open")), (Btn::CampaignSave, tr("campaign.save")), (Btn::Back, tr("menu.back"))]); + for (b, label) in actions { + let w = (gfx.measure(&label, S()) + CW() * 4.0).max(CW() * 6.0); + let r = Rect::new(bx, by, w, CH() * 2.0); + if r.x + r.w <= right { + button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); + self.buttons.push((r, b)); + } + bx += w + CW(); + } + } + // Briefing: the old imp tells the story, then the goals and what comes along fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) { let sw = sw(); @@ -1236,7 +1459,7 @@ impl Menu { // scrolling on pages with lists (mouse wheel) pub fn scrollable(&self) -> bool { - matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::Briefing { .. })) + matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. })) } pub fn scroll_lines(&mut self, d: i32) { diff --git a/src/render3d.rs b/src/render3d.rs index 6b54cc8..56eeff5 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -2231,11 +2231,14 @@ fn draw_marks(g: &Game, t: f32) { return; } for m in &g.marks { + if !m.fixed && !g.debug_markers { + continue; + } let left = m.until.saturating_sub(g.sim_steps) as f32 * STEP; if left <= 0.0 { continue; } - let a = (left / 2.0).min(1.0).max(0.18); + let a = if m.fixed { 1.0 } else { (left / 2.0).min(1.0).max(0.18) }; let base = mark_color(m.kind); let side = mark_side_color(m.side); draw_command_flag(m.x, m.y, Color::new(base.r, base.g, base.b, a), Color::new(side.r, side.g, side.b, a), t); diff --git a/src/script.rs b/src/script.rs new file mode 100644 index 0000000..cc91f69 --- /dev/null +++ b/src/script.rs @@ -0,0 +1,246 @@ +// First Rhai mission-script runtime. This step supports log/objective/spawn hooks. +// Zone/branch/gameplay hooks come in the next implementation steps. + +use std::sync::{Arc, Mutex}; + +use rhai::{Dynamic, Engine, Scope}; + +use crate::game::EK; + +#[derive(Clone, Debug, PartialEq)] +pub enum ScriptAction { + Spawn { side: i32, kind: String, x: f32, y: f32, count: i32, level: i32 }, + SpawnInZone { zone: String, kind: String, side: i32, count: i32, level: i32 }, + Wave { count: i32 }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MissionScript { + messages: Vec, + objective: Option, + actions: Vec, +} + +impl MissionScript { + pub fn new() -> MissionScript { + MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() } + } + + pub fn messages(&self) -> Vec { + self.messages.clone() + } + + pub fn objective(&self) -> Option { + self.objective.clone() + } + + pub fn actions(&self) -> Vec { + self.actions.clone() + } + + fn merge(&mut self, other: MissionScript) { + self.messages.extend(other.messages); + self.actions.extend(other.actions); + if other.objective.is_some() { + self.objective = other.objective; + } + } +} + +impl Default for MissionScript { + fn default() -> Self { + MissionScript::new() + } +} + +pub fn parse_side_name(name: &str) -> Option { + match name.trim().to_lowercase().as_str() { + "player" => Some(0), + "heroes" | "hero" => Some(1), + "mossgrim" => Some(2), + "nightwell" => Some(3), + "rival3" | "rival" => Some(4), + _ => None, + } +} + +pub fn parse_spawn_kind(name: &str) -> Option { + match name.trim().to_lowercase().as_str() { + "worker" => Some(EK::Worker), + "kobold" => Some(EK::Kobold), + "spider" => Some(EK::Spider), + "mage" => Some(EK::Mage), + "ogre" => Some(EK::Ogre), + "knight" => Some(EK::Knight), + "archer" => Some(EK::Archer), + "priest" => Some(EK::Priest), + "dwarf" => Some(EK::Dwarf), + "paladin" => Some(EK::Paladin), + "jailer" => Some(EK::Jailer), + "priestess" => Some(EK::Priestess), + "chainbreaker" => Some(EK::Chainbreaker), + "bat" => Some(EK::Bat), + "gargoyle" => Some(EK::Gargoyle), + "owl" => Some(EK::Owl), + "wyrm" => Some(EK::Wyrm), + "cogling" => Some(EK::Cogling), + "revenant" => Some(EK::Revenant), + "giant" => Some(EK::Giant), + "fairy" => Some(EK::Fairy), + "battlemage" => Some(EK::BattleMage), + _ => None, + } +} + +pub fn run_start(source: &str) -> Result { + run_hook(source, "on_start", MissionScript::new()) +} + +pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result { + let needle = format!("fn {hook}("); + if !source.contains(&needle) { + return Ok(initial); + } + + let output = Arc::new(Mutex::new(initial)); + let engine = script_engine(output.clone()); + let code = format!("{}\n{hook}(m)", source); + let mut scope = Scope::new(); + scope.push("m", MissionScript::new()); + + let returned = engine.eval_with_scope::(&mut scope, &code).map_err(|e| e.to_string())?; + if let Some(returned) = returned.try_cast::() { + if !returned.messages.is_empty() || !returned.actions.is_empty() || returned.objective.is_some() { + output.lock().map_err(|e| e.to_string())?.merge(returned); + } + } + + let done = output.lock().map_err(|e| e.to_string())?.clone(); + Ok(done) +} + +fn script_engine(output: Arc>) -> Engine { + let mut engine = Engine::new(); + engine.set_max_operations(200_000); + engine.register_type_with_name::("mission"); + let log_output = output.clone(); + engine.register_fn("log", move |_: &mut MissionScript, s: &str| { + if let Ok(mut m) = log_output.lock() { + m.messages.push(s.to_string()); + } + }); + let objective_output = output.clone(); + engine.register_fn("objective", move |_: &mut MissionScript, s: &str| { + if let Ok(mut m) = objective_output.lock() { + m.objective = Some(s.to_string()); + } + }); + let spawn_output = output.clone(); + engine.register_fn("spawn_at", move |_: &mut MissionScript, side: i64, kind: &str, x: f64, y: f64, count: i64, level: i64| { + if let Ok(mut m) = spawn_output.lock() { + m.actions.push(ScriptAction::Spawn { side: side as i32, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); + } + }); + let spawn_side_output = output.clone(); + engine.register_fn("spawn_at", move |_: &mut MissionScript, side: &str, kind: &str, x: f64, y: f64, count: i64, level: i64| { + if let Some(side) = parse_side_name(side) { + if let Ok(mut m) = spawn_side_output.lock() { + m.actions.push(ScriptAction::Spawn { side, kind: kind.to_string(), x: x as f32, y: y as f32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); + } + } + }); + let zone_output = output.clone(); + engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: i64, level: i64| { + if let Ok(mut m) = zone_output.lock() { + m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side: side as i32, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); + } + }); + let zone_side_output = output.clone(); + engine.register_fn("spawn_count_in_zone", move |_: &mut MissionScript, zone: &str, kind: &str, count: i64, side: &str, level: i64| { + if let Some(side) = parse_side_name(side) { + if let Ok(mut m) = zone_side_output.lock() { + m.actions.push(ScriptAction::SpawnInZone { zone: zone.to_string(), kind: kind.to_string(), side, count: count.clamp(1, 10) as i32, level: level.clamp(1, 10) as i32 }); + } + } + }); + let wave_output = output.clone(); + engine.register_fn("spawn_wave", move |_: &mut MissionScript, count: i64| { + if let Ok(mut m) = wave_output.lock() { + m.actions.push(ScriptAction::Wave { count: count.clamp(1, 20) as i32 }); + } + }); + engine +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_mission_script_can_log_and_change_the_objective() { + let source = r#" +fn on_start(m) { + m.log("Die Wachstube ist offen."); + m.objective("Finde drei Scherben."); +} +"#; + + let m = run_start(source).expect("the script runs"); + assert_eq!(m.messages(), vec!["Die Wachstube ist offen.".to_string()]); + assert_eq!(m.objective().as_deref(), Some("Finde drei Scherben.")); + } + + #[test] + fn a_mission_script_can_record_spawn_actions_in_zones() { + let source = r#" +fn on_start(m) { + m.spawn_count_in_zone("north_gate", "Knight", 3, "HEROES", 2); +} +"#; + + let m = run_start(source).expect("the script runs"); + assert_eq!(m.actions(), vec![ScriptAction::SpawnInZone { zone: "north_gate".into(), kind: "Knight".into(), side: 1, count: 3, level: 2 }]); + } + + #[test] + fn a_mission_script_can_record_spawn_actions() { + let source = r#" +fn on_start(m) { + m.spawn_at("HEROES", "Knight", 21.5, 21.5, 3, 2); +} +"#; + + let m = run_start(source).expect("the script runs"); + assert_eq!(m.actions(), vec![ScriptAction::Spawn { side: 1, kind: "Knight".into(), x: 21.5, y: 21.5, count: 3, level: 2 }]); + } + + #[test] + fn a_mission_script_can_request_a_wave() { + let source = r#" +fn on_start(m) { + m.spawn_wave(3); +} +"#; + + let m = run_start(source).expect("the script runs"); + assert_eq!(m.actions(), vec![ScriptAction::Wave { count: 3 }]); + } + + #[test] + fn a_script_without_the_hook_is_allowed() { + let m = run_hook("fn unrelated(m) { m.log('x'); }", "on_start", MissionScript::new()).expect("no hook call"); + assert!(m.messages().is_empty()); + } + + #[test] + fn a_broken_script_returns_an_error() { + let source = r#" +fn on_start(m) { + m.log("broken +} +"#; + + let error = run_start(source).expect_err("the parser rejects this script"); + assert!(!error.is_empty()); + } +} diff --git a/src/settings.rs b/src/settings.rs index 13188c7..9126e3c 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -150,7 +150,12 @@ pub struct Settings { pub ui_factor: u8, // interface size: 0 = automatic, 1..4 = fixed integer factor pub render_scale: u8, // 3D world resolution divisor: 1 = full, 2..16 = pixel look pub touch_ui: u8, // touch interface: 0 = automatic (after the first touch), 1 = on, 2 = off - pub debug_fps: bool, // show FPS and rendering backend + pub debug_fps: bool, // show FPS and rendering backend + pub debug_frame_time: bool, // show the frame-time line and graph + pub debug_ping: bool, // show network ping + pub debug_trigger_zones: bool, // draw mission trigger zones + pub debug_command_markers: bool, // draw extra command markers (guard/attack/gather stay fixed) + pub debug_path_lines: bool, // draw creature path lines // game pub game_speed: f32, // 1 or 2 pub hints: bool, @@ -181,6 +186,11 @@ impl Default for Settings { render_scale: 1, touch_ui: 0, debug_fps: false, + debug_frame_time: false, + debug_ping: false, + debug_trigger_zones: false, + debug_command_markers: false, + debug_path_lines: false, game_speed: 1.0, hints: true, difficulty: 1, @@ -236,10 +246,21 @@ mod tests { fn debug_fps_round_trips() { let mut s = Settings::default(); assert!(!s.debug_fps); + assert!(!s.debug_frame_time); + assert!(!s.debug_ping); + assert!(!s.debug_trigger_zones); + assert!(!s.debug_command_markers); + assert!(!s.debug_path_lines); s.debug_fps = true; + s.debug_trigger_zones = true; + s.debug_command_markers = true; let l = Settings::from_json(&s.to_json()).expect("settings serialize back"); assert!(l.debug_fps); + assert!(l.debug_trigger_zones); + assert!(l.debug_command_markers); let old = Settings::from_json(r#"{"language":"en","map_w":64,"map_h":64}"#).expect("old settings remain readable"); assert!(!old.debug_fps); + assert!(!old.debug_trigger_zones); + assert!(!old.debug_command_markers); } }