Files
anekdotin be5c41f82f Add match structure (countdown, timer, scoreboard, banner, persistent stats) and capital-ship fleet polish
Match phases (autoload/match_manager.gd, world/game_modes/):
- New server-authoritative MatchManager loops PRE_MATCH (30s countdown,
  ships held invisible/uncollidable, flagships creep into formation) ->
  IN_PROGRESS (active GameMode's clock runs) -> POST_MATCH (winner
  banner, result reported to matchmaking-api) -> back to a fresh
  PRE_MATCH forever, matching the always-on server-pool model instead
  of kicking players to the menu at match end.
- Win-condition logic lives in a new GameMode abstraction (game_mode.gd
  base + team_deathmatch_mode.gd, the only mode so far) so a future
  mode is a new subclass plus one factory branch, no timer/scoreboard/
  banner code changes needed.
- Three new HUD pieces: match_timer.gd (countdown/clock), scoreboard.gd
  (hold-Tab two-team panel), match_banner.gd (winner banner).
- New MatchStats autoload tracks per-match kills/deaths by peer_id
  (including bots), hooked into kill_feed_manager's existing
  report_kill() call site.

Persistent stats (matchmaking-api/):
- Player gains kills/deaths/hours_played columns; new
  POST /matches/report endpoint (server-only, called once at
  POST_MATCH) upserts each real player's totals by callsign via a
  shared app/crud.py helper also used by the matchmaking-queue join
  path. GET /stats/{callsign} returns the new fields alongside mmr/
  wins/losses.

Capital-ship fleet polish (world/flagship.gd, world/world.gd,
ships/ship_movement.gd, autoload/game_config.gd):
- Flagship formations are now a clean vertical line (no per-ship
  position/rotation jitter) so play_creep_in()'s rigid-group tween
  reads as one disciplined fleet arriving together, rising from
  directly below (not a random compass direction) over the full 30s
  countdown.
- Fixed a real bug where the creep-in tween only ever played on the
  server -- MatchManager._run_pre_match() called straight into World,
  server-only code a remote client's own process never runs, leaving
  their flagships static all match. World now triggers it off
  MatchManager.phase_changed instead, which fires identically on every
  peer.
- Fixed a second bug (only reachable on a fresh server boot's very
  first spawn): a phase==PRE_MATCH check that's true even before the
  match loop has genuinely started that phase for the first time fired
  play_creep_in() with a bogus zero-duration tween, corrupting the
  target the real 30s tween read moments later -- flagships would
  settle 4000 units off from their intended formation slot and fire
  from there instead. Guarded on get_remaining_seconds() > 0 too.
- The held ship's camera now actively tracks its own team's flagship
  centroid every tick during the countdown (position_smoothing
  disabled for the hold, since it fights a manually-driven target and
  was the reason the fleet read as invisible) instead of sitting fixed
  and wide-angle; local offset/zoom/smoothing are explicitly reset on
  release so control handback doesn't inherit a stale camera transform.
- _apply_pre_match_hold()/_apply_respawn() now set _dead/
  _held_for_pre_match inside the RPC itself, not just in the
  server-only caller -- those flags never reached remote clients
  before, so WASD wasn't actually blocked for them during the hold.
- Ships now launch from a narrow point directly beneath their own
  flagship formation instead of a full-circle scatter around the spawn
  marker (which could land a spawn behind/inside a hull). Bumped
  flagship_defense_radius so it still comfortably reaches a player who
  flies a straight line to the enemy side without correcting for that
  new offset.

ISN gets a Stealth Corvette hull mixed into its flagship formation
(assets/images/ships/isn/), banner art renamed off opaque UUID
filenames to isn_banner.jpeg/orc_banner.jpeg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:40:15 -04:00

279 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project Structure
## File Tree
```
spacewar/ ← repo root
└── spacewar/ ← Godot project root (open this in Godot editor)
├── project.godot
├── autoload/
│ ├── game_config.gd ← match-wide tuning constants, input-focus flags, persisted user settings (see GameConfig table below)
│ ├── network_manager.gd ← owns the ENetMultiplayerPeer; host_server()/join_server(); server self-registration+heartbeat with matchmaking-api; UDP query responder for pre-connect server-select pings
│ ├── player_registry.gd ← per-peer loadout registry (name/race/ship/role/flame/sound paths), keyed by peer_id; server-relayed to every peer
│ ├── matchmaking_client.gd ← HTTP wrapper around matchmaking-api (queue, list_servers, register_server); don't call _request() directly from outside this file
│ ├── chat_manager.gd ← server-relayed all/team chat
│ ├── kill_feed_manager.gd ← server-authoritative kill-feed broadcast (victim/killer name+race per death); also feeds MatchStats
│ ├── match_stats.gd ← per-match (resets every loop) kills/deaths by peer_id, incl. bots; hooked from kill_feed_manager.gd
│ ├── match_manager.gd ← server-authoritative match-phase state machine (PRE_MATCH/IN_PROGRESS/POST_MATCH, loops forever); owns the active GameMode
│ └── music_manager.gd ← menu background music player, routed through the Music audio bus
├── menu/
│ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav (QUICK PLAY, SERVER SELECT, OPTIONS, PROFILE, QUIT)
│ ├── server_select.tscn/gd ← server list UI, backed by matchmaking-api's GET /servers + a live UDP ping probe
│ ├── pause_menu.tscn/gd ← in-game pause overlay (ESC / Start button)
│ ├── settings_panel.tscn/gd ← Options screen; one instance shared by main menu (OPTIONS) and pause menu (SETTINGS); audio/frame-rate/vsync/window-mode/colorblind/key-rebind sections
│ └── team_select.tscn/gd ← faction + ship selection overlay (shown on world load and on pause-menu SELECT TEAM)
├── ships/
│ ├── ship.tscn ← player ship scene
│ ├── ship_movement.gd ← player ship logic (movement, prediction/reconciliation, health/energy, nameplate interpolation)
│ └── bullet.tscn/gd ← projectile
├── chat/
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
├── hud/
│ ├── player_list.tscn/gd ← top-left player roster (white=team, yellow/orange=enemy per colorblind mode, "(b)"=bot)
│ ├── kill_feed.tscn/gd ← last 4 deaths, stacked above ChatBox's history panel
│ ├── mini_map.tscn/gd ← polls game state (hazards, players, flagships), pokes MiniMapView, calls queue_redraw()
│ ├── mini_map_view.gd ← pure Control _draw() surface for the minimap (split out since CanvasLayer can't override _draw())
│ ├── ping_display.tscn/gd ← top-right live RTT via ENetPacketPeer.get_statistic(PEER_ROUND_TRIP_TIME)
│ ├── energy_bar.tscn/gd ← blue center-out mirrored bar; also instanced for the health bar (top of world.tscn)
│ ├── match_timer.tscn/gd ← top-center match clock (PRE_MATCH countdown / IN_PROGRESS 7:00 countdown), driven by MatchManager
│ ├── scoreboard.tscn/gd ← hold-Tab CS:GO-style scoreboard, two team panels (name/role/K/D), driven by MatchStats + the active GameMode
│ └── match_banner.tscn/gd ← full-screen winner banner (ISN/ORC art + "<RACE> WINS"/"DRAW"), shown during MatchManager's POST_MATCH phase
├── bots/
│ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation)
│ ├── bot_ai.gd ← class_name BotAI; seek-nearest-enemy-and-shoot brain, tuned per-bot by BotPersonality
│ ├── bot_personality.gd ← class_name BotPersonality; 5 independent 0-1 traits (aggression/caution/accuracy/reaction/awareness) rolled once per bot-slot
│ └── bot_names.gd ← class_name BotNames; procedural callsign pool
├── world/
│ ├── world.tscn ← game world; world.gd loads the active map, spawns flagships, brokers bullets/hazard colliders
│ ├── world.gd ← picks/instances a map scene from world/maps/ (MAPS array), decides offered races, spawns flagship formations, hosts MatchManager's ship-hold/creep-in hooks
│ ├── world_tileset.tres ← shared TileSet resource (walls.png + asteroids.png atlas sources)
│ ├── flagship.tscn/gd ← capital-ship point-defense formation (class_name Flagship), see CLAUDE.md item 24; also plays the PRE_MATCH creep-in tween
│ ├── starfield.gdshader ← procedural starfield background
│ ├── game_modes/
│ │ ├── game_mode.gd ← class_name GameMode; base ruleset (win condition, score label, match duration) MatchManager drives every mode through
│ │ └── team_deathmatch_mode.gd ← class_name TeamDeathmatchMode; most-kills-when-clock-runs-out, the only implemented mode today (see overview/map1.md)
│ └── maps/
│ └── map_01.tscn ← hand-painted map: Walls + Asteroids TileMapLayers (asteroids deal impact damage, not just bounce), team spawn Marker2Ds
└── assets/
├── icon.svg
├── default_bus_layout.tres ← Master/Music/SFX audio buses
└── images/
├── background/skybox/ ← space background PNGs
├── effects/
│ ├── explosion/ ← ship-death explosion animation frames
│ └── bullets/ ← per-race/role bullet sprites, incl. flagship turret fire
├── banners/ ← faction banner art
├── ships/isn/, ships/orc/ ← live faction ship art (idle + _flame sprites), each with a source/ concept-sheet copy
└── tiles/ ← walls.png, asteroids.png, used by world_tileset.tres
```
> `assets/images/ships/apex/` and the older `terran/mech/vorg` folders are left on disk but unreferenced by any code — not deleted, see CLAUDE.md items 17/19.
## Scene Graph
### `menu/main_menu.tscn` — entry point
```
MainMenu (Control) ← main_menu.gd builds all UI in _ready()
```
### `menu/server_select.tscn` — server list
```
ServerSelect (Control) ← server_select.gd builds all UI in _ready(), fetches GET /servers
```
### `world/world.tscn` — game world
```
World (Node2D) ← world.gd instances a map into MapContainer, spawns flagships, brokers bullet/hazard colliders
├── MapContainer (Node2D) ← holds the instanced map (world/maps/map_01.tscn), background included
├── Players (Node2D) ← per-peer ship.tscn instances live here
├── MultiplayerSpawner ← spawns/despawns ships into Players
├── Bullets (Node2D) ← server-spawned bullet.tscn instances
├── BulletSpawner (MultiplayerSpawner)
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; also reopened live from the pause menu's SELECT TEAM
├── SettingsPanel (CanvasLayer, layer=25) ← menu/settings_panel.tscn; opened by pause menu's SETTINGS
├── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
├── PlayerList (CanvasLayer) ← hud/player_list.tscn
├── KillFeed (CanvasLayer, layer=5) ← hud/kill_feed.tscn
├── MiniMap ← hud/mini_map.tscn
├── PingDisplay ← hud/ping_display.tscn
├── HealthBar ← hud/energy_bar.tscn instance, reused for health
├── EnergyBar ← hud/energy_bar.tscn instance
├── MatchTimer (CanvasLayer, layer=4) ← hud/match_timer.tscn
├── Scoreboard (CanvasLayer, layer=6) ← hud/scoreboard.tscn; visible only while Tab is held
└── MatchBanner (CanvasLayer, layer=30) ← hud/match_banner.tscn; visible only during MatchManager's POST_MATCH phase
```
### `world/maps/map_01.tscn` — hand-painted map
```
Map01 (Node2D)
├── Background (TextureRect) ← per-map space background; procedural starfield.gdshader, not a tiled skybox
├── Walls (TileMapLayer) ← painted by hand in the Godot Tile Editor, uses world_tileset.tres; bounces ships (environment_wall group)
├── Asteroids (TileMapLayer) ← painted by hand, same shared tileset; bounces AND deals impact damage (environment_hazard group, GameConfig.ship_wall_damage)
└── SpawnPoints (Node2D)
├── TeamASpawn1 (Marker2D) ← group "team_a_spawn"; also where world.gd spawns Team A's flagship formation
└── TeamBSpawn1 (Marker2D) ← group "team_b_spawn"; also where world.gd spawns Team B's flagship formation
```
### `ships/ship.tscn` — player ship
```
Player (CharacterBody2D) ← ship_movement.gd
├── CollisionShape2D ← CircleShape2D
├── Sprite2D ← texture set at runtime from PlayerRegistry's per-peer ship_path
└── VisibleOnScreenNotifier2D
```
## Input Map
| Action | Key | Controller |
|--------|-----|------------|
| `move_up` | W | — |
| `move_left` | A | — |
| `move_right` | D | — |
| `move_down` | S | — |
| `shoot` | Space | — |
| `toggle_pause` | Esc | Start / Options button (JoyButton 6) |
| `chat_all` | T | — |
| `chat_team` | Y | — |
| `scoreboard` | Tab | — |
## Game Flow
```
main_menu.tscn
├── QUICK PLAY → matchmaking queue (casual) → world.tscn
│ ├── TeamSelect overlay: pick race (both offered every match)
│ ├── TeamSelect overlay: pick ship
│ └── Player spawns → game live
├── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
├── OPTIONS → settings_panel.tscn (audio, frame-rate cap, vsync, window mode, colorblind mode, key rebinding) — same panel the in-game pause menu's SETTINGS opens
├── PROFILE → callsign-edit overlay (also auto-opens from QUICK PLAY if no callsign is set)
└── QUIT
```
Ranked matchmaking is removed from the menu entirely (not just disabled) until it's real — see `CLAUDE.md` Current Tasks.
## Display
Launches borderless fullscreen at the real screen resolution (`window/size/mode=3`
in `project.godot`) with stretch mode **disabled** — 1 game pixel = 1 screen
pixel everywhere, no UI/world scaling. Chosen over Godot's default
`canvas_items`+`expand` stretch (which scales the whole 2D canvas to fill the
window) because the game's design canvas is deliberately sized to the Steam
Deck's native 1280×800; scaling that up to fill a PC monitor made everything
look zoomed in. With stretch disabled, PC monitors just reveal more of the
world/HUD at native size instead — the actual map (`world/maps/map_01.tscn`)
is already a fixed-size 14016×6000 arena, not viewport-sized, so there's more
world to reveal.
This means every menu Control has to position itself relative to the *real*
window size, not a fixed 1280×800 design canvas — anchors (0.0=edge,
0.5=center, 1.0=opposite edge) plus fixed pixel offsets from that anchor,
not raw absolute pixel coordinates. `main_menu.gd` and `team_select.gd` both
have a `_place()`/direct-property-assignment helper for this — **always set
anchor properties directly (`node.anchor_left = ...`) rather than through
sequential `set_anchor_and_offset()` calls**: that method's default
`push_opposite_anchor=true` drags the opposite side's anchor/offset along
whenever two sequential calls momentarily disagree (e.g. left set to a 0.5
anchor while right is still its 0.0 default), corrupting layout — hit this
exact bug converting the play-mode buttons to be centered.
## Key Autoload — `GameConfig`
Per-peer identity (`player_race`, `player_ship_path`) moved out of `GameConfig`
early on, per the networking plan's Phase 2 — that's `PlayerRegistry`'s job
now (see below). `GameConfig` today holds match-wide tuning constants plus
persisted user settings. Grouped by area rather than listed exhaustively
(~70 properties) — see `autoload/game_config.gd` for the full list:
| Area | Examples | Notes |
|------|----------|-------|
| Persisted settings | `max_fps`, `vsync_mode`, `window_mode`, `master_volume`/`music_volume`/`sfx_volume`, `colorblind_mode`, `keybinds` | Loaded/applied in `_ready()`, written to `user://settings.cfg` by `save_settings()`; each has a `set_*()` that applies live |
| Ship movement | `ship_thrust`, `ship_max_speed`, `ship_rotation_speed`, `ship_rotation_ramp_delay` | Tuning constants |
| Combat | `ship_fire_rate`, `bullet_speed`, `ship_bullet_damage_by_role`, `ship_bullet_count_by_role`, `bullet_max_range` | Per-role dictionaries keyed `"Fighter"/"Gunner"/"Tank"` |
| Energy | `ship_max_energy_by_role`, `ship_bullet_energy_cost_by_role`, `ship_energy_regen_rate` | See CLAUDE.md item 23 |
| Health/respawn | `ship_max_health`, `ship_respawn_delay`, `ship_invincibility_time`, `ship_wall_damage` | `ship_wall_damage` applies on both wall bounces and asteroid hazard hits |
| Collision/visuals | `ship_bounce_restitution`, `ship_collision_damage_scale`, `ship_scale_factor`, `ship_hitbox_scale`, `explosion_scale`, `damage_number_*` | |
| Colors | `team_color`, `enemy_color`, `colorblind_mode` (swaps `enemy_color` between `ENEMY_COLOR_DEFAULT`/`ENEMY_COLOR_COLORBLIND`) | Read by nameplates, `hud/player_list.gd`, `hud/mini_map.gd` |
| Camera/world | `camera_zoom`, `world_bounds`, `spawn_area_inner_radius`/`outer_radius` | |
| Flagships | `flagship_count_per_spawn`, `flagship_defense_radius`, `flagship_fire_rate`, `flagship_missile_*`, `flagship_bullet_*`, `flagship_aim_jitter_px`, `flagship_creep_in_distance` | See CLAUDE.md item 24; `flagship_creep_in_distance` is the PRE_MATCH cosmetic tween's start offset |
| Match structure | `default_game_mode_id`, `match_pre_match_duration`, `match_duration`, `match_post_match_duration` | Read by `MatchManager`'s phase state machine — see item 26 |
| Input-focus gates | `chat_focused`, `team_select_focused`, `settings_focused` | Each blocks ship movement/fire input while its overlay owns keyboard focus |
| Bot tuning | `bot_min_team_size`, `bot_engage_range`, `bot_stop_distance_min`/`max`, `bot_retreat_health_frac_min`/`max`, `bot_aim_tolerance_best_deg`/`worst_deg`, `bot_aim_jitter_max_px`, `bot_reaction_update_best_sec`/`worst_sec`, `bot_awareness_range_min`/`max` | Min/max pairs are interpolated per-bot by that bot's rolled `BotPersonality` (0=worst trait, 1=best) |
## Key Autoload — `NetworkManager`
Owns the `ENetMultiplayerPeer` (`autoload/network_manager.gd`). `host_server(port)` /
`join_server(ip, port)`. A hosted server self-registers with matchmaking-api on boot
and re-heartbeats every 8s via `MatchmakingClient.register_server()` (reusing one
persistent `HTTPRequest` node — a fresh one per call caused a periodic stutter, see
CLAUDE.md item 20/21). Also runs a `UDPServer` on game-port+10000 (`_start_query_responder`)
that answers a raw `"SPACEWAR_PING"` datagram with live player count, used by
`menu/server_select.gd`'s pre-connect probe (post-connect ping uses ENet's own RTT stat
instead, via `hud/ping_display.gd`).
## Key Autoload — `PlayerRegistry`
Per-peer loadout registry (`autoload/player_registry.gd`), `Dictionary[peer_id, info]`
where `info` holds `name`/`race`/`ship_path`/`ship_scale`/`ship_speed_factor`/`role`/
`ship_flame_path`/`ship_sound_path`. `submit_local_loadout()` (real players) and
`register_bot()` (bots, negative peer_ids) both funnel through the same server-relayed
storage, so every system that reads a loadout (spawning, HUD, bots, bullets) treats
players and bots identically. `loadout_updated`/`race_changed`/`player_removed` signals
drive `BotManager`, `hud/player_list.gd`, and TeamSelect's live headcounts.
## Key Autoload — `MatchmakingClient`
HTTP wrapper around `matchmaking-api` (`autoload/matchmaking_client.gd`) — queueing,
`list_servers()` (backs `menu/server_select.gd`'s `GET /servers`), and
`register_server()`/heartbeat. Don't call the internal `_request()` directly from
outside this file; use the public wrapper methods.
## Key Autoload — `BotManager`
Server-authoritative bot fill for casual (`bots/bot_manager.gd`). Keeps each of the
match's 2 offered factions at `GameConfig.bot_min_team_size` total humans+bots,
reacting to `PlayerRegistry.loadout_updated`/`race_changed`/`player_removed` and
`World.decide_offered_races()`. Also rolls a `BotPersonality` per bot-slot. See `overview/bots.md`.
## Key Autoload — `ChatManager`
Server-relayed chat (`autoload/chat_manager.gd`). `send_chat(text, team_only)` sends;
`message_received(sender_name, sender_race, team_only, text)` signal delivers
incoming messages to `ChatBox`. "Team" messages are filtered server-side to
peers sharing the sender's race (race doubles as team — see `PlayerRegistry`).
## Key Autoload — `KillFeedManager`
Server-authoritative kill-feed broadcast (`autoload/kill_feed_manager.gd`). One RPC per
death, called from `ship_movement.gd`'s `_die()` — no client → server leg, unlike chat.
`kill_reported` signal feeds `hud/kill_feed.gd`'s last-4-lines display, and
`report_kill()` also calls `MatchStats.record_kill()` — the one hook point both
systems share.
## Key Autoload — `MatchStats`
Per-match (resets every `MatchManager` loop) kills/deaths by `peer_id`, including
bots (`autoload/match_stats.gd`). `get_team_kills(race_id)` sums a whole team via
`PlayerRegistry`. `stats_updated`/`stats_reset` signals drive `hud/scoreboard.gd`.
## Key Autoload — `MatchManager`
Server-authoritative match-phase state machine (`autoload/match_manager.gd`):
`PRE_MATCH` (30s countdown, ships held via `ship_movement.gd`'s
`server_hold_for_match_start()`, flagships cosmetically creep into formation) →
`IN_PROGRESS` (the active `GameMode`'s clock runs, ships released) → `POST_MATCH`
(winner banner, match result reported to matchmaking-api) → loops back into a
fresh `PRE_MATCH` indefinitely — no menu kick, matching this project's always-on
server-pool model (see item 16). Follows the same "decide once on the server,
RPC-broadcast the value, late joiners pull it" pattern `world.gd`'s
`decide_offered_races()` established. Owns the active `GameMode`
(`world/game_modes/`) instance, built identically on every peer off the shared
`GameConfig.default_game_mode_id` constant — adding a new mode is a new
`GameMode` subclass plus one factory branch, no changes to this state machine.
## Key Autoload — `MusicManager`
Menu background-music player (`autoload/music_manager.gd`), routed through the Music
audio bus so `GameConfig.music_volume` controls it live.