aboutsummaryrefslogtreecommitdiffstats
path: root/src/engine/app.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'src/engine/app.cpp')
-rw-r--r--src/engine/app.cpp824
1 files changed, 824 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() {}