Pointer in the editor; new screenshots and an editor section in the README

Scenes for the screenshots come from export_scenes (src/scenes.rs) and are
taken with tools/screenshots.py in the Playwright image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 18:31:10 +01:00
1 parent f3ee6296db
commit ef7add2846
16 files changed
+257 -2

No files matched your search

+32 -2
View File
@@ -22,7 +22,7 @@ An evil little dungeon game for the browser - in glorious pixels, in 3D, for fre
</p>
<p align="center">
<img src="docs/screenshots/title-battle.png" width="820" alt="The title screen with the endless bridge battle">
<img src="docs/screenshots/title-battle.png" width="820" alt="The title screen: the endless battle between a lava river and an underground river">
</p>
---
@@ -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*.
<p align="center">
<img src="docs/screenshots/bridge-battle.png" width="820" alt="A battle at a bridge over an underground river">
<img src="docs/screenshots/bridge-battle.png" width="820" alt="A battle on a tongue of rock between lava and water">
<br><i>The knights brought a bridge. How thoughtful. Now they're bringing it back, one piece at a time.</i>
</p>
@@ -98,6 +98,36 @@ caves, rival masters, and me, telling you the story. With tea.
<br><i>A dwarf cave, master. Nice dwarves. Don't go too close. - And this is where every game begins: in the dark.</i>
</p>
<p align="center">
<img src="docs/screenshots/lava-light.png" width="820" alt="Lava lights up the dungeon, torches on stands in a big hall">
<br><i>Lava glows ten tiles wide, master, and my imps put torches on stands where the walls are too far away.</i>
</p>
## 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.*
<p align="center">
<img src="docs/screenshots/editor-campaign.png" width="400" alt="The campaign graph: missions as cards, linked by next missions and branches">
<img src="docs/screenshots/editor-map.png" width="400" alt="The map editor on the 3D map with the zone tool">
<br>
<img src="docs/screenshots/editor-triggers.png" width="400" alt="Triggers of a mission: zone entered, line crossed, zone claimed, ...">
<img src="docs/screenshots/editor-blocks.png" width="400" alt="The block editor with message, objective, variable and spawn blocks">
</p>
- **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
<p align="center">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 785 KiB

After

Width:  |  Height:  |  Size: 951 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

After

Width:  |  Height:  |  Size: 966 KiB

+5
View File
@@ -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 => {
+165
View File
@@ -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);
}
+55
View File
@@ -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()