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>
No files matched your search
@@ -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">
|
||||
|
||||
|
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 784 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 785 KiB After Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 467 KiB After Width: | Height: | Size: 758 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 832 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 720 KiB After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 717 KiB After Width: | Height: | Size: 966 KiB |
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||