Add campaign editor script runtime and debug overlays

This commit is contained in:
Richard Zink committed 2026-11-05 12:22:59 +01:00
1 parent cd35ca8e3d
commit b06c48172b
13 files changed
+2193 -48

No files matched your search

Generated
+319 -2
View File
@@ -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"
+1
View File
@@ -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"
+42 -1
View File
@@ -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.
+42 -1
View File
@@ -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.
+49 -3
View File
@@ -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<Goal>) -> MissionDef {
fn base(key: &str, size: (usize, usize), seed: u32, goals: Vec<Goal>) -> 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<Goal>) ->
}
}
// 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<MissionDef> {
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() {
+569
View File
@@ -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<bool>,
pub completed_choice: Vec<Option<String>>,
}
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<String>) {
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<MissionDoc>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
pub branches: Vec<BranchDoc>,
#[serde(default)]
pub zones: Vec<ZoneDoc>,
#[serde(default)]
pub script: Option<ScriptDoc>,
}
#[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<u32>,
}
#[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<f32>,
#[serde(default)]
pub tiles: Option<Vec<(i32, i32)>>,
#[serde(default)]
pub from: Option<(f32, f32)>,
#[serde(default)]
pub to: Option<(f32, f32)>,
#[serde(default)]
pub width: Option<f32>,
}
#[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<CampaignDoc, String> {
serde_json::from_str::<CampaignDoc>(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<String> {
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<F>(&self, i: usize, goal_done: F) -> (Option<usize>, Option<String>)
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<String> {
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
}
pub fn parse_branches(text: &str) -> Vec<BranchDoc> {
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::<u32>().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::<Vec<_>>()
.join("; ")
}
pub fn parse_goals(raws: &[String]) -> Vec<Goal> {
raws.iter().filter_map(|r| parse_goal(r)).collect()
}
pub fn parse_goal(raw: &str) -> Option<Goal> {
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<u32> {
let rest = s.strip_prefix(prefix)?;
if !rest.starts_with('(') || !rest.ends_with(')') {
return None;
}
rest[1..rest.len() - 1].trim().parse::<u32>().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)));
}
}
+28 -4
View File
@@ -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]
+9 -4
View File
@@ -616,6 +616,8 @@ pub struct Game {
pub marks: Vec<Mark>, // 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) {
+632 -24
View File
@@ -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<String> {
let value = platform::prompt(title, initial)?;
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
let clean = clean.trim().to_string();
if clean.is_empty() {
None
} else {
Some(clean)
}
}
fn prompt_optional_line(title: &str, initial: &str, max: usize) -> Option<String> {
let value = platform::prompt(title, initial)?;
let clean: String = value.chars().filter(|c| !c.is_control()).take(max).collect();
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<usize>, Option<String>) {
campaign.next_mission_choice(i, |goal| g.goal_done(goal))
}
const PROGRESS_KEY: &str = "dc.campaign30"; // new key: the 30-mission order differs from the old one
fn store_progress(p: &campaign::Progress) {
@@ -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<campaign_doc::CampaignDoc>,
custom_progress: Option<campaign_doc::CampaignProgress>,
custom_mission_index: Option<usize>,
custom_next_index: Option<usize>,
}
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,8 +896,26 @@ async fn main() {
}
}
if let Some(bytes) = incoming {
if menu.campaign_pending {
menu.campaign_pending = false;
match campaign_doc::CampaignDoc::load(&String::from_utf8_lossy(&bytes)) {
Ok(campaign) => {
let errors = campaign.validate();
if errors.is_empty() {
app.set_status(i18n::trf("campaign.loaded", &[campaign.name.clone()]), now);
menu.campaign_doc = campaign;
menu.campaign_selected = 0;
menu.page = Some(Page::CampaignEditor);
} else {
app.set_status(i18n::trf("campaign.invalid", &[errors.join(" | ")]), now);
}
}
Err(e) => app.set_status(i18n::trf("campaign.invalid", &[e]), now),
}
} else {
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
}
}
// ---------------- savegame service: finished requests
match cloud.poll() {
@@ -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,6 +1747,7 @@ 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 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;
@@ -1319,6 +1757,25 @@ async fn main() {
}
}
}
} else if app.game.state != 0 && !app.recorded {
app.recorded = true;
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
app.custom_mission_index = Some(i);
if app.game.state == 1 {
let next_info = match &app.custom_campaign {
Some(campaign) => Some(custom_next_choice(campaign, i, &app.game)),
None => None,
};
let mission_count = app.custom_campaign.as_ref().map(|c| c.missions.len()).unwrap_or(0);
if let Some((next_i, choice)) = next_info {
if let Some(progress) = &mut app.custom_progress {
progress.mark_done(i, mission_count, choice);
}
app.custom_next_index = next_i;
}
}
}
}
menu.mission = app.game.mission.as_ref().map(|m| m.index);
let on_title = menu.page == Some(Page::Title)
|| matches!(
@@ -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,6 +1898,9 @@ 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 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();
@@ -1450,10 +1915,153 @@ fn draw_debug_fps(app: &App, gfx: &Gfx) {
}
};
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 head: String = format!("{} FPS {}", fps, renderer).chars().take(max_cols).collect();
let head_w = gfx.measure(&head, scale);
let head_x = (ui::sw() - head_w - 2.0).max(2.0);
draw_rectangle(head_x - 2.0, 0.0, head_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
gfx.text(&head, head_x, 2.0, scale, GOLD);
}
if app.settings.debug_frame_time {
let ms = app.frame_ms.max(0.0);
let graph_w = (ui::sw() * 0.42).clamp(80.0, 260.0).min(ui::sw() - 4.0).max(40.0);
let graph_h = (scale * 30.0).clamp(20.0, 52.0);
let frame_y = (ui::sh() - text_h - 2.0).max(12.0);
let graph_y = (frame_y - graph_h - 6.0).max(12.0);
let graph_x = (ui::sw() - graph_w - 2.0).max(2.0);
let mut max_ms = ms.max(16.7);
for v in app.frame_hist.iter() {
max_ms = max_ms.max(*v);
}
let max_ms = max_ms.max(16.7).max(ms * 1.3).min(100.0);
draw_rectangle(graph_x - 2.0, graph_y - 2.0, graph_w + 4.0, graph_h + 4.0, Color::from_rgba(0, 0, 0, 170));
let bar_w = graph_w / FRAME_HIST as f32;
for k in 0..FRAME_HIST {
let v = app.frame_hist[(app.frame_hist_pos + k) % FRAME_HIST];
if v <= 0.0 {
continue;
}
let h = graph_h * (v / max_ms).clamp(0.0, 1.0).max(0.08);
let col = if v <= 16.7 {
Color::from_rgba(90, 220, 120, 235)
} else if v <= 33.3 {
Color::from_rgba(240, 210, 100, 235)
} else {
Color::from_rgba(235, 90, 90, 235)
};
draw_rectangle(graph_x + k as f32 * bar_w, graph_y + graph_h - h, bar_w.max(1.0), h, col);
}
draw_rectangle(graph_x, graph_y + graph_h, graph_w, 1.0, Color::from_rgba(255, 255, 255, 50));
let frame_line = format!("{:.1} ms", ms);
let frame_w = gfx.measure(&frame_line, scale);
let frame_x = (ui::sw() - frame_w - 2.0).max(2.0);
draw_rectangle(frame_x - 2.0, frame_y - 2.0, frame_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
gfx.text(&frame_line, frame_x, frame_y, scale, GOLD);
}
if app.settings.debug_ping {
let ping = if app.client.connected { format!("{:.0} ms", app.client.ping_ms) } else { "no match".into() };
let max_cols = (((ui::sw() - 6.0) / ui::CW()).floor() as i32).max(12) as usize;
let line: String = format!("ping {}", ping).chars().take(max_cols).collect();
let line_w = gfx.measure(&line, scale);
draw_rectangle(0.0, ui::sh() - text_h - 2.0, line_w + 4.0, text_h, Color::from_rgba(0, 0, 0, 170));
let color = if !app.client.connected {
ui::DIM
} else if app.client.ping_ms <= 120.0 {
ui::GREEN
} else if app.client.ping_ms <= 300.0 {
Color::from_rgba(240, 210, 100, 255)
} else {
ui::RED
};
gfx.text(&line, 2.0, ui::sh() - text_h, scale, color);
}
}
fn draw_debug_3d(app: &App, now: f64) {
let pulse = 0.35 + 0.12 * ((now * 2.5).sin() as f32);
if app.settings.debug_trigger_zones {
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)
}
+231 -8
View File
@@ -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::<Vec<u8>>(), 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<Btn>, // 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<f32> = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect();
let total_w = widths.iter().sum::<f32>() + (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::<Vec<_>>().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) {
+4 -1
View File
@@ -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);
+246
View File
@@ -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<String>,
objective: Option<String>,
actions: Vec<ScriptAction>,
}
impl MissionScript {
pub fn new() -> MissionScript {
MissionScript { messages: Vec::new(), objective: None, actions: Vec::new() }
}
pub fn messages(&self) -> Vec<String> {
self.messages.clone()
}
pub fn objective(&self) -> Option<String> {
self.objective.clone()
}
pub fn actions(&self) -> Vec<ScriptAction> {
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<i32> {
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<EK> {
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<MissionScript, String> {
run_hook(source, "on_start", MissionScript::new())
}
pub fn run_hook(source: &str, hook: &str, initial: MissionScript) -> Result<MissionScript, String> {
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::<Dynamic>(&mut scope, &code).map_err(|e| e.to_string())?;
if let Some(returned) = returned.try_cast::<MissionScript>() {
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<Mutex<MissionScript>>) -> Engine {
let mut engine = Engine::new();
engine.set_max_operations(200_000);
engine.register_type_with_name::<MissionScript>("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());
}
}
+21
View File
@@ -151,6 +151,11 @@ pub struct Settings {
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_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);
}
}