aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorVaino Kauppila <vaino@vke.fi>2026-07-02 22:58:25 +0300
committerVaino Kauppila <vaino@vke.fi>2026-07-02 23:47:07 +0300
commitcd0c08dc7ce754473178ad3f32f7740a1dddc1eb (patch)
tree589c676b41d6f3d322ea5ce339ddb0811f29ec31 /src
downloadforts_clone-cd0c08dc7ce754473178ad3f32f7740a1dddc1eb.tar.gz
forts_clone-cd0c08dc7ce754473178ad3f32f7740a1dddc1eb.zip
Initial import: LibreForts (M0-M5 in progress)
Open-source Forts clone: custom C++20 engine + data-driven (Lua) 2D physics artillery RTS. - Building: node/strut graph on a stiff mass-spring solver (canon Forts model), triangulation rigidity, axial + 30-degree angle-stress breaking, cascading collapse, fire (DoT + spread), ground destroys debris. - Weapons (M4): cannon (ballistic) + laser (beam/ignite) with select-and-aim-in- arc UX; splash / beam damage; bg-brace passthrough. - Devices + economy (M5): reactors + win/loss + restart, mines/turbines/battery, metal deposits, storage caps, per-shot energy cost. - Data-driven via Lua/sol2 with a layered mod loader; enemy-fort scenario mod. - Renderer: SDL3 + textures + ImGui dev UI. stb_image + ImGui vendored. - Assets: CC0 placeholders only; real game art loaded at runtime from the user's own install via forts: paths (bring-your-own; nothing copyrighted committed). See README.md / CLAUDE.md and research/ + requirements/ for detail.
Diffstat (limited to 'src')
-rw-r--r--src/engine/app.cpp824
-rw-r--r--src/engine/app.hpp29
-rw-r--r--src/engine/camera.cpp55
-rw-r--r--src/engine/camera.hpp59
-rw-r--r--src/engine/input.cpp101
-rw-r--r--src/engine/input.hpp53
-rw-r--r--src/engine/physics.cpp70
-rw-r--r--src/engine/physics.hpp41
-rw-r--r--src/engine/renderer.cpp285
-rw-r--r--src/engine/renderer.hpp60
-rw-r--r--src/engine/script.cpp18
-rw-r--r--src/engine/script.hpp21
-rw-r--r--src/engine/stb_impl.cpp9
-rw-r--r--src/game/build_graph.cpp487
-rw-r--r--src/game/build_graph.hpp151
-rw-r--r--src/game/build_system.hpp32
-rw-r--r--src/game/data.cpp261
-rw-r--r--src/game/data.hpp73
-rw-r--r--src/main.cpp13
19 files changed, 2642 insertions, 0 deletions
diff --git a/src/engine/app.cpp b/src/engine/app.cpp
new file mode 100644
index 0000000..64b9f01
--- /dev/null
+++ b/src/engine/app.cpp
@@ -0,0 +1,824 @@
+#include "engine/app.hpp"
+#include "engine/renderer.hpp"
+#include "engine/input.hpp"
+#include "engine/camera.hpp"
+#include "engine/script.hpp"
+#include "game/build_graph.hpp"
+#include "game/data.hpp"
+#include "imgui.h"
+
+#include <cmath>
+#include <cstdio>
+#include <vector>
+#include <algorithm>
+#include <random>
+
+App::App(const AppConfig& cfg) : config_(cfg) {
+ if (!SDL_Init(SDL_INIT_VIDEO)) {
+ fprintf(stderr, "SDL_Init failed: %s\n", SDL_GetError());
+ return;
+ }
+ int flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_OPENGL;
+ window_ = SDL_CreateWindow(config_.title,
+ config_.window_width,
+ config_.window_height, flags);
+ if (!window_)
+ fprintf(stderr, "SDL_CreateWindow failed: %s\n", SDL_GetError());
+}
+
+App::~App() {
+ if (window_) SDL_DestroyWindow(window_);
+ SDL_Quit();
+}
+
+static void update_camera(Camera& cam, const Input& input, float dt) {
+ if (input.key_down(SDLK_LEFT)) cam.pan({-cam.pan_speed * dt / cam.zoom(), 0.0f});
+ if (input.key_down(SDLK_RIGHT)) cam.pan({ cam.pan_speed * dt / cam.zoom(), 0.0f});
+ if (input.key_down(SDLK_UP)) cam.pan({0.0f, cam.pan_speed * dt / cam.zoom()});
+ if (input.key_down(SDLK_DOWN)) cam.pan({0.0f, -cam.pan_speed * dt / cam.zoom()});
+ if (input.mouse_down(SDL_BUTTON_MIDDLE)) {
+ auto d = input.mouse_delta();
+ cam.pan({-d.x / cam.zoom(), d.y / cam.zoom()});
+ }
+ float scroll = input.scroll_delta();
+ if (scroll != 0.0f)
+ cam.zoom_at(scroll, cam.screen_to_world(input.mouse_pos()));
+}
+
+static void draw_preview_line(Renderer& r, float ax, float ay, float bx, float by,
+ float thick, float cr, float cg, float cb, float ca) {
+ float mx = (ax + bx) * 0.5f, my = (ay + by) * 0.5f;
+ float dx = bx - ax, dy = by - ay;
+ float len = std::sqrt(dx*dx + dy*dy);
+ float ang = std::atan2(dy, dx);
+ if (len > 0.01f) r.draw_box(mx, my, len * 0.5f, thick, ang, cr, cg, cb, ca);
+}
+
+void App::run() {
+ if (!window_) return;
+
+ Renderer renderer;
+ Input input;
+ Camera camera;
+ BuildGraph graph; // mass-spring building structure
+
+ // Load material data from Lua (base + mods). Falls back to a single wood
+ // material so the game still runs if the data files are missing.
+ ScriptEngine script;
+ graph.materials = load_materials(script, "data");
+ if (graph.materials.empty()) {
+ fprintf(stderr, "WARNING: no materials loaded; using built-in fallback\n");
+ MaterialDef wood{};
+ wood.stiffness = 600; wood.damping = 12; wood.mass = 0.25f;
+ wood.max_compression = 0.90f; wood.max_expansion = 1.10f;
+ wood.angle_threshold = 0.5236f; wood.tension_only = false;
+ wood.half_width = 0.15f;
+ wood.r = 0.55f; wood.g = 0.35f; wood.b = 0.15f; wood.a = 1.0f;
+ wood.name = "wood";
+ graph.materials.push_back(wood);
+ }
+
+ int w, h;
+ SDL_GetWindowSize(window_, &w, &h);
+ if (!renderer.init(window_, w, h)) return;
+ camera.set_viewport(w, h);
+ camera.set_position({0.0f, 6.0f});
+ camera.set_zoom(10.0f);
+
+ // The world is a simple half-plane: below ground_level is solid ground,
+ // above is sky. Anything that falls into the ground is destroyed.
+ graph.ground_level = 1.0f;
+
+ // Resolve each material's texture once (nullptr = draw flat colour).
+ std::vector<SDL_Texture*> mat_tex(graph.materials.size(), nullptr);
+ for (size_t i = 0; i < graph.materials.size(); i++)
+ if (!graph.materials[i].texture.empty())
+ mat_tex[i] = renderer.load_texture(graph.materials[i].texture);
+ SDL_Texture* ground_tex = renderer.load_texture("data/textures/ground.png");
+
+ // Short-lived explosion puffs spawned when debris hits the ground.
+ struct Explosion { float x, y, life; };
+ std::vector<Explosion> explosions;
+ const float EXPLOSION_LIFE = 0.4f;
+
+ // --- Weapons (M4) ---------------------------------------------------------
+ std::vector<WeaponDef> weapons = load_weapons(script, "data");
+ int cannon_type = 0, laser_type = -1;
+ for (size_t i = 0; i < weapons.size(); i++) {
+ if (weapons[i].name == "cannon") cannon_type = (int)i;
+ if (weapons[i].name == "laser") laser_type = (int)i;
+ }
+ SDL_Texture* turret_tex = renderer.load_texture("data/textures/weapon_turret.png");
+ SDL_Texture* shell_tex = renderer.load_texture("data/textures/projectile_laser.png");
+
+ struct Cannon { float x, y, mount, aim, cooldown; int type; };
+ std::vector<Cannon> cannons;
+
+ struct Projectile { float x, y, vx, vy, damage, splash, knock, radius, life, ignite; };
+ std::vector<Projectile> projectiles;
+ struct Beam { float ox, oy, angle, range, dps, time_left, hx, hy; bool ignite; };
+ std::vector<Beam> beams;
+
+ std::mt19937 rng(12345);
+ std::uniform_real_distribution<float> uni(0.0f, 1.0f);
+
+ // --- Devices + map (M5) ---------------------------------------------------
+ std::vector<DeviceDef> device_defs = load_devices(script, "data");
+ MapData map = load_map(script, "data");
+ std::vector<SDL_Texture*> dev_tex(device_defs.size(), nullptr);
+ for (size_t i = 0; i < device_defs.size(); i++)
+ if (!device_defs[i].texture.empty())
+ dev_tex[i] = renderer.load_texture(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; };
+ std::vector<Device> devices;
+
+ auto devidx = [&](const char* n) -> int {
+ for (size_t i = 0; i < device_defs.size(); i++)
+ if (device_defs[i].name == n) return (int)i;
+ return -1;
+ };
+ int turbine_i = devidx("turbine"), mine_i = devidx("mine"), battery_i = devidx("battery");
+
+ auto on_deposit = [&](float x, float y) -> bool {
+ for (auto& d : map.deposits)
+ if (std::hypot(x - d.first, y - d.second) < 1.6f) return true;
+ return false;
+ };
+
+ const float BASE_ECAP = 1000.0f, BASE_MCAP = 1000.0f;
+ float metal = 0.0f, energy = 0.0f, ecap = BASE_ECAP, mcap = BASE_MCAP;
+ bool game_over = false; int winner = -1; // 0 = player, 1 = enemy
+
+ // Build the scenario once, snapshot the graph, then reset() rebuilds it all
+ // (used on 'R') WITHOUT re-running the Lua (which would duplicate structures).
+ load_structures(script, "data", graph);
+ std::vector<BuildNode> snap_nodes = graph.nodes;
+ std::vector<BuildEdge> snap_edges = graph.edges;
+
+ int selected_cannon = -1;
+ int cur_mat = 0;
+ int place_dev = -1; // >=0 = placing that device type; -1 = build materials
+
+ auto reset = [&]() {
+ graph.nodes = snap_nodes;
+ graph.edges = snap_edges;
+ graph.rebuild();
+ devices.clear();
+ for (auto& pd : map.devices) {
+ int di = -1;
+ for (size_t i = 0; i < device_defs.size(); i++)
+ if (device_defs[i].name == pd.type) di = (int)i;
+ 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);
+ devices.push_back(dv);
+ }
+ cannons.clear();
+ cannons.push_back({-18.0f, graph.ground_level + 0.6f, 0.0f, 0.0f, 0.0f, cannon_type});
+ if (laser_type >= 0)
+ cannons.push_back({-14.0f, graph.ground_level + 0.6f, 0.0f, 0.0f, 0.0f, laser_type});
+ projectiles.clear(); beams.clear(); explosions.clear();
+ metal = 200.0f; energy = 200.0f;
+ game_over = false; winner = -1; selected_cannon = -1; place_dev = -1;
+ };
+ reset();
+
+ // Build state
+ int drag_node = -1;
+ bool placing = false;
+ int extruding_edge = -1;
+ float extrude_start_x = 0.0f;
+ float extrude_start_y = 0.0f;
+
+ const float FIXED_DT = 1.0f / 60.0f;
+ const float MAX_FRAME = 0.25f;
+ float accumulator = 0.0f;
+
+ last_tick_ = SDL_GetTicks();
+
+ while (running_) {
+ uint64_t now = SDL_GetTicks();
+ float frame_dt = static_cast<float>(now - last_tick_) / 1000.0f;
+ last_tick_ = now;
+ if (frame_dt > MAX_FRAME) frame_dt = MAX_FRAME;
+
+ input.begin_frame();
+ if (input.quit_requested()) running_ = false;
+ // Escape deselects a weapon first, otherwise quits.
+ if (input.key_pressed(SDLK_ESCAPE)) {
+ if (selected_cannon >= 0) selected_cannon = -1;
+ else running_ = false;
+ }
+
+ // 1/2/3 pick a build material; 5/6/7 pick a device to place; R restarts.
+ if (input.key_pressed(SDLK_1)) { cur_mat = 0; selected_cannon = -1; place_dev = -1; }
+ if (input.key_pressed(SDLK_2)) { cur_mat = 1; selected_cannon = -1; place_dev = -1; }
+ if (input.key_pressed(SDLK_3)) { cur_mat = 2; selected_cannon = -1; place_dev = -1; }
+ if (input.key_pressed(SDLK_5)) { place_dev = turbine_i; selected_cannon = -1; }
+ if (input.key_pressed(SDLK_6)) { place_dev = mine_i; selected_cannon = -1; }
+ if (input.key_pressed(SDLK_7)) { place_dev = battery_i; selected_cannon = -1; }
+ if (input.key_pressed(SDLK_R)) reset();
+ if (cur_mat >= (int)graph.materials.size()) cur_mat = 0;
+
+ glm::vec2 mpos = input.mouse_pos();
+ float wx = camera.screen_to_world(mpos).x;
+ float wy = camera.screen_to_world(mpos).y;
+
+ // Suppress world clicks when the mouse is over the dev UI (last frame's
+ // state — valid before this frame's ImGui::NewFrame).
+ bool over_ui = ImGui::GetIO().WantCaptureMouse;
+
+ // ---- Weapon select / aim / fire ----
+ // A left-click on a cannon selects it. While a cannon is selected, the
+ // mouse aims it (clamped to its firing arc) and a click elsewhere fires.
+ bool left_click = input.mouse_pressed(SDL_BUTTON_LEFT) && !over_ui;
+ int clicked_cannon = -1;
+ if (left_click) {
+ float best = 0.8f * 0.8f; // select radius^2
+ for (int i = 0; i < (int)cannons.size(); i++) {
+ float dx = wx - cannons[i].x, dy = wy - cannons[i].y;
+ float d2 = dx*dx + dy*dy;
+ if (d2 < best) { best = d2; clicked_cannon = i; }
+ }
+ }
+ if (clicked_cannon >= 0) { selected_cannon = clicked_cannon; place_dev = -1; }
+
+ if (selected_cannon >= 0 && !weapons.empty()) {
+ Cannon& c = cannons[selected_cannon];
+ const WeaponDef& wpn = weapons[c.type];
+ // Aim toward cursor, clamped to [min,max] fire angle around the mount.
+ const float PI = 3.14159265358979323846f;
+ float rel = std::atan2(wy - c.y, wx - c.x) - c.mount;
+ while (rel > PI) rel -= 2.0f * PI;
+ while (rel < -PI) rel += 2.0f * PI;
+ rel = std::clamp(rel, wpn.min_fire_angle, wpn.max_fire_angle);
+ c.aim = c.mount + rel;
+ // Fire on a click that wasn't the one selecting this cannon (costs energy).
+ if (left_click && clicked_cannon < 0 && c.cooldown <= 0.0f &&
+ !game_over && energy >= wpn.energy_cost) {
+ const float BL = 1.0f;
+ float tx = c.x + std::cos(c.aim)*BL, ty = c.y + std::sin(c.aim)*BL;
+ if (wpn.is_beam) {
+ beams.push_back({ tx, ty, c.aim, wpn.beam_range, wpn.beam_dps,
+ wpn.beam_duration, tx, ty, wpn.fire_chance > 0.0f });
+ } else {
+ projectiles.push_back({ tx, ty,
+ std::cos(c.aim) * wpn.muzzle_speed, std::sin(c.aim) * wpn.muzzle_speed,
+ wpn.splash_damage, wpn.splash_radius, wpn.knockback,
+ wpn.projectile_radius, 6.0f, wpn.fire_chance });
+ }
+ c.cooldown = wpn.reload;
+ energy -= wpn.energy_cost;
+ }
+ }
+
+ // ---- Device placement (mine/turbine/battery) ----
+ if (place_dev >= 0 && place_dev < (int)device_defs.size() &&
+ left_click && clicked_cannon < 0 && !game_over) {
+ 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 });
+ metal -= def.cost_metal;
+ energy -= def.cost_energy;
+ }
+ }
+
+ bool building = (selected_cannon < 0 && place_dev < 0); // suppress while aiming/placing
+
+ // Queries (only meaningful when building)
+ int hover_node = building ? graph.find_nearest_node(wx, wy, 0.5f) : -1;
+ int hover_edge = (building && hover_node < 0) ? graph.find_nearest_edge(wx, wy, 0.4f) : -1;
+
+ // ---- Left click: start an edge ----
+ // A build can only START from something already anchored: an existing
+ // node, or the ground (which creates a foundation node). Clicking empty
+ // air does nothing — you can't grow structure out of thin air.
+ if (building && !over_ui && !placing && extruding_edge < 0 && input.mouse_pressed(SDL_BUTTON_LEFT)) {
+ if (hover_node >= 0) {
+ drag_node = hover_node;
+ placing = true;
+ } else if (graph.is_on_ground(wy)) {
+ int new_id = graph.add_node(wx, graph.ground_level, true);
+ if (new_id >= 0) {
+ drag_node = new_id;
+ placing = true;
+ metal -= 50.0f; energy -= 400.0f;
+ }
+ }
+ }
+
+ // ---- Right click on edge: extrude ----
+ if (building && !over_ui && !placing && extruding_edge < 0 && input.mouse_pressed(SDL_BUTTON_RIGHT)) {
+ if (hover_edge >= 0) {
+ extruding_edge = hover_edge;
+ extrude_start_x = wx;
+ extrude_start_y = wy;
+ }
+ }
+
+ // ---- Finish edge placement ---- (add_link enforces the length rules
+ // and subdivides a long drag into segments)
+ if (placing && !input.mouse_down(SDL_BUTTON_LEFT)) {
+ int target = graph.find_nearest_node(wx, wy, 0.5f);
+ if (target >= 0 && target != drag_node) {
+ if (graph.add_link(drag_node, target, cur_mat) > 0) {
+ metal -= 5.0f; energy -= 25.0f;
+ }
+ } else if (drag_node >= 0 && drag_node < (int)graph.nodes.size()) {
+ bool foundation = graph.is_on_ground(wy);
+ float ny = foundation ? graph.ground_level : wy;
+ // Length guard first, so a rejected link leaves no orphan node.
+ const auto& m = graph.materials[cur_mat];
+ float dlen = std::hypot(wx - graph.nodes[drag_node].x,
+ ny - graph.nodes[drag_node].y);
+ if (dlen >= m.min_length && dlen <= m.max_link_length) {
+ int new_id = graph.add_node(wx, ny, foundation);
+ if (graph.add_link(drag_node, new_id, cur_mat) > 0) {
+ metal -= 5.0f; energy -= 25.0f;
+ if (foundation) { metal -= 50.0f; energy -= 400.0f; }
+ }
+ }
+ }
+ drag_node = -1;
+ placing = false;
+ }
+
+ // ---- Finish extrusion ----
+ if (extruding_edge >= 0 && !input.mouse_down(SDL_BUTTON_RIGHT)) {
+ float offx = wx - extrude_start_x;
+ float offy = wy - extrude_start_y;
+ if (std::sqrt(offx*offx + offy*offy) > 0.1f) {
+ graph.extrude_edge(extruding_edge, offx, offy);
+ metal -= 10.0f; energy -= 25.0f;
+ }
+ extruding_edge = -1;
+ }
+
+ update_camera(camera, input, frame_dt);
+
+ // ---- Simulation (fixed timestep, so it stays frame-rate independent
+ // and deterministic) ----
+ accumulator += frame_dt;
+ while (accumulator >= FIXED_DT) {
+ graph.step(FIXED_DT); // mass-spring integration (oversampled)
+ graph.check_strain(); // snap struts past axial/angle limits
+ graph.update_timers(FIXED_DT); // build-grace countdown
+ graph.update_fire(FIXED_DT); // burn DoT + spread
+ graph.kill_grounded(); // destroy debris below the ground
+
+ // Weapons: tick reloads.
+ for (auto& c : cannons)
+ if (c.cooldown > 0.0f) c.cooldown = std::max(0.0f, c.cooldown - FIXED_DT);
+
+ // Cannon shells: ballistic, detonate on ground or a projectile-blocking
+ // strut (shells pass THROUGH background bracing / ropes).
+ for (auto& p : projectiles) {
+ if (p.life <= 0.0f) continue;
+ p.vy -= graph.gravity * FIXED_DT;
+ p.x += p.vx * FIXED_DT;
+ p.y += p.vy * FIXED_DT;
+ p.life -= FIXED_DT;
+ bool hit = false;
+ if (p.y <= graph.ground_level) { p.y = graph.ground_level; hit = true; }
+ else if (graph.find_blocking_edge(p.x, p.y, p.radius + 0.15f) >= 0) hit = true;
+ if (hit) {
+ if (p.ignite > 0.0f && uni(rng) < p.ignite)
+ graph.ignite_area(p.x, p.y, p.splash * 0.7f); // ~25% incendiary
+ graph.apply_splash(p.x, p.y, p.splash, p.damage, p.knock);
+ for (auto& dv : devices) if (dv.hp > 0.0f) { // splash hits devices too
+ float d = std::hypot(dv.x - p.x, dv.y - p.y);
+ if (d < p.splash) dv.hp -= p.damage * (1.0f - d / p.splash);
+ }
+ explosions.push_back({p.x, p.y, EXPLOSION_LIFE});
+ p.life = 0.0f;
+ }
+ }
+
+ // Laser beams: damage + ignite everything crossed, pass through
+ // bg-brace/ropes, stop at the first wood strut. Also burns devices.
+ for (auto& bm : beams) {
+ if (bm.time_left <= 0.0f) continue;
+ graph.beam_fire(bm.ox, bm.oy, std::cos(bm.angle), std::sin(bm.angle),
+ bm.range, bm.dps * FIXED_DT, bm.ignite, bm.hx, bm.hy);
+ float ex = bm.hx - bm.ox, ey = bm.hy - bm.oy;
+ float len2 = ex*ex + ey*ey;
+ for (auto& dv : devices) if (dv.hp > 0.0f && len2 > 1e-6f) {
+ float t = std::clamp(((dv.x-bm.ox)*ex + (dv.y-bm.oy)*ey) / len2, 0.0f, 1.0f);
+ float d = std::hypot(dv.x - (bm.ox+t*ex), dv.y - (bm.oy+t*ey));
+ if (d < 0.6f) dv.hp -= bm.dps * FIXED_DT;
+ }
+ bm.time_left -= FIXED_DT;
+ }
+
+ // Resources: team-0 devices generate metal/energy (capped).
+ float egen = 0.0f, mgen = 0.0f; ecap = BASE_ECAP; mcap = BASE_MCAP;
+ for (auto& dv : devices) if (dv.team == 0 && dv.hp > 0.0f) {
+ const DeviceDef& def = device_defs[dv.def];
+ ecap += def.energy_storage; mcap += def.metal_storage;
+ if (def.wind)
+ egen += def.wind_max_rate *
+ std::clamp((dv.y - graph.ground_level) / def.wind_max_height, 0.0f, 1.0f);
+ else if (def.needs_deposit) { if (dv.on_deposit) { egen += def.energy_rate; mgen += def.metal_rate; } }
+ else { egen += def.energy_rate; mgen += def.metal_rate; }
+ }
+ energy = std::clamp(energy + egen * FIXED_DT, 0.0f, ecap);
+ metal = std::clamp(metal + mgen * FIXED_DT, 0.0f, mcap);
+
+ // Win/loss: a team with no living reactor loses.
+ if (!game_over) {
+ int pa = 0, ea = 0;
+ for (auto& dv : devices)
+ if (device_defs[dv.def].is_core && dv.hp > 0.0f) (dv.team == 0 ? pa : ea)++;
+ if (ea == 0) { game_over = true; winner = 0; }
+ else if (pa == 0) { game_over = true; winner = 1; }
+ }
+ accumulator -= FIXED_DT;
+ }
+ devices.erase(std::remove_if(devices.begin(), devices.end(),
+ [](const Device& d){ return d.hp <= 0.0f; }), devices.end());
+ projectiles.erase(
+ std::remove_if(projectiles.begin(), projectiles.end(),
+ [](const Projectile& p){ return p.life <= 0.0f; }),
+ projectiles.end());
+ beams.erase(
+ std::remove_if(beams.begin(), beams.end(),
+ [](const Beam& b){ return b.time_left <= 0.0f; }),
+ beams.end());
+
+ // Spawn an explosion puff for everything the ground destroyed this frame.
+ for (const auto& d : graph.destroyed_events)
+ explosions.push_back({d.x, d.y, EXPLOSION_LIFE});
+ graph.destroyed_events.clear();
+
+ // Age out explosion puffs.
+ for (auto& ex : explosions) ex.life -= frame_dt;
+ explosions.erase(
+ std::remove_if(explosions.begin(), explosions.end(),
+ [](const Explosion& e){ return e.life <= 0.0f; }),
+ explosions.end());
+
+ // Destruction above may have removed nodes/edges, invalidating indices
+ // cached at frame start. Drop stale ones and re-query hover for rendering.
+ if (drag_node >= (int)graph.nodes.size()) { drag_node = -1; placing = false; }
+ if (extruding_edge >= (int)graph.edges.size()) extruding_edge = -1;
+ hover_node = graph.find_nearest_node(wx, wy, 0.5f);
+ hover_edge = (hover_node < 0) ? graph.find_nearest_edge(wx, wy, 0.4f) : -1;
+
+ // ---- Render ----
+ renderer.begin_frame(camera);
+
+ // Ground: solid fill for depth, then a tiled dirt strip along the surface.
+ renderer.draw_box(0.0f, graph.ground_level - 1.0e4f,
+ 1.0e5f, 1.0e4f, 0.0f,
+ 0.20f, 0.16f, 0.12f, 1.0f);
+ if (ground_tex) {
+ float halfvis = camera.viewport_width() / (2.0f * camera.zoom());
+ float left = camera.position().x - halfvis - 2.0f;
+ float right = camera.position().x + halfvis + 2.0f;
+ const float ts = 2.0f; // tile size (world units)
+ float startx = std::floor(left / ts) * ts + ts * 0.5f;
+ for (float cx = startx; cx < right; cx += ts)
+ renderer.draw_sprite(cx, graph.ground_level - ts * 0.5f,
+ ts * 0.5f, ts * 0.5f, 0.0f, ground_tex);
+ }
+
+ // Metal deposits (markers on the ground; mines must sit on these).
+ for (auto& d : map.deposits) {
+ if (deposit_tex) renderer.draw_sprite(d.first, d.second + 0.2f, 0.35f, 0.35f, 0.0f, deposit_tex);
+ else renderer.draw_box(d.first, d.second + 0.2f, 0.3f, 0.3f, 0.0f, 0.8f, 0.7f, 0.2f, 0.9f);
+ }
+
+ // Triangle faces FIRST — a dark cladding panel that sits BEHIND the
+ // struts (filling triangulated cells), so it never hides the edges.
+ for (const auto& f : graph.faces) {
+ auto& pa = graph.nodes[f.node_a];
+ auto& pb = graph.nodes[f.node_b];
+ auto& pc = graph.nodes[f.node_c];
+ SDL_Renderer* sdl = renderer.sdl_renderer();
+ auto to_scr = [&](float px, float py) -> SDL_FPoint {
+ float zoom = camera.zoom();
+ float ox = camera.viewport_width() * 0.5f - camera.position().x * zoom;
+ float oy = camera.viewport_height() * 0.5f + camera.position().y * zoom;
+ return {ox + px * zoom, oy - py * zoom};
+ };
+ auto p0 = to_scr(pa.x, pa.y);
+ auto p1 = to_scr(pb.x, pb.y);
+ auto p2 = to_scr(pc.x, pc.y);
+ // Dark, semi-transparent panel (SDL_FColor is 0..1 floats).
+ SDL_FColor fc = {0.10f, 0.07f, 0.05f, 0.75f};
+ SDL_Vertex verts[3] = {
+ {{p0.x, p0.y}, fc, {0,0}},
+ {{p1.x, p1.y}, fc, {0,0}},
+ {{p2.x, p2.y}, fc, {0,0}},
+ };
+ int idx[3] = {0, 1, 2};
+ SDL_RenderGeometry(sdl, NULL, verts, 3, idx, 3);
+ }
+
+ // Build edges — drawn ON TOP of the panels so struts stay visible.
+ // Textured if the material has one; tinted by stress (red compressed,
+ // blue tension) multiplied by the material colour.
+ for (const auto& e : graph.edges) {
+ auto& pa = graph.nodes[e.node_a];
+ auto& pb = graph.nodes[e.node_b];
+ float mx = (pa.x + pb.x) * 0.5f;
+ float my = (pa.y + pb.y) * 0.5f;
+ float dx = pb.x - pa.x;
+ float dy = pb.y - pa.y;
+ float len = std::sqrt(dx*dx + dy*dy);
+ float ang = std::atan2(dy, dx);
+
+ // stress tint (white -> red/blue), then modulated by material colour
+ float t = std::min(1.0f, std::fabs(e.stress) / 0.10f);
+ float tr = 1.0f, tg = 1.0f, tb = 1.0f;
+ if (e.stress < 0.0f) { tg = 1.0f - t; tb = 1.0f - t; } // compressed
+ else if (e.stress > 0.0f) { tr = 1.0f - t; tg = 1.0f - t; } // stretched
+ float cr = e.r * tr, cg = e.g * tg, cb = e.b * tb;
+ if (e.burning) { // fiery orange, flickering
+ float fl = 0.6f + 0.4f * std::sin((float)SDL_GetTicks() * 0.02f + mx);
+ cr = 1.0f; cg = 0.35f * fl; cb = 0.08f;
+ }
+
+ SDL_Texture* tex = (e.mat < (int)mat_tex.size()) ? mat_tex[e.mat] : nullptr;
+ if (tex) renderer.draw_sprite(mx, my, len * 0.5f, e.half_width, ang, tex, cr, cg, cb, e.a);
+ else renderer.draw_box (mx, my, len * 0.5f, e.half_width, ang, cr, cg, cb, e.a);
+ }
+
+ // Nodes
+ for (const auto& n : graph.nodes) {
+ float col = n.is_foundation ? 0.3f : 0.9f;
+ renderer.draw_box(n.x, n.y, 0.15f, 0.15f, 0.0f, col, col, col, 0.8f);
+ }
+
+ // Radial timer (yellow) while a fresh strut is curing (build grace).
+ for (const auto& e : graph.edges) {
+ if (e.settle <= 0.0f) continue;
+ auto& pa = graph.nodes[e.node_a];
+ auto& pb = graph.nodes[e.node_b];
+ float mx = (pa.x + pb.x) * 0.5f, my = (pa.y + pb.y) * 0.5f;
+ float frac = e.settle / BuildGraph::SETTLE_TIME;
+ renderer.draw_radial_timer(mx, my, 0.28f, frac, 1.0f, 0.75f, 0.15f, 0.85f);
+ }
+
+ // Preview during edge drag
+ if (placing && drag_node >= 0 && drag_node < (int)graph.nodes.size()) {
+ float px = graph.nodes[drag_node].x;
+ float py = graph.nodes[drag_node].y;
+ int target = graph.find_nearest_node(wx, wy, 0.5f);
+ float ex = wx, ey = wy;
+ if (target >= 0 && target != drag_node) {
+ ex = graph.nodes[target].x;
+ ey = graph.nodes[target].y;
+ }
+ const auto& m = graph.materials[cur_mat];
+ // Tint red if the link would be rejected (too short / too long).
+ float len = std::hypot(ex - px, ey - py);
+ bool valid = len >= m.min_length && len <= m.max_link_length;
+ float cr = valid ? m.r : 1.0f, cg = valid ? m.g : 0.2f, cb = valid ? m.b : 0.2f;
+ draw_preview_line(renderer, px, py, ex, ey, m.half_width, cr, cg, cb, 0.6f);
+ }
+
+ // Preview during extrusion — angled box + auto-brace diagonal
+ if (extruding_edge >= 0) {
+ auto* ee = graph.mutable_edge(extruding_edge);
+ if (ee) {
+ const auto& na = graph.nodes[ee->node_a];
+ const auto& nb = graph.nodes[ee->node_b];
+ float offx = wx - extrude_start_x, offy = wy - extrude_start_y;
+ // Clamp the preview drag to max_length (matches extrude_edge).
+ {
+ float ol = std::sqrt(offx*offx + offy*offy);
+ float maxlen = graph.materials[cur_mat].max_length;
+ if (ol > maxlen && ol > 1e-6f) { offx *= maxlen/ol; offy *= maxlen/ol; }
+ }
+ if (std::sqrt(offx*offx + offy*offy) > 0.1f) {
+ float cx = na.x + offx, cy = na.y + offy;
+ float dx = nb.x + offx, dy = nb.y + offy;
+ const auto& m = graph.materials[cur_mat];
+ float hw = m.half_width;
+ draw_preview_line(renderer, na.x, na.y, cx, cy, hw, m.r, m.g, m.b, 0.5f);
+ draw_preview_line(renderer, nb.x, nb.y, dx, dy, hw, m.r, m.g, m.b, 0.5f);
+ draw_preview_line(renderer, cx, cy, dx, dy, hw, m.r, m.g, m.b, 0.5f);
+
+ // brace preview: longer diagonal, if it clears the minimum
+ if (!graph.materials[cur_mat].tension_only) {
+ float l1 = std::hypot(dx - na.x, dy - na.y); // ea-nd
+ float l2 = std::hypot(cx - nb.x, cy - nb.y); // eb-nc
+ if (l1 >= l2) {
+ if (l1 >= BuildGraph::MIN_BRACE_LENGTH)
+ draw_preview_line(renderer, na.x, na.y, dx, dy, hw, m.r, m.g, m.b, 0.35f);
+ } else {
+ if (l2 >= BuildGraph::MIN_BRACE_LENGTH)
+ draw_preview_line(renderer, nb.x, nb.y, cx, cy, hw, m.r, m.g, m.b, 0.35f);
+ }
+ }
+ }
+ }
+ }
+
+ // Highlight hovered node/edge
+ if (hover_node >= 0 && !placing) {
+ auto& p = graph.nodes[hover_node];
+ renderer.draw_box(p.x, p.y, 0.22f, 0.22f, 0.0f, 1.0f, 0.8f, 0.2f, 0.8f);
+ }
+ if (hover_edge >= 0 && !placing && extruding_edge < 0) {
+ auto* he = graph.mutable_edge(hover_edge);
+ if (he) {
+ auto& pa = graph.nodes[he->node_a];
+ auto& pb = graph.nodes[he->node_b];
+ float mx = (pa.x+pb.x)*0.5f, my = (pa.y+pb.y)*0.5f;
+ float dx = pb.x-pa.x, dy = pb.y-pa.y;
+ float len = std::sqrt(dx*dx+dy*dy), ang = std::atan2(dy,dx);
+ renderer.draw_box(mx, my, len*0.5f, 0.18f, ang, 0.3f, 0.7f, 1.0f, 0.5f);
+ }
+ }
+
+ // Devices (reactors glow + pulse by team; turbines/mines/batteries) + HP bars.
+ for (const auto& dv : devices) {
+ const DeviceDef& def = device_defs[dv.def];
+ float tr = def.r, tg = def.g, tb = def.b;
+ if (def.is_core) {
+ float pulse = 0.7f + 0.3f * std::sin((float)SDL_GetTicks() * 0.006f);
+ if (dv.team == 0) { tr = 0.3f*pulse; tg = 1.0f*pulse; tb = 0.5f*pulse; }
+ else { tr = 1.0f*pulse; tg = 0.3f*pulse; tb = 0.3f*pulse; }
+ }
+ SDL_Texture* tex = dev_tex[dv.def];
+ if (tex) renderer.draw_sprite(dv.x, dv.y, def.half_w, def.half_h, 0.0f, tex, tr, tg, tb, 1.0f);
+ else renderer.draw_box(dv.x, dv.y, def.half_w, def.half_h, 0.0f, tr, tg, tb, 1.0f);
+ // HP bar above
+ float frac = dv.max_hp > 0.0f ? dv.hp / dv.max_hp : 0.0f;
+ float bw = def.half_w, by = dv.y + def.half_h + 0.2f;
+ renderer.draw_box(dv.x, by, bw, 0.06f, 0.0f, 0.1f, 0.1f, 0.1f, 0.85f);
+ float fh = bw * frac;
+ renderer.draw_box(dv.x - bw + fh, by, fh, 0.06f, 0.0f,
+ frac > 0.5f ? 0.2f : 0.9f, frac > 0.5f ? 0.9f : 0.3f, 0.2f, 0.9f);
+ }
+
+ // Device placement ghost.
+ if (place_dev >= 0 && place_dev < (int)device_defs.size()) {
+ const DeviceDef& def = device_defs[place_dev];
+ bool ok = (!def.needs_deposit || on_deposit(wx, wy)) &&
+ metal >= def.cost_metal && energy >= def.cost_energy;
+ renderer.draw_box(wx, wy, def.half_w, def.half_h, 0.0f,
+ ok ? 0.4f : 1.0f, ok ? 1.0f : 0.3f, 0.4f, 0.4f);
+ }
+
+ // Cannons: barrel (aimed) then turret base on top.
+ for (const auto& c : cannons) {
+ float bl = 1.0f;
+ float bx = c.x + std::cos(c.aim) * bl * 0.5f;
+ float by = c.y + std::sin(c.aim) * bl * 0.5f;
+ renderer.draw_box(bx, by, bl * 0.5f, 0.12f, c.aim, 0.30f, 0.30f, 0.34f, 1.0f);
+ if (turret_tex) renderer.draw_sprite(c.x, c.y, 0.5f, 0.5f, 0.0f, turret_tex);
+ else renderer.draw_box(c.x, c.y, 0.5f, 0.5f, 0.0f, 0.4f, 0.4f, 0.45f, 1.0f);
+ }
+
+ // Firing arc of the selected cannon (the cone of allowed directions).
+ // No trajectory is shown — you aim a direction within the arc.
+ if (selected_cannon >= 0 && !weapons.empty()) {
+ const Cannon& c = cannons[selected_cannon];
+ const WeaponDef& wpn = weapons[c.type];
+ renderer.draw_arc(c.x, c.y, 3.0f,
+ c.mount + wpn.min_fire_angle, c.mount + wpn.max_fire_angle,
+ 0.9f, 0.85f, 0.3f, 0.18f);
+ float ax = c.x + std::cos(c.aim) * 3.3f;
+ float ay = c.y + std::sin(c.aim) * 3.3f;
+ draw_preview_line(renderer, c.x, c.y, ax, ay, 0.05f, 1.0f, 0.9f, 0.3f, 0.7f);
+ }
+
+ // Laser beams (glow + bright core, from muzzle to hit point).
+ for (const auto& bm : beams) {
+ float a = std::min(1.0f, bm.time_left / 0.3f); // quick fade at the end
+ draw_preview_line(renderer, bm.ox, bm.oy, bm.hx, bm.hy, 0.14f, 1.0f, 0.35f, 0.25f, 0.35f * a);
+ draw_preview_line(renderer, bm.ox, bm.oy, bm.hx, bm.hy, 0.05f, 1.0f, 0.85f, 0.5f, 0.95f * a);
+ renderer.draw_box(bm.hx, bm.hy, 0.2f, 0.2f, 0.0f, 1.0f, 0.9f, 0.5f, 0.6f * a);
+ }
+
+ // Projectiles (shell sprite oriented along velocity).
+ for (const auto& p : projectiles) {
+ float ang = std::atan2(p.vy, p.vx);
+ if (shell_tex) renderer.draw_sprite(p.x, p.y, 0.32f, 0.1f, ang, shell_tex);
+ else renderer.draw_box(p.x, p.y, p.radius, p.radius, 0.0f, 1.0f, 0.8f, 0.3f, 1.0f);
+ }
+
+ // Explosion puffs (expanding, fading) where debris hit the ground.
+ for (const auto& ex : explosions) {
+ float t = 1.0f - ex.life / EXPLOSION_LIFE; // 0 -> 1 over lifetime
+ float radius = 0.2f + t * 0.9f;
+ float alpha = (1.0f - t) * 0.8f;
+ renderer.draw_radial_timer(ex.x, ex.y, radius, 1.0f,
+ 1.0f, 0.55f, 0.12f, alpha);
+ }
+
+ // HUD (resources vs current caps)
+ renderer.draw_hud_bar(10, 10, mcap > 0 ? metal / mcap : 0.0f, 0.8f, 0.65f, 0.2f);
+ renderer.draw_hud_bar(10, 28, ecap > 0 ? energy / ecap : 0.0f, 0.2f, 0.5f, 0.9f);
+ if (selected_cannon >= 0 && !weapons.empty()) {
+ // reload gauge of the selected cannon (fills as it reloads)
+ float rt = weapons[cannons[selected_cannon].type].reload;
+ float rl = rt > 0.0f ? 1.0f - cannons[selected_cannon].cooldown / rt : 1.0f;
+ renderer.draw_hud_bar(10, 46, rl, 0.9f, 0.3f, 0.2f);
+ } else {
+ const auto& curm = graph.materials[cur_mat];
+ renderer.draw_hud_bar(10, 46, 0.3f, curm.r, curm.g, curm.b);
+ }
+
+ // ---- Dev UI (ImGui) — live tuning of the (unverified) constants ----
+ {
+ int burning = 0;
+ for (const auto& e : graph.edges) if (e.burning) burning++;
+ ImGuiIO& io = ImGui::GetIO();
+ ImGui::Begin("Dev");
+ ImGui::Text("%.0f FPS (%.2f ms)", io.Framerate, 1000.0f / io.Framerate);
+ ImGui::Text("nodes %zu edges %zu devices %zu", graph.nodes.size(),
+ graph.edges.size(), devices.size());
+ ImGui::Text("proj %zu beams %zu burning %d",
+ projectiles.size(), beams.size(), burning);
+ ImGui::Text("metal %.0f/%.0f energy %.0f/%.0f", metal, mcap, energy, ecap);
+ ImGui::Separator();
+ // Build palette — current tool.
+ const char* tool = place_dev >= 0 ? device_defs[place_dev].name.c_str()
+ : selected_cannon >= 0 ? "weapon" : graph.materials[cur_mat].name.c_str();
+ ImGui::Text("tool: %s", tool);
+ if (ImGui::Button("wood")) { cur_mat = 0; place_dev = -1; selected_cannon = -1; }
+ ImGui::SameLine(); if (ImGui::Button("bg")) { cur_mat = 1; place_dev = -1; selected_cannon = -1; }
+ ImGui::SameLine(); if (ImGui::Button("rope")) { cur_mat = 2; place_dev = -1; selected_cannon = -1; }
+ if (turbine_i >= 0 && ImGui::Button("turbine")) { place_dev = turbine_i; selected_cannon = -1; }
+ if (mine_i >= 0) { ImGui::SameLine(); if (ImGui::Button("mine")) { place_dev = mine_i; selected_cannon = -1; } }
+ if (battery_i >= 0) { ImGui::SameLine(); if (ImGui::Button("battery")) { place_dev = battery_i; selected_cannon = -1; } }
+ if (ImGui::Button("restart (R)")) reset();
+ ImGui::Separator();
+ if (ImGui::CollapsingHeader("Physics", ImGuiTreeNodeFlags_DefaultOpen)) {
+ ImGui::SliderFloat("gravity", &graph.gravity, 0.0f, 40.0f);
+ ImGui::SliderFloat("air drag", &graph.air_drag, 0.0f, 3.0f);
+ }
+ if (ImGui::CollapsingHeader("Materials")) {
+ for (auto& m : graph.materials) {
+ ImGui::PushID(&m);
+ if (ImGui::TreeNode(m.name.c_str())) {
+ ImGui::SliderFloat("stiffness", &m.stiffness, 100.0f, 3000.0f);
+ ImGui::SliderFloat("damping", &m.damping, 0.0f, 60.0f);
+ ImGui::SliderFloat("hit_points", &m.hit_points, 10.0f, 600.0f);
+ ImGui::SliderFloat("burn_rate", &m.burn_rate, 0.0f, 60.0f);
+ ImGui::SliderFloat("spread_time", &m.spread_time, 0.1f, 5.0f);
+ ImGui::TreePop();
+ }
+ ImGui::PopID();
+ }
+ }
+ if (ImGui::CollapsingHeader("Weapons")) {
+ for (auto& wpn : weapons) {
+ ImGui::PushID(&wpn);
+ if (ImGui::TreeNode(wpn.name.c_str())) {
+ ImGui::SliderFloat("reload", &wpn.reload, 0.1f, 6.0f);
+ if (wpn.is_beam) {
+ ImGui::SliderFloat("beam_dps", &wpn.beam_dps, 10.0f, 300.0f);
+ ImGui::SliderFloat("beam_duration", &wpn.beam_duration, 0.2f, 4.0f);
+ ImGui::SliderFloat("beam_range", &wpn.beam_range, 10.0f, 120.0f);
+ } else {
+ ImGui::SliderFloat("muzzle_speed", &wpn.muzzle_speed, 10.0f, 100.0f);
+ ImGui::SliderFloat("splash_damage", &wpn.splash_damage, 20.0f, 500.0f);
+ ImGui::SliderFloat("splash_radius", &wpn.splash_radius, 0.5f, 8.0f);
+ ImGui::SliderFloat("knockback", &wpn.knockback, 0.0f, 150.0f);
+ ImGui::SliderFloat("fire_chance", &wpn.fire_chance, 0.0f, 1.0f);
+ }
+ float mn = wpn.min_fire_angle * 57.2958f;
+ float mx = wpn.max_fire_angle * 57.2958f;
+ if (ImGui::SliderFloat("min angle", &mn, -90.0f, 90.0f)) wpn.min_fire_angle = mn / 57.2958f;
+ if (ImGui::SliderFloat("max angle", &mx, -90.0f, 90.0f)) wpn.max_fire_angle = mx / 57.2958f;
+ ImGui::TreePop();
+ }
+ ImGui::PopID();
+ }
+ }
+ ImGui::TextWrapped("Build: 1/2/3 wood/bg/rope, 5/6/7 turbine/mine/battery. "
+ "Weapons: click a turret, aim in the arc, click to fire. "
+ "Esc deselects. R restarts.");
+ ImGui::End();
+
+ // Win/loss banner.
+ if (game_over) {
+ ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.35f),
+ ImGuiCond_Always, ImVec2(0.5f, 0.5f));
+ ImGui::Begin("gameover", nullptr,
+ ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
+ ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove);
+ ImGui::Text("%s", winner == 0 ? "REACTOR DESTROYED — YOU WIN"
+ : "YOUR REACTOR IS DOWN — YOU LOSE");
+ ImGui::Text("Press R to restart");
+ ImGui::End();
+ }
+ }
+
+ renderer.end_frame();
+ }
+
+ renderer.shutdown();
+}
+
+void App::update(float /*dt*/) {}
+void App::render() {}
diff --git a/src/engine/app.hpp b/src/engine/app.hpp
new file mode 100644
index 0000000..a57fdbe
--- /dev/null
+++ b/src/engine/app.hpp
@@ -0,0 +1,29 @@
+#pragma once
+
+#include <SDL3/SDL.h>
+
+struct AppConfig {
+ const char* title = "Forts Clone";
+ int window_width = 1280;
+ int window_height = 720;
+};
+
+class App {
+public:
+ explicit App(const AppConfig& cfg = {});
+ ~App();
+
+ App(const App&) = delete;
+ App& operator=(const App&) = delete;
+
+ void run();
+
+private:
+ void update(float dt);
+ void render();
+
+ SDL_Window* window_ = nullptr;
+ AppConfig config_;
+ bool running_ = true;
+ uint64_t last_tick_ = 0;
+};
diff --git a/src/engine/camera.cpp b/src/engine/camera.cpp
new file mode 100644
index 0000000..6ab2c57
--- /dev/null
+++ b/src/engine/camera.cpp
@@ -0,0 +1,55 @@
+#include "engine/camera.hpp"
+
+void Camera::set_viewport(int width, int height) {
+ vp_width_ = width;
+ vp_height_ = height;
+}
+
+void Camera::set_position(glm::vec2 pos) {
+ pos_ = pos;
+}
+
+void Camera::set_zoom(float zoom) {
+ if (zoom < min_zoom) zoom = min_zoom;
+ if (zoom > max_zoom) zoom = max_zoom;
+ zoom_ = zoom;
+}
+
+void Camera::pan(glm::vec2 delta) {
+ pos_ += delta;
+}
+
+void Camera::zoom_at(float delta, glm::vec2 world_cursor) {
+ float new_zoom = zoom_ * (1.0f + delta * zoom_speed);
+ if (new_zoom < min_zoom) new_zoom = min_zoom;
+ if (new_zoom > max_zoom) new_zoom = max_zoom;
+
+ // Adjust position so world_cursor stays under the same screen point
+ float ratio = zoom_ / new_zoom;
+ pos_ = world_cursor + (pos_ - world_cursor) * ratio;
+ zoom_ = new_zoom;
+}
+
+void Camera::compute_view_proj() {
+ float half_w = (vp_width_ / zoom_) * 0.5f;
+ float half_h = (vp_height_ / zoom_) * 0.5f;
+
+ proj_ = glm::ortho(-half_w, half_w, -half_h, half_h, -1.0f, 1.0f);
+ view_ = glm::translate(glm::mat4(1.0f), glm::vec3(-pos_.x, -pos_.y, 0.0f));
+
+ glm::mat4 vp = proj_ * view_;
+
+ // Copy to column-major float array for bgfx
+ const float* src = &vp[0][0];
+ float* dst = &vp_[0][0];
+ for (int i = 0; i < 16; i++) {
+ dst[i] = src[i];
+ }
+}
+
+glm::vec2 Camera::screen_to_world(glm::vec2 screen) const {
+ // Convert from screen (y-down) to world (y-up)
+ float world_x = pos_.x + (screen.x - vp_width_ * 0.5f) / zoom_;
+ float world_y = pos_.y - (screen.y - vp_height_ * 0.5f) / zoom_;
+ return glm::vec2(world_x, world_y);
+}
diff --git a/src/engine/camera.hpp b/src/engine/camera.hpp
new file mode 100644
index 0000000..ecaadc1
--- /dev/null
+++ b/src/engine/camera.hpp
@@ -0,0 +1,59 @@
+#pragma once
+
+#include <glm/glm.hpp>
+#include <glm/gtc/matrix_transform.hpp>
+
+// 2D orthographic camera. Works in world-space coordinates (Box2D space).
+// x+ = right, y+ = up.
+
+class Camera {
+public:
+ Camera() = default;
+
+ // Set the viewport size in pixels (window size).
+ void set_viewport(int width, int height);
+
+ // World-space position the camera is looking at.
+ void set_position(glm::vec2 pos);
+ glm::vec2 position() const { return pos_; }
+
+ // Zoom: higher = more zoomed in. Clamped to [min_zoom, max_zoom].
+ void set_zoom(float zoom);
+ float zoom() const { return zoom_; }
+
+ // Pan by delta in world units (not pixels).
+ void pan(glm::vec2 delta);
+
+ // Zoom by delta at the given screen position (world-space under cursor).
+ void zoom_at(float delta, glm::vec2 world_cursor);
+
+ // Build view-projection matrix. Call once per frame.
+ void compute_view_proj();
+
+ const float* view_proj() const { return &vp_[0][0]; }
+ glm::mat4 view() const { return view_; }
+ glm::mat4 proj() const { return proj_; }
+
+ // Convert screen coordinates (pixels, y-down) to world coordinates.
+ glm::vec2 screen_to_world(glm::vec2 screen) const;
+
+ int viewport_width() const { return vp_width_; }
+ int viewport_height() const { return vp_height_; }
+
+ // Config
+ float min_zoom = 0.1f;
+ float max_zoom = 200.0f;
+ float zoom_speed = 0.1f;
+ float pan_speed = 500.0f; // pixels per second
+
+private:
+ glm::vec2 pos_ = {0.0f, 5.0f}; // world center at start
+ float zoom_ = 10.0f; // pixels per world unit
+
+ int vp_width_ = 1280;
+ int vp_height_ = 720;
+
+ glm::mat4 view_ = glm::mat4(1.0f);
+ glm::mat4 proj_ = glm::mat4(1.0f);
+ float vp_[4][4] = {}; // column-major for bgfx
+};
diff --git a/src/engine/input.cpp b/src/engine/input.cpp
new file mode 100644
index 0000000..ffa8076
--- /dev/null
+++ b/src/engine/input.cpp
@@ -0,0 +1,101 @@
+#include "engine/input.hpp"
+#include "imgui_impl_sdl3.h"
+
+void Input::begin_frame() {
+ // Save previous state
+ for (int i = 0; i < SDL_SCANCODE_COUNT; i++) {
+ key_prev_[i] = key_state_[i];
+ key_just_pressed_[i] = false;
+ key_just_released_[i] = false;
+ }
+ mouse_prev_ = mouse_pos_;
+ mouse_delta_ = glm::vec2(0.0f);
+ scroll_ = 0.0f;
+ for (int i = 0; i < 8; i++) {
+ mouse_prev_state_[i] = mouse_state_[i];
+ mouse_pressed_[i] = false;
+ mouse_released_[i] = false;
+ }
+
+ // Pump SDL events
+ SDL_Event event;
+ while (SDL_PollEvent(&event)) {
+ ImGui_ImplSDL3_ProcessEvent(&event); // let the dev UI see input too
+ switch (event.type) {
+ case SDL_EVENT_QUIT:
+ quit_ = true;
+ break;
+
+ case SDL_EVENT_KEY_DOWN: {
+ auto sc = event.key.scancode;
+ if (sc < SDL_SCANCODE_COUNT) {
+ if (!key_state_[sc]) key_just_pressed_[sc] = true;
+ key_state_[sc] = true;
+ }
+ break;
+ }
+ case SDL_EVENT_KEY_UP: {
+ auto sc = event.key.scancode;
+ if (sc < SDL_SCANCODE_COUNT) {
+ key_just_released_[sc] = true;
+ key_state_[sc] = false;
+ }
+ break;
+ }
+
+ case SDL_EVENT_MOUSE_MOTION:
+ mouse_pos_ = glm::vec2(event.motion.x, event.motion.y);
+ mouse_delta_ = glm::vec2(event.motion.xrel, event.motion.yrel);
+ break;
+
+ case SDL_EVENT_MOUSE_BUTTON_DOWN:
+ if (event.button.button < 8) {
+ if (!mouse_state_[event.button.button])
+ mouse_pressed_[event.button.button] = true;
+ mouse_state_[event.button.button] = true;
+ }
+ break;
+ case SDL_EVENT_MOUSE_BUTTON_UP:
+ if (event.button.button < 8) {
+ mouse_released_[event.button.button] = true;
+ mouse_state_[event.button.button] = false;
+ }
+ break;
+
+ case SDL_EVENT_MOUSE_WHEEL:
+ scroll_ = event.wheel.y;
+ break;
+
+ default:
+ break;
+ }
+ }
+}
+
+bool Input::key_down(SDL_Keycode key) const {
+ return key_state_[SDL_GetScancodeFromKey(key, nullptr)];
+}
+
+bool Input::key_pressed(SDL_Keycode key) const {
+ return key_just_pressed_[SDL_GetScancodeFromKey(key, nullptr)];
+}
+
+bool Input::key_released(SDL_Keycode key) const {
+ return key_just_released_[SDL_GetScancodeFromKey(key, nullptr)];
+}
+
+glm::vec2 Input::mouse_pos() const { return mouse_pos_; }
+glm::vec2 Input::mouse_delta() const { return mouse_delta_; }
+float Input::scroll_delta() const { return scroll_; }
+
+bool Input::mouse_down(int button) const {
+ return button < 8 && mouse_state_[button];
+}
+
+bool Input::mouse_pressed(int button) const {
+ return button < 8 && mouse_pressed_[button];
+}
+
+bool Input::mouse_released(int button) const {
+ return button < 8 && mouse_released_[button];
+}
diff --git a/src/engine/input.hpp b/src/engine/input.hpp
new file mode 100644
index 0000000..764ca96
--- /dev/null
+++ b/src/engine/input.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include <SDL3/SDL.h>
+#include <glm/glm.hpp>
+
+// Simple polling-based input state. Call begin_frame() once per frame,
+// then query state during update.
+
+class Input {
+public:
+ // Call once per frame BEFORE reading any input state.
+ // Processes the SDL event queue and updates internal state.
+ void begin_frame();
+
+ // Keyboard
+
+ bool key_down(SDL_Keycode key) const;
+ bool key_pressed(SDL_Keycode key) const; // true only on the frame it went down
+ bool key_released(SDL_Keycode key) const; // true only on the frame it went up
+
+ // Mouse
+
+ glm::vec2 mouse_pos() const; // in window coordinates
+ glm::vec2 mouse_delta() const; // movement since last frame
+ float scroll_delta() const; // vertical scroll (positive = scroll up)
+
+ bool mouse_down(int button) const; // SDL_BUTTON_LEFT, MIDDLE, RIGHT
+ bool mouse_pressed(int button) const;
+ bool mouse_released(int button) const;
+
+ // Window
+
+ bool quit_requested() const { return quit_; }
+
+private:
+ // Keyboard
+ bool key_state_[SDL_SCANCODE_COUNT] = {};
+ bool key_prev_[SDL_SCANCODE_COUNT] = {};
+ bool key_just_pressed_[SDL_SCANCODE_COUNT] = {};
+ bool key_just_released_[SDL_SCANCODE_COUNT] = {};
+
+ // Mouse
+ glm::vec2 mouse_pos_ = {0.0f, 0.0f};
+ glm::vec2 mouse_prev_ = {0.0f, 0.0f};
+ glm::vec2 mouse_delta_ = {0.0f, 0.0f};
+ float scroll_ = 0.0f;
+ bool mouse_state_[8] = {};
+ bool mouse_prev_state_[8] = {};
+ bool mouse_pressed_[8] = {};
+ bool mouse_released_[8] = {};
+
+ bool quit_ = false;
+};
diff --git a/src/engine/physics.cpp b/src/engine/physics.cpp
new file mode 100644
index 0000000..af5d8ee
--- /dev/null
+++ b/src/engine/physics.cpp
@@ -0,0 +1,70 @@
+#include "engine/physics.hpp"
+#include <cstdio>
+
+PhysicsWorld::PhysicsWorld() {
+ b2WorldDef def = b2DefaultWorldDef();
+ def.gravity = {0.0f, -10.0f};
+ world_id_ = b2CreateWorld(&def);
+ printf("Physics: Box2D world created\n");
+}
+
+PhysicsWorld::~PhysicsWorld() {
+ b2DestroyWorld(world_id_);
+}
+
+void PhysicsWorld::step(float dt) {
+ // High sub-step count for stable distance joint networks
+ b2World_Step(world_id_, dt, 8);
+}
+
+PhysicsBody* PhysicsWorld::add_static_box(b2Vec2 center,
+ float half_w, float half_h,
+ float r, float g, float b) {
+ b2BodyDef body_def = b2DefaultBodyDef();
+ body_def.type = b2_staticBody;
+ body_def.position = center;
+
+ b2BodyId body_id = b2CreateBody(world_id_, &body_def);
+
+ b2ShapeDef shape_def = b2DefaultShapeDef();
+ b2Polygon box = b2MakeBox(half_w, half_h);
+ b2CreatePolygonShape(body_id, &shape_def, &box);
+
+ PhysicsBody pb;
+ pb.id = body_id;
+ pb.size = {half_w, half_h};
+ pb.red = r;
+ pb.green = g;
+ pb.blue = b;
+ pb.alpha = 1.0f;
+ pb.is_static = true;
+ bodies_.push_back(pb);
+ return &bodies_.back();
+}
+
+PhysicsBody* PhysicsWorld::add_dynamic_box(b2Vec2 center,
+ float half_w, float half_h,
+ float r, float g, float b) {
+ b2BodyDef body_def = b2DefaultBodyDef();
+ body_def.type = b2_dynamicBody;
+ body_def.position = center;
+
+ b2BodyId body_id = b2CreateBody(world_id_, &body_def);
+
+ b2ShapeDef shape_def = b2DefaultShapeDef();
+ shape_def.density = 1.0f;
+ shape_def.material.friction = 0.3f;
+ b2Polygon box = b2MakeBox(half_w, half_h);
+ b2CreatePolygonShape(body_id, &shape_def, &box);
+
+ PhysicsBody pb;
+ pb.id = body_id;
+ pb.size = {half_w, half_h};
+ pb.red = r;
+ pb.green = g;
+ pb.blue = b;
+ pb.alpha = 1.0f;
+ pb.is_static = false;
+ bodies_.push_back(pb);
+ return &bodies_.back();
+}
diff --git a/src/engine/physics.hpp b/src/engine/physics.hpp
new file mode 100644
index 0000000..6a8abda
--- /dev/null
+++ b/src/engine/physics.hpp
@@ -0,0 +1,41 @@
+#pragma once
+
+#include <box2d/box2d.h>
+#include <vector>
+
+// Box2D rigid-body world. The building structure runs on the custom Verlet
+// solver (see build_graph / verlet); this exists as the foundation for M4
+// projectiles and any terrain collision they need.
+struct PhysicsBody {
+ b2BodyId id;
+ b2Vec2 size; // half-width, half-height for box shapes
+ float red, green, blue, alpha;
+ bool is_static;
+};
+
+class PhysicsWorld {
+public:
+ PhysicsWorld();
+ ~PhysicsWorld();
+
+ PhysicsWorld(const PhysicsWorld&) = delete;
+ PhysicsWorld& operator=(const PhysicsWorld&) = delete;
+
+ void step(float dt);
+
+ // Create a static box (terrain, walls).
+ PhysicsBody* add_static_box(b2Vec2 center, float half_w, float half_h,
+ float r = 0.3f, float g = 0.3f, float b = 0.3f);
+
+ // Create a dynamic box (projectiles, debris).
+ PhysicsBody* add_dynamic_box(b2Vec2 center, float half_w, float half_h,
+ float r = 0.8f, float g = 0.6f, float b = 0.2f);
+
+ const std::vector<PhysicsBody>& bodies() const { return bodies_; }
+
+ b2WorldId world_id() const { return world_id_; }
+
+private:
+ b2WorldId world_id_;
+ std::vector<PhysicsBody> bodies_;
+};
diff --git a/src/engine/renderer.cpp b/src/engine/renderer.cpp
new file mode 100644
index 0000000..0be0e53
--- /dev/null
+++ b/src/engine/renderer.cpp
@@ -0,0 +1,285 @@
+#include "engine/renderer.hpp"
+#include "engine/camera.hpp"
+#include "stb_image.h"
+#include "imgui.h"
+#include "imgui_impl_sdl3.h"
+#include "imgui_impl_sdlrenderer3.h"
+
+#include <cstdio>
+#include <cstdlib>
+#include <cmath>
+#include <vector>
+#include <algorithm>
+
+bool Renderer::init(SDL_Window* window, int width, int height) {
+ width_ = width;
+ height_ = height;
+
+ sdl_renderer_ = SDL_CreateRenderer(window, NULL);
+ if (!sdl_renderer_) {
+ fprintf(stderr, "SDL_CreateRenderer failed: %s\n", SDL_GetError());
+ return false;
+ }
+
+ SDL_SetRenderDrawColor(sdl_renderer_, 0x1a, 0x1a, 0x2e, 0xff);
+
+ const char* name = SDL_GetRendererName(sdl_renderer_);
+ printf("Renderer: %s %dx%d\n", name ? name : "unknown", width, height);
+
+ // Dear ImGui (dev UI).
+ IMGUI_CHECKVERSION();
+ ImGui::CreateContext();
+ ImGui::StyleColorsDark();
+ if (ImGui_ImplSDL3_InitForSDLRenderer(window, sdl_renderer_) &&
+ ImGui_ImplSDLRenderer3_Init(sdl_renderer_)) {
+ imgui_ready_ = true;
+ }
+ return true;
+}
+
+void Renderer::shutdown() {
+ if (imgui_ready_) {
+ ImGui_ImplSDLRenderer3_Shutdown();
+ ImGui_ImplSDL3_Shutdown();
+ ImGui::DestroyContext();
+ imgui_ready_ = false;
+ }
+ if (sdl_renderer_) { SDL_DestroyRenderer(sdl_renderer_); sdl_renderer_ = nullptr; }
+}
+
+void Renderer::begin_frame(Camera& camera) {
+ current_cam_ = &camera;
+ SDL_SetRenderDrawColor(sdl_renderer_, 0x1a, 0x1a, 0x2e, 0xff);
+ SDL_RenderClear(sdl_renderer_);
+ if (imgui_ready_) {
+ ImGui_ImplSDLRenderer3_NewFrame();
+ ImGui_ImplSDL3_NewFrame();
+ ImGui::NewFrame();
+ }
+}
+
+void Renderer::end_frame() {
+ if (imgui_ready_) {
+ ImGui::Render();
+ ImGui_ImplSDLRenderer3_RenderDrawData(ImGui::GetDrawData(), sdl_renderer_);
+ }
+ SDL_RenderPresent(sdl_renderer_);
+ current_cam_ = nullptr;
+}
+
+void Renderer::resize(int width, int height) {
+ width_ = width;
+ height_ = height;
+}
+
+void Renderer::draw_box(float cx, float cy,
+ float half_w, float half_h,
+ float angle,
+ float r, float g, float b, float a) {
+ if (!current_cam_) return;
+
+ // Convert world coords to screen pixels
+ const auto& cam = *current_cam_;
+ float zoom = cam.zoom();
+ float vp_w = static_cast<float>(cam.viewport_width());
+ float vp_h = static_cast<float>(cam.viewport_height());
+
+ // Screen position of world origin
+ float ox = vp_w * 0.5f - cam.position().x * zoom;
+ float oy = vp_h * 0.5f + cam.position().y * zoom;
+
+ // Box center in screen space
+ float sx = ox + cx * zoom;
+ float sy = oy - cy * zoom; // y flips
+
+ // Box half-size in screen pixels
+ float shw = half_w * zoom;
+ float shh = half_h * zoom;
+
+ SDL_SetRenderDrawColor(sdl_renderer_,
+ static_cast<uint8_t>(r * 255),
+ static_cast<uint8_t>(g * 255),
+ static_cast<uint8_t>(b * 255),
+ static_cast<uint8_t>(a * 255));
+
+ if (angle == 0.0f) {
+ // Fast path: axis-aligned rect
+ SDL_FRect rect = {
+ sx - shw, sy - shh,
+ shw * 2.0f, shh * 2.0f
+ };
+ SDL_RenderFillRect(sdl_renderer_, &rect);
+ } else {
+ // Rotated: use SDL_RenderGeometry with 4 vertices
+ float ca = std::cos(angle);
+ float sa = std::sin(angle);
+
+ SDL_Vertex verts[4];
+ float corners[4][2] = {
+ {-half_w, -half_h}, { half_w, -half_h},
+ { half_w, half_h}, {-half_w, half_h}
+ };
+ for (int i = 0; i < 4; i++) {
+ float rx = corners[i][0] * ca - corners[i][1] * sa;
+ float ry = corners[i][0] * sa + corners[i][1] * ca;
+ verts[i].position.x = ox + (cx + rx) * zoom;
+ verts[i].position.y = oy - (cy + ry) * zoom;
+ // SDL_Vertex.color is SDL_FColor (0..1 floats), NOT 0..255.
+ verts[i].color = {r, g, b, a};
+ }
+ int indices[6] = {0, 1, 2, 0, 2, 3};
+ SDL_RenderGeometry(sdl_renderer_, NULL, verts, 4, indices, 6);
+ }
+}
+
+SDL_Texture* Renderer::load_texture(const std::string& path) {
+ auto it = tex_cache_.find(path);
+ if (it != tex_cache_.end()) return it->second;
+
+ // "forts:REL" resolves to the user's own Forts install (bring-your-own game
+ // files, like a Doom source port + WAD). Nothing is copied into the repo.
+ std::string file = path;
+ if (path.rfind("forts:", 0) == 0) {
+ const char* env = std::getenv("FORTS_DATA");
+ std::string root = env ? env
+ : "/mnt/games/SteamLibrary/steamapps/common/Forts/data";
+ file = root + "/" + path.substr(6);
+ }
+
+ int w = 0, h = 0, n = 0;
+ unsigned char* px = stbi_load(file.c_str(), &w, &h, &n, 4);
+ SDL_Texture* tex = nullptr;
+ if (px) {
+ tex = SDL_CreateTexture(sdl_renderer_, SDL_PIXELFORMAT_RGBA32,
+ SDL_TEXTUREACCESS_STATIC, w, h);
+ if (tex) {
+ SDL_UpdateTexture(tex, nullptr, px, w * 4);
+ SDL_SetTextureBlendMode(tex, SDL_BLENDMODE_BLEND);
+ SDL_SetTextureScaleMode(tex, SDL_SCALEMODE_LINEAR);
+ }
+ stbi_image_free(px);
+ printf("Texture: loaded %s (%dx%d)\n", path.c_str(), w, h);
+ } else {
+ fprintf(stderr, "Texture: FAILED to load %s\n", path.c_str());
+ }
+ tex_cache_[path] = tex; // cache even nullptr so we don't retry every frame
+ return tex;
+}
+
+void Renderer::draw_sprite(float cx, float cy, float half_w, float half_h,
+ float angle, SDL_Texture* tex,
+ float r, float g, float b, float a) {
+ if (!current_cam_ || !tex) return;
+ const auto& cam = *current_cam_;
+ float zoom = cam.zoom();
+ float ox = cam.viewport_width() * 0.5f - cam.position().x * zoom;
+ float oy = cam.viewport_height() * 0.5f + cam.position().y * zoom;
+ float ca = std::cos(angle), sa = std::sin(angle);
+
+ const float corners[4][2] = {
+ {-half_w, -half_h}, { half_w, -half_h}, { half_w, half_h}, {-half_w, half_h}
+ };
+ const float uv[4][2] = { {0,1}, {1,1}, {1,0}, {0,0} }; // flip V (screen y is down)
+ SDL_FColor col = {r, g, b, a};
+ SDL_Vertex v[4];
+ for (int i = 0; i < 4; i++) {
+ float rx = corners[i][0]*ca - corners[i][1]*sa;
+ float ry = corners[i][0]*sa + corners[i][1]*ca;
+ v[i].position = { ox + (cx + rx) * zoom, oy - (cy + ry) * zoom };
+ v[i].color = col;
+ v[i].tex_coord = { uv[i][0], uv[i][1] };
+ }
+ int idx[6] = {0, 1, 2, 0, 2, 3};
+ SDL_RenderGeometry(sdl_renderer_, tex, v, 4, idx, 6);
+}
+
+void Renderer::draw_arc(float cx, float cy, float radius_world,
+ float a0, float a1, float r, float g, float b, float a) {
+ if (!current_cam_) return;
+ const auto& cam = *current_cam_;
+ float zoom = cam.zoom();
+ float ox = cam.viewport_width() * 0.5f - cam.position().x * zoom;
+ float oy = cam.viewport_height() * 0.5f + cam.position().y * zoom;
+ float scx = ox + cx * zoom, scy = oy - cy * zoom; // centre (screen)
+
+ const float PI = 3.14159265358979323846f;
+ int n = std::max(1, (int)(std::fabs(a1 - a0) / (2.0f * PI) * 64.0f) + 1);
+ SDL_FColor col = {r, g, b, a};
+ fan_scratch_.clear();
+ for (int i = 0; i < n; i++) {
+ float t0 = a0 + (a1 - a0) * (float)i / n;
+ float t1 = a0 + (a1 - a0) * (float)(i + 1) / n;
+ // world direction (y up) -> screen (y down)
+ SDL_Vertex c { {scx, scy}, col, {0, 0} };
+ SDL_Vertex p0{ {scx + std::cos(t0) * radius_world * zoom,
+ scy - std::sin(t0) * radius_world * zoom}, col, {0, 0} };
+ SDL_Vertex p1{ {scx + std::cos(t1) * radius_world * zoom,
+ scy - std::sin(t1) * radius_world * zoom}, col, {0, 0} };
+ fan_scratch_.push_back(c); fan_scratch_.push_back(p0); fan_scratch_.push_back(p1);
+ }
+ SDL_RenderGeometry(sdl_renderer_, nullptr, fan_scratch_.data(),
+ (int)fan_scratch_.size(), nullptr, 0);
+}
+
+void Renderer::draw_radial_timer(float cx, float cy, float radius_world,
+ float frac,
+ float r, float g, float b, float a) {
+ if (!current_cam_) return;
+ if (frac < 0.0f) frac = 0.0f;
+ if (frac > 1.0f) frac = 1.0f;
+
+ const auto& cam = *current_cam_;
+ float zoom = cam.zoom();
+ float vp_w = static_cast<float>(cam.viewport_width());
+ float vp_h = static_cast<float>(cam.viewport_height());
+ float ox = vp_w * 0.5f - cam.position().x * zoom;
+ float oy = vp_h * 0.5f + cam.position().y * zoom;
+ float sx = ox + cx * zoom;
+ float sy = oy - cy * zoom;
+ float rad = radius_world * zoom;
+
+ const int SEG = 32;
+ const float PI = 3.14159265358979323846f;
+
+ auto fan = [&](float start, float sweep, SDL_FColor col) {
+ if (sweep <= 0.0f) return;
+ int n = std::max(1, (int)(SEG * (sweep / (2.0f * PI)) + 0.5f));
+ fan_scratch_.clear(); // reused buffer, no per-call alloc
+ for (int i = 0; i < n; i++) {
+ float a0 = start + sweep * (float)i / n;
+ float a1 = start + sweep * (float)(i + 1) / n;
+ fan_scratch_.push_back({ {sx, sy}, col, {0, 0} });
+ fan_scratch_.push_back({ {sx + std::cos(a0) * rad, sy + std::sin(a0) * rad}, col, {0, 0} });
+ fan_scratch_.push_back({ {sx + std::cos(a1) * rad, sy + std::sin(a1) * rad}, col, {0, 0} });
+ }
+ SDL_RenderGeometry(sdl_renderer_, NULL, fan_scratch_.data(),
+ (int)fan_scratch_.size(), NULL, 0);
+ };
+
+ // Dim background ring (full circle), then the remaining wedge on top.
+ // Start at top (-90deg) and sweep clockwise (screen y is down).
+ fan(-PI * 0.5f, 2.0f * PI, SDL_FColor{0.0f, 0.0f, 0.0f, 0.45f});
+ fan(-PI * 0.5f, 2.0f * PI * frac, SDL_FColor{r, g, b, a});
+}
+
+void Renderer::draw_hud_bar(float x, float y, float fill,
+ float r, float g, float b) {
+ const float BAR_W = 100.0f;
+ const float BAR_H = 14.0f;
+ const float PAD = 2.0f;
+
+ SDL_FRect bg = {x, y, BAR_W, BAR_H};
+ SDL_SetRenderDrawColor(sdl_renderer_, 0x20, 0x20, 0x20, 0xcc);
+ SDL_RenderFillRect(sdl_renderer_, &bg);
+
+ if (fill > 0.0f) {
+ if (fill > 1.0f) fill = 1.0f;
+ SDL_FRect fg = {x + PAD, y + PAD,
+ (BAR_W - PAD * 2) * fill, BAR_H - PAD * 2};
+ SDL_SetRenderDrawColor(sdl_renderer_,
+ static_cast<uint8_t>(r * 255),
+ static_cast<uint8_t>(g * 255),
+ static_cast<uint8_t>(b * 255), 0xcc);
+ SDL_RenderFillRect(sdl_renderer_, &fg);
+ }
+}
diff --git a/src/engine/renderer.hpp b/src/engine/renderer.hpp
new file mode 100644
index 0000000..ddc8055
--- /dev/null
+++ b/src/engine/renderer.hpp
@@ -0,0 +1,60 @@
+#pragma once
+
+#include <SDL3/SDL.h>
+#include <vector>
+#include <string>
+#include <unordered_map>
+
+class Camera;
+
+class Renderer {
+public:
+ Renderer() = default;
+
+ bool init(SDL_Window* window, int width, int height);
+ void shutdown();
+
+ void begin_frame(Camera& camera);
+ void end_frame();
+ void resize(int width, int height);
+
+ // Draw a filled axis-aligned box in world coordinates.
+ void draw_box(float cx, float cy, float half_w, float half_h,
+ float angle, float r, float g, float b, float a);
+
+ // Load a texture (cached by path). Returns nullptr on failure.
+ SDL_Texture* load_texture(const std::string& path);
+
+ // Draw a textured quad in world coordinates, colour-modulated by (r,g,b,a).
+ void draw_sprite(float cx, float cy, float half_w, float half_h,
+ float angle, SDL_Texture* tex,
+ float r = 1.0f, float g = 1.0f, float b = 1.0f, float a = 1.0f);
+
+ // Draw a simple HUD bar in screen pixels (top-left origin).
+ void draw_hud_bar(float x, float y, float fill,
+ float r, float g, float b);
+
+ // Draw a radial (pie) countdown at a world position. `frac` in [0,1] is the
+ // remaining portion drawn as a filled wedge over a dim background ring.
+ void draw_radial_timer(float cx, float cy, float radius_world, float frac,
+ float r, float g, float b, float a);
+
+ // Draw a filled wedge (world space) between world angles a0..a1 — used for
+ // a weapon's firing arc.
+ void draw_arc(float cx, float cy, float radius_world, float a0, float a1,
+ float r, float g, float b, float a);
+
+ int width() const { return width_; }
+ int height() const { return height_; }
+
+ SDL_Renderer* sdl_renderer() const { return sdl_renderer_; }
+
+private:
+ SDL_Renderer* sdl_renderer_ = nullptr;
+ Camera* current_cam_ = nullptr;
+ int width_ = 1280;
+ int height_ = 720;
+ bool imgui_ready_ = false;
+ std::vector<SDL_Vertex> fan_scratch_; // reused by draw_radial_timer
+ std::unordered_map<std::string, SDL_Texture*> tex_cache_;
+};
diff --git a/src/engine/script.cpp b/src/engine/script.cpp
new file mode 100644
index 0000000..13d7cf5
--- /dev/null
+++ b/src/engine/script.cpp
@@ -0,0 +1,18 @@
+#include "engine/script.hpp"
+#include <cstdio>
+
+ScriptEngine::ScriptEngine() {
+ lua_.open_libraries(sol::lib::base, sol::lib::table, sol::lib::string,
+ sol::lib::math, sol::lib::os);
+}
+
+bool ScriptEngine::run_file(const std::string& path) {
+ sol::protected_function_result r =
+ lua_.safe_script_file(path, sol::script_pass_on_error);
+ if (!r.valid()) {
+ sol::error err = r;
+ fprintf(stderr, "Lua error (%s): %s\n", path.c_str(), err.what());
+ return false;
+ }
+ return true;
+}
diff --git a/src/engine/script.hpp b/src/engine/script.hpp
new file mode 100644
index 0000000..8008ded
--- /dev/null
+++ b/src/engine/script.hpp
@@ -0,0 +1,21 @@
+#pragma once
+
+#include <sol/sol.hpp>
+#include <string>
+
+// Thin wrapper around a sol2 Lua state. This is the engine's scripting seam:
+// data files (materials, later weapons/devices) are Lua, loaded on top of each
+// other so mods can extend or override the base tables.
+class ScriptEngine {
+public:
+ ScriptEngine();
+
+ sol::state& lua() { return lua_; }
+
+ // Run a Lua file in the shared state. Returns false and logs on error
+ // (errors are caught, never thrown out, so a bad mod can't crash the game).
+ bool run_file(const std::string& path);
+
+private:
+ sol::state lua_;
+};
diff --git a/src/engine/stb_impl.cpp b/src/engine/stb_impl.cpp
new file mode 100644
index 0000000..8011e5a
--- /dev/null
+++ b/src/engine/stb_impl.cpp
@@ -0,0 +1,9 @@
+// Single translation unit that compiles the stb_image implementation.
+#if defined(__GNUC__)
+#pragma GCC diagnostic ignored "-Wunused-function"
+#endif
+#define STB_IMAGE_IMPLEMENTATION
+#define STBI_ONLY_PNG
+#define STBI_ONLY_TGA // Forts ships weapon/device art as TGA
+#define STBI_NO_STDIO_DEPRECATE
+#include "stb_image.h"
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);
diff --git a/src/main.cpp b/src/main.cpp
new file mode 100644
index 0000000..547b55f
--- /dev/null
+++ b/src/main.cpp
@@ -0,0 +1,13 @@
+#include "engine/app.hpp"
+
+int main(int /*argc*/, char** /*argv*/) {
+ AppConfig cfg;
+ cfg.title = "Forts Clone v0.1";
+ cfg.window_width = 1280;
+ cfg.window_height = 720;
+
+ App app(cfg);
+ app.run();
+
+ return 0;
+}