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/engine/app.cpp | 32 +++++++++- src/game/build_graph.cpp | 158 +++++++++++++++++++++++++++++++++++++++++++++-- src/game/build_graph.hpp | 30 +++++++++ 3 files changed, 212 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/engine/app.cpp b/src/engine/app.cpp index 0de71c5..cfe924c 100644 --- a/src/engine/app.cpp +++ b/src/engine/app.cpp @@ -148,7 +148,7 @@ void App::run() { device_defs[i].texture); SDL_Texture* deposit_tex = renderer.load_texture("data/textures/deposit.png"); - struct Device { int def, team; float x, y, hp, max_hp; bool on_deposit; }; + struct Device { int def, team; float x, y, hp, max_hp; bool on_deposit; int graph_id = -1; }; std::vector devices; auto devidx = [&](const char* n) -> int { @@ -177,11 +177,24 @@ void App::run() { int cur_mat = 0; int place_dev = -1; // >=0 = placing that device type; -1 = build materials + // Mount a device onto the nearest strut so cutting its supports destroys + // it (win-condition path (c): reactor falls when its supports are cut). + // Devices with no nearby strut (radius 2.0) stay unmounted (-1) and are + // only killed by weapon damage, same as before this feature existed. + const float MOUNT_RADIUS = 2.0f; + auto mount_at = [&](float x, float y, int def_idx, int team, float hp) -> int { + int eid = graph.find_nearest_edge(x, y, MOUNT_RADIUS); + if (eid < 0) return -1; + const BuildEdge& e = graph.edges[eid]; + return graph.mount_device(e.node_a, e.node_b, def_idx, team, hp); + }; + auto reset = [&]() { const MapDef& m = maps[current_map]; graph.nodes = snap_nodes; graph.edges = snap_edges; graph.rebuild(); + graph.devices.clear(); devices.clear(); for (auto& pd : m.devices) { int di = -1; @@ -190,6 +203,7 @@ void App::run() { if (di < 0) continue; Device dv{di, pd.team, pd.x, pd.y, device_defs[di].hp, device_defs[di].hp, true}; if (device_defs[di].needs_deposit) dv.on_deposit = on_deposit(pd.x, pd.y); + dv.graph_id = mount_at(pd.x, pd.y, di, pd.team, device_defs[di].hp); devices.push_back(dv); } cannons.clear(); @@ -317,8 +331,10 @@ void App::run() { const DeviceDef& def = device_defs[place_dev]; bool dep_ok = !def.needs_deposit || on_deposit(wx, wy); if (dep_ok && metal >= def.cost_metal && energy >= def.cost_energy) { - devices.push_back({ place_dev, 0, wx, wy, def.hp, def.hp, - def.needs_deposit ? on_deposit(wx, wy) : true }); + Device dv{ place_dev, 0, wx, wy, def.hp, def.hp, + def.needs_deposit ? on_deposit(wx, wy) : true }; + dv.graph_id = mount_at(wx, wy, place_dev, 0, def.hp); + devices.push_back(dv); metal -= def.cost_metal; energy -= def.cost_energy; } @@ -419,6 +435,14 @@ void App::run() { graph.update_fire(FIXED_DT); // burn DoT + spread graph.kill_grounded(); // destroy debris below the ground + // Devices mounted on the strut graph: if structural collapse (a + // snapped strut, kill_grounded) took out both mount nodes, the + // device falls too -- this is win-condition path (c), cutting + // supports to drop the reactor. + for (auto& dv : devices) + if (dv.graph_id >= 0 && !graph.is_device_alive(dv.graph_id)) + dv.hp = 0.0f; + // Weapons: tick reloads. for (auto& c : cannons) if (c.cooldown > 0.0f) c.cooldown = std::max(0.0f, c.cooldown - FIXED_DT); @@ -487,6 +511,8 @@ void App::run() { } accumulator -= FIXED_DT; } + for (auto& dv : devices) + if (dv.hp <= 0.0f && dv.graph_id >= 0) graph.unmount_device(dv.graph_id); devices.erase(std::remove_if(devices.begin(), devices.end(), [](const Device& d){ return d.hp <= 0.0f; }), devices.end()); projectiles.erase( 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 // ============================================================================= 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& 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 nodes; std::vector edges; std::vector faces; + std::vector devices; struct Destroyed { float x, y; }; std::vector destroyed_events; // drained by the app for FX -- cgit v1.3