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>
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
# Spacewar
|
||||
|
||||
2D multiplayer space shooter inspired by Subspace Continuum. Built in **Godot 4.7** (GDScript). Target platform: Steam Deck + PC. Three races fight for galaxy control across casual (25v25) and ranked (5v5) modes.
|
||||
2D multiplayer space shooter inspired by Subspace Continuum. Built in **Godot 4.7** (GDScript). Target platform: Steam Deck + PC. Two human factions (Inner Sphere Navy, Outer Rim Collective) fight for galaxy control in casual (25v25, bots fill to 7v7 minimum) matches — ranked is removed until it's real, see Current Tasks.
|
||||
|
||||
## Design Docs (`overview/`)
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| `overview.md` | Concept, core loop, game flow, match sizes |
|
||||
| `racesclasses.md` | 3 chosen races, 5 ship classes each |
|
||||
| `multiplayer.md` | Maps, game modes, galaxy war meta |
|
||||
| `racesclasses.md` | 2 chosen factions, 3 ship classes each |
|
||||
| `multiplayer.md` | Maps, game modes (design ideas — casual's Sector Control etc. not yet built), galaxy war meta |
|
||||
| `bots.md` | Bot fill rules for casual matches |
|
||||
| `tech.md` | Engine, networking architecture, checklist |
|
||||
| `money.md` | Monetization strategy |
|
||||
| `structure.md` | Project file tree, scene graph, input map |
|
||||
| `networking_implementation_plan.md` | Historical — the phased plan that took the project from zero networking to item 6/16's real implementation; all phases done |
|
||||
| `sprite-tileset-brief.md` | Art-request brief for ship/tileset needs, derived from `racesclasses.md` |
|
||||
| `chat.md` | Original chat-system spec (implemented, item 8) |
|
||||
| `map1.md` | Original match-structure spec — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats (implemented, item 26) |
|
||||
| `match_framework_progress.md` | Implementation checklist for item 26 — historical, all boxes checked |
|
||||
| `issue.md` | Dev notes (first Godot project, UI built in code, Steam Deck target) |
|
||||
|
||||
Matchmaking API (`matchmaking-api/`, separate FastAPI + Postgres service, own
|
||||
`README.md`) is a second codebase alongside the Godot project — see its
|
||||
@@ -60,13 +66,18 @@ Added the other half of that same knob: a VSYNC dropdown (Enabled/Adaptive/Disab
|
||||
- **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.
|
||||
23. **Per-role energy system** — each ship has an energy pool alongside health, spent on firing: Fighter 100 max / 75 per shot, Gunner 150 max / 25 per shot, Tank 250 max / 50 per shot (`GameConfig.ship_max_energy_by_role`/`ship_bullet_energy_cost_by_role`), regenerating at a flat 100-per-2.5s rate (`ship_energy_regen_rate`) regardless of role. Server-authoritative in `ships/ship_movement.gd`, same pattern as health — a shot is only fired if the server's copy of the ship has enough energy, and current energy broadcasts to every peer over the existing `_receive_state` RPC. Role is threaded through `PlayerRegistry`'s loadout (so remote peers and bots all get the right pool/cost, not just the local owner) and `bots/bot_manager.gd`'s bot registration. Shown as a text readout (`HUD/EnergyLabel`, below the health label) plus a blue center-out mirrored bar top-middle of screen (`hud/energy_bar.gd`/`.tscn`).
|
||||
24. **Capital ships (Flagships) + kill feed + bot personalities** — `world/flagship.gd` (`class_name Flagship`) spawns a 3-ship point-defense formation at each team's spawn marker (`world.gd`'s `_spawn_flagships()`, called with the same server-decided race_ids/match_seed every peer already uses for map/race setup, so every client builds an identical formation locally with no replication needed). Each flagship independently scans for the nearest enemy ship in `GameConfig.flagship_defense_radius` and fires a lead-aimed (with jitter, so it can miss) slow missile plus a faster bullet stream, both riding the existing networked `BulletSpawner` — deters a team from parking on the enemy spawn and farming respawns. 5000 HP, shootable for damage-number feedback but no destroy/respawn logic yet (that's future Flagship Assault mode, see `multiplayer.md`'s ranked-mode ideas). Collision/detection hulls are traced from each sprite's actual alpha channel via `Geometry2D.convex_hull`, not a bounding box, since the hulls taper sharply at the nose/tail. A ship flying under a flagship's hull ghosts (reuses `ship_movement.gd`'s existing explosion-cover ghost mechanism) rather than colliding. New per-race/role bullet sprites for turret fire live in `assets/images/effects/bullets/`. `hud/mini_map.gd`/`mini_map_view.gd` draw flagships as larger team-colored blips (`_update_flagships()`), and `bots/bot_ai.gd`'s awareness was tuned to notice them. Alongside this: a server-authoritative **kill feed** (`autoload/kill_feed_manager.gd` broadcasts victim/killer name+race on every death from `ship_movement.gd`'s `_die()`; `hud/kill_feed.gd` shows the last 4 lines stacked directly above ChatBox's history panel) and **bot personalities** (`bots/bot_personality.gd`, `class_name BotPersonality` — 5 independent 0.0-1.0 traits: aggression/caution/accuracy/reaction/awareness, rolled once per bot-slot by `BotManager` and kept for the bot's lifetime so a player can learn and counter a specific bot's behavior, see `bots.md`).
|
||||
25. **ISN flagship formation mixes in a Stealth Corvette hull** — item 24's 3-ship flagship formation was 3 identical `flagship_colossus.png` copies per faction; ISN's formation is now 2 Colossus + 1 `stealth_corvette.png` (a new hull processed from a user-supplied render: background matted out via border flood-fill, a disconnected decorative sparkle dropped, rotated 90° to this project's nose-up convention — see `assets/images/ships/isn/source/isn_stealth_corvette_obsidian_source.jpeg` for the original). `team_select.gd`'s ISN entry gained a `flagship_paths` array (list of `{path, scale}`, cycled by formation-slot index in `world.gd`'s `_spawn_flagship()`); a race with no `flagship_paths` (ORC, so far) falls through to the original singular `flagship_path`/`flagship_scale` for every slot, so this is additive and backward compatible. The Corvette's `scale: 0.68` was calibrated against its native resolution (516×1019, notably taller than Colossus's 535×692) so all 3 formation members read at the same on-screen size despite the size mismatch in source art.
|
||||
26. **Match structure — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats, reusable GameMode/map framework** (see `overview/map1.md`) — matches now have real phases instead of ships spawning the instant a peer connects. New `MatchManager` autoload (`autoload/match_manager.gd`) runs a server-authoritative `PRE_MATCH` (30s countdown, ships held invisible/uncollidable via `ship_movement.gd`'s `server_hold_for_match_start()`/`server_release_from_hold()` — reuses the existing dead-ship machinery rather than a parallel state, so prediction/reconciliation are untouched; flagships cosmetically tween in from a random offset via `flagship.gd`'s `play_creep_in()`) → `IN_PROGRESS` (the active `GameMode`'s 7-minute clock runs, ships released) → `POST_MATCH` (winner banner, result reported to matchmaking-api) → loops back into a fresh `PRE_MATCH` forever, matching the always-on server-pool model from item 16 rather than kicking players to the menu. Phase state is replicated with the same "decide once on the server, RPC-broadcast, late joiners pull it" pattern `world.gd`'s `decide_offered_races()` already established. Win-condition logic lives entirely in a new `GameMode` abstraction (`world/game_modes/game_mode.gd` base + `team_deathmatch_mode.gd`, the only mode implemented so far — most kills when the clock runs out wins, draw on a tie) built identically on every peer off a shared `GameConfig.default_game_mode_id` constant, so a future mode (`multiplayer.md`'s Flagship Assault/Last Ship Standing/Capture and Hold ideas) is a new subclass plus one factory branch in `MatchManager`, with zero changes to the timer/scoreboard/banner code around it — none of that ever hardcodes "kills," only ever calling `GameMode.get_score_for_team()`/`get_score_label()`. `world.gd`'s hardcoded `MAP_SCENE` const became a `MAPS` array + `_pick_map()` (same static-array convention as `TeamSelect.RACES`) so a second map is an append, not a rewrite — still only `map_01` exists today. New `MatchStats` autoload (`autoload/match_stats.gd`) tracks per-match (not persistent) kills/deaths by peer_id, including bots, hooked into the single existing death-detection call site (`kill_feed_manager.gd`'s `report_kill()`) rather than duplicating it in `ship_movement.gd`. Three new signal-driven HUD `CanvasLayer`s follow every existing HUD file's autonomous-`_build_ui()` convention: `hud/match_timer.gd` (top-center countdown/clock), `hud/scoreboard.gd` (hold-Tab CS:GO-style two-team panel, new `scoreboard` input action bound to Tab), `hud/match_banner.gd` (full-screen winner banner, `layer=30` — highest in the project). Banner art (`assets/images/banners/`) was renamed from opaque UUID filenames to `isn_banner.jpeg`/`orc_banner.jpeg` (the third, Apex Dynamics, stays orphaned per item 19's precedent of leaving unused assets on disk) and wired via a new `banner_path` key on `team_select.gd`'s ISN/ORC `RACES` entries, same pattern as the existing `flagship_path` key. Persistent per-player history (total kills, deaths, wins, losses, hours played) is a new `matchmaking-api` capability: `Player` gained `kills`/`deaths`/`hours_played` columns, a new `POST /matches/report` endpoint (`app/routers/matches.py`) is called once by the hosting server's `MatchManager` (never a client, same server-only trust model as `/servers/register`) at `POST_MATCH`, upserting each real (non-bot) player's totals by callsign via a shared `app/crud.py` helper also now used by the matchmaking-queue join path; `GET /stats/{callsign}` returns the 3 new fields alongside the existing `mmr`/`wins`/`losses`. No migrations tooling exists in this API (schema changes just land in `models.py` for `create_all()` to pick up on a fresh DB) — a `docker compose down -v` is needed once to add the new columns to an already-existing local `players` table, verified end-to-end via a real `docker compose up` round-trip during implementation.
|
||||
|
||||
## Current Tasks
|
||||
|
||||
- [ ] Asteroids and environment hazards
|
||||
- [ ] Sound effects (thrust, shoot, explosion, UI)
|
||||
- [x] ~~Asteroids and environment hazards~~ — done, undocumented until now: `world.gd`'s collider builder gives the map's `Asteroids` TileMapLayer round hitboxes in an `environment_hazard` group distinct from `environment_wall`, and deals impact damage (`GameConfig.ship_wall_damage`) on top of the bounce every wall/asteroid tile already causes
|
||||
- [ ] Sound effects — shoot (per-ship, `world.gd`'s `_play_shoot_sound()`) and menu music (`autoload/music_manager.gd`) exist; thrust, explosion, and UI-click sounds are still missing
|
||||
- [ ] 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`)
|
||||
- [ ] Additional game modes (Flagship Assault, Last Ship Standing, Capture and Hold — see `multiplayer.md`) via item 26's new `GameMode` framework (`world/game_modes/`); only Team Deathmatch is implemented so far
|
||||
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`) — Sector Control specifically would also need its own `GameMode` subclass, see above
|
||||
- [ ] Real account-linked identity — GodotSteam auth + VAC still not started; `callsign` is the only player identity today
|
||||
- [ ] Lag compensation (basic rewind) — matters once testing moves beyond localhost
|
||||
|
||||
Reference in New Issue
Block a user