diff options
| author | Vaino Kauppila <vaino@vke.fi> | 2026-07-30 11:56:27 +0300 |
|---|---|---|
| committer | Vaino Kauppila <vaino@vke.fi> | 2026-07-30 11:56:27 +0300 |
| commit | 6cdd436caf9e2103f516442ab6e70a870d4cba04 (patch) | |
| tree | 7a69242d34ef3739f3e554227213ec5d91a2a958 /src/game | |
| parent | 521b5da83485a956811fc510cba9f139acad8b26 (diff) | |
| download | forts_clone-6cdd436caf9e2103f516442ab6e70a870d4cba04.tar.gz forts_clone-6cdd436caf9e2103f516442ab6e70a870d4cba04.zip | |
Mount devices on the strut graph so cutting supports destroys them
Implements win-condition path (c): destroy the enemy reactor by cutting
its supports so it falls. Previously devices sat at fixed map coordinates,
structurally immune to collapse.
- BuildGraph gains MountedDevice tracking: mount_device/unmount_device,
is_device_alive/get_device_hp/damage_device, nearest_device, with a
break_node index-shift fix so mount references stay correct as other
geometry on the same fort is destroyed. update_fire now also damages
any device mounted on a burning strut.
- app.cpp mounts devices onto the nearest strut (within 2 units) at both
map-load and interactive placement, and after each tick's structural
collapse pass, zeroes a device's HP if its mount didn't survive -- which
flows through the existing win-condition/cleanup code unchanged.
- tools/device_collapse_test.cpp: 15 headless tests covering collapse,
partial support loss, splash/beam/fire-through-mount damage, multiple
devices, index stability, and clean unmount.
Verified: full test suite passes (15/15), sim_check shows zero regressions
across all 3 maps.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'src/game')
| -rw-r--r-- | src/game/build_graph.cpp | 158 | ||||
| -rw-r--r-- | src/game/build_graph.hpp | 30 |
2 files changed, 183 insertions, 5 deletions
diff --git a/src/game/build_graph.cpp b/src/game/build_graph.cpp index a1195af..2398b9c 100644 --- a/src/game/build_graph.cpp +++ b/src/game/build_graph.cpp @@ -331,10 +331,25 @@ void BuildGraph::apply_splash(float x, float y, float radius, float damage, e.hp -= damage * (1.0f - d / radius); if (e.hp <= 0.0f) brk.push_back(ei); } - if (brk.empty()) return; - for (auto it = brk.rbegin(); it != brk.rend(); ++it) - edges.erase(edges.begin() + *it); - rebuild_topology(); + if (!brk.empty()) { + for (auto it = brk.rbegin(); it != brk.rend(); ++it) + edges.erase(edges.begin() + *it); + rebuild_topology(); + } + + // Splash also damages devices mounted within the radius. + for (auto& dv : devices) { + if (!dv.alive) continue; + float mx = 0.0f, my = 0.0f; int mounts = 0; + if (dv.node_a >= 0 && dv.node_a < (int)nodes.size()) + { mx += nodes[dv.node_a].x; my += nodes[dv.node_a].y; mounts++; } + if (dv.node_b >= 0 && dv.node_b < (int)nodes.size()) + { mx += nodes[dv.node_b].x; my += nodes[dv.node_b].y; mounts++; } + if (mounts == 0) continue; + mx /= mounts; my /= mounts; + float d = std::sqrt((mx-x)*(mx-x) + (my-y)*(my-y)); + if (d < radius) { dv.hp -= damage * (1.0f - d / radius); if (dv.hp <= 0.0f) dv.alive = false; } + } } int BuildGraph::find_blocking_edge(float x, float y, float radius) const { @@ -367,7 +382,17 @@ float BuildGraph::beam_fire(float ox, float oy, float dx, float dy, float range, const BuildNode& B = nodes[edges[i].node_b]; float ex = B.x - A.x, ey = B.y - A.y; float denom = dx * ey - dy * ex; - if (std::fabs(denom) < 1e-6f) continue; // parallel + if (std::fabs(denom) < 1e-6f) { + // Ray parallel to segment. Check for collinear overlap. + float perp = std::fabs((ox - A.x) * ey - (oy - A.y) * ex); + if (perp > 1e-4f) continue; // parallel, no overlap + float ta = (A.x - ox) * dx + (A.y - oy) * dy; + float tb = (B.x - ox) * dx + (B.y - oy) * dy; + if (ta > tb) std::swap(ta, tb); + if (tb < 0.0f || ta > range) continue; // no overlap + crosses.push_back({std::max(0.0f, ta), i}); + continue; + } float t = ((A.x - ox) * ey - (A.y - oy) * ex) / denom; // dist along ray float u = ((A.x - ox) * dy - (A.y - oy) * dx) / denom; // param along segment if (t >= 0.0f && t <= range && u >= 0.0f && u <= 1.0f) crosses.push_back({t, i}); @@ -395,6 +420,28 @@ float BuildGraph::beam_fire(float ox, float oy, float dx, float dy, float range, edges.erase(edges.begin() + *it); rebuild_topology(); } + + // Beam also damages devices within threshold distance of its path. + { + float ex = hit_x - ox, ey = hit_y - oy; + float len2 = ex*ex + ey*ey; + if (len2 > 1e-6f) { + for (auto& dv : devices) { + if (!dv.alive) continue; + float mx = 0.0f, my = 0.0f; int mounts = 0; + if (dv.node_a >= 0 && dv.node_a < (int)nodes.size()) + { mx += nodes[dv.node_a].x; my += nodes[dv.node_a].y; mounts++; } + if (dv.node_b >= 0 && dv.node_b < (int)nodes.size()) + { mx += nodes[dv.node_b].x; my += nodes[dv.node_b].y; mounts++; } + if (mounts == 0) continue; + mx /= mounts; my /= mounts; + float t = std::clamp(((mx-ox)*ex + (my-oy)*ey) / len2, 0.0f, 1.0f); + float d = std::hypot(mx - (ox + t*ex), my - (oy + t*ey)); + if (d < 0.6f) { dv.hp -= damage; if (dv.hp <= 0.0f) dv.alive = false; } + } + } + } + return stop; } @@ -439,6 +486,20 @@ void BuildGraph::update_fire(float dt) { edges.erase(edges.begin() + *it); rebuild_topology(); } + + // Fire also damages devices mounted on burning struts. + for (int ei = 0; ei < (int)edges.size(); ei++) { + const auto& e = edges[ei]; + if (!e.burning) continue; + float fd = materials[e.mat].burn_rate * dt; + for (auto& dv : devices) { + if (!dv.alive) continue; + if (dv.node_a == e.node_a || dv.node_a == e.node_b || + dv.node_b == e.node_a || dv.node_b == e.node_b) { + dv.hp -= fd; if (dv.hp <= 0.0f) dv.alive = false; + } + } + } } void BuildGraph::break_edge(int edge_id) { @@ -460,10 +521,97 @@ void BuildGraph::break_node(int node_id) { if (e.node_a > node_id) e.node_a--; if (e.node_b > node_id) e.node_b--; } + + // Fix up device mount indices: invalidate mounts on the removed node, + // shift indices past it, and mark devices dead when both mounts are gone. + for (auto& dv : devices) { + if (!dv.alive) continue; + if (dv.node_a == node_id) dv.node_a = -1; + if (dv.node_b == node_id) dv.node_b = -1; + if (dv.node_a > node_id) dv.node_a--; + if (dv.node_b > node_id) dv.node_b--; + if (dv.node_a < 0 && dv.node_b < 0) dv.alive = false; + } + rebuild_topology(); } // ============================================================================= +// Devices mounted on the strut graph +// ============================================================================= + +int BuildGraph::mount_device(int na, int nb, int device_type, int team, float hp) { + if (na < 0 || na >= (int)nodes.size()) return -1; + if (nb < 0 || nb >= (int)nodes.size()) return -1; + if (na == nb) return -1; + + MountedDevice md{}; + md.node_a = na; + md.node_b = nb; + md.type = device_type; + md.team = team; + md.hp = hp; + md.max_hp = hp; + md.alive = true; + devices.push_back(md); + return (int)devices.size() - 1; +} + +void BuildGraph::unmount_device(int device_id) { + if (device_id < 0 || device_id >= (int)devices.size()) return; + devices[device_id].alive = false; +} + +bool BuildGraph::is_device_alive(int device_id) const { + if (device_id < 0 || device_id >= (int)devices.size()) return false; + return devices[device_id].alive && devices[device_id].hp > 0.0f; +} + +float BuildGraph::get_device_hp(int device_id) const { + if (device_id < 0 || device_id >= (int)devices.size()) return 0.0f; + return devices[device_id].hp; +} + +bool BuildGraph::damage_device(int device_id, float amount) { + if (device_id < 0 || device_id >= (int)devices.size()) return false; + auto& dv = devices[device_id]; + if (!dv.alive || dv.hp <= 0.0f) return false; + dv.hp -= amount; + if (dv.hp <= 0.0f) { + dv.alive = false; + return true; + } + return false; +} + +int BuildGraph::nearest_device(float x, float y, float radius) const { + int best = -1; + float best_d2 = radius * radius; + for (int i = 0; i < (int)devices.size(); i++) { + if (!devices[i].alive || devices[i].hp <= 0.0f) continue; + // Device position = midpoint of its valid mount nodes. + float mx = 0.0f, my = 0.0f; + int mounts = 0; + if (devices[i].node_a >= 0 && devices[i].node_a < (int)nodes.size()) { + mx += nodes[devices[i].node_a].x; + my += nodes[devices[i].node_a].y; + mounts++; + } + if (devices[i].node_b >= 0 && devices[i].node_b < (int)nodes.size()) { + mx += nodes[devices[i].node_b].x; + my += nodes[devices[i].node_b].y; + mounts++; + } + if (mounts == 0) continue; + mx /= mounts; my /= mounts; + float dx = mx - x, dy = my - y; + float d2 = dx*dx + dy*dy; + if (d2 < best_d2) { best_d2 = d2; best = i; } + } + return best; +} + +// ============================================================================= // Queries // ============================================================================= diff --git a/src/game/build_graph.hpp b/src/game/build_graph.hpp index 4452139..cd8dc3d 100644 --- a/src/game/build_graph.hpp +++ b/src/game/build_graph.hpp @@ -67,6 +67,17 @@ struct BuildEdge { struct BuildFace { int node_a, node_b, node_c; }; +// A device mounted on the strut graph via two mount nodes. It is destroyed when +// (a) both mount nodes are removed (fall below ground, snap, break), or +// (b) its HP reaches zero from splash/beam/fire damage. +struct MountedDevice { + int node_a, node_b; // mount-point node indices (-1 if destroyed) + int type; // index into DeviceDefs (from data.hpp) + int team = 0; // team index (0 = player, 1 = enemy) + float hp, max_hp; + bool alive = true; +}; + class BuildGraph { public: // Material table, loaded from Lua at startup (build modes index into it). @@ -133,6 +144,24 @@ public: // e.g. restoring a scenario snapshot). void rebuild() { rebuild_topology(); } + // --- devices mounted on the strut graph ------------------------------ + // Attach a device to two mount nodes (any type from DeviceDefs). + // Returns the device index in the graph's devices vector. + int mount_device(int na, int nb, int device_type, int team, float hp); + + // Remove a device (e.g. when a weapon shot destroys it outright). + // Does NOT shift indices — marks it dead and skips it in queries. + void unmount_device(int device_id); + + bool is_device_alive(int device_id) const; + float get_device_hp(int device_id) const; + const std::vector<MountedDevice>& get_devices() const { return devices; } + int nearest_device(float x, float y, float radius) const; + + // Deal damage to a device (splash/beam/fire). Returns true if the device + // was alive before and is dead after (for downstream win-condition checks). + bool damage_device(int device_id, float amount); + // --- destruction primitives ------------------------------------------- void break_edge(int edge_id); void break_node(int node_id); @@ -147,6 +176,7 @@ public: std::vector<BuildNode> nodes; std::vector<BuildEdge> edges; std::vector<BuildFace> faces; + std::vector<MountedDevice> devices; struct Destroyed { float x, y; }; std::vector<Destroyed> destroyed_events; // drained by the app for FX |
