From cd0c08dc7ce754473178ad3f32f7740a1dddc1eb Mon Sep 17 00:00:00 2001 From: Vaino Kauppila Date: Thu, 2 Jul 2026 22:58:25 +0300 Subject: 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. --- src/engine/app.cpp | 824 ++++++++++++++++++++++++++++++++++++++++++++++++ src/engine/app.hpp | 29 ++ src/engine/camera.cpp | 55 ++++ src/engine/camera.hpp | 59 ++++ src/engine/input.cpp | 101 ++++++ src/engine/input.hpp | 53 ++++ src/engine/physics.cpp | 70 ++++ src/engine/physics.hpp | 41 +++ src/engine/renderer.cpp | 285 +++++++++++++++++ src/engine/renderer.hpp | 60 ++++ src/engine/script.cpp | 18 ++ src/engine/script.hpp | 21 ++ src/engine/stb_impl.cpp | 9 + 13 files changed, 1625 insertions(+) create mode 100644 src/engine/app.cpp create mode 100644 src/engine/app.hpp create mode 100644 src/engine/camera.cpp create mode 100644 src/engine/camera.hpp create mode 100644 src/engine/input.cpp create mode 100644 src/engine/input.hpp create mode 100644 src/engine/physics.cpp create mode 100644 src/engine/physics.hpp create mode 100644 src/engine/renderer.cpp create mode 100644 src/engine/renderer.hpp create mode 100644 src/engine/script.cpp create mode 100644 src/engine/script.hpp create mode 100644 src/engine/stb_impl.cpp (limited to 'src/engine') 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 +#include +#include +#include +#include + +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 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 explosions; + const float EXPLOSION_LIFE = 0.4f; + + // --- Weapons (M4) --------------------------------------------------------- + std::vector 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 cannons; + + struct Projectile { float x, y, vx, vy, damage, splash, knock, radius, life, ignite; }; + std::vector projectiles; + struct Beam { float ox, oy, angle, range, dps, time_left, hx, hy; bool ignite; }; + std::vector beams; + + std::mt19937 rng(12345); + std::uniform_real_distribution uni(0.0f, 1.0f); + + // --- Devices + map (M5) --------------------------------------------------- + std::vector device_defs = load_devices(script, "data"); + MapData map = load_map(script, "data"); + std::vector 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 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 snap_nodes = graph.nodes; + std::vector 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(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 + +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 +#include + +// 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 +#include + +// 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 + +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 +#include + +// 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& bodies() const { return bodies_; } + + b2WorldId world_id() const { return world_id_; } + +private: + b2WorldId world_id_; + std::vector 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 +#include +#include +#include +#include + +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(cam.viewport_width()); + float vp_h = static_cast(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(r * 255), + static_cast(g * 255), + static_cast(b * 255), + static_cast(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(cam.viewport_width()); + float vp_h = static_cast(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(r * 255), + static_cast(g * 255), + static_cast(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 +#include +#include +#include + +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 fan_scratch_; // reused by draw_radial_timer + std::unordered_map 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 + +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 +#include + +// 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" -- cgit v1.3