#include "game/build_graph.hpp" #include #include #include namespace { constexpr float PI = 3.14159265358979323846f; uint64_t edge_key(int a, int b) { if (a > b) std::swap(a, b); return (uint64_t)(uint32_t)a << 32 | (uint32_t)b; } float wrap_angle(float d) { while (d > PI) d -= 2.0f * PI; while (d < -PI) d += 2.0f * PI; return d; } } // ============================================================================= // Construction // ============================================================================= int BuildGraph::add_node(float x, float y, bool foundation) { nodes.push_back({x, y, 0.0f, 0.0f, MIN_NODE_MASS, foundation}); return (int)nodes.size() - 1; } int BuildGraph::add_edge(int na, int nb, int mat) { if (na < 0 || na >= (int)nodes.size()) return -1; if (nb < 0 || nb >= (int)nodes.size()) return -1; if (na == nb || edge_exists(na, nb)) return -1; if (materials.empty()) return -1; if (mat < 0 || mat >= (int)materials.size()) mat = 0; const BuildNode& A = nodes[na]; const BuildNode& B = nodes[nb]; float dx = B.x - A.x, dy = B.y - A.y; const MaterialDef& m = materials[mat]; BuildEdge e{}; e.node_a = na; e.node_b = nb; e.mat = mat; e.rest_length = std::sqrt(dx*dx + dy*dy); e.rest_angle = std::atan2(dy, dx); e.stress = 0.0f; e.hp = e.max_hp = m.hit_points; e.burning = false; e.burn = 0.0f; e.settle = m.tension_only ? 0.0f : SETTLE_TIME; // ropes are floppy by nature e.half_width = m.half_width; e.r = m.r; e.g = m.g; e.b = m.b; e.a = m.a; edges.push_back(e); rebuild_topology(); return (int)edges.size() - 1; } int BuildGraph::add_link(int na, int nb, int mat) { if (na < 0 || na >= (int)nodes.size()) return 0; if (nb < 0 || nb >= (int)nodes.size()) return 0; if (na == nb || materials.empty()) return 0; if (mat < 0 || mat >= (int)materials.size()) mat = 0; const MaterialDef& m = materials[mat]; float ax = nodes[na].x, ay = nodes[na].y; float bx = nodes[nb].x, by = nodes[nb].y; float dist = std::hypot(bx - ax, by - ay); if (dist < m.min_length || dist > m.max_link_length) return 0; int segs = (m.max_length > 0.0f) ? (int)std::ceil(dist / m.max_length) : 1; if (segs < 1) segs = 1; if (segs == 1) return add_edge(na, nb, mat) >= 0 ? 1 : 0; // Subdivide into a chain of intermediate nodes so no segment exceeds max_length. int created = 0, prev = na; for (int i = 1; i < segs; i++) { float t = (float)i / segs; float px = ax + (bx - ax) * t, py = ay + (by - ay) * t; int mid = add_node(px, py, is_on_ground(py)); if (add_edge(prev, mid, mat) >= 0) created++; prev = mid; } if (add_edge(prev, nb, mat) >= 0) created++; return created; } void BuildGraph::extrude_edge(int edge_id, float off_x, float off_y) { if (edge_id < 0 || edge_id >= (int)edges.size()) return; int mat0 = edges[edge_id].mat; if (mat0 >= 0 && mat0 < (int)materials.size()) { // Keep box sides single (braceable) edges: clamp the drag to max_length. float ol = std::sqrt(off_x*off_x + off_y*off_y); float maxlen = materials[mat0].max_length; if (ol > maxlen && ol > 1e-6f) { off_x *= maxlen / ol; off_y *= maxlen / ol; } } if (std::sqrt(off_x*off_x + off_y*off_y) < 0.1f) return; const BuildEdge e = edges[edge_id]; // snapshot before vectors move const int ea = e.node_a, eb = e.node_b, mat = e.mat; const float ax = nodes[ea].x, ay = nodes[ea].y; const float bx = nodes[eb].x, by = nodes[eb].y; float cx = ax + off_x, cy = ay + off_y; // slanted parallelogram is fine float dx = bx + off_x, dy = by + off_y; // Node merging: reuse a nearby existing node so the box grafts into the graph. int nc = find_nearest_node(cx, cy, SNAP_RADIUS); if (nc < 0 || nc == ea || nc == eb) nc = add_node(cx, cy, is_on_ground(cy)); int nd = find_nearest_node(dx, dy, SNAP_RADIUS); if (nd < 0 || nd == ea || nd == eb || nd == nc) nd = add_node(dx, dy, is_on_ground(dy)); add_edge(nc, nd, mat); // three new sides (original edge is the fourth) add_edge(ea, nc, mat); add_edge(eb, nd, mat); // Auto diagonal brace (longer diagonal) so the box is rigid on creation. if (!materials[mat].tension_only) { float l1 = std::hypot(nodes[nd].x - nodes[ea].x, nodes[nd].y - nodes[ea].y); float l2 = std::hypot(nodes[nc].x - nodes[eb].x, nodes[nc].y - nodes[eb].y); if (l1 >= l2) { if (l1 >= MIN_BRACE_LENGTH) add_edge(ea, nd, mat); } else { if (l2 >= MIN_BRACE_LENGTH) add_edge(eb, nc, mat); } } } // ============================================================================= // Topology — adjacency, edge lookup, faces, triangulation, node mass O(E.deg) // ============================================================================= void BuildGraph::rebuild_topology() { adj_.assign(nodes.size(), {}); edge_set_.clear(); for (int ei = 0; ei < (int)edges.size(); ei++) { const auto& e = edges[ei]; adj_[e.node_a].push_back({e.node_b, ei}); adj_[e.node_b].push_back({e.node_a, ei}); edge_set_.insert(edge_key(e.node_a, e.node_b)); } // Faces + per-edge triangulated flag (common neighbour of both endpoints). faces.clear(); for (auto& e : edges) { int a = std::min(e.node_a, e.node_b); int b = std::max(e.node_a, e.node_b); bool tri = false; for (auto [c, _] : adj_[a]) { if (c == b) continue; if (edge_set_.count(edge_key(b, c))) { tri = true; if (c > b) faces.push_back({a, b, c}); // emit each triangle once } } e.triangulated = tri; } // Node mass = half of each incident strut's mass, floored. for (auto& n : nodes) n.mass = 0.0f; for (const auto& e : edges) { float half = materials[e.mat].mass * 0.5f; nodes[e.node_a].mass += half; nodes[e.node_b].mass += half; } for (auto& n : nodes) if (n.mass < MIN_NODE_MASS) n.mass = MIN_NODE_MASS; } bool BuildGraph::edge_exists(int a, int b) const { return edge_set_.count(edge_key(a, b)) != 0; } // ============================================================================= // Simulation — stiff damped mass-spring, integrated with oversampling // ============================================================================= void BuildGraph::step(float dt) { const int n = (int)nodes.size(); const float h = dt / OVERSAMPLES; float dampf = 1.0f - air_drag * h; if (dampf < 0.0f) dampf = 0.0f; // Fresh struts are held rigid during their build grace: freeze their nodes. held_.assign(n, 0); for (const auto& e : edges) { if (e.settle <= 0.0f) continue; if (!nodes[e.node_a].is_foundation) held_[e.node_a] = 1; if (!nodes[e.node_b].is_foundation) held_[e.node_b] = 1; } for (int s = 0; s < OVERSAMPLES; s++) { fx_.assign(n, 0.0f); fy_.assign(n, 0.0f); // gravity (held/pinned nodes don't move, so skip) for (int i = 0; i < n; i++) if (!nodes[i].is_foundation && !held_[i]) fy_[i] = -gravity * nodes[i].mass; // spring forces: F = k*stretch + c*(relative velocity along axis) for (const auto& e : edges) { BuildNode& A = nodes[e.node_a]; BuildNode& B = nodes[e.node_b]; float dx = B.x - A.x, dy = B.y - A.y; float dist = std::sqrt(dx*dx + dy*dy); if (dist < 1e-6f) continue; float nx = dx / dist, ny = dy / dist; float stretch = dist - e.rest_length; const MaterialDef& m = materials[e.mat]; if (m.tension_only && stretch < 0.0f) continue; // rope slack float relv = (B.vx - A.vx) * nx + (B.vy - A.vy) * ny; float f = m.stiffness * stretch + m.damping * relv; fx_[e.node_a] += f * nx; fy_[e.node_a] += f * ny; fx_[e.node_b] -= f * nx; fy_[e.node_b] -= f * ny; } // semi-implicit Euler integration for (int i = 0; i < n; i++) { BuildNode& p = nodes[i]; if (p.is_foundation || held_[i]) { p.vx = p.vy = 0.0f; continue; } p.vx = (p.vx + fx_[i] / p.mass * h) * dampf; p.vy = (p.vy + fy_[i] / p.mass * h) * dampf; p.x += p.vx * h; p.y += p.vy * h; } } // Record signed axial deformation for stress colouring. for (auto& e : edges) { float dx = nodes[e.node_b].x - nodes[e.node_a].x; float dy = nodes[e.node_b].y - nodes[e.node_a].y; float dist = std::sqrt(dx*dx + dy*dy); e.stress = (e.rest_length > 1e-6f) ? (dist - e.rest_length) / e.rest_length : 0.0f; } } int BuildGraph::check_strain() { std::vector brk; for (int ei = 0; ei < (int)edges.size(); ei++) { const BuildEdge& e = edges[ei]; const MaterialDef& m = materials[e.mat]; const BuildNode& A = nodes[e.node_a]; const BuildNode& B = nodes[e.node_b]; float dx = B.x - A.x, dy = B.y - A.y; float dist = std::sqrt(dx*dx + dy*dy); float ratio = (e.rest_length > 1e-6f) ? dist / e.rest_length : 1.0f; bool fail = false; if (m.tension_only) { fail = ratio > m.max_expansion; // rope: over-stretch only } else { fail = ratio < m.max_compression || ratio > m.max_expansion; // Angle stress: a loose (un-triangulated) strut past its grace snaps // once it rotates too far from the angle it was built at. if (!fail && !e.triangulated && e.settle <= 0.0f) { float dev = std::fabs(wrap_angle(std::atan2(dy, dx) - e.rest_angle)); if (dev > m.angle_threshold) fail = true; } } if (fail) brk.push_back(ei); } if (brk.empty()) return 0; for (auto it = brk.rbegin(); it != brk.rend(); ++it) // descending: indices stay valid edges.erase(edges.begin() + *it); rebuild_topology(); printf("Build: %zu strut(s) snapped\n", brk.size()); return (int)brk.size(); } void BuildGraph::update_timers(float dt) { for (auto& e : edges) if (e.settle > 0.0f) e.settle = e.triangulated ? 0.0f : std::max(0.0f, e.settle - dt); } int BuildGraph::kill_grounded() { int killed = 0; for (;;) { int hit = -1; for (int i = 0; i < (int)nodes.size(); i++) if (!nodes[i].is_foundation && nodes[i].y < ground_level) { hit = i; break; } if (hit < 0) break; destroyed_events.push_back({nodes[hit].x, nodes[hit].y}); break_node(hit); killed++; } return killed; } // ============================================================================= // Destruction // ============================================================================= void BuildGraph::apply_recoil(float x, float y, float radius, float dx, float dy, float strength) { if (radius <= 0.0f || strength <= 0.0f) return; for (auto& n : nodes) { if (n.is_foundation) continue; // pinned: the ground eats the recoil float ox = n.x - x, oy = n.y - y; float d = std::sqrt(ox*ox + oy*oy); if (d >= radius) continue; float f = 1.0f - d / radius; // linear falloff, as in apply_splash n.vx += dx * strength * f; n.vy += dy * strength * f; } } void BuildGraph::apply_splash(float x, float y, float radius, float damage, float knockback) { if (radius <= 0.0f) return; // Knockback: shove nearby free nodes away from the blast (linear falloff). for (auto& n : nodes) { if (n.is_foundation) continue; float dx = n.x - x, dy = n.y - y; float d = std::sqrt(dx*dx + dy*dy); if (d >= radius) continue; float f = 1.0f - d / radius; if (d > 1e-4f) { n.vx += (dx/d) * knockback * f; n.vy += (dy/d) * knockback * f; } } // Damage struts by their midpoint distance; collect those that hit 0 HP. std::vector brk; for (int ei = 0; ei < (int)edges.size(); ei++) { auto& e = edges[ei]; float mx = (nodes[e.node_a].x + nodes[e.node_b].x) * 0.5f; float my = (nodes[e.node_a].y + nodes[e.node_b].y) * 0.5f; float d = std::sqrt((mx-x)*(mx-x) + (my-y)*(my-y)); if (d >= radius) continue; e.hp -= damage * (1.0f - d / radius); if (e.hp <= 0.0f) brk.push_back(ei); } 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 { int best = -1; float best_d = radius; for (int i = 0; i < (int)edges.size(); i++) { if (!materials[edges[i].mat].blocks_projectiles) continue; const BuildNode& A = nodes[edges[i].node_a]; const BuildNode& B = nodes[edges[i].node_b]; float ex = B.x - A.x, ey = B.y - A.y; float len2 = ex*ex + ey*ey; if (len2 < 1e-6f) continue; float t = std::clamp(((x-A.x)*ex + (y-A.y)*ey) / len2, 0.0f, 1.0f); float d = std::hypot(x - (A.x + t*ex), y - (A.y + t*ey)); if (d < best_d) { best_d = d; best = i; } } return best; } float BuildGraph::beam_fire(float ox, float oy, float dx, float dy, float range, float damage, bool ignite, float& hit_x, float& hit_y) { float dl = std::hypot(dx, dy); if (dl < 1e-6f) { hit_x = ox; hit_y = oy; return 0.0f; } dx /= dl; dy /= dl; // Gather every strut the ray crosses (of any material), sorted by distance. struct Cross { float t; int edge; }; std::vector crosses; for (int i = 0; i < (int)edges.size(); i++) { const BuildNode& A = nodes[edges[i].node_a]; 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) { // 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}); } std::sort(crosses.begin(), crosses.end(), [](const Cross& a, const Cross& b){ return a.t < b.t; }); // Damage/ignite each crossed strut; pass through transparent ones; stop at wood. float stop = range; std::vector brk; for (const auto& c : crosses) { BuildEdge& e = edges[c.edge]; e.hp -= damage; if (ignite && materials[e.mat].flammable) e.burning = true; if (e.hp <= 0.0f) brk.push_back(c.edge); if (materials[e.mat].blocks_beam) { stop = c.t; break; } // wood halts the beam } hit_x = ox + dx * stop; hit_y = oy + dy * stop; if (!brk.empty()) { std::sort(brk.begin(), brk.end()); brk.erase(std::unique(brk.begin(), brk.end()), brk.end()); for (auto it = brk.rbegin(); it != brk.rend(); ++it) 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; } void BuildGraph::ignite_edge(int edge_id) { if (edge_id < 0 || edge_id >= (int)edges.size()) return; if (materials[edges[edge_id].mat].flammable) edges[edge_id].burning = true; } void BuildGraph::ignite_area(float x, float y, float radius) { for (int i = 0; i < (int)edges.size(); i++) { float mx = (nodes[edges[i].node_a].x + nodes[edges[i].node_b].x) * 0.5f; float my = (nodes[edges[i].node_a].y + nodes[edges[i].node_b].y) * 0.5f; if (std::hypot(mx - x, my - y) < radius) ignite_edge(i); } } void BuildGraph::update_fire(float dt) { // Burn: DoT + advance spread timer; ignite flammable neighbours; destroy at 0. std::vector newly_lit; std::vector brk; for (int i = 0; i < (int)edges.size(); i++) { auto& e = edges[i]; if (!e.burning) continue; const MaterialDef& m = materials[e.mat]; e.hp -= m.burn_rate * dt; e.burn += dt; if (e.burn >= m.spread_time) { e.burn = 0.0f; // spread again after each interval for (int end : { e.node_a, e.node_b }) for (auto [nb, ei] : adj_[end]) if (!edges[ei].burning && materials[edges[ei].mat].flammable) newly_lit.push_back(ei); } if (e.hp <= 0.0f) brk.push_back(i); } for (int ei : newly_lit) if (ei >= 0 && ei < (int)edges.size()) edges[ei].burning = true; if (!brk.empty()) { std::sort(brk.begin(), brk.end()); brk.erase(std::unique(brk.begin(), brk.end()), brk.end()); for (auto it = brk.rbegin(); it != brk.rend(); ++it) 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) { if (edge_id < 0 || edge_id >= (int)edges.size()) return; edges.erase(edges.begin() + edge_id); rebuild_topology(); } void BuildGraph::break_node(int node_id) { if (node_id < 0 || node_id >= (int)nodes.size()) return; edges.erase(std::remove_if(edges.begin(), edges.end(), [node_id](const BuildEdge& e) { return e.node_a == node_id || e.node_b == node_id; }), edges.end()); nodes.erase(nodes.begin() + node_id); for (auto& e : edges) { 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 // ============================================================================= int BuildGraph::find_nearest_node(float x, float y, float radius) const { int best = -1; float best_d2 = radius * radius; for (int i = 0; i < (int)nodes.size(); i++) { float dx = nodes[i].x - x, dy = nodes[i].y - y; float d2 = dx*dx + dy*dy; if (d2 < best_d2) { best_d2 = d2; best = i; } } return best; } int BuildGraph::find_nearest_edge(float x, float y, float radius) const { int best = -1; float best_d = radius; for (int i = 0; i < (int)edges.size(); i++) { const BuildNode& A = nodes[edges[i].node_a]; const BuildNode& B = nodes[edges[i].node_b]; float ex = B.x - A.x, ey = B.y - A.y; float len2 = ex*ex + ey*ey; if (len2 < 0.0001f) continue; float t = ((x - A.x)*ex + (y - A.y)*ey) / len2; t = std::clamp(t, 0.0f, 1.0f); float px = A.x + t*ex, py = A.y + t*ey; float d = std::hypot(x - px, y - py); if (d < best_d) { best_d = d; best = i; } } return best; } BuildEdge* BuildGraph::mutable_edge(int id) { if (id < 0 || id >= (int)edges.size()) return nullptr; return &edges[id]; }