diff options
Diffstat (limited to 'src/game')
| -rw-r--r-- | src/game/build_graph.cpp | 487 | ||||
| -rw-r--r-- | src/game/build_graph.hpp | 151 | ||||
| -rw-r--r-- | src/game/build_system.hpp | 32 | ||||
| -rw-r--r-- | src/game/data.cpp | 261 | ||||
| -rw-r--r-- | src/game/data.hpp | 73 |
5 files changed, 1004 insertions, 0 deletions
diff --git a/src/game/build_graph.cpp b/src/game/build_graph.cpp new file mode 100644 index 0000000..032bbf5 --- /dev/null +++ b/src/game/build_graph.cpp @@ -0,0 +1,487 @@ +#include "game/build_graph.hpp" +#include <cmath> +#include <cstdio> +#include <algorithm> + +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<int> 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_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<int> 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()) return; + for (auto it = brk.rbegin(); it != brk.rend(); ++it) + edges.erase(edges.begin() + *it); + rebuild_topology(); +} + +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<Cross> 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) continue; // parallel + 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<int> 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(); + } + 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<int> newly_lit; + std::vector<int> 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(); + } +} + +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--; + } + rebuild_topology(); +} + +// ============================================================================= +// 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]; +} diff --git a/src/game/build_graph.hpp b/src/game/build_graph.hpp new file mode 100644 index 0000000..f92dbd3 --- /dev/null +++ b/src/game/build_graph.hpp @@ -0,0 +1,151 @@ +#pragma once + +#include <vector> +#include <cstdint> +#include <string> +#include <unordered_set> + +// The building structure is a single graph of nodes (point masses) joined by +// edges (struts). The solver is a stiff damped mass-spring system — the canon +// Forts model: each strut is a Hookean spring (F = k.dx) with damping, and the +// stiff springs are integrated with oversampling for stability. +// +// There is ONE index space (node and edge indices). Rigidity is geometric +// (triangulation): a lone strut is a free-hinging pin joint that SNAPS once it +// rotates past its material's angle threshold (Forts' 30-degree rule), or once +// it deforms axially past MaxCompression/MaxExpansion. + +// Material definition — loaded from Lua data (see game/data.cpp). +struct MaterialDef { + float stiffness; // spring constant k (force per unit stretch) + float damping; // axial spring damping + float mass; // contributed to each endpoint node + float max_compression; // snap if length/rest < this (e.g. 0.90 = -10%) + float max_expansion; // snap if length/rest > this (e.g. 1.10 = +10%) + float angle_threshold; // snap a loose strut rotated this far (radians) + float min_length; // shortest a single strut may be (placement rejected below) + float max_length; // longest a single segment (a longer drag subdivides) + float max_link_length; // longest a whole drag/link (rejected beyond) + float hit_points; // HP pool depleted by weapon damage (separate from stress) + bool tension_only; // ropes: slack (no force, no snap) in compression + bool flammable; // can catch fire + float burn_rate; // HP lost per second while burning + float spread_time; // seconds of burning before it ignites a neighbour + bool blocks_projectiles; // stops cannon shells (wood yes; bg-brace/rope no) + bool blocks_beam; // stops laser beams (all yes in Forts) + float half_width; // visual thickness + float r, g, b, a; // visual colour (also tints the texture) + std::string name; + std::string texture; // optional texture path ("" = flat colour) +}; + +struct BuildNode { + float x, y; // position + float vx, vy; // velocity + float mass; // aggregate of incident struts (>= MIN_NODE_MASS) + bool is_foundation; // pinned to the ground +}; + +struct BuildEdge { + int node_a, node_b; // node indices + int mat; // index into MATERIALS + float rest_length; // captured at build time + float rest_angle; // world orientation at build time + float stress; // signed deformation (length-rest)/rest, for colour + float hp, max_hp; // weapon-damage health pool + bool burning; // on fire (takes damage-over-time, spreads) + float burn; // seconds spent burning (drives spread timing) + float settle; // build-grace timer (s); while >0 the strut is held rigid + bool triangulated; // cached: part of a triangle (rigid) — set by topology + float half_width; // cached from material (render) + float r, g, b, a; // cached from material (render) +}; + +struct BuildFace { int node_a, node_b, node_c; }; + +class BuildGraph { +public: + // Material table, loaded from Lua at startup (build modes index into it). + std::vector<MaterialDef> materials; + + // --- tunables --------------------------------------------------------- + static constexpr float SETTLE_TIME = 4.0f; // rigid build grace (Forts TempBracing) + static constexpr float SNAP_RADIUS = 0.4f; // node merge distance + static constexpr float MIN_BRACE_LENGTH = 0.6f; // smallest auto brace + static constexpr int OVERSAMPLES = 14; // spring substeps per tick + static constexpr float MIN_NODE_MASS = 0.05f; + + float ground_level = 1.0f; // below = solid ground, above = sky + float gravity = 10.0f; // world units / s^2 + float air_drag = 0.8f; // linear velocity drag coefficient + + // --- construction ----------------------------------------------------- + int add_node(float x, float y, bool foundation = false); + int add_edge(int na, int nb, int mat); + + // Place a strut between two nodes, subdividing into segments no longer than + // the material's max_length (intermediate nodes auto-created). Rejects links + // outside [min_length, max_link_length]. Returns segments created (0 = none). + int add_link(int na, int nb, int mat); + + void extrude_edge(int edge_id, float off_x, float off_y); + + // --- simulation (call inside the fixed timestep) ---------------------- + void step(float dt); // integrate the mass-spring system (oversampled) + int check_strain(); // snap struts past axial / angle limits + void update_timers(float dt); // build-grace countdown + int kill_grounded(); // destroy debris below the ground + + // --- weapon damage ---------------------------------------------------- + // Radial splash at (x,y): damages struts (HP, linear falloff), applies + // knockback to nodes, and breaks struts whose HP hits zero. Structure does + // not block splash (pure radius falloff, like Forts AoE). + void apply_splash(float x, float y, float radius, float damage, float knockback); + + // Trace a laser beam from (ox,oy) along (dx,dy) up to `range`: damage + + // optionally ignite EVERY strut it crosses, passing through beam-transparent + // materials (bg-brace, ropes) and STOPPING at the first blocking one (wood). + // Fills the stop point and returns the beam length. + float beam_fire(float ox, float oy, float dx, float dy, float range, + float damage, bool ignite, float& hit_x, float& hit_y); + + // Nearest edge to (x,y) within radius that blocks projectiles (what a + // cannon shell detonates on). Returns edge index or -1. + int find_blocking_edge(float x, float y, float radius) const; + + // --- fire ------------------------------------------------------------- + void ignite_edge(int edge_id); // set a strut on fire (if flammable) + void ignite_area(float x, float y, float radius); // ignite flammable struts in radius + void update_fire(float dt); // burn DoT + spread + destroy at 0 HP + + // Recompute adjacency/faces/masses (call after directly assigning nodes/edges, + // e.g. restoring a scenario snapshot). + void rebuild() { rebuild_topology(); } + + // --- destruction primitives ------------------------------------------- + void break_edge(int edge_id); + void break_node(int node_id); + + // --- queries ---------------------------------------------------------- + int find_nearest_node(float x, float y, float radius = 0.5f) const; + int find_nearest_edge(float x, float y, float radius = 0.5f) const; + bool is_on_ground(float y) const { return y <= ground_level + 0.3f; } + BuildEdge* mutable_edge(int id); + + // --- data ------------------------------------------------------------- + std::vector<BuildNode> nodes; + std::vector<BuildEdge> edges; + std::vector<BuildFace> faces; + + struct Destroyed { float x, y; }; + std::vector<Destroyed> destroyed_events; // drained by the app for FX + +private: + std::vector<std::vector<std::pair<int,int>>> adj_; // node -> (neighbour, edge) + std::unordered_set<uint64_t> edge_set_; + std::vector<float> fx_, fy_; // per-node force scratch + std::vector<char> held_; // per-node build-grace freeze + + void rebuild_topology(); // adj_, edge_set_, faces, triangulated, node mass + bool edge_exists(int a, int b) const; +}; diff --git a/src/game/build_system.hpp b/src/game/build_system.hpp new file mode 100644 index 0000000..0bf615d --- /dev/null +++ b/src/game/build_system.hpp @@ -0,0 +1,32 @@ +#pragma once + +// Material types for building. +enum class Material : int { + Wood = 0, + Background = 1, + Rope = 2, + COUNT = 3, +}; + +struct MaterialInfo { + const char* name; + float half_w, half_h; // half-size of placed piece + float metal_cost; + float energy_cost; + float r, g, b; // color + float alpha; // 1.0 = opaque, 0.4 = semi-transparent + bool blocks_projectiles; +}; + +inline MaterialInfo material_info(Material m) { + switch (m) { + case Material::Wood: + return {"Wood", 1.0f, 0.4f, 10.0f, 5.0f, 0.55f, 0.35f, 0.15f, 1.0f, true}; + case Material::Background: + return {"BgBrace", 1.0f, 0.4f, 5.0f, 3.0f, 0.45f, 0.40f, 0.30f, 0.4f, false}; + case Material::Rope: + return {"Rope", 0.0f, 0.0f, 3.0f, 2.0f, 0.15f, 0.12f, 0.10f, 1.0f, false}; + default: + return {"???", 1.0f, 0.4f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, true}; + } +} diff --git a/src/game/data.cpp b/src/game/data.cpp new file mode 100644 index 0000000..54f7fdb --- /dev/null +++ b/src/game/data.cpp @@ -0,0 +1,261 @@ +#include "game/data.hpp" +#include "engine/script.hpp" + +#include <filesystem> +#include <algorithm> +#include <cstdio> + +// sol2's string-literal table keys trip a GCC -Warray-bounds false positive +// under inlining/optimisation. The access is correct; silence the noise here. +#if defined(__GNUC__) && !defined(__clang__) +#pragma GCC diagnostic ignored "-Warray-bounds" +#endif + +namespace fs = std::filesystem; + +namespace { +constexpr float DEG2RAD = 3.14159265358979323846f / 180.0f; + +MaterialDef read_material(const sol::table& m) { + MaterialDef d{}; + d.name = m.get_or("name", std::string("unnamed")); + d.stiffness = m.get_or("stiffness", 600.0f); + d.damping = m.get_or("damping", 12.0f); + d.mass = m.get_or("mass", 0.25f); + d.max_compression = m.get_or("max_compression", 0.90f); + d.max_expansion = m.get_or("max_expansion", 1.10f); + d.angle_threshold = m.get_or("angle_threshold", 30.0f) * DEG2RAD; // data is degrees + d.min_length = m.get_or("min_length", 0.4f); + d.max_length = m.get_or("max_length", 3.0f); + d.max_link_length = m.get_or("max_link_length", 6.0f); + d.hit_points = m.get_or("hit_points", 150.0f); + d.flammable = m.get_or("flammable", true); + d.burn_rate = m.get_or("burn_rate", 15.0f); + d.spread_time = m.get_or("spread_time", 2.0f); + d.blocks_projectiles = m.get_or("blocks_projectiles", true); + d.blocks_beam = m.get_or("blocks_beam", true); + d.tension_only = m.get_or("tension_only", false); + d.half_width = m.get_or("half_width", 0.15f); + d.texture = m.get_or("texture", std::string("")); + + sol::optional<sol::table> col = m["color"]; + d.r = col ? col->get_or(1, 0.8f) : 0.8f; + d.g = col ? col->get_or(2, 0.5f) : 0.5f; + d.b = col ? col->get_or(3, 0.2f) : 0.2f; + d.a = col ? col->get_or(4, 1.0f) : 1.0f; + return d; +} +} // namespace + +std::vector<MaterialDef> load_materials(ScriptEngine& script, + const std::string& data_dir) { + std::vector<MaterialDef> out; + + // 1. Base data. + if (!script.run_file(data_dir + "/materials.lua")) return out; + + // 2. Mods, ordered by (priority asc, path). Each mod.lua sets Priority; + // each materials.lua mutates the shared global Materials table. + std::error_code ec; + std::string mods_dir = data_dir + "/mods"; + std::vector<std::pair<int, std::string>> mods; + if (fs::is_directory(mods_dir, ec)) { + for (const auto& e : fs::directory_iterator(mods_dir, ec)) { + if (!e.is_directory()) continue; + std::string mp = e.path().string(); + if (!fs::exists(mp + "/materials.lua")) continue; + int priority = 5; + if (fs::exists(mp + "/mod.lua") && script.run_file(mp + "/mod.lua")) { + sol::optional<int> p = script.lua()["Priority"]; + if (p) priority = *p; + } + mods.emplace_back(priority, mp); + } + std::sort(mods.begin(), mods.end()); + for (const auto& [prio, mp] : mods) { + printf("Mod: applying %s (priority %d)\n", mp.c_str(), prio); + script.run_file(mp + "/materials.lua"); + } + } + + // 3. Read the final Materials array (1-based Lua array part, in order). + sol::optional<sol::table> mats = script.lua()["Materials"]; + if (!mats) { + fprintf(stderr, "load_materials: no global 'Materials' table\n"); + return out; + } + for (std::size_t i = 1; ; i++) { + sol::object o = (*mats)[i]; + if (o.get_type() != sol::type::table) break; + out.push_back(read_material(o.as<sol::table>())); + } + printf("Loaded %zu materials from Lua\n", out.size()); + return out; +} + +namespace { +// Run <data_dir>/<file> then every mod's <file> (priority, name order). +void run_layered(ScriptEngine& script, const std::string& data_dir, + const std::string& file) { + if (!script.run_file(data_dir + "/" + file)) return; + std::error_code ec; + std::string mods_dir = data_dir + "/mods"; + std::vector<std::pair<int, std::string>> mods; + if (fs::is_directory(mods_dir, ec)) { + for (const auto& e : fs::directory_iterator(mods_dir, ec)) { + if (!e.is_directory()) continue; + std::string mp = e.path().string(); + if (!fs::exists(mp + "/" + file)) continue; + int priority = 5; + if (fs::exists(mp + "/mod.lua") && script.run_file(mp + "/mod.lua")) { + sol::optional<int> p = script.lua()["Priority"]; + if (p) priority = *p; + } + mods.emplace_back(priority, mp); + } + std::sort(mods.begin(), mods.end()); + for (const auto& [prio, mp] : mods) + script.run_file(mp + "/" + file); + } +} + +WeaponDef read_weapon(const sol::table& w) { + WeaponDef d{}; + d.name = w.get_or("name", std::string("unnamed")); + d.muzzle_speed = w.get_or("muzzle_speed", 35.0f); + d.reload = w.get_or("reload", 2.0f); + d.splash_damage = w.get_or("splash_damage", 200.0f); + d.splash_radius = w.get_or("splash_radius", 3.0f); + d.knockback = w.get_or("knockback", 60.0f); + d.projectile_radius = w.get_or("projectile_radius", 0.15f); + d.fire_chance = w.get_or("fire_chance", 0.0f); + d.min_fire_angle = w.get_or("min_fire_angle", -20.0f) * DEG2RAD; + d.max_fire_angle = w.get_or("max_fire_angle", 30.0f) * DEG2RAD; + d.is_beam = w.get_or("is_beam", false); + d.beam_range = w.get_or("beam_range", 60.0f); + d.beam_dps = w.get_or("beam_dps", 120.0f); + d.beam_duration = w.get_or("beam_duration", 1.5f); + d.energy_cost = w.get_or("energy_cost", 0.0f); + d.texture = w.get_or("texture", std::string("")); + d.projectile_texture = w.get_or("projectile_texture", std::string("")); + return d; +} +} // namespace + +std::vector<WeaponDef> load_weapons(ScriptEngine& script, const std::string& data_dir) { + std::vector<WeaponDef> out; + run_layered(script, data_dir, "weapons.lua"); + sol::optional<sol::table> ws = script.lua()["Weapons"]; + if (!ws) { fprintf(stderr, "load_weapons: no global 'Weapons' table\n"); return out; } + for (std::size_t i = 1; ; i++) { + sol::object o = (*ws)[i]; + if (o.get_type() != sol::type::table) break; + out.push_back(read_weapon(o.as<sol::table>())); + } + printf("Loaded %zu weapons from Lua\n", out.size()); + 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; + + auto mat_index = [&](const std::string& nm) -> int { + for (int i = 0; i < (int)graph.materials.size(); i++) + if (graph.materials[i].name == nm) return i; + return 0; + }; + auto node_at = [&](float x, float y) -> int { + int n = graph.find_nearest_node(x, y, BuildGraph::SNAP_RADIUS); + return (n >= 0) ? n : graph.add_node(x, y, graph.is_on_ground(y)); + }; + + 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 (auto& e : graph.edges) e.settle = 0.0f; // prebuilt = already cured + printf("Loaded %d prebuilt struts from Structures\n", built); +} + +namespace { +DeviceDef read_device(const sol::table& d) { + DeviceDef v{}; + v.name = d.get_or("name", std::string("device")); + v.hp = d.get_or("hp", 100.0f); + v.energy_rate = d.get_or("energy_rate", 0.0f); + v.metal_rate = d.get_or("metal_rate", 0.0f); + v.energy_storage = d.get_or("energy_storage", 0.0f); + v.metal_storage = d.get_or("metal_storage", 0.0f); + v.needs_deposit = d.get_or("needs_deposit", false); + v.wind = d.get_or("wind", false); + v.wind_max_rate = d.get_or("wind_max_rate", 15.0f); + v.wind_max_height = d.get_or("wind_max_height", 20.0f); + v.is_core = d.get_or("is_core", false); + v.half_w = d.get_or("half_w", 0.6f); + v.half_h = d.get_or("half_h", 0.6f); + v.cost_metal = d.get_or("cost_metal", 0.0f); + v.cost_energy = d.get_or("cost_energy", 0.0f); + v.texture = d.get_or("texture", std::string("")); + sol::optional<sol::table> col = d["color"]; + v.r = col ? col->get_or(1, 0.8f) : 0.8f; + v.g = col ? col->get_or(2, 0.8f) : 0.8f; + v.b = col ? col->get_or(3, 0.8f) : 0.8f; + return v; +} +} // namespace + +std::vector<DeviceDef> load_devices(ScriptEngine& script, const std::string& data_dir) { + std::vector<DeviceDef> out; + run_layered(script, data_dir, "devices.lua"); + sol::optional<sol::table> ds = script.lua()["DeviceDefs"]; + if (!ds) { fprintf(stderr, "load_devices: no 'DeviceDefs' table\n"); return out; } + for (std::size_t i = 1; ; i++) { + sol::object o = (*ds)[i]; + if (o.get_type() != sol::type::table) break; + out.push_back(read_device(o.as<sol::table>())); + } + printf("Loaded %zu devices from Lua\n", out.size()); + 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"]) { + for (std::size_t i = 1; ; i++) { + sol::object o = (*dep)[i]; + if (o.get_type() != sol::type::table) break; + sol::table p = o.as<sol::table>(); + 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"]) { + for (std::size_t i = 1; ; i++) { + sol::object o = (*md)[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")), + d.get_or("x", 0.0f), d.get_or("y", 0.0f), + d.get_or("team", 0) }); + } + } + printf("Loaded map: %zu deposits, %zu devices\n", m.deposits.size(), m.devices.size()); + return m; +} diff --git a/src/game/data.hpp b/src/game/data.hpp new file mode 100644 index 0000000..3a7f912 --- /dev/null +++ b/src/game/data.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "game/build_graph.hpp" // MaterialDef +#include <vector> +#include <string> + +class ScriptEngine; + +// A weapon definition, loaded from Lua (data/weapons.lua). Values are in our +// world units (scaled from the Forts data). +struct WeaponDef { + std::string name; + float muzzle_speed; // launch speed (units/s) + float reload; // seconds between shots + float splash_damage; // damage at blast centre + float splash_radius; // blast radius (units) + float knockback; // impulse applied to nearby nodes + float projectile_radius; // collision radius of the shell + float fire_chance; // 0..1 chance to ignite on impact (M4 fire) + float min_fire_angle; // firing-arc lower bound, radians rel. to mount + float max_fire_angle; // firing-arc upper bound, radians rel. to mount + bool is_beam; // laser (hitscan beam) vs ballistic shell + float beam_range; // max beam length (units) + float beam_dps; // HP damage per second to the struck strut + float beam_duration; // seconds the beam stays on + float energy_cost; // energy spent per shot (Forts EnergyFireCost) + std::string texture; // turret/base sprite + std::string projectile_texture; // shell sprite +}; + +// Loads the material table from <data_dir>/materials.lua, then applies every +// mod under <data_dir>/mods/<name>/ (each may define mod.lua with a Priority and +// a materials.lua that mutates the base table) in (priority, name) order. +// Returns the final material list. Empty on failure. +std::vector<MaterialDef> load_materials(ScriptEngine& script, + const std::string& data_dir); + +// Loads weapons from <data_dir>/weapons.lua (+ mod overrides), same layering. +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 device type (reactor / mine / turbine / battery / ...), from data/devices.lua. +struct DeviceDef { + std::string name; + float hp; + float energy_rate, metal_rate; // resources produced per second (mine e<0) + float energy_storage, metal_storage;// added to the resource caps + bool needs_deposit; // mine: only produces on a metal deposit + bool wind; // turbine: energy scales with height + float wind_max_rate, wind_max_height; + bool is_core; // reactor: destroying it decides the game + float half_w, half_h; + float cost_metal, cost_energy; // build cost + std::string texture; + 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 { + std::vector<std::pair<float,float>> deposits; + std::vector<PlacedDevice> devices; +}; + +std::vector<DeviceDef> load_devices(ScriptEngine& script, const std::string& data_dir); +MapData load_map(ScriptEngine& script, const std::string& data_dir); |
