From 6cdd436caf9e2103f516442ab6e70a870d4cba04 Mon Sep 17 00:00:00 2001 From: Vaino Kauppila Date: Thu, 30 Jul 2026 11:56:27 +0300 Subject: 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 --- src/game/build_graph.cpp | 158 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 5 deletions(-) (limited to 'src/game/build_graph.cpp') 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,9 +521,96 @@ 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 // ============================================================================= -- cgit v1.3