aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEmil A. Overbeck <emil.a.overbeck@gmail.com>2026-07-19 04:19:48 +0200
committerEmil A. Overbeck <emil.a.overbeck@gmail.com>2026-07-19 04:19:48 +0200
commit521b5da83485a956811fc510cba9f139acad8b26 (patch)
tree29b210263300479e9a565af568812c5e707d23f7
parentff85499d5567c36ce63e2d7903d5f872fd9aef1d (diff)
downloadforts_clone-521b5da83485a956811fc510cba9f139acad8b26.tar.gz
forts_clone-521b5da83485a956811fc510cba9f139acad8b26.zip
New maps and map selectormain
-rw-r--r--CLAUDE.md36
-rw-r--r--Makefile12
-rw-r--r--data/map.lua13
-rw-r--r--data/maps.lua198
-rw-r--r--data/mods/enemy-fort/map.lua3
-rw-r--r--data/mods/enemy-fort/maps.lua21
-rw-r--r--data/mods/enemy-fort/mod.lua3
-rw-r--r--data/mods/enemy-fort/structures.lua19
-rw-r--r--data/structures.lua5
-rw-r--r--meson.build9
-rw-r--r--requirements/milestones.org26
-rw-r--r--research/gameplay-gaps.org11
-rw-r--r--src/engine/app.cpp65
-rw-r--r--src/game/data.cpp107
-rw-r--r--src/game/data.hpp37
-rw-r--r--tools/sim_check.cpp80
16 files changed, 533 insertions, 112 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 4234a9b..a859f8d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,7 +37,10 @@ refuses a plain re-setup), `make shell` (interactive container), `make clean`,
layers don't rebuild) unless it must come earlier.
**Verify without a display:** you can't run the GUI in the container, so validate
-by (a) compiling clean and (b) parsing the Lua data. `make check` does both.
+by (a) compiling clean, (b) parsing the Lua data and (c) settling every map in
+the headless soak test (`tools/sim_check.cpp` — the solver needs no display, so
+it catches "the fort collapses on load"). `make check` does all three;
+`make sim-check SOAK=20` for a longer settle.
Note mods must be validated **layered** (base file first) because they
`table.insert` into globals the base defines — `make check-lua` already does
this; running a mod file standalone always fails.
@@ -114,14 +117,30 @@ the ImGui Dev panel** — use it; the constants were tuned by eye, not proven.
Game content is **Lua data**, loaded at startup. This is the seam mods hook into.
-Base files in `data/`: `materials.lua`, `weapons.lua`, `devices.lua`,
-`structures.lua`, `map.lua`. Each defines a global table (`Materials`, `Weapons`,
-`DeviceDefs`, `Structures`, `Deposits`/`MapDevices`) plus `Find*`/`IndexOf*` helpers.
+Base files in `data/`: `materials.lua`, `weapons.lua`, `devices.lua`, `maps.lua`.
+Each defines a global table (`Materials`, `Weapons`, `DeviceDefs`, `Maps`) plus
+`Find*`/`IndexOf*` helpers.
+
+**Maps** (`data/maps.lua`) are the selectable scenarios: each entry bundles
+`ground_level`, `deposits`, `devices`, the player's starting `weapons`, and the
+prebuilt `structures` (lists of `{x1,y1,x2,y2,material}` beams). `Maps[1]` is the
+default; the Dev panel's **Map** section switches between them at runtime, which
+tears the graph down and rebuilds it via `build_map()`. Beams must be within the
+material's `max_length` (they are welded raw, **not** auto-subdivided) and must
+be triangulated, or the fort hinges apart on the 30° angle rule.
+
+> **Gotcha:** clearing `graph.nodes`/`graph.edges` does **not** clear the private
+> `adj_`/`edge_set_` caches. Call `graph.rebuild()` straight after clearing, or
+> the stale edge keys make `add_edge` reject the next map's beams as duplicates
+> and the fort silently loads half-built. `build_map` warns on every rejected
+> beam; `make sim-check` catches the collapse.
**Layered loader** (`data.cpp::run_layered`): base file runs first, then every
`data/mods/<name>/<file>.lua` runs on top in **(priority, name)** order, mutating
or extending the global tables — exactly like Forts. A mod's `mod.lua` sets
-`Priority`. Example: `data/mods/enemy-fort/` adds the target fort + enemy reactor.
+`Priority`. Example: `data/mods/enemy-fort/maps.lua` uses `FindMap()` to bolt
+extra armour plating onto the Proving Ground enemy fort — the worked example of
+a mod mutating base data rather than replacing it.
To add content, prefer **editing Lua data** over hard-coding C++. When you must
add a field, add it to the `*Def` struct, read it in `data.cpp` (`get_or` with a
@@ -168,8 +187,11 @@ See `requirements/milestones.org` for the live checklist. Roughly:
device framework + placement. Two gaps: fire does **not** damage devices, and
devices are not mounted on the strut graph, so the "cut its supports and the
reactor falls" victory path does not exist.
-- **M6 partial:** enemy fort (`data/mods/enemy-fort/`) + deposits both sides;
- terrain is still a flat half-plane and there is no sky backdrop.
+- **M6 partial:** three selectable scenarios in `data/maps.lua` (Proving Ground,
+ Iron Bastion, The Narrows), each with its own forts, deposits, player starter
+ structure and weapon spawns, switchable from the Dev panel. Terrain is still a
+ flat half-plane — maps can move the ground up and down but not change its
+ shape — and there is no sky backdrop.
- **Not started / later:** repair & recycle (M7), armour/door/shield materials,
tech tree + upgrades, commanders, real map/terrain, AI, netcode.
diff --git a/Makefile b/Makefile
index df2eb4d..ca8a942 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,7 @@ PODMAN_RUN := podman run --rm -v "$(CURDIR)":/src:Z --userns=keep-id \
localhost/$(IMAGE):latest bash -c
.DEFAULT_GOAL := build
-.PHONY: help image build reconfigure run check check-lua shell clean clean-image
+.PHONY: help image build reconfigure run check check-lua sim-check shell clean clean-image
help:
@echo "LibreForts:"
@@ -26,6 +26,8 @@ help:
@echo " make check compile + validate the Lua data (do this before"
@echo " claiming a change works)"
@echo " make check-lua validate the Lua data only"
+ @echo " make sim-check settle every map headlessly, fail if a fort"
+ @echo " collapses (SOAK=seconds)"
@echo " make reconfigure re-run meson setup, after editing meson.build"
@echo " make shell interactive shell in the dev container"
@echo " make clean remove $(BUILDDIR)"
@@ -48,7 +50,13 @@ reconfigure:
run: build
./$(BIN)
-check: build check-lua
+check: build check-lua sim-check
+
+# Settles every map headlessly and fails if a fort loses struts. Catches
+# "the fort collapses on load" without a display. SOAK=seconds.
+SOAK ?= 5
+sim-check: build
+ ./$(BUILDDIR)/sim_check $(SOAK)
# The GUI cannot run headless here, so parsing the Lua is the other half of
# "it works". Mods are validated LAYERED (base file first), because they
diff --git a/data/map.lua b/data/map.lua
deleted file mode 100644
index 13f9c94..0000000
--- a/data/map.lua
+++ /dev/null
@@ -1,13 +0,0 @@
--- Base scenario map: metal deposits and pre-placed devices. Mods append (the
--- enemy-fort mod adds the enemy reactor). Coordinates are world units; y ~ 1 is
--- the ground surface.
-Deposits = Deposits or {}
-MapDevices = MapDevices or {}
-
--- Metal deposits (mines must sit on these). Player side (left) + enemy side (right).
-for _, x in ipairs({ -24, -11, 23, 31 }) do
- table.insert(Deposits, { x, 1.0 })
-end
-
--- Player reactor on the left.
-table.insert(MapDevices, { type = "reactor", x = -22, y = 2.0, team = 0 })
diff --git a/data/maps.lua b/data/maps.lua
new file mode 100644
index 0000000..d49d9e6
--- /dev/null
+++ b/data/maps.lua
@@ -0,0 +1,198 @@
+-- Selectable scenarios. The engine loads every entry of the global `Maps` array
+-- and the Dev panel ("Map" section) switches between them at runtime; switching
+-- tears the world down and rebuilds it from the chosen entry. Maps[1] is the
+-- one the game starts on.
+--
+-- Each map is self-contained:
+-- name, description shown in the Dev panel
+-- ground_level height of the ground plane. Terrain is still a flat
+-- half-plane, so this moves the ground up/down but cannot
+-- change its SHAPE (real terrain is M6).
+-- deposits { {x, y}, ... } mines only produce while on one
+-- devices { {type=, x=, y=, team=}, ... } team 0 = player
+-- weapons { {type=, x=, y=}, ... } the player's starting guns
+-- structures { {name=, beams={ {x1,y1,x2,y2,material}, ... }}, ... }
+--
+-- Building rules the beams must respect (see data/materials.lua):
+-- * a beam should be <= 3 units (wood/armour/bg-brace max_length); prebuilt
+-- beams are welded raw and are NOT auto-subdivided
+-- * TRIANGULATE. An un-triangulated strut snaps once it rotates ~30 degrees,
+-- so every bay wants a diagonal or the fort folds up on the first hit
+-- * an endpoint at y == ground_level becomes a pinned foundation; keep other
+-- nodes clear of the ground+0.3 band so they don't pin by accident
+
+Maps = Maps or {}
+
+-- 1. Open ground, a wide firing lane, and a boxy enemy fort with an armour face
+-- turned towards you. The balanced default.
+table.insert(Maps, {
+ name = "Proving Ground",
+ description = "Open lane, boxy enemy fort with an armoured front.",
+ ground_level = 1.0,
+ deposits = { {-24, 1}, {-11, 1}, {23, 1}, {31, 1} },
+ devices = {
+ { type = "reactor", x = -22, y = 2.0, team = 0 },
+ { type = "reactor", x = 27, y = 3.0, team = 1 },
+ },
+ weapons = {
+ { type = "cannon", x = -18, y = 2.6 },
+ { type = "laser", x = -14, y = 2.6 },
+ },
+ structures = {
+ {
+ name = "player deck", -- gun platform; gives recoil something to shove
+ beams = {
+ {-20,1,-20,2,"wood"}, {-18,1,-18,2,"wood"}, {-16,1,-16,2,"wood"},
+ {-14,1,-14,2,"wood"}, {-12,1,-12,2,"wood"},
+ {-20,2,-18,2,"wood"}, {-18,2,-16,2,"wood"},
+ {-16,2,-14,2,"wood"}, {-14,2,-12,2,"wood"},
+ {-20,1,-18,2,"wood"}, {-18,1,-16,2,"wood"},
+ {-16,1,-14,2,"wood"}, {-14,1,-12,2,"wood"},
+ },
+ },
+ {
+ name = "enemy fort",
+ beams = {
+ -- front column facing the player is armour, the rest is wood
+ {25,1,25,3,"armour"}, {25,3,25,5,"armour"},
+ {27,1,27,3,"wood"}, {27,3,27,5,"wood"},
+ {29,1,29,3,"wood"}, {29,3,29,5,"wood"},
+ {25,1,27,1,"wood"}, {27,1,29,1,"wood"},
+ {25,3,27,3,"wood"}, {27,3,29,3,"wood"},
+ {25,5,27,5,"wood"}, {27,5,29,5,"wood"},
+ {25,1,27,3,"wood"}, {27,1,29,3,"wood"},
+ {25,3,27,5,"wood"}, {27,3,29,5,"wood"},
+ },
+ },
+ },
+})
+
+-- 2. A tall armoured tower braced internally with background bracing: shells
+-- pass straight through the bracing, so you have to chew the armour or burn
+-- the bracing out from under it. Vertical fight.
+table.insert(Maps, {
+ name = "Iron Bastion",
+ description = "A two-storey gun emplacement against a walled keep: armoured "
+ .. "outer wall, a bridge back to the tower, bg-brace interior.",
+ ground_level = 1.0,
+ deposits = { {-28, 1}, {-13, 1}, {19, 1}, {34, 1} },
+ devices = {
+ { type = "reactor", x = -26, y = 2.0, team = 0 },
+ { type = "reactor", x = 28, y = 3.0, team = 1 },
+ },
+ weapons = {
+ { type = "cannon", x = -21, y = 5.6 }, -- upper deck
+ { type = "laser", x = -17, y = 3.6 }, -- lower deck
+ },
+ structures = {
+ {
+ name = "player emplacement",
+ beams = {
+ -- lower storey; armour on the RIGHT column, the face the enemy
+ -- actually shoots at
+ {-24,1,-24,3,"wood"}, {-22,1,-22,3,"wood"}, {-20,1,-20,3,"wood"},
+ {-18,1,-18,3,"wood"}, {-16,1,-16,3,"armour"},
+ {-24,3,-22,3,"wood"}, {-22,3,-20,3,"wood"},
+ {-20,3,-18,3,"wood"}, {-18,3,-16,3,"wood"},
+ {-24,1,-22,3,"wood"}, {-22,1,-20,3,"wood"},
+ {-20,1,-18,3,"wood"}, {-18,1,-16,3,"wood"},
+ -- upper storey over the middle bays
+ {-22,3,-22,5,"wood"}, {-20,3,-20,5,"wood"}, {-18,3,-18,5,"wood"},
+ {-22,5,-20,5,"wood"}, {-20,5,-18,5,"wood"},
+ {-22,3,-20,5,"wood"}, {-20,3,-18,5,"wood"},
+ },
+ },
+ {
+ name = "outer wall", -- fully armoured, soaks the first volleys
+ beams = {
+ {22,1,22,3,"armour"}, {22,3,22,5,"armour"},
+ {24,1,24,3,"armour"}, {24,3,24,5,"armour"},
+ {22,1,24,1,"wood"}, {22,3,24,3,"wood"}, {22,5,24,5,"wood"},
+ {22,1,24,3,"wood"}, {22,3,24,5,"wood"},
+ },
+ },
+ {
+ name = "curtain bridge", -- cut this and the wall fights alone
+ beams = {
+ {24,3,26,3,"wood"}, {24,5,26,5,"wood"}, {24,3,26,5,"wood"},
+ },
+ },
+ {
+ name = "the keep",
+ beams = {
+ -- armoured outer face, full height
+ {26,1,26,3,"armour"}, {26,3,26,5,"armour"},
+ {26,5,26,7,"armour"}, {26,7,26,9,"armour"},
+ {28,1,28,3,"wood"}, {28,3,28,5,"wood"},
+ {28,5,28,7,"wood"}, {28,7,28,9,"wood"},
+ {30,1,30,3,"wood"}, {30,3,30,5,"wood"},
+ {30,5,30,7,"wood"}, {30,7,30,9,"wood"},
+ -- floors
+ {26,1,28,1,"wood"}, {28,1,30,1,"wood"},
+ {26,3,28,3,"wood"}, {28,3,30,3,"wood"},
+ {26,5,28,5,"wood"}, {28,5,30,5,"wood"},
+ {26,7,28,7,"wood"}, {28,7,30,7,"wood"},
+ {26,9,28,9,"wood"}, {28,9,30,9,"wood"},
+ -- internal bracing: shells pass through it, fire eats it fast
+ {26,1,28,3,"bgbrace"}, {28,1,30,3,"bgbrace"},
+ {26,3,28,5,"bgbrace"}, {28,3,30,5,"bgbrace"},
+ {26,5,28,7,"bgbrace"}, {28,5,30,7,"bgbrace"},
+ {26,7,28,9,"bgbrace"}, {28,7,30,9,"bgbrace"},
+ },
+ },
+ },
+})
+
+-- 3. Raised ground and forts almost in each other's faces: short flight times,
+-- both sides armoured on the facing wall. A brawl rather than a siege.
+table.insert(Maps, {
+ name = "The Narrows",
+ description = "Raised ground, two three-storey redoubts almost muzzle to "
+ .. "muzzle, armour facing armour. Reactors sit inside the forts.",
+ ground_level = 2.0,
+ deposits = { {-18, 2}, {-4, 2}, {4, 2}, {18, 2} },
+ devices = {
+ { type = "reactor", x = -13, y = 3.0, team = 0 },
+ { type = "reactor", x = 11, y = 3.0, team = 1 },
+ },
+ weapons = {
+ { type = "cannon", x = -12, y = 6.6 }, -- on the roof
+ { type = "laser", x = -10, y = 6.6 },
+ },
+ structures = {
+ {
+ name = "player redoubt", -- armour on the inward (enemy-facing) column
+ beams = {
+ {-14,2,-14,4,"wood"}, {-12,2,-12,4,"wood"},
+ {-10,2,-10,4,"wood"}, {-8,2,-8,4,"armour"},
+ {-14,4,-14,6,"wood"}, {-12,4,-12,6,"wood"},
+ {-10,4,-10,6,"wood"}, {-8,4,-8,6,"armour"},
+ {-14,4,-12,4,"wood"}, {-12,4,-10,4,"wood"}, {-10,4,-8,4,"wood"},
+ {-14,6,-12,6,"wood"}, {-12,6,-10,6,"wood"}, {-10,6,-8,6,"wood"},
+ {-14,2,-12,4,"wood"}, {-12,2,-10,4,"wood"}, {-10,2,-8,4,"wood"},
+ {-14,4,-12,6,"wood"}, {-12,4,-10,6,"wood"}, {-10,4,-8,6,"wood"},
+ },
+ },
+ {
+ name = "enemy redoubt",
+ beams = {
+ {8,2,8,4,"armour"}, {10,2,10,4,"wood"},
+ {12,2,12,4,"wood"}, {14,2,14,4,"wood"},
+ {8,4,8,6,"armour"}, {10,4,10,6,"wood"},
+ {12,4,12,6,"wood"}, {14,4,14,6,"wood"},
+ {8,4,10,4,"wood"}, {10,4,12,4,"wood"}, {12,4,14,4,"wood"},
+ {8,6,10,6,"wood"}, {10,6,12,6,"wood"}, {12,6,14,6,"wood"},
+ {8,2,10,4,"wood"}, {10,2,12,4,"wood"}, {12,2,14,4,"wood"},
+ {8,4,10,6,"wood"}, {10,4,12,6,"wood"}, {12,4,14,6,"wood"},
+ },
+ },
+ },
+})
+
+function IndexOfMap(name)
+ for i, m in ipairs(Maps) do if m.name == name then return i end end
+ return nil
+end
+function FindMap(name)
+ local i = IndexOfMap(name); if i then return Maps[i] end; return nil
+end
diff --git a/data/mods/enemy-fort/map.lua b/data/mods/enemy-fort/map.lua
deleted file mode 100644
index 443b74b..0000000
--- a/data/mods/enemy-fort/map.lua
+++ /dev/null
@@ -1,3 +0,0 @@
--- The enemy fort's reactor (team 1). Sits inside the fort (columns x=25..29).
--- Destroy it to win.
-table.insert(MapDevices, { type = "reactor", x = 27, y = 3.0, team = 1 })
diff --git a/data/mods/enemy-fort/maps.lua b/data/mods/enemy-fort/maps.lua
new file mode 100644
index 0000000..4f748ae
--- /dev/null
+++ b/data/mods/enemy-fort/maps.lua
@@ -0,0 +1,21 @@
+-- Layered-modding demo: this runs AFTER data/maps.lua, so it can reach into a
+-- map the base game defined and change it. Here we bolt an extra armour skin
+-- onto the Proving Ground enemy fort, making it a tougher target.
+--
+-- A mod could equally `table.insert(Maps, { ... })` to ship a whole new map;
+-- see data/maps.lua for the shape of an entry.
+
+local m = FindMap("Proving Ground")
+if m then
+ table.insert(m.structures, {
+ name = "enemy outer plating",
+ beams = {
+ -- a second armoured skin one unit in front of the fort's face,
+ -- tied back to it so the whole thing stays triangulated
+ {24,1,24,3,"armour"}, {24,3,24,5,"armour"},
+ {24,1,25,1,"wood"}, {24,3,25,3,"wood"}, {24,5,25,5,"wood"},
+ {24,1,25,3,"wood"}, {24,3,25,5,"wood"},
+ },
+ })
+ m.description = m.description .. " (+ enemy-fort mod plating)"
+end
diff --git a/data/mods/enemy-fort/mod.lua b/data/mods/enemy-fort/mod.lua
index b2bc990..01a6b42 100644
--- a/data/mods/enemy-fort/mod.lua
+++ b/data/mods/enemy-fort/mod.lua
@@ -1,4 +1,5 @@
--- Scenario mod: a prebuilt enemy fort on the right for the player to shoot at.
+-- Scenario mod: reinforces the Proving Ground enemy fort with extra armour
+-- plating. Doubles as the worked example of layered map modding (maps.lua).
Selectable = true
Priority = 5
Category = "Scenario"
diff --git a/data/mods/enemy-fort/structures.lua b/data/mods/enemy-fort/structures.lua
deleted file mode 100644
index ce6bdd0..0000000
--- a/data/mods/enemy-fort/structures.lua
+++ /dev/null
@@ -1,19 +0,0 @@
--- A triangulated wooden fort on the right side (columns at x=25,27,29; rows at
--- y=1,3,5). Foundations sit on the ground (y=1). All beams <= 3 units so they
--- obey the material length limits. Shoot it apart with the cannon.
-table.insert(Structures, {
- name = "enemy fort",
- beams = {
- -- verticals
- {25,1,25,3,"wood"}, {25,3,25,5,"wood"},
- {27,1,27,3,"wood"}, {27,3,27,5,"wood"},
- {29,1,29,3,"wood"}, {29,3,29,5,"wood"},
- -- horizontals
- {25,1,27,1,"wood"}, {27,1,29,1,"wood"},
- {25,3,27,3,"wood"}, {27,3,29,3,"wood"},
- {25,5,27,5,"wood"}, {27,5,29,5,"wood"},
- -- diagonals (triangulation, so it's rigid)
- {25,1,27,3,"wood"}, {27,1,29,3,"wood"},
- {25,3,27,5,"wood"}, {27,3,29,5,"wood"},
- },
-})
diff --git a/data/structures.lua b/data/structures.lua
deleted file mode 100644
index f7d88f2..0000000
--- a/data/structures.lua
+++ /dev/null
@@ -1,5 +0,0 @@
--- Base scenario structures. Empty by default; mods under data/mods/ append to
--- the global `Structures` list. Each structure is:
--- { name = "...", beams = { {x1,y1,x2,y2,material}, ... } }
--- The loader creates/merges nodes at the endpoints and welds the beams.
-Structures = Structures or {}
diff --git a/meson.build b/meson.build
index e063136..02e2d3a 100644
--- a/meson.build
+++ b/meson.build
@@ -165,3 +165,12 @@ executable('forts-clone', 'src/main.cpp',
dependencies: engine_deps,
cpp_args: extra_cpp_args,
)
+
+# Headless map soak test. The solver and the Lua loaders need no display, so
+# this can settle every map in the container and report whether the forts stand.
+executable('sim_check', 'tools/sim_check.cpp',
+ 'src/game/build_graph.cpp', 'src/game/data.cpp', 'src/engine/script.cpp',
+ include_directories: [engine_inc] + extra_inc,
+ dependencies: [lua_dep, glm_dep, sol2_dep],
+ cpp_args: extra_cpp_args,
+)
diff --git a/requirements/milestones.org b/requirements/milestones.org
index 87b198b..7cc2e89 100644
--- a/requirements/milestones.org
+++ b/requirements/milestones.org
@@ -187,13 +187,25 @@ Goal: There's a reactor to destroy, and a real economy to build it with.
Goal: The dummy fort on the right is a hand-crafted target. The map has metal
deposits and a complete layout.
-- [X] Dummy fort pre-built structure -- =data/mods/enemy-fort/= builds a
- triangulated fort at x=25..29 with the enemy reactor inside. Wood only;
- no bg-brace or ropes in it yet.
-- [~] Map layout: metal deposits on both sides exist (=data/map.lua=), but the
- terrain is still a flat half-plane, not a real heightmap.
-- [~] Player starting area: player reactor + nearby deposits on the left; there
- is no pre-built starter shell.
+- [X] Dummy fort pre-built structure -- every map in =data/maps.lua= ships a
+ triangulated enemy fort, now using armour faces and (Iron Bastion)
+ bg-brace interior bracing.
+- [X] Selectable maps: three scenarios chosen live from the Dev panel's "Map"
+ section, which rebuilds the world via =build_map()=. Maps carry their own
+ ground height, deposits, devices, weapon spawns and structures.
+ Proving Ground (36 struts) is the simple default; Iron Bastion (62: walled
+ keep, armoured outer wall, curtain bridge, bg-brace interior) and The
+ Narrows (40: two three-storey redoubts) are the larger ones.
+- [X] Headless map soak test (=tools/sim_check.cpp=, =make sim-check=): settles
+ every map with the game's tick order and fails if any fort loses struts.
+ Added after a stale-topology bug shipped forts that collapsed on load.
+- [~] Map layout: metal deposits on both sides of every map, but the terrain is
+ still a flat half-plane. =ground_level= moves the plane up and down (The
+ Narrows sits at 2.0) yet cannot change its SHAPE -- that needs a real
+ heightmap and is the remaining M6 work.
+- [X] Player starting area: each map places the player reactor, nearby deposits,
+ a small triangulated starter deck and the weapon spawns on top of it (the
+ deck also gives firing recoil something to shove against).
- [~] Visual polish: materials are visually distinct and can load real Forts art
(see CLAUDE.md section 5); no sky backdrop is drawn yet.
diff --git a/research/gameplay-gaps.org b/research/gameplay-gaps.org
index fb02b4f..0d28e80 100644
--- a/research/gameplay-gaps.org
+++ b/research/gameplay-gaps.org
@@ -38,6 +38,8 @@ Since the first draft of this document we also have:
- *Armour* (build mode 4): HP 400, 25% stiffer, 2x mass, never burns.
- *Per-material build costs*, enforced: you cannot place what you cannot afford.
- *Firing recoil*: a shot shoves the firing structure back, not just the target.
+- *Three selectable maps* with their own forts, deposits and ground height,
+ switchable live from the Dev panel.
* MISSING — grouped by system, each with the in-game reference
@@ -136,8 +138,13 @@ Doors and shields are real tactical mechanics (open to shoot, shield vs lasers).
(e.g. Firebird halves fire spread & double-extinguishes on her forts). Huge
matchup variety. Purely a post-v1 concern but defines Forts' identity.
-** 10. Map / world systems
-- Metal *deposits* embedded in terrain (mine targets) — we have none.
+** 10. Map / world systems [PARTIAL]
+DONE: three selectable maps (=data/maps.lua=), switchable at runtime from the
+Dev panel, each carrying its own ground height, deposits, devices, weapon spawns
+and prebuilt forts. STILL MISSING: the terrain SHAPE. =ground_level= only slides
+a flat half-plane up and down, so everything below is still open.
+
+- Metal *deposits* embedded in terrain (mine targets) — DONE, per map.
- Terrain shape + foundation material chosen by ground *slope angle*
(StandardNode.Foundations brackets by angle). Ours is a flat half-plane.
- No-build zones, destructible terrain, water/buoyancy (High Seas DLC).
diff --git a/src/engine/app.cpp b/src/engine/app.cpp
index fa1155c..0de71c5 100644
--- a/src/engine/app.cpp
+++ b/src/engine/app.cpp
@@ -89,7 +89,9 @@ void App::run() {
camera.set_zoom(10.0f);
// The world is a simple half-plane: below ground_level is solid ground,
- // above is sky. Anything that falls into the ground is destroyed.
+ // above is sky. Anything that falls into the ground is destroyed. The
+ // selected map sets the actual height (build_map); this is just the default
+ // until the first map is loaded below.
graph.ground_level = 1.0f;
// Resolve each material's texture once: prefer BYO Forts art, fall back to
@@ -137,7 +139,9 @@ void App::run() {
// --- Devices + map (M5) ---------------------------------------------------
std::vector<DeviceDef> device_defs = load_devices(script, "data");
- MapData map = load_map(script, "data");
+ std::vector<MapDef> maps = load_maps(script, "data");
+ if (maps.empty()) maps.push_back(MapDef{}); // empty fallback: flat, bare
+ int current_map = 0; // Maps[1] in Lua = the default
std::vector<SDL_Texture*> dev_tex(device_defs.size(), nullptr);
for (size_t i = 0; i < device_defs.size(); i++)
dev_tex[i] = renderer.load_texture_or(device_defs[i].texture_forts,
@@ -155,7 +159,7 @@ void App::run() {
int turbine_i = devidx("turbine"), mine_i = devidx("mine"), battery_i = devidx("battery");
auto on_deposit = [&](float x, float y) -> bool {
- for (auto& d : map.deposits)
+ for (auto& d : maps[current_map].deposits)
if (std::hypot(x - d.first, y - d.second) < 1.6f) return true;
return false;
};
@@ -164,22 +168,22 @@ void App::run() {
float metal = 0.0f, energy = 0.0f, ecap = BASE_ECAP, mcap = BASE_MCAP;
bool game_over = false; int winner = -1; // 0 = player, 1 = enemy
- // Build the scenario once, snapshot the graph, then reset() rebuilds it all
- // (used on 'R') WITHOUT re-running the Lua (which would duplicate structures).
- load_structures(script, "data", graph);
- std::vector<BuildNode> snap_nodes = graph.nodes;
- std::vector<BuildEdge> snap_edges = graph.edges;
+ // Snapshot of the current map's prebuilt structure. reset() ('R') restores
+ // from it rather than re-running the Lua, which would duplicate the forts.
+ std::vector<BuildNode> snap_nodes;
+ std::vector<BuildEdge> snap_edges;
int selected_cannon = -1;
int cur_mat = 0;
int place_dev = -1; // >=0 = placing that device type; -1 = build materials
auto reset = [&]() {
+ const MapDef& m = maps[current_map];
graph.nodes = snap_nodes;
graph.edges = snap_edges;
graph.rebuild();
devices.clear();
- for (auto& pd : map.devices) {
+ for (auto& pd : m.devices) {
int di = -1;
for (size_t i = 0; i < device_defs.size(); i++)
if (device_defs[i].name == pd.type) di = (int)i;
@@ -189,14 +193,29 @@ void App::run() {
devices.push_back(dv);
}
cannons.clear();
- cannons.push_back({-18.0f, graph.ground_level + 0.6f, 0.0f, 0.0f, 0.0f, cannon_type});
- if (laser_type >= 0)
- cannons.push_back({-14.0f, graph.ground_level + 0.6f, 0.0f, 0.0f, 0.0f, laser_type});
+ for (auto& pw : m.weapons) {
+ int t = pw.type == "laser" ? laser_type : cannon_type;
+ if (t < 0) continue;
+ cannons.push_back({pw.x, pw.y, 0.0f, 0.0f, 0.0f, t});
+ }
+ if (cannons.empty()) // map declared no weapons: fall back to a cannon
+ cannons.push_back({-18.0f, graph.ground_level + 0.6f,
+ 0.0f, 0.0f, 0.0f, cannon_type});
projectiles.clear(); beams.clear(); explosions.clear();
metal = 200.0f; energy = 200.0f;
game_over = false; winner = -1; selected_cannon = -1; place_dev = -1;
};
- reset();
+
+ // Tear the world down and rebuild it from maps[i]. Used at startup and by
+ // the Dev panel's map selector.
+ auto load_map_at = [&](int i) {
+ current_map = std::clamp(i, 0, (int)maps.size() - 1);
+ build_map(maps[current_map], graph); // also sets graph.ground_level
+ snap_nodes = graph.nodes;
+ snap_edges = graph.edges;
+ reset();
+ };
+ load_map_at(current_map);
// Build state
int drag_node = -1;
@@ -517,7 +536,7 @@ void App::run() {
}
// Metal deposits (markers on the ground; mines must sit on these).
- for (auto& d : map.deposits) {
+ for (auto& d : maps[current_map].deposits) {
if (deposit_tex) renderer.draw_sprite(d.first, d.second + 0.2f, 0.35f, 0.35f, 0.0f, deposit_tex);
else renderer.draw_box(d.first, d.second + 0.2f, 0.3f, 0.3f, 0.0f, 0.8f, 0.7f, 0.2f, 0.9f);
}
@@ -788,6 +807,24 @@ void App::run() {
if (battery_i >= 0) { ImGui::SameLine(); if (ImGui::Button("battery")) { place_dev = battery_i; selected_cannon = -1; } }
if (ImGui::Button("restart (R)")) reset();
ImGui::Separator();
+ // Map selector. Picking one rebuilds the world from that map.
+ if (ImGui::CollapsingHeader("Map", ImGuiTreeNodeFlags_DefaultOpen)) {
+ const MapDef& cur = maps[current_map];
+ if (ImGui::BeginCombo("scenario", cur.name.c_str())) {
+ for (int i = 0; i < (int)maps.size(); i++) {
+ bool sel = (i == current_map);
+ if (ImGui::Selectable(maps[i].name.c_str(), sel) && !sel)
+ load_map_at(i);
+ if (sel) ImGui::SetItemDefaultFocus();
+ }
+ ImGui::EndCombo();
+ }
+ if (!cur.description.empty())
+ ImGui::TextWrapped("%s", cur.description.c_str());
+ ImGui::Text("ground %.1f deposits %zu struts %zu",
+ cur.ground_level, cur.deposits.size(), graph.edges.size());
+ if (ImGui::Button("reload map")) load_map_at(current_map);
+ }
if (ImGui::CollapsingHeader("Physics", ImGuiTreeNodeFlags_DefaultOpen)) {
ImGui::SliderFloat("gravity", &graph.gravity, 0.0f, 40.0f);
ImGui::SliderFloat("air drag", &graph.air_drag, 0.0f, 3.0f);
diff --git a/src/game/data.cpp b/src/game/data.cpp
index 3d9f6c0..bbcf2d0 100644
--- a/src/game/data.cpp
+++ b/src/game/data.cpp
@@ -163,11 +163,17 @@ std::vector<WeaponDef> load_weapons(ScriptEngine& script, const std::string& dat
return out;
}
-void load_structures(ScriptEngine& script, const std::string& data_dir,
- BuildGraph& graph) {
- run_layered(script, data_dir, "structures.lua");
- sol::optional<sol::table> structs = script.lua()["Structures"];
- if (!structs) return;
+void build_map(const MapDef& m, BuildGraph& graph) {
+ graph.nodes.clear();
+ graph.edges.clear();
+ // Clearing the vectors does NOT touch the private adjacency and edge-key
+ // caches, so rebuild them now. Skipping this leaves the PREVIOUS map's edge
+ // keys in the set and add_edge then refuses the new map's beams as
+ // duplicates, quietly producing a fort that collapses on load.
+ graph.rebuild();
+ // Set the ground BEFORE welding: node_at asks is_on_ground() to decide which
+ // endpoints are pinned foundations.
+ graph.ground_level = m.ground_level;
auto mat_index = [&](const std::string& nm) -> int {
for (int i = 0; i < (int)graph.materials.size(); i++)
@@ -180,25 +186,22 @@ void load_structures(ScriptEngine& script, const std::string& data_dir,
};
int built = 0;
- for (std::size_t si = 1; ; si++) {
- sol::object so = (*structs)[si];
- if (so.get_type() != sol::type::table) break;
- sol::table S = so.as<sol::table>();
- sol::optional<sol::table> beams = S["beams"];
- if (!beams) continue;
- for (std::size_t bi = 1; ; bi++) {
- sol::object bo = (*beams)[bi];
- if (bo.get_type() != sol::type::table) break;
- sol::table b = bo.as<sol::table>();
- float x1 = b.get_or(1, 0.0f), y1 = b.get_or(2, 0.0f);
- float x2 = b.get_or(3, 0.0f), y2 = b.get_or(4, 0.0f);
- std::string mat = b.get_or(5, std::string("wood"));
- if (graph.add_edge(node_at(x1, y1), node_at(x2, y2), mat_index(mat)) >= 0)
- built++;
- }
+ for (const auto& b : m.beams) {
+ int na = node_at(b.x1, b.y1), nb = node_at(b.x2, b.y2);
+ if (graph.add_edge(na, nb, mat_index(b.material)) >= 0) { built++; continue; }
+ // A dropped beam silently weakens the fort, so never fail quietly:
+ // endpoints closer than SNAP_RADIUS collapse onto one node, and a
+ // repeated node pair is refused as a duplicate.
+ fprintf(stderr,
+ "map '%s': beam (%g,%g)-(%g,%g) [%s] rejected -> nodes %d,%d%s\n",
+ m.name.c_str(), b.x1, b.y1, b.x2, b.y2, b.material.c_str(),
+ na, nb, na == nb ? " (endpoints snapped together)" : " (duplicate)");
}
+
for (auto& e : graph.edges) e.settle = 0.0f; // prebuilt = already cured
- printf("Loaded %d prebuilt struts from Structures\n", built);
+ graph.rebuild();
+ printf("Map '%s': %d prebuilt struts, ground %.1f\n",
+ m.name.c_str(), built, m.ground_level);
}
namespace {
@@ -243,10 +246,14 @@ std::vector<DeviceDef> load_devices(ScriptEngine& script, const std::string& dat
return out;
}
-MapData load_map(ScriptEngine& script, const std::string& data_dir) {
- MapData m;
- run_layered(script, data_dir, "map.lua");
- if (sol::optional<sol::table> dep = script.lua()["Deposits"]) {
+namespace {
+MapDef read_map(const sol::table& M) {
+ MapDef m;
+ m.name = M.get_or("name", std::string("unnamed"));
+ m.description = M.get_or("description", std::string(""));
+ m.ground_level = M.get_or("ground_level", 1.0f);
+
+ if (sol::optional<sol::table> dep = M["deposits"]) {
for (std::size_t i = 1; ; i++) {
sol::object o = (*dep)[i];
if (o.get_type() != sol::type::table) break;
@@ -254,9 +261,9 @@ MapData load_map(ScriptEngine& script, const std::string& data_dir) {
m.deposits.emplace_back(p.get_or(1, 0.0f), p.get_or(2, 0.0f));
}
}
- if (sol::optional<sol::table> md = script.lua()["MapDevices"]) {
+ if (sol::optional<sol::table> dv = M["devices"]) {
for (std::size_t i = 1; ; i++) {
- sol::object o = (*md)[i];
+ sol::object o = (*dv)[i];
if (o.get_type() != sol::type::table) break;
sol::table d = o.as<sol::table>();
m.devices.push_back({ d.get_or("type", std::string("reactor")),
@@ -264,6 +271,48 @@ MapData load_map(ScriptEngine& script, const std::string& data_dir) {
d.get_or("team", 0) });
}
}
- printf("Loaded map: %zu deposits, %zu devices\n", m.deposits.size(), m.devices.size());
+ if (sol::optional<sol::table> wp = M["weapons"]) {
+ for (std::size_t i = 1; ; i++) {
+ sol::object o = (*wp)[i];
+ if (o.get_type() != sol::type::table) break;
+ sol::table w = o.as<sol::table>();
+ m.weapons.push_back({ w.get_or("type", std::string("cannon")),
+ w.get_or("x", 0.0f), w.get_or("y", 0.0f) });
+ }
+ }
+ // structures[] -> beams[] {x1,y1,x2,y2,material}, flattened: which fort a
+ // beam belonged to does not matter once it is welded into the graph.
+ if (sol::optional<sol::table> st = M["structures"]) {
+ for (std::size_t si = 1; ; si++) {
+ sol::object so = (*st)[si];
+ if (so.get_type() != sol::type::table) break;
+ sol::table S = so.as<sol::table>();
+ sol::optional<sol::table> beams = S["beams"];
+ if (!beams) continue;
+ for (std::size_t bi = 1; ; bi++) {
+ sol::object bo = (*beams)[bi];
+ if (bo.get_type() != sol::type::table) break;
+ sol::table b = bo.as<sol::table>();
+ m.beams.push_back({ b.get_or(1, 0.0f), b.get_or(2, 0.0f),
+ b.get_or(3, 0.0f), b.get_or(4, 0.0f),
+ b.get_or(5, std::string("wood")) });
+ }
+ }
+ }
return m;
}
+} // namespace
+
+std::vector<MapDef> load_maps(ScriptEngine& script, const std::string& data_dir) {
+ std::vector<MapDef> out;
+ run_layered(script, data_dir, "maps.lua");
+ sol::optional<sol::table> maps = script.lua()["Maps"];
+ if (!maps) { fprintf(stderr, "load_maps: no global 'Maps' table\n"); return out; }
+ for (std::size_t i = 1; ; i++) {
+ sol::object o = (*maps)[i];
+ if (o.get_type() != sol::type::table) break;
+ out.push_back(read_map(o.as<sol::table>()));
+ }
+ printf("Loaded %zu maps\n", out.size());
+ return out;
+}
diff --git a/src/game/data.hpp b/src/game/data.hpp
index 9b9aec1..3207ba0 100644
--- a/src/game/data.hpp
+++ b/src/game/data.hpp
@@ -43,11 +43,9 @@ std::vector<MaterialDef> load_materials(ScriptEngine& script,
std::vector<WeaponDef> load_weapons(ScriptEngine& script,
const std::string& data_dir);
-// Loads prebuilt structures from <data_dir>/structures.lua (+ mods) and builds
-// them into `graph`. Each structure is a list of beams {x1,y1,x2,y2,material}.
-// Used for scenario forts (e.g. the enemy target). Requires graph.materials set.
-void load_structures(ScriptEngine& script, const std::string& data_dir,
- BuildGraph& graph);
+// A prebuilt strut of a scenario fort, kept in map data so a map can be rebuilt
+// on demand (the dev UI switches maps at runtime).
+struct MapBeam { float x1, y1, x2, y2; std::string material; };
// A device type (reactor / mine / turbine / battery / ...), from data/devices.lua.
struct DeviceDef {
@@ -66,13 +64,32 @@ struct DeviceDef {
float r, g, b; // fallback colour when no texture
};
-// Scenario map: metal deposits + pre-placed devices (player reactor, enemy
-// reactor, starter generators), from data/map.lua (+ mods).
struct PlacedDevice { std::string type; float x, y; int team; };
-struct MapData {
+struct PlacedWeapon { std::string type; float x, y; };
+
+// One selectable scenario from data/maps.lua (+ mod layers): the ground height,
+// metal deposits, pre-placed devices (reactors, generators), the player's
+// starting weapons, and every prebuilt strut. Self-contained on purpose, so the
+// dev UI can tear the world down and rebuild it from another map at runtime.
+//
+// NOTE: terrain is still a flat half-plane, so `ground_level` moves the ground
+// up and down but cannot change its SHAPE. Real terrain is M6.
+struct MapDef {
+ std::string name;
+ std::string description;
+ float ground_level = 1.0f;
std::vector<std::pair<float,float>> deposits;
- std::vector<PlacedDevice> devices;
+ std::vector<PlacedDevice> devices;
+ std::vector<PlacedWeapon> weapons;
+ std::vector<MapBeam> beams;
};
std::vector<DeviceDef> load_devices(ScriptEngine& script, const std::string& data_dir);
-MapData load_map(ScriptEngine& script, const std::string& data_dir);
+
+// Loads every map from <data_dir>/maps.lua (+ mods), same layering as the rest.
+std::vector<MapDef> load_maps(ScriptEngine& script, const std::string& data_dir);
+
+// Clears `graph` and rebuilds it from `m`: sets ground_level, then welds every
+// prebuilt beam (creating/merging endpoint nodes, ground-level ones becoming
+// foundations). Requires graph.materials to be loaded.
+void build_map(const MapDef& m, BuildGraph& graph);
diff --git a/tools/sim_check.cpp b/tools/sim_check.cpp
new file mode 100644
index 0000000..449a7c3
--- /dev/null
+++ b/tools/sim_check.cpp
@@ -0,0 +1,80 @@
+// Headless map soak test.
+//
+// The GUI needs a display, but the solver does not: build_graph and the Lua
+// loaders have no SDL dependency. This loads every map, settles it under the
+// same fixed-step tick order the game uses, and reports whether the forts stand.
+// Use it to catch "the fort collapses on load" without opening the game.
+//
+// ./builddir/sim_check [seconds]
+//
+// Exit status is non-zero if any map lost struts, so it can gate a commit.
+
+#include "engine/script.hpp"
+#include "game/build_graph.hpp"
+#include "game/data.hpp"
+
+#include <cmath>
+#include <cstdio>
+#include <cstdlib>
+#include <string>
+#include <vector>
+
+int main(int argc, char** argv) {
+ const float FIXED_DT = 1.0f / 60.0f;
+ const float seconds = (argc > 1) ? std::atof(argv[1]) : 5.0f;
+ const int ticks = (int)(seconds / FIXED_DT);
+
+ ScriptEngine script; // ctor brings the Lua state up
+
+ BuildGraph graph;
+ graph.materials = load_materials(script, "data");
+ if (graph.materials.empty()) { fprintf(stderr, "no materials\n"); return 2; }
+ std::vector<MapDef> maps = load_maps(script, "data");
+ if (maps.empty()) { fprintf(stderr, "no maps\n"); return 2; }
+
+ int failures = 0;
+ for (const auto& m : maps) {
+ build_map(m, graph);
+ // Beams the graph refused (duplicate node pair after snapping, or a
+ // degenerate endpoint) silently weaken a fort, so surface them.
+ if ((int)graph.edges.size() != (int)m.beams.size())
+ printf(" %s: %zu beams declared but %zu welded (%zu rejected)\n",
+ m.name.c_str(), m.beams.size(), graph.edges.size(),
+ m.beams.size() - graph.edges.size());
+ const int edges0 = (int)graph.edges.size();
+ const int nodes0 = (int)graph.nodes.size();
+ std::vector<float> y0;
+ for (const auto& n : graph.nodes) y0.push_back(n.y);
+
+ int snapped = 0, killed = 0, first_break = -1;
+ for (int t = 0; t < ticks; t++) {
+ graph.step(FIXED_DT);
+ int s = graph.check_strain();
+ graph.update_timers(FIXED_DT);
+ graph.update_fire(FIXED_DT);
+ int k = graph.kill_grounded();
+ if (s > 0 && first_break < 0) first_break = t;
+ snapped += s;
+ killed += k;
+ }
+
+ // Worst downward drift of any surviving node (sag).
+ float sag = 0.0f;
+ for (size_t i = 0; i < graph.nodes.size() && i < y0.size(); i++)
+ sag = std::max(sag, y0[i] - graph.nodes[i].y);
+
+ const bool ok = (snapped == 0 && killed == 0);
+ if (!ok) failures++;
+ printf("%-16s %s struts %d -> %d, nodes %d -> %d, snapped %d, killed %d",
+ m.name.c_str(), ok ? "STANDS " : "COLLAPSE",
+ edges0, (int)graph.edges.size(),
+ nodes0, (int)graph.nodes.size(), snapped, killed);
+ if (first_break >= 0)
+ printf(", first break at t=%.2fs", first_break * FIXED_DT);
+ printf(", max sag %.3f\n", sag);
+ }
+
+ printf("\n%d/%zu maps stand after %.1fs\n", (int)maps.size() - failures,
+ maps.size(), seconds);
+ return failures == 0 ? 0 : 1;
+}