diff --git a/README.md b/README.md index 1bf7cb2..bb8fe39 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ An evil little dungeon game for the browser - in glorious pixels, in 3D, for fre

- The title screen with the endless bridge battle + The title screen: the endless battle between a lava river and an underground river

--- @@ -63,7 +63,7 @@ with them, up to golden armour, a crown and a halo. With the level come abilitie to *step through shadows*, the chainbreaker to spin like a *whirlwind*.

- A battle at a bridge over an underground river + A battle on a tongue of rock between lava and water
The knights brought a bridge. How thoughtful. Now they're bringing it back, one piece at a time.

@@ -98,6 +98,36 @@ caves, rival masters, and me, telling you the story. With tea.
A dwarf cave, master. Nice dwarves. Don't go too close. - And this is where every game begins: in the dark.

+

+ Lava lights up the dungeon, torches on stands in a big hall +
Lava glows ten tiles wide, master, and my imps put torches on stands where the walls are too far away. +

+ +## Build your own campaigns + +> *You want to torment heroes your own way? Fine. The* **campaign editor** *is right in the title menu - in the same +> graphics as the game, no tools needed.* + +

+ The campaign graph: missions as cards, linked by next missions and branches + The map editor on the 3D map with the zone tool +
+ Triggers of a mission: zone entered, line crossed, zone claimed, ... + The block editor with message, objective, variable and spawn blocks +

+ +- **Campaign graph**: missions as cards; drag arrows for the next mission and for branches (another goal reached + first leads elsewhere). Save as a file, online, and publish for every player on the server. +- **Maps**: a random map rolled once per run of the campaign, or a painted one - start from the generator and paint + ground, rooms, veins, the core, portals, the hero gate, units, towers, traps, knight fortresses and rival masters, + with brush, rectangle, fill, mirroring and undo. Trigger zones are drawn right on the map. +- **Scripts as blocks**, no code: a start script, victory and defeat checks, a clock, and triggers - zone entered or + left, line crossed, zone claimed or dug, units in a zone, timers, a unit or tower falls, values, rooms, spells, + slaps, research. Problems are marked red while you build. +- **Test play** right from the editor, with the trigger log and the variables on screen; **co-op** for editor + campaigns. *Open the demo* in the editor shows every trigger at work. Details: [docs/CAMPAIGN_EDITOR.md](docs/CAMPAIGN_EDITOR.md), + [docs/MISSION_SCRIPTING.md](docs/MISSION_SCRIPTING.md). + ## Meet the household

diff --git a/docs/screenshots/bridge-battle.png b/docs/screenshots/bridge-battle.png index 4c984a5..24dd128 100644 Binary files a/docs/screenshots/bridge-battle.png and b/docs/screenshots/bridge-battle.png differ diff --git a/docs/screenshots/dark-rooms.png b/docs/screenshots/dark-rooms.png index a889dd1..aba499f 100644 Binary files a/docs/screenshots/dark-rooms.png and b/docs/screenshots/dark-rooms.png differ diff --git a/docs/screenshots/dungeon-heart.png b/docs/screenshots/dungeon-heart.png index 70ed8c7..7ef6734 100644 Binary files a/docs/screenshots/dungeon-heart.png and b/docs/screenshots/dungeon-heart.png differ diff --git a/docs/screenshots/dwarf-cave.png b/docs/screenshots/dwarf-cave.png index c5eca01..671a5f6 100644 Binary files a/docs/screenshots/dwarf-cave.png and b/docs/screenshots/dwarf-cave.png differ diff --git a/docs/screenshots/editor-blocks.png b/docs/screenshots/editor-blocks.png new file mode 100644 index 0000000..2a68730 Binary files /dev/null and b/docs/screenshots/editor-blocks.png differ diff --git a/docs/screenshots/editor-campaign.png b/docs/screenshots/editor-campaign.png new file mode 100644 index 0000000..1583dc5 Binary files /dev/null and b/docs/screenshots/editor-campaign.png differ diff --git a/docs/screenshots/editor-map.png b/docs/screenshots/editor-map.png new file mode 100644 index 0000000..fe3f19f Binary files /dev/null and b/docs/screenshots/editor-map.png differ diff --git a/docs/screenshots/editor-triggers.png b/docs/screenshots/editor-triggers.png new file mode 100644 index 0000000..2c2ec95 Binary files /dev/null and b/docs/screenshots/editor-triggers.png differ diff --git a/docs/screenshots/fog-of-war.png b/docs/screenshots/fog-of-war.png index 3bad60f..d243fa2 100644 Binary files a/docs/screenshots/fog-of-war.png and b/docs/screenshots/fog-of-war.png differ diff --git a/docs/screenshots/forge-and-traps.png b/docs/screenshots/forge-and-traps.png index 3c36fe5..3622884 100644 Binary files a/docs/screenshots/forge-and-traps.png and b/docs/screenshots/forge-and-traps.png differ diff --git a/docs/screenshots/lava-light.png b/docs/screenshots/lava-light.png new file mode 100644 index 0000000..333ef33 Binary files /dev/null and b/docs/screenshots/lava-light.png differ diff --git a/docs/screenshots/title-battle.png b/docs/screenshots/title-battle.png index 3546bbe..8361647 100644 Binary files a/docs/screenshots/title-battle.png and b/docs/screenshots/title-battle.png differ diff --git a/src/main.rs b/src/main.rs index e6704bc..ecd0f63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,8 @@ mod rival; mod render3d; mod rooms; mod save; +#[cfg(test)] +mod scenes; mod blocks; mod mscript; mod settings; @@ -1900,6 +1902,9 @@ fn editor_frame(app: &mut App, menu: &mut Menu, cloud: &mut Cloud, r3d: &mut R3d g.map.activate(); } let act = ed.frame(gfx, r3d, dt, now); + // the game draws its own pointer (the system one is hidden) + ui::begin_ui(); + ui.draw_cursor(gfx, &app.game, now, true); match act { editor::EdAction::None => {} editor::EdAction::Close => { diff --git a/src/scenes.rs b/src/scenes.rs new file mode 100644 index 0000000..84e8b40 --- /dev/null +++ b/src/scenes.rs @@ -0,0 +1,165 @@ +// Scenes for the screenshots in docs/screenshots: savegames with a fitting camera, written by +// `cargo test --release export_scenes -- --ignored` to /tmp/scenes (see tools/screenshots.py). + +use crate::game::*; +use crate::map::*; +use crate::render3d::Cam; +use crate::settings::Settings; + +fn open(g: &mut Game, x0: i32, y0: i32, x1: i32, y1: i32) { + for y in y0..=y1 { + for x in x0..=x1 { + if inb(x, y) { + let t = &mut g.map.t[idx(x, y)]; + if !matches!(t.kind, Kind::Core | Kind::Portal) { + t.kind = Kind::Floor; + t.owner = g.me; + t.gold = 0; + } + } + } + } + g.recount(); +} + +fn tower(g: &mut Game, kind: TowerKind, x: i32, y: i32, hero: bool, level: u32) { + let i = idx(x, y); + g.map.t[i].kind = Kind::Tower; + g.map.t[i].owner = if hero { Owner::Hero } else { g.me }; + let id = g.next_id; + g.next_id += 1; + let mut tw = Tower { id, kind, tile: i, level, hp: 1.0, cd: 1.0, aim: 0.0, flash: 0.0, hero, rune_final: false }; + tw.hp = tw.max_hp(); + g.towers.push(tw); +} + +fn creatures(g: &mut Game, kinds: &[(EK, u32)], x: f32, y: f32) { + for (k, &(kind, level)) in kinds.iter().enumerate() { + let a = k as f32 * 1.3; + let id = g.spawn(kind, false, x + a.cos() * 2.0, y + a.sin() * 2.0, level); + if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { + e.happy = 1.0; + e.settled = true; + } + } +} + +fn save(name: &str, g: &mut Game, x: f32, z: f32, zoom: f32, yaw: f32, fog: bool) { + if !fog { + g.map.explored.clear(); + g.map.visible.clear(); + } + let mut cam = Cam::new(x, z); + cam.zoom = zoom; + cam.yaw += yaw; + let mut s = Settings::default(); + s.language = "en".into(); + s.hints = false; + std::fs::write(format!("/tmp/scenes/{}.json", name), crate::save::to_json(g, &cam, &s)).unwrap(); +} + +fn run(g: &mut Game, secs: f32) { + for _ in 0..(secs * 20.0) as usize { + g.update(0.05); + } +} + +#[test] +#[ignore] +fn export_scenes() { + std::fs::create_dir_all("/tmp/scenes").unwrap(); + + // the dungeon's heart: the crystal core, dark towers, a demon gate, rooms around + let mut g = Game::new(11, 80, 64); + let (cx, cy) = g.map.core; + g.gold = 50000.0; + open(&mut g, cx - 9, cy - 8, cx + 11, cy + 9); + g.build_rect(1, cx - 8, cy - 7, cx - 4, cy - 3); + g.build_rect(0, cx + 5, cy - 7, cx + 9, cy - 4); + g.build_rect(3, cx - 8, cy + 4, cx - 3, cy + 8); + g.build_rect(4, cx + 5, cy + 4, cx + 10, cy + 8); + tower(&mut g, TowerKind::Arrow, cx - 2, cy - 3, false, 3); + tower(&mut g, TowerKind::Cannon, cx + 4, cy - 2, false, 2); + tower(&mut g, TowerKind::Magic, cx - 2, cy + 4, false, 3); + creatures(&mut g, &[(EK::Ogre, 6), (EK::Mage, 4), (EK::Spider, 3), (EK::Kobold, 5), (EK::Priestess, 7), (EK::Jailer, 3)], cx as f32 + 1.5, cy as f32 + 6.0); + g.place_all_torches(); + run(&mut g, 6.0); + save("dungeon-heart", &mut g, cx as f32 + 1.5, cy as f32 + 1.5, 20.0, 0.0, false); + + // forge and traps: coglings at work, every trap and door + let mut g = Game::new(12, 80, 64); + let (cx, cy) = g.map.core; + g.gold = 50000.0; + open(&mut g, cx - 4, cy - 10, cx + 16, cy + 8); + g.build_rect(8, cx + 6, cy - 9, cx + 13, cy - 4); + creatures(&mut g, &[(EK::Cogling, 4), (EK::Cogling, 6), (EK::Cogling, 2)], cx as f32 + 9.5, cy as f32 - 6.5); + use crate::traps::*; + let kinds = [TrapKind::Spikes, TrapKind::Poison, TrapKind::Fire, TrapKind::Roller, TrapKind::Alarm, TrapKind::Mine, TrapKind::Frost, TrapKind::WoodDoor, TrapKind::IronDoor, TrapKind::MagicDoor]; + for (k, kind) in kinds.iter().enumerate() { + let i = idx(cx + 5 + k as i32, cy + 4); + if kind.door() { + g.map.t[i].kind = Kind::Door; + } + let id = g.next_id; + g.next_id += 1; + g.traps.push(Trap { id, kind: *kind, tile: i, stage: ARMED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: kind.hp(), locked: false, carrier: 0, fired: if k % 3 == 0 { 0.2 } else { 99.0 }, aim: 0.0, revealed: false, sprung: true }); + } + g.place_all_torches(); + run(&mut g, 4.0); + save("forge-and-traps", &mut g, cx as f32 + 9.5, cy as f32 - 1.0, 18.0, 0.0, false); + + // the dark rooms: prison, graveyard, arena, altar and guard posts + let mut g = Game::new(13, 80, 64); + let (cx, cy) = g.map.core; + g.gold = 60000.0; + open(&mut g, cx - 4, cy - 10, cx + 18, cy + 10); + g.build_rect(9, cx + 5, cy - 9, cx + 10, cy - 5); + g.build_rect(10, cx + 12, cy - 9, cx + 17, cy - 4); + g.build_rect(12, cx + 5, cy + 4, cx + 11, cy + 9); + g.build_rect(13, cx + 13, cy + 4, cx + 16, cy + 7); + g.build_rect(11, cx + 13, cy - 1, cx + 14, cy + 1); + creatures(&mut g, &[(EK::Revenant, 4), (EK::Ogre, 5), (EK::Kobold, 3), (EK::Jailer, 6)], cx as f32 + 9.0, cy as f32 + 2.0); + g.place_all_torches(); + run(&mut g, 5.0); + save("dark-rooms", &mut g, cx as f32 + 11.0, cy as f32, 22.0, 0.0, false); + + // a dwarf cave with its hoard + let mut g = Game::new(14, 96, 96); + if let Some(c) = g.dwarf_caves.first().map(|c| c.at) { + run(&mut g, 2.0); + save("dwarf-cave", &mut g, c.0 as f32 + 0.5, c.1 as f32 + 0.5, 16.0, 0.0, false); + } + + // the start of a game in the fog of war + let mut g = Game::new(15, 96, 96); + let (cx, cy) = g.map.core; + run(&mut g, 3.0); + save("fog-of-war", &mut g, cx as f32 + 1.5, cy as f32 + 1.5, 34.0, 0.0, true); + + // a battle between lava and water (the title demo's map, saved as a game) + let (mut g, _) = Game::new_demo(21); + run(&mut g, 25.0); + g.demo = None; + // a mission without goals: the battle goes on (a free game without opponents would be won) + g.mission = Some(MissionState { index: 0, goals: Vec::new(), tools: vec![true; crate::ui::TOOLS.len()], waves: false, wave_limit: 0, gate_defenders: false, heart: false, time_limit: 0.0, shards: 0 }); + let (x, z) = (35.5, 23.5); + save("bridge-battle", &mut g, x, z, 16.0, 0.0, false); + + // lava light, torches on stands and in the walls + let mut g = Game::new(16, 80, 64); + let (cx, cy) = g.map.core; + g.gold = 50000.0; + open(&mut g, cx - 6, cy - 9, cx + 16, cy + 9); + for y in cy - 9..=cy + 9 { + for x in cx + 9..cx + 12 { + let t = &mut g.map.t[idx(x, y)]; + t.kind = Kind::Lava; + t.owner = Owner::None; + } + } + g.build_rect(3, cx - 5, cy + 4, cx + 2, cy + 8); + creatures(&mut g, &[(EK::Wyrm, 6), (EK::Bat, 3), (EK::Gargoyle, 4)], cx as f32 + 6.0, cy as f32); + g.place_all_torches(); + run(&mut g, 4.0); + save("lava-light", &mut g, cx as f32 + 5.0, cy as f32, 22.0, 0.0, false); +} diff --git a/tools/screenshots.py b/tools/screenshots.py new file mode 100644 index 0000000..341fc7d --- /dev/null +++ b/tools/screenshots.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Screenshots for docs/screenshots. + +1. `cargo test --release export_scenes -- --ignored` writes the scene savegames to /tmp/scenes. +2. Build the WASM file and serve www/ with it and mq_js_bundle.js on http://127.0.0.1:18190/ + (no savegame service, so no login). +3. Copy /tmp/scenes/*.json next to this script into a folder mounted as /shots and run it in the + Playwright image with host networking, e.g. + docker run --rm --network host -v /tmp/shots:/shots mcr.microsoft.com/playwright/python:v1.62.0-noble \ + bash -c "pip install playwright==1.62.0 --break-system-packages; python3 /shots/screenshots.py" + The pictures land in /shots/out (1280x800, English). +""" +import json, glob, os +from playwright.sync_api import sync_playwright +URL = 'http://127.0.0.1:18190/' +ARGS = ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'] +def page_with(p, store): + b = p.chromium.launch(args=ARGS) + page = b.new_page(viewport={'width': 1280, 'height': 800}) + page.goto(URL) + page.evaluate("(s) => { for (const k in s) localStorage.setItem(k, s[k]); }", store) + page.reload() + page.wait_for_timeout(13000) + return b, page +def steps(page, st): + for s in st: + if 'click' in s: page.mouse.click(*s['click']) + if 'move' in s: page.mouse.move(*s['move']) + if 'key' in s: page.keyboard.press(s['key']) + page.wait_for_timeout(s.get('wait', 1500)) + if 'shot' in s: page.screenshot(path='/shots/out/' + s['shot'] + '.png') +os.makedirs('/shots/out', exist_ok=True) +with sync_playwright() as p: + # title and bestiary + b, page = page_with(p, {'dc.lang': 'en'}) + page.mouse.move(1270, 790); page.wait_for_timeout(500) + page.screenshot(path='/shots/out/title-battle.png') + b.close() + # scenes from savegames: Continue on the title screen + for f in sorted(glob.glob('/shots/*.json')): + name = os.path.basename(f)[:-5] + b, page = page_with(p, {'dc.lang': 'en', 'dc.autosave': open(f).read()}) + steps(page, [{'click': [635, 207], 'wait': 6000}, {'move': [700, 160], 'wait': 3000, 'shot': name}]) + b.close() + # the editors (English button positions) + b, page = page_with(p, {'dc.lang': 'en'}) + steps(page, [ + {'click': [635, 255], 'wait': 2000}, {'click': [840, 20], 'wait': 1500}, + {'click': [840, 172], 'wait': 3000}, {'move': [700, 420], 'wait': 800, 'shot': 'editor-campaign'}, + {'click': [200, 200], 'wait': 1500}, {'click': [336, 61], 'wait': 1500}, {'move': [1000, 120], 'wait': 600, 'shot': 'editor-triggers'}, + {'click': [750, 136], 'wait': 2000}, {'move': [900, 500], 'wait': 600, 'shot': 'editor-blocks'}, + {'click': [70, 22], 'wait': 1500}, {'click': [246, 61], 'wait': 1200}, {'click': [200, 183], 'wait': 4000}, + {'click': [250, 145], 'wait': 1500}, {'move': [1000, 600], 'wait': 800, 'shot': 'editor-map'}, + ]) + b.close()