Add capital ships, and settings screen with smooth-motion fixes
Capital ships & support art: - New Flagship capital ships (world/flagship.gd) spawn a small point-defense formation at each team's spawn marker, independently targeting the nearest enemy in range with a slow unmissable-looking missile and a faster bullet stream -- keeps a team from parking on the enemy spawn and farming respawns. - Per-race/role bullet sprites for turret fire (assets/images/effects/ bullets/), flagship art for both factions, minimap now draws flagship blips, bot_ai awareness tuned to notice them. Settings screen (items 20-21 in CLAUDE.md) -- was a stub, now five real sections shared between the main menu and the in-game pause menu: - Diagnosed and fixed a "movement looks blurry/laggy" report down to three independent causes: physics-tick vs. display-refresh judder (fixed via Godot's built-in physics interpolation, plus a frame-rate-cap + VSync dropdown so each player can match their own monitor), missing mipmaps on every minified ship texture (real GPU sampling shimmer, unrelated to frame timing), and a periodic hitch from the matchmaking heartbeat spinning up a new HTTPRequest thread every 8 seconds instead of reusing one. - Nameplates get their own manual per-frame interpolation, since Godot's physics interpolation only covers Node2D/Node3D, not the Control-based Label they're built from. - Audio: Master/Music/SFX volume sliders backed by a real bus layout (default_bus_layout.tres) -- the project had no volume control at all before this. - Window mode (Fullscreen/Exclusive Fullscreen/Windowed), a colorblind-friendly enemy-color toggle (also fixes the minimap's own separate, inconsistent yellow enemy color), and keyboard rebinding for every action with a live capture UI. - New GameConfig.settings_focused flag (same pattern as chat_focused/ team_select_focused) so a key-rebind capture can't also move the ship or toggle the pause menu underneath the panel. Spawn intro: a ship's first-ever appearance now eases in from a random direction (fast off the start, decelerating into its landing spot) instead of popping into place -- purely a cosmetic sprite offset, so collision/camera/networking are untouched and every peer (including bots) sees the same warp-in. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,12 +41,31 @@ README for how to run/extend it.
|
||||
18. **Engine-flame sprite swap on thrust** — 6 of item 17's 9 ships (ISN's Patriot/Barrage/Behemoth, ORC's Rail-Jack/Scrap-Spitter/Iron-Clad) gained a second `_flame.png` sprite alongside their idle one, cropped from the same concept sheets' flame-frame columns (ISN's sheet has a clean idle/flame pair per ship; ORC's only unambiguous case was Rail-Jack's brighter 3rd-column flame, so Scrap-Spitter/Iron-Clad's flame PNGs are just copies of their idle sprite for now — their sheet's other columns are damage/weapon-fire poses, not more exhaust). Behemoth's flame was composited from two separate sheet crops (clean idle hull + a flame-only crop of just its thrusters) rather than cropped directly, since the sheet's flame frame has a large red shield-ability graphic overlapping the hull that isn't exhaust. Apex's 3 ships (Lancet/Pulsar/Sovereign) have no flame art — their concept sheet never drew a thrust pose — so they're untouched and always show their one sprite. Wired into gameplay via a new optional `flame_path` per ship in `team_select.gd`'s `RACES`, threaded through `PlayerRegistry` (`submit_local_loadout`/`register_bot`/the loadout RPCs, new `ship_flame_path` field, default `""`) so bots and remote peers all get it too, not just the local owner. `ship_movement.gd`'s `_set_thrust_sprite()` swaps the `Sprite2D` texture based on whether the up/down thrust key is *currently held*, not current speed — this game's movement has no drag, so a first cut keyed off `velocity.length()` left the flame on long after letting off the gas since a released ship keeps drifting at speed. The owner predicts this locally the instant it samples input (`_owner_tick`); the server does the same for bots/itself (`_server_tick`) and broadcasts its authoritative value as a new `thrusting` bool on the existing `_receive_state` RPC so every other peer's view of a ship — which never sees raw input, only replicated state — stays in sync too, with no separate RPC needed. Ships with no `flame_path` (Apex) just skip the check entirely (`_flame_texture == null` short-circuits).
|
||||
|
||||
19. **Apex Dynamics faction removed** — cut down to 2 playable races (Inner Sphere Navy, Outer Rim Collective) to simplify the 2D art pipeline. `team_select.gd`'s `RACES` array lost its Apex Dynamics block (Lancet/Pulsar/Sovereign), with ISN/ORC renumbered to `id` 1/2. `world.gd`'s `decide_offered_races()` no longer picks "2 of N" races — with only 2 total, that logic was vestigial, so it now just shuffles the order of both remaining races (kept for spawn-side/left-right variety, since `team_select.gd`/`ship_movement.gd` still key off `offered[0]`/`offered[1]`). `overview/racesclasses.md`'s faction-ability Rock-Paper-Scissors loop (which structurally needed 3 legs) is rewritten as a single ISN-shield-vs-ORC-mines counter relationship; `overview.md`/`bots.md`/`structure.md`'s "2 of 3 races" language updated to reflect both races always being fielded. `assets/images/ships/apex/` and `assets/sound/ships/apex/` are left on disk but unreferenced, same as the older unused `terran/mech/vorg` folders from item 17 — not deleted since that wasn't asked for.
|
||||
20. **Options/Settings screen — max frame-rate cap** — diagnosed a "moving forward feels blurry/laggy" report as physics-tick (fixed 60Hz, see `ships/ship_movement.gd`) vs. display-refresh-rate judder, made far more visible by item's-ago `starfield.gdshader` switching from a blurry tiled skybox to pixel-crisp procedural stars — the right fix depends on each player's own monitor, so it's now a user-facing setting rather than a hardcoded value. New `menu/settings_panel.gd`/`.tscn` (`class_name SettingsPanel`, `extends CanvasLayer`, `layer = 25` — above `PauseMenu`'s 10 and `TeamSelect`'s 20, same layering convention, so it still renders/receives input on top when opened from inside the already-paused pause menu) offers a wide spread of common refresh rates (30 up to 360, plus Unlimited) that set `Engine.max_fps` via a new `GameConfig.set_max_fps()`, persisted to `user://settings.cfg` (`GameConfig._ready()` loads and applies it on boot) so the choice survives restarts. One `SettingsPanel` instance lives in `world.tscn` (opened by the pause menu's now-live SETTINGS button) and a second is instantiated directly by `main_menu.gd` (opened by OPTIONS, no longer a stub) — both just read/write the same `GameConfig` state, so a choice made in one is already in effect if the other is opened afterward. The FPS cap alone turned out to only be a partial fix -- it only removes judder if the chosen rate happens to be a clean multiple of the physics tick (e.g. a 165Hz-monitor player capping to 165 sees *worse* judder than capping to 60, since 165/60 isn't an integer ratio and every physics update ends up displayed for an uneven number of frames). The actual fix, layered underneath so it helps regardless of which cap a player picks, is Godot's built-in physics interpolation (`physics/common/physics_interpolation=true` in `project.godot`), which blends each node's last two 60Hz physics-tick transforms for rendering instead of holding a position static between ticks. Costs no netcode bandwidth (physics_ticks_per_second is untouched, so `submit_input`/`_receive_state`'s RPC rate in `ship_movement.gd` doesn't change) — it only smooths what's already being simulated. Needed two explicit `reset_physics_interpolation()` calls in `_apply_respawn()` (on the ship, and on its owner's `Camera2D` child, now kept as `_camera` instead of a `_ready()`-local var) so a spawn/respawn's instant position jump snaps cleanly instead of visibly sliding across the map over one interpolation window — a reset on a parent doesn't propagate to children, since each `CanvasItem` tracks its own transform history independently. Prediction/reconciliation (`_reconcile()`) and remote-ship snapshot interpolation (`_remote_tick()`) were deliberately left untouched: both already produce a fresh authoritative-or-predicted position every physics tick, so physics interpolation layers on top for free as an additional render-side smoothing pass, with small reconciliation corrections now smoothed away instead of visibly popping. Tried removing `ship.tscn`'s `Camera2D` built-in `position_smoothing` (speed 8.0) next, on a theory that it was fighting physics interpolation (two independent lag/smoothing systems stacked) — made things feel worse, not better, so it was put back (`position_smoothing_enabled = true` again) rather than keep an unproven regression.
|
||||
|
||||
The actual remaining culprit: every one of the 9 in-use ISN/ORC ship textures (18 counting `_flame` variants, plus both flagships) had `mipmaps/generate=false` in their `.import` file, and every ship is drawn shrunk to roughly 20-35% of native texture size on screen (per-role sprite `scale` in `team_select.gd`'s `RACES` × `GameConfig.ship_scale_factor` (0.75) × `GameConfig.camera_zoom` (0.85)). Minifying a texture that far with no mipmap chain forces the GPU into raw bilinear point-sampling, which visibly shimmers/blurs as the sub-pixel sampling phase shifts every frame during motion — a purely spatial/GPU-sampling artifact with zero relationship to physics tick rate, frame cap, or camera smoothing, which is exactly why none of those changes touched it (and why disabling camera smoothing, which had incidentally been low-pass-filtering the shimmer, made it more visible instead of less). Fixed by flipping `mipmaps/generate=false` → `true` in all 14 affected `.import` files under `assets/images/ships/isn/` and `assets/images/ships/orc/` and letting Godot re-import (`godot4 --headless --path . --import --quit` regenerates the cached `.ctex` files with a proper mip chain baked in — needed once after this kind of `.import` edit, same as the class-cache-rebuild step needed after adding a new `class_name`, see the headless-testing memory notes).
|
||||
|
||||
Even after that, a *periodic* (roughly every 8s) stutter remained — smooth for a stretch, then a hitch, invisible while sitting still but very visible while panning (a dropped/delayed frame just doesn't register on an unchanging screen). Root cause: `autoload/network_manager.gd`'s hosting heartbeat (`HEARTBEAT_INTERVAL = 8.0`, re-registers a hosted server with the matchmaking API for as long as it stays up — see item 16) went through `MatchmakingClient._request()`, which spun up a brand-new `HTTPRequest` node (and its background thread) per call and `queue_free()`'d it right after. Every other caller of `_request()` fires rarely enough (once, or for a short pre-match queueing window) that this per-call churn is fine; the heartbeat is the one endpoint called on a tight, indefinitely-repeating cadence for a hosted server's entire uptime, so it's the one that needed a persistent, reused `HTTPRequest` instead. Added `MatchmakingClient._heartbeat_http` (created once in `_ready()`) and an optional `reuse_http` param on `_request()` that `register_server()` now passes — every other call site is untouched, still short-lived per-call nodes so concurrent unrelated requests (e.g. matchmaking queue polling) can't collide on a shared node mid-flight.
|
||||
|
||||
Last remaining piece: the bottom-right nameplate (`ship_movement.gd`'s `_nameplate`, a `top_level` `Label` manually repositioned every `_physics_process()` tick) stayed juddery/blurry in motion even after everything else smoothed out, because Godot's built-in physics interpolation only covers `Node2D`/`Node3D` transforms — `Label`'s base class is `Control`, which isn't part of that system at all, so it was still snapping at the raw 60Hz tick rate while the ship sprite and camera (both `Node2D`) rode the engine's interpolation for free. Fixed the same way this codebase already smooths a remote ship's position between snapshots (`_remote_tick()`'s lerp pattern): added `_nameplate_from_pos`/`_nameplate_to_pos`/`_nameplate_interp_elapsed`, set once per physics tick, consumed by a new `_process()` that lerps the nameplate's `global_position` across render frames the same `elapsed / _fixed_delta` way `_remote_tick()` does.
|
||||
|
||||
Confirmed there was one more variable left: a 165 FPS cap (`menu/settings_panel.gd`) still stuttered badly in a repeating smooth/bad cycle even after all of the above — not a code bug, a vsync mismatch (the player's monitor isn't natively 165Hz, so `Engine.max_fps=165` fights vsync's own throttling, causing an alternating catch-up/backlog pattern). Capping to exactly 60 fixed it completely. Takeaway for anyone hitting this again: physics interpolation + a correct mipmap/heartbeat/nameplate setup makes *any* cap that actually matches the display look smooth, but the frame-rate-cap dropdown is still a "match your real monitor" knob, not a free "bigger number is smoother" one — 60 is the one value guaranteed to be judder-free regardless of the player's actual display, since it's an exact match to the fixed physics tick.
|
||||
|
||||
Added the other half of that same knob: a VSYNC dropdown (Enabled/Adaptive/Disabled, `DisplayServer.window_set_vsync_mode()`) next to the frame-rate cap in `SettingsPanel`, backed by a new `GameConfig.vsync_mode` (persisted the same way as `max_fps`, both now written together by a shared `GameConfig._save_settings()`). Not every display/driver combination will land cleanly on Enabled vsync at a given cap the way 60 did here — Adaptive (only syncs when the frame rate would otherwise exceed the display's refresh) and Disabled (no sync, lowest input lag, tearing possible) are the standard fallbacks for a player who's still stuttering at the right cap.
|
||||
|
||||
21. **Options/Settings screen expanded — audio, window mode, colorblind mode, key rebinding** — item 20 only ever had a frame-rate cap; `menu/settings_panel.gd` now has four more sections, all following the same pattern (a `GameConfig` var + setter that applies live and persists to `user://settings.cfg`, read back by both `SettingsPanel` instances via `_refresh_selected()`). Body is now a fixed-size panel + `ScrollContainer` instead of growing per section, so it still fits Steam Deck's 800px-tall screen. `_add_section()`/`_add_hint()`/`_add_dropdown()`/`_add_slider()` helpers factor out the repeated per-section boilerplate now that there are 5 sections instead of 2.
|
||||
- **Audio** — the project had *zero* volume control before this (every sound used a hardcoded `volume_db`). Added `default_bus_layout.tres` (Master/Music/SFX, Music and SFX both routed to Master) and pointed `autoload/music_manager.gd`'s player and `world.gd`'s `_play_shoot_sound()`'s per-shot `AudioStreamPlayer2D` at their respective buses. `GameConfig.master_volume`/`music_volume`/`sfx_volume` are linear 0..1 (`HSlider`'s native range), converted to dB only in `_apply_bus_volume()` (0.0 explicitly mutes the bus rather than relying on `linear_to_db(0)`'s `-inf` edge case). The volume setters deliberately don't call `save_settings()` themselves — `HSlider.value_changed` fires continuously through a drag, and writing the config file to disk on every one of those would reintroduce exactly the per-event hitch item 20 already hunted down once (the matchmaking heartbeat). `SettingsPanel` applies live on every `value_changed` and only persists once on the slider's `drag_ended`.
|
||||
- **Window mode** — `GameConfig.window_mode` (`DisplayServer.WindowMode`), default `WINDOW_MODE_FULLSCREEN` matching `project.godot`'s existing boot default (item 10) so nothing changes until a player picks something else. Exclusive Fullscreen and Windowed are the other two options.
|
||||
- **Colorblind-friendly enemy color** — `GameConfig.colorblind_mode` swaps `enemy_color` between the existing red (`ENEMY_COLOR_DEFAULT`) and a high-contrast orange (`ENEMY_COLOR_COLORBLIND`) that stays distinct from `team_color` (white, unchanged in both palettes) and from the minimap's own blue self-marker across effectively all forms of color vision deficiency. `hud/mini_map.gd` had its own separate hardcoded `TEAM_COLOR`/`ENEMY_COLOR` consts (white/yellow, inconsistent with the red used everywhere else) — replaced with reads of `GameConfig.team_color`/`enemy_color` so the toggle (and the color scheme generally) is consistent across nameplates, the top-left roster, and the minimap instead of just the first two.
|
||||
- **Key rebinding** — keyboard-only (the only non-keyboard binding in the project, `toggle_pause`'s joypad Start button for Steam Deck, is deliberately left alone). `GameConfig.KEYBIND_ACTIONS`/`DEFAULT_KEYCODES` mirror `project.godot`'s `[input]` section, needed because `InputMap` resets to those compiled-in defaults on every engine boot — `GameConfig._apply_single_keybind()` reapplies any persisted override on top at `_ready()`, and only ever erases/re-adds an action's `InputEventKey` entries specifically (not the whole action), so rebinding e.g. `toggle_pause`'s keyboard key can never silently wipe its separate joypad binding. `SettingsPanel`'s CONTROLS section shows one row per action with a button reading its current key (`InputEventKey.as_text_physical_keycode()`); clicking it sets `_rebinding_action` and the panel's own `_input()` captures the next physical key (Esc cancels instead of binding). New `GameConfig.settings_focused` flag (same pattern as `chat_focused`/`team_select_focused`) gates ship movement input and `pause_menu.gd`'s own Escape-toggles-pause handling while the panel is open — without it, a rebind capture pressing W/A/S/D/Space would also thrust/turn/fire the ship if unpaused behind the panel, and pressing Esc to cancel a rebind would also close the pause menu underneath it.
|
||||
|
||||
22. **Spawn "fly in" intro** — a ship's first-ever appearance now eases in from a random direction instead of just popping into place. `ship_movement.gd`'s `_play_spawn_intro()` is purely a cosmetic `Sprite2D.position` tween (`TRANS_EXPO`/`EASE_OUT` — fast off the start, tailing off into the landing spot) layered on top of the already-correct `global_position`; collision, camera, and every other peer's replicated view of this ship are untouched; the sprite is what everyone (including bots) visibly sees warp in, since `_apply_respawn()` runs identically on every peer via its `call_local` RPC. Gated by a new `_played_spawn_intro` bool that's never reset, so only the ship's true first spawn plays it — a later death/respawn or mid-match team swap (`_apply_respawn()`'s other two call sites) don't repeat it, matching "when the match starts" rather than every respawn.
|
||||
|
||||
## Current Tasks
|
||||
|
||||
- [ ] Asteroids and environment hazards
|
||||
- [ ] Sound effects (thrust, shoot, explosion, UI)
|
||||
- [ ] Options screen — currently just a disabled stub button on the main menu
|
||||
- [ ] Graphics quality presets / resolution scale in Options (audio, window mode, colorblind, and key rebinding are now done — see item 21)
|
||||
- [ ] Real rank/level backend — main menu's top-right badge and RANK_DATA are still placeholders (`CURRENT_RANK`/`CURRENT_LEVEL` constants in `main_menu.gd`)
|
||||
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
|
||||
- [ ] Real account-linked identity — GodotSteam auth + VAC still not started; `callsign` is the only player identity today
|
||||
|
||||
Reference in New Issue
Block a user