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>
@@ -1,18 +1,24 @@
|
|||||||
# Spacewar
|
# 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/`)
|
## Design Docs (`overview/`)
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `overview.md` | Concept, core loop, game flow, match sizes |
|
| `overview.md` | Concept, core loop, game flow, match sizes |
|
||||||
| `racesclasses.md` | 3 chosen races, 5 ship classes each |
|
| `racesclasses.md` | 2 chosen factions, 3 ship classes each |
|
||||||
| `multiplayer.md` | Maps, game modes, galaxy war meta |
|
| `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 |
|
| `bots.md` | Bot fill rules for casual matches |
|
||||||
| `tech.md` | Engine, networking architecture, checklist |
|
| `tech.md` | Engine, networking architecture, checklist |
|
||||||
| `money.md` | Monetization strategy |
|
| `money.md` | Monetization strategy |
|
||||||
| `structure.md` | Project file tree, scene graph, input map |
|
| `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
|
Matchmaking API (`matchmaking-api/`, separate FastAPI + Postgres service, own
|
||||||
`README.md`) is a second codebase alongside the Godot project — see its
|
`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.
|
- **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.
|
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
|
## Current Tasks
|
||||||
|
|
||||||
- [ ] Asteroids and environment hazards
|
- [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 (thrust, shoot, explosion, UI)
|
- [ ] 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)
|
- [ ] 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`)
|
- [ ] 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
|
- [ ] 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
|
- [ ] Lag compensation (basic rewind) — matters once testing moves beyond localhost
|
||||||
|
|||||||
@@ -76,6 +76,36 @@ value; `menu/server_select.gd` also queries each server directly over UDP
|
|||||||
(`network_manager.gd`'s ping responder) for a live, pre-connect number and
|
(`network_manager.gd`'s ping responder) for a live, pre-connect number and
|
||||||
RTT, overriding the DB value in the UI once that probe answers.
|
RTT, overriding the DB value in the UI once that probe answers.
|
||||||
|
|
||||||
|
## Match results & persistent stats
|
||||||
|
|
||||||
|
`POST /matches/report` `{server_ip, server_port, mode, winner_team, players}` —
|
||||||
|
called once by the hosting game server (never a client) when a match ends,
|
||||||
|
see `spacewar/autoload/match_manager.gd`'s `_report_match_result()` (fired
|
||||||
|
from its `MatchManager` phase state machine's `POST_MATCH` transition, driven
|
||||||
|
by `overview/map1.md`'s 7-minute-match design). `players` is a list of
|
||||||
|
`{callsign, team, kills, deaths, is_winner, seconds_played}` — one entry per
|
||||||
|
*human* player present at match end (bots, negative peer_id in the Godot
|
||||||
|
client, never report). For each player this upserts their `Player` row
|
||||||
|
(`app/crud.py`'s `get_or_create_player`, same helper `/matchmaking/queue/join`
|
||||||
|
uses) and accumulates `kills`/`deaths`/`hours_played` (`seconds_played / 3600`)
|
||||||
|
plus `wins`/`losses` (a draw — `winner_team: null` — touches neither). If the
|
||||||
|
reporting `(server_ip, server_port, mode)` matches a registered `GameServer`,
|
||||||
|
a `Match`/`MatchPlayer` row is also written — independent of any `Match` row
|
||||||
|
the matchmaking queue already created when the match was *formed*
|
||||||
|
(`queue_manager.py`), so a queued match can end up with two `Match` rows (one
|
||||||
|
per "formed"/"ended"); nothing currently reads these tables, so this hasn't
|
||||||
|
been reconciled. No auth on this endpoint, same posture as `/servers/register`.
|
||||||
|
|
||||||
|
`GET /stats/{callsign}` now also returns `kills`/`deaths`/`hours_played`
|
||||||
|
alongside the existing `mmr`/`wins`/`losses`.
|
||||||
|
|
||||||
|
**Schema changes have no migration path** (this project has no migrations
|
||||||
|
tooling — see below): `Player.kills`/`deaths`/`hours_played` are new columns
|
||||||
|
on an existing table, so `Base.metadata.create_all()` on API boot will NOT
|
||||||
|
add them to a database that already has a `players` table from before this
|
||||||
|
change. Run `docker compose down -v` once to pick them up on an existing local
|
||||||
|
dev database.
|
||||||
|
|
||||||
## Client integration
|
## Client integration
|
||||||
|
|
||||||
The Godot client is wired up (`spacewar/autoload/matchmaking_client.gd`):
|
The Godot client is wired up (`spacewar/autoload/matchmaking_client.gd`):
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models import Player
|
||||||
|
|
||||||
|
|
||||||
|
# Shared by app/routers/matchmaking.py (queue join) and app/routers/matches.py
|
||||||
|
# (match result reporting) -- both need "find this callsign's Player row, or
|
||||||
|
# create one" with no account/auth system yet (see Player's own docstring).
|
||||||
|
async def get_or_create_player(db: AsyncSession, callsign: str) -> Player:
|
||||||
|
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||||
|
if player is None:
|
||||||
|
player = Player(callsign=callsign)
|
||||||
|
db.add(player)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(player)
|
||||||
|
return player
|
||||||
@@ -8,7 +8,7 @@ from app.config import settings
|
|||||||
from app.database import Base, async_session, engine
|
from app.database import Base, async_session, engine
|
||||||
from app.models import GameServer, Mode, ServerStatus
|
from app.models import GameServer, Mode, ServerStatus
|
||||||
from app.queue_manager import queue_manager
|
from app.queue_manager import queue_manager
|
||||||
from app.routers import matchmaking, ranks, servers, stats
|
from app.routers import matches, matchmaking, ranks, servers, stats
|
||||||
from app.routers.servers import sweep_stale_servers
|
from app.routers.servers import sweep_stale_servers
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +72,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
|
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
|
||||||
|
|
||||||
app.include_router(matchmaking.router)
|
app.include_router(matchmaking.router)
|
||||||
|
app.include_router(matches.router)
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
app.include_router(ranks.router)
|
app.include_router(ranks.router)
|
||||||
app.include_router(servers.router)
|
app.include_router(servers.router)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import enum
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String
|
||||||
from sqlalchemy import Enum as SAEnum
|
from sqlalchemy import Enum as SAEnum
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -30,6 +30,14 @@ class Player(Base):
|
|||||||
mmr: Mapped[int] = mapped_column(Integer, default=1000)
|
mmr: Mapped[int] = mapped_column(Integer, default=1000)
|
||||||
wins: Mapped[int] = mapped_column(Integer, default=0)
|
wins: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
losses: Mapped[int] = mapped_column(Integer, default=0)
|
losses: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
# Match-history stats (see spacewar/autoload/match_manager.gd's
|
||||||
|
# _report_match_result(), POST /matches/report) -- kills/deaths accumulate
|
||||||
|
# across every match reported for this callsign; hours_played is derived
|
||||||
|
# from each match's full duration for every player present at match end
|
||||||
|
# (not precise per-player join/leave timing -- a documented simplification).
|
||||||
|
kills: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
deaths: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
hours_played: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app import crud
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import GameServer, Match, MatchPlayer
|
||||||
|
from app.schemas import MatchReportRequest
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/matches", tags=["matches"])
|
||||||
|
|
||||||
|
|
||||||
|
# Called once by the hosting server's MatchManager (spacewar/autoload/
|
||||||
|
# match_manager.gd's _report_match_result()) when a match's POST_MATCH phase
|
||||||
|
# begins -- persists this match's final kills/deaths/win-loss into each
|
||||||
|
# player's running Player totals (kills, deaths, wins, losses, hours_played),
|
||||||
|
# and records a Match/MatchPlayer row if the reporting server is a known,
|
||||||
|
# registered GameServer. No auth -- same posture as /servers/register today,
|
||||||
|
# not a new gap introduced here (see overview/map1.md's design notes).
|
||||||
|
#
|
||||||
|
# Independent of any Match row the matchmaking queue may have already created
|
||||||
|
# when this match was *formed* (see queue_manager.py's _try_form_match) --
|
||||||
|
# this always writes a fresh Match/MatchPlayer pair representing how the
|
||||||
|
# match actually ended. A queued match can therefore end up with two Match
|
||||||
|
# rows (one "how it was formed", one "how it ended"); acceptable since no
|
||||||
|
# existing code reads these tables today and there's no migrations tooling to
|
||||||
|
# reconcile the schema around it.
|
||||||
|
@router.post("/report")
|
||||||
|
async def report_match(req: MatchReportRequest, db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
|
server = (
|
||||||
|
await db.execute(
|
||||||
|
select(GameServer).where(
|
||||||
|
GameServer.ip == req.server_ip,
|
||||||
|
GameServer.port == req.server_port,
|
||||||
|
GameServer.mode == req.mode,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
|
||||||
|
match_row = None
|
||||||
|
if server is not None:
|
||||||
|
match_row = Match(mode=req.mode, server_id=server.id)
|
||||||
|
db.add(match_row)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
for result in req.players:
|
||||||
|
player = await crud.get_or_create_player(db, result.callsign)
|
||||||
|
player.kills += result.kills
|
||||||
|
player.deaths += result.deaths
|
||||||
|
player.hours_played += result.seconds_played / 3600.0
|
||||||
|
if result.is_winner:
|
||||||
|
player.wins += 1
|
||||||
|
elif req.winner_team is not None:
|
||||||
|
player.losses += 1
|
||||||
|
if match_row is not None:
|
||||||
|
db.add(MatchPlayer(match_id=match_row.id, player_id=player.id, team=result.team))
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"status": "recorded"}
|
||||||
@@ -1,30 +1,19 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app import crud
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Player
|
|
||||||
from app.queue_manager import queue_manager
|
from app.queue_manager import queue_manager
|
||||||
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
|
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
|
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
|
||||||
|
|
||||||
|
|
||||||
async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
|
|
||||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
|
||||||
if player is None:
|
|
||||||
player = Player(callsign=callsign)
|
|
||||||
db.add(player)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(player)
|
|
||||||
return player
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/queue/join", response_model=QueueJoinResponse)
|
@router.post("/queue/join", response_model=QueueJoinResponse)
|
||||||
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
|
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
|
||||||
player = await _get_or_create_player(db, req.callsign)
|
player = await crud.get_or_create_player(db, req.callsign)
|
||||||
|
|
||||||
ticket = await queue_manager.join(player.id, player.callsign, req.mode, player.mmr)
|
ticket = await queue_manager.join(player.id, player.callsign, req.mode, player.mmr)
|
||||||
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
|
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
|
||||||
|
|||||||
@@ -14,4 +14,12 @@ async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> Player
|
|||||||
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
|
||||||
if player is None:
|
if player is None:
|
||||||
raise HTTPException(status_code=404, detail="Player not found")
|
raise HTTPException(status_code=404, detail="Player not found")
|
||||||
return PlayerStatsResponse(callsign=player.callsign, mmr=player.mmr, wins=player.wins, losses=player.losses)
|
return PlayerStatsResponse(
|
||||||
|
callsign=player.callsign,
|
||||||
|
mmr=player.mmr,
|
||||||
|
wins=player.wins,
|
||||||
|
losses=player.losses,
|
||||||
|
kills=player.kills,
|
||||||
|
deaths=player.deaths,
|
||||||
|
hours_played=player.hours_played,
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,3 +36,28 @@ class PlayerStatsResponse(BaseModel):
|
|||||||
mmr: int
|
mmr: int
|
||||||
wins: int
|
wins: int
|
||||||
losses: int
|
losses: int
|
||||||
|
kills: int
|
||||||
|
deaths: int
|
||||||
|
hours_played: float
|
||||||
|
|
||||||
|
|
||||||
|
# Reported by the hosting game server (never a client directly -- see
|
||||||
|
# spacewar/autoload/network_manager.gd's register_server()/heartbeat being
|
||||||
|
# server-only-triggered the same way) once a match's MatchManager POST_MATCH
|
||||||
|
# phase begins. team here is a race_id (races double as teams, see
|
||||||
|
# menu/team_select.gd's RACES), not a 0/1 index.
|
||||||
|
class MatchPlayerResult(BaseModel):
|
||||||
|
callsign: str
|
||||||
|
team: int
|
||||||
|
kills: int
|
||||||
|
deaths: int
|
||||||
|
is_winner: bool
|
||||||
|
seconds_played: float
|
||||||
|
|
||||||
|
|
||||||
|
class MatchReportRequest(BaseModel):
|
||||||
|
server_ip: str
|
||||||
|
server_port: int
|
||||||
|
mode: Mode = Mode.casual
|
||||||
|
winner_team: int | None = None # None = draw
|
||||||
|
players: list[MatchPlayerResult]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
I wanted to work on making the matches seem more official with winners and losers . Each player will have a history of total kills, total wins, total losses, hours in a match.
|
||||||
|
I wanted the current match to have a timer. Each match is 7 minutes long. The way the match works is all
|
||||||
|
players are at the startpoint where the ships are on there perspective side. They dont see there ships yet. The timer starts counting down from 30 seconds. the 3 capital ships slowly start creeping to there spots. WHen the countdown reaches 0 the players or bots ships appear in the spawn. The match will be 7 minutes long. In that time there will be stats for each player. Total kills, total deaths. At the end of the match the team with the most kills wins. There will be a banner of the winner displayhed on the map saying ORC or ISN wins . A user can press tab to see a current scoreboard similiar to csgo.
|
||||||
|
. Banners are located here /mnt/code/spacewar/spacewar/assets/images/banners/.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Match Framework Implementation Progress
|
||||||
|
|
||||||
|
Tracking checklist for the plan in `overview/map1.md` (pre-match countdown, 7-minute
|
||||||
|
timer, kills/deaths, scoreboard, winner banner, persistent stats, reusable
|
||||||
|
GameMode/map framework). Full design plan: see the approved plan this session
|
||||||
|
(sections referenced below). Check items off as they're completed.
|
||||||
|
|
||||||
|
- [x] 1. GameMode abstraction — `world/game_modes/game_mode.gd` (base) + `team_deathmatch_mode.gd`
|
||||||
|
- [x] 2. `MatchStats` autoload — per-match kills/deaths, hook into `kill_feed_manager.gd`
|
||||||
|
- [x] 3. `MatchManager` autoload — PRE_MATCH/IN_PROGRESS/POST_MATCH phase state machine
|
||||||
|
- [x] 4. Flagship creep-in tween — `flagship.gd:play_creep_in()`, `world.gd:play_flagship_creep_in()`, `GameConfig.flagship_creep_in_distance`
|
||||||
|
- [x] 5. Ship-spawn gating — `ship_movement.gd` pre-match hold (`_server_respawn` phase gate, `server_hold_for_match_start`/`server_release_from_hold`/`_apply_pre_match_hold`), `world.gd` hold/release helpers
|
||||||
|
- [x] 6. Map abstraction — `world.gd` `MAPS` array + `_pick_map()` replacing hardcoded `MAP_SCENE`
|
||||||
|
- [x] 7. Scoreboard HUD — `hud/scoreboard.gd`+`.tscn`, hold-Tab, `scoreboard` input action in `project.godot`
|
||||||
|
- [x] 8. Winner banner HUD — `hud/match_banner.gd`+`.tscn`, `banner_path` in `team_select.gd` RACES, rename ISN/ORC banner assets
|
||||||
|
- [x] 9. Match timer HUD — `hud/match_timer.gd`+`.tscn`, top-center MM:SS readout
|
||||||
|
- [x] 10. Wiring — new autoloads in `project.godot`, new HUD nodes in `world.tscn`, `MatchManager.start(self)` + late-joiner pull RPCs in `world.gd:_ready()`
|
||||||
|
- [x] 11. Backend schema + endpoint — `matchmaking-api` `Player.kills/deaths/hours_played`, `app/crud.py`, `app/schemas.py`, `app/routers/matches.py` (`POST /matches/report`), `app/routers/stats.py`, `app/main.py` (verified end-to-end via docker compose)
|
||||||
|
- [x] 12. Godot-side backend call site — `network_manager.gd:get_registered_address()`, `matchmaking_client.gd:report_match_result()`, `MatchManager._report_match_result()`
|
||||||
|
- [x] 13. Docs update — `CLAUDE.md` new numbered item, `overview/multiplayer.md`, `overview/structure.md`
|
||||||
|
- [x] 14. Verification — headless server smoke test: clean compile, full PRE_MATCH→IN_PROGRESS→POST_MATCH→loop cycle with a temporarily-sped-up clock, bot-vs-bot kills flowing through MatchStats and correctly deciding the winning race, backend /matches/report round-trip via a real docker compose run. Additionally ran a REAL two-process ENet test (separate server + client, real network handshake, not single-process offline mode): confirmed real peer-connect, correct late-joiner phase/timer pull mid-countdown, and every ship (bots, server's own ghost, the real second client's ship) held/released in perfect lockstep across both processes over two full match loops. Also incidentally confirmed the pre-existing bot-rebalancing logic (item 9) correctly reacted to the real human joining. NOT verified: visual rendering (hidden ships, flagship creep-in tween, Tab scoreboard, winner banner) and the Tab keybind itself, since this sandbox has no display — needs a real client pass on the user's machine.
|
||||||
|
|
||||||
|
## All 14 items complete.
|
||||||
|
|
||||||
|
Remaining follow-up recommended: a real (non-headless) client session on the user's machine to
|
||||||
|
visually confirm the countdown/creep-in/scoreboard/banner and to exercise Tab's input-map binding
|
||||||
|
(see hud/scoreboard.gd's comment about verifying the hand-typed Tab keycode in the Godot editor's
|
||||||
|
Input Map UI).
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
|
|
||||||
## Ranked Mode — 5v5
|
## Ranked Mode — 5v5
|
||||||
|
|
||||||
|
> **Status:** design ideas only — not implemented. The matchmaking API's ranked
|
||||||
|
> queue path was built and then deleted (`CLAUDE.md` item 15) once it became
|
||||||
|
> clear no client would hit it before casual was solid; it's absent from the
|
||||||
|
> main menu entirely (not just disabled) until it's built for real.
|
||||||
|
|
||||||
Competitive, skill-based, MMR/ELO ladder. Penalty for leaving mid-match. Small–medium maps only.
|
Competitive, skill-based, MMR/ELO ladder. Penalty for leaving mid-match. Small–medium maps only.
|
||||||
|
|
||||||
### Ranked Mode Ideas
|
### Ranked Mode Ideas
|
||||||
@@ -36,10 +41,21 @@ Competitive, skill-based, MMR/ELO ladder. Penalty for leaving mid-match. Small
|
|||||||
|
|
||||||
No penalty for leaving. Larger maps. Chaotic and fun — you can drop in mid-match.
|
No penalty for leaving. Larger maps. Chaotic and fun — you can drop in mid-match.
|
||||||
|
|
||||||
|
> **Status:** **Team Deathmatch** (the first row below) is implemented — a
|
||||||
|
> 7-minute timed version of Annihilation's "pure kills" idea (most kills when
|
||||||
|
> the clock runs out wins, not last-team-standing), plus a 30s pre-match
|
||||||
|
> countdown, live kill/death tracking, a Tab scoreboard, and a winner banner
|
||||||
|
> (see `overview/map1.md`, `CLAUDE.md` item 26). It's built through a
|
||||||
|
> `GameMode` abstraction (`spacewar/world/game_modes/`) specifically so the
|
||||||
|
> other rows here — Sector Control, King of the Hill, Base Assault, Convoy
|
||||||
|
> Escort — can each become a new `GameMode` subclass later without changing
|
||||||
|
> the match-phase/timer/scoreboard/banner machinery around them.
|
||||||
|
|
||||||
### Casual Mode Ideas
|
### Casual Mode Ideas
|
||||||
|
|
||||||
| Mode | Description |
|
| Mode | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| **Team Deathmatch** *(implemented)* | Most kills when the match timer runs out wins. Timed variant of the Annihilation idea below. |
|
||||||
| **Sector Control** | Map divided into sectors. Hold more sectors at time limit. Ties to galactic war meta. |
|
| **Sector Control** | Map divided into sectors. Hold more sectors at time limit. Ties to galactic war meta. |
|
||||||
| **King of the Hill** | One contested zone in the center. Hold it to rack up points. |
|
| **King of the Hill** | One contested zone in the center. Hold it to rack up points. |
|
||||||
| **Annihilation** | Pure kills. Last team standing or first to X kills. Chaos mode. |
|
| **Annihilation** | Pure kills. Last team standing or first to X kills. Chaos mode. |
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
# Networking Implementation Plan
|
# Networking Implementation Plan
|
||||||
|
|
||||||
|
> **Status: complete.** This was the ordered checklist that took Spacewar from
|
||||||
|
> zero networking code to the real ENet authoritative-server implementation —
|
||||||
|
> all 10 phases below were executed (see `CLAUDE.md` item 6 and the many items
|
||||||
|
> after it that build on top of this foundation). Kept as a historical record
|
||||||
|
> of the approach and ordering; the checkboxes are left unchecked as
|
||||||
|
> originally written rather than retroactively edited. For current
|
||||||
|
> architecture, see `tech.md`; for the present file layout, see `structure.md`.
|
||||||
|
|
||||||
Step-by-step breakdown for taking Spacewar from single-player-only to working
|
Step-by-step breakdown for taking Spacewar from single-player-only to working
|
||||||
multiplayer. `tech.md` describes the target architecture; this doc is the
|
multiplayer. `tech.md` describes the target architecture; this doc is the
|
||||||
ordered checklist for getting there from the current codebase.
|
ordered checklist for getting there from the current codebase.
|
||||||
|
|
||||||
**Current state (as of this doc):** zero networking code exists anywhere in
|
**Original starting state:** zero networking code existed anywhere in
|
||||||
the project. The server browser's JOIN button and the main menu's CASUAL
|
the project. The server browser's JOIN button and the main menu's CASUAL
|
||||||
button just set local `GameConfig` fields and load `world.tscn` — no
|
button just set local `GameConfig` fields and load `world.tscn` — no
|
||||||
connection is ever made. Everything downstream assumes exactly one player.
|
connection was ever made. Everything downstream assumed exactly one player.
|
||||||
|
|
||||||
**Guiding principle:** get two peers moving/shooting/dying correctly over
|
**Guiding principle:** get two peers moving/shooting/dying correctly over
|
||||||
ENet *first*, with ugly/no prediction. Polish feel (prediction,
|
ENet *first*, with ugly/no prediction. Polish feel (prediction,
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
## Concept
|
## Concept
|
||||||
|
|
||||||
A 2D pixel-art top-down space shooter inspired by **Subspace Continuum**, built for **Steam Deck and PC**. Two alien races fight for control of a galaxy. Fast matchmaking like Rocket League — quick in, quick out.
|
A 2D pixel-art top-down space shooter inspired by **Subspace Continuum**, built for **Steam Deck and PC**. Two human factions — Inner Sphere Navy (disciplined military) and Outer Rim Collective (scrappy belters), not aliens — fight for control of a galaxy inside the tight corridors of factories, refineries, and shipyards. Fast matchmaking like Rocket League — quick in, quick out.
|
||||||
|
|
||||||
## Core Loop
|
## Core Loop
|
||||||
|
|
||||||
1. Launch → Main Menu
|
1. Launch → Main Menu
|
||||||
2. Enter callsign → click CASUAL
|
2. Enter callsign (PROFILE overlay, auto-opens first time) → click QUICK PLAY
|
||||||
3. World loads → pick your race (both offered every match) → pick your ship
|
3. World loads → pick your faction (both offered every match) → pick your ship
|
||||||
4. Fight in a 25v25 battle
|
4. Fight in a 25v25 battle
|
||||||
5. Return to main menu
|
5. Return to main menu
|
||||||
|
|
||||||
@@ -16,45 +16,45 @@ A 2D pixel-art top-down space shooter inspired by **Subspace Continuum**, built
|
|||||||
|
|
||||||
| Mode | Size | Status |
|
| Mode | Size | Status |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| Casual | 25v25 (bots fill to 7v7 minimum) | In progress |
|
| Casual | 25v25 (bots fill to 7v7 minimum) | Live |
|
||||||
| Ranked | 5v5, competitive MMR | Disabled — coming later |
|
| Ranked | 5v5, competitive MMR | Removed (not just disabled) until it's real — see `CLAUDE.md` Current Tasks |
|
||||||
|
|
||||||
## Current Game Flow (as built)
|
## Current Game Flow (as built)
|
||||||
|
|
||||||
```
|
```
|
||||||
Main Menu
|
Main Menu (HL2-style vertical nav: QUICK PLAY / SERVER SELECT / OPTIONS / PROFILE / QUIT)
|
||||||
└─ CASUAL ──► World loads immediately (game active)
|
└─ QUICK PLAY ──► matchmaking queue (casual) ──► World loads (game active)
|
||||||
|
(SERVER SELECT skips the queue, connects to a picked server directly)
|
||||||
└─ TeamSelect overlay appears
|
└─ TeamSelect overlay appears
|
||||||
├─ Pick race (both offered every match)
|
├─ Pick faction (both offered every match)
|
||||||
└─ Pick ship (4 available, sprites shown)
|
└─ Pick ship (3 available, sprites shown)
|
||||||
└─ Player spawns centred, invincible briefly
|
└─ Player spawns centred, invincible briefly, flies in from a random direction
|
||||||
└─ ESC / Start ──► Pause menu overlay
|
└─ ESC / Start ──► Pause menu overlay
|
||||||
├─ RESUME
|
├─ RESUME
|
||||||
├─ SETTINGS (stub)
|
├─ SETTINGS (live — audio, frame-rate cap, vsync, window mode, colorblind mode, key rebinding)
|
||||||
├─ SELECT TEAM (stub)
|
├─ SELECT TEAM (live — reopens TeamSelect mid-match, swaps faction/ship with a fresh respawn)
|
||||||
├─ QUIT TO MENU
|
├─ QUIT TO MENU
|
||||||
└─ QUIT TO DESKTOP
|
└─ QUIT TO DESKTOP
|
||||||
```
|
```
|
||||||
|
|
||||||
## Races
|
## Factions
|
||||||
|
|
||||||
2 playable races (a third, Apex Dynamics, was cut to simplify the art pipeline) — see `racesclasses.md`. Each race has 5 ship classes:
|
2 playable factions (a third, Apex Dynamics, was cut to simplify the art pipeline) — see `racesclasses.md`. Each faction has 3 ship classes:
|
||||||
|
|
||||||
| Class | Role |
|
| Class | Role |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| Interceptor | Fast, agile generalist |
|
| Fighter | Single, high-precision, high-damage shot |
|
||||||
| Bomber | Area damage, slow |
|
| Gunner | Rapid-fire spray/burst, more shots for less damage each |
|
||||||
| Support | Heal/rally; allies can attach |
|
| Tank | Bigger and slower; fires both bullets and missiles |
|
||||||
| Stealth | Assassin, hit-and-run |
|
|
||||||
| Heavy | Tank, massive firepower |
|
|
||||||
|
|
||||||
Races are visually distinct — critical for reading a 25v25 battlefield at a glance.
|
Factions are visually distinct — critical for reading a 25v25 battlefield at a glance.
|
||||||
|
|
||||||
## Match Setup
|
## Match Setup
|
||||||
|
|
||||||
- Both races are fielded every match (order they're presented in is randomized)
|
- Both factions are fielded every match (order they're presented in is randomized)
|
||||||
- Player picks which of the 2 they fight for, then picks their ship
|
- Player picks which of the 2 they fight for, then picks their ship
|
||||||
- Bot fill ensures a minimum of **7v7** in casual; no bots added beyond that
|
- Bot fill ensures a minimum of **7v7** in casual; no bots added beyond that
|
||||||
|
- Each ship has an energy pool spent on firing (Fighter 100/Gunner 150/Tank 250 max, regenerating over time) alongside health — see `CLAUDE.md` item 23
|
||||||
|
|
||||||
## Design Philosophy
|
## Design Philosophy
|
||||||
|
|
||||||
|
|||||||
@@ -7,40 +7,67 @@ spacewar/ ← repo root
|
|||||||
└── spacewar/ ← Godot project root (open this in Godot editor)
|
└── spacewar/ ← Godot project root (open this in Godot editor)
|
||||||
├── project.godot
|
├── project.godot
|
||||||
├── autoload/
|
├── autoload/
|
||||||
│ └── game_config.gd ← autoload singleton (tuning values, player state, signals)
|
│ ├── 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/
|
├── menu/
|
||||||
│ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav
|
│ ├── 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
|
│ ├── 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)
|
│ ├── pause_menu.tscn/gd ← in-game pause overlay (ESC / Start button)
|
||||||
│ └── team_select.tscn/gd ← race + ship selection overlay (shown on world load)
|
│ ├── 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/
|
├── ships/
|
||||||
│ ├── ship.tscn ← player ship scene (formerly node_2d.tscn)
|
│ ├── ship.tscn ← player ship scene
|
||||||
│ ├── ship_movement.gd ← player ship logic
|
│ ├── ship_movement.gd ← player ship logic (movement, prediction/reconciliation, health/energy, nameplate interpolation)
|
||||||
│ └── bullet.tscn/gd ← projectile
|
│ └── bullet.tscn/gd ← projectile
|
||||||
├── chat/
|
├── chat/
|
||||||
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
|
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
|
||||||
├── hud/
|
├── hud/
|
||||||
│ └── player_list.tscn/gd ← top-left player roster (white=team, yellow=enemy, "(b)"=bot), added to world.tscn
|
│ ├── 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/
|
├── bots/
|
||||||
│ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation)
|
│ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation)
|
||||||
│ ├── bot_ai.gd ← class_name BotAI; seek-nearest-enemy-and-shoot brain
|
│ ├── 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
|
│ └── bot_names.gd ← class_name BotNames; procedural callsign pool
|
||||||
├── world/
|
├── world/
|
||||||
│ ├── world.tscn ← game world; world.gd loads the active map into MapContainer
|
│ ├── 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/
|
│ ├── 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)
|
│ ├── 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/
|
│ └── maps/
|
||||||
│ └── map_01.tscn ← hand-painted map: Walls + Asteroids TileMapLayers, team spawn Marker2Ds
|
│ └── map_01.tscn ← hand-painted map: Walls + Asteroids TileMapLayers (asteroids deal impact damage, not just bounce), team spawn Marker2Ds
|
||||||
└── assets/
|
└── assets/
|
||||||
├── icon.svg
|
├── icon.svg
|
||||||
|
├── default_bus_layout.tres ← Master/Music/SFX audio buses
|
||||||
└── images/
|
└── images/
|
||||||
├── background/skybox/ ← 6 space background PNGs (1.png – 6.png)
|
├── background/skybox/ ← space background PNGs
|
||||||
├── effects/ ← explosion1.png
|
├── effects/
|
||||||
├── ships/example_ships/ ← 4 placeholder ship sprites (1.png, 1B.png, 2a.png, 3b.png)
|
│ ├── explosion/ ← ship-death explosion animation frames
|
||||||
└── tiles/ ← walls.png, asteroids.png, used by world_tileset.tres
|
│ └── 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
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `asteroid_movement.gd` referenced in an earlier version of this doc does not exist yet — it's still an open task (see `CLAUDE.md`).
|
> `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
|
## Scene Graph
|
||||||
|
|
||||||
@@ -56,33 +83,43 @@ ServerSelect (Control) ← server_select.gd builds all UI in _ready(), fetches
|
|||||||
|
|
||||||
### `world/world.tscn` — game world
|
### `world/world.tscn` — game world
|
||||||
```
|
```
|
||||||
World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready()
|
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
|
├── MapContainer (Node2D) ← holds the instanced map (world/maps/map_01.tscn), background included
|
||||||
├── Player ← instance of ships/ship.tscn
|
├── Players (Node2D) ← per-peer ship.tscn instances live here
|
||||||
├── HUD (CanvasLayer)
|
├── MultiplayerSpawner ← spawns/despawns ships into Players
|
||||||
│ └── HealthLabel
|
├── Bullets (Node2D) ← server-spawned bullet.tscn instances
|
||||||
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
|
├── BulletSpawner (MultiplayerSpawner)
|
||||||
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
|
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
|
||||||
├── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
|
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; also reopened live from the pause menu's SELECT TEAM
|
||||||
└── PlayerList (CanvasLayer, layer=4) ← hud/player_list.tscn
|
├── 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
|
### `world/maps/map_01.tscn` — hand-painted map
|
||||||
```
|
```
|
||||||
Map01 (Node2D)
|
Map01 (Node2D)
|
||||||
├── Background (TextureRect) ← per-map space background (skybox/1.png); visible while editing this scene
|
├── 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
|
├── 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
|
├── Asteroids (TileMapLayer) ← painted by hand, same shared tileset; bounces AND deals impact damage (environment_hazard group, GameConfig.ship_wall_damage)
|
||||||
└── SpawnPoints (Node2D)
|
└── SpawnPoints (Node2D)
|
||||||
├── TeamASpawn1 (Marker2D) ← group "team_a_spawn"
|
├── TeamASpawn1 (Marker2D) ← group "team_a_spawn"; also where world.gd spawns Team A's flagship formation
|
||||||
└── TeamBSpawn1 (Marker2D) ← group "team_b_spawn"
|
└── TeamBSpawn1 (Marker2D) ← group "team_b_spawn"; also where world.gd spawns Team B's flagship formation
|
||||||
```
|
```
|
||||||
|
|
||||||
### `ships/ship.tscn` — player ship
|
### `ships/ship.tscn` — player ship
|
||||||
```
|
```
|
||||||
Player (CharacterBody2D) ← ship_movement.gd
|
Player (CharacterBody2D) ← ship_movement.gd
|
||||||
├── CollisionShape2D ← CircleShape2D
|
├── CollisionShape2D ← CircleShape2D
|
||||||
├── Sprite2D ← texture set at runtime from GameConfig.player_ship_path
|
├── Sprite2D ← texture set at runtime from PlayerRegistry's per-peer ship_path
|
||||||
└── VisibleOnScreenNotifier2D
|
└── VisibleOnScreenNotifier2D
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -98,6 +135,7 @@ Player (CharacterBody2D) ← ship_movement.gd
|
|||||||
| `toggle_pause` | Esc | Start / Options button (JoyButton 6) |
|
| `toggle_pause` | Esc | Start / Options button (JoyButton 6) |
|
||||||
| `chat_all` | T | — |
|
| `chat_all` | T | — |
|
||||||
| `chat_team` | Y | — |
|
| `chat_team` | Y | — |
|
||||||
|
| `scoreboard` | Tab | — |
|
||||||
|
|
||||||
## Game Flow
|
## Game Flow
|
||||||
|
|
||||||
@@ -108,7 +146,7 @@ main_menu.tscn
|
|||||||
│ ├── TeamSelect overlay: pick ship
|
│ ├── TeamSelect overlay: pick ship
|
||||||
│ └── Player spawns → game live
|
│ └── Player spawns → game live
|
||||||
├── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
|
├── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
|
||||||
├── OPTIONS → stub, coming soon
|
├── 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)
|
├── PROFILE → callsign-edit overlay (also auto-opens from QUICK PLAY if no callsign is set)
|
||||||
└── QUIT
|
└── QUIT
|
||||||
```
|
```
|
||||||
@@ -142,34 +180,61 @@ exact bug converting the play-mode buttons to be centered.
|
|||||||
|
|
||||||
## Key Autoload — `GameConfig`
|
## Key Autoload — `GameConfig`
|
||||||
|
|
||||||
| Property | Type | Set by |
|
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
|
||||||
| `player_name` | String | Main menu callsign input |
|
now (see below). `GameConfig` today holds match-wide tuning constants plus
|
||||||
| `player_race` | int | TeamSelect race pick |
|
persisted user settings. Grouped by area rather than listed exhaustively
|
||||||
| `player_ship_path` | String | TeamSelect ship pick |
|
(~70 properties) — see `autoload/game_config.gd` for the full list:
|
||||||
| `team_selected` | signal | Emitted by TeamSelect when done |
|
|
||||||
| `ship_thrust` | float | Tuning constant |
|
| Area | Examples | Notes |
|
||||||
| `ship_max_speed` | float | Tuning constant |
|
|------|----------|-------|
|
||||||
| `ship_rotation_speed` | float | Tuning constant |
|
| 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_fire_rate` | float | Tuning constant |
|
| Ship movement | `ship_thrust`, `ship_max_speed`, `ship_rotation_speed`, `ship_rotation_ramp_delay` | Tuning constants |
|
||||||
| `bullet_speed` | float | Tuning constant |
|
| 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"` |
|
||||||
| `ship_max_health` | int | Tuning constant |
|
| Energy | `ship_max_energy_by_role`, `ship_bullet_energy_cost_by_role`, `ship_energy_regen_rate` | See CLAUDE.md item 23 |
|
||||||
| `ship_respawn_delay` | float | Tuning constant |
|
| 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 |
|
||||||
| `ship_invincibility_time` | float | Tuning constant |
|
| Collision/visuals | `ship_bounce_restitution`, `ship_collision_damage_scale`, `ship_scale_factor`, `ship_hitbox_scale`, `explosion_scale`, `damage_number_*` | |
|
||||||
| `bullet_damage` | int | Tuning constant |
|
| 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` |
|
||||||
| `ship_wall_damage` | int | Tuning constant |
|
| Camera/world | `camera_zoom`, `world_bounds`, `spawn_area_inner_radius`/`outer_radius` | |
|
||||||
| `chat_focused` | bool | Set by ChatBox while its input line has keyboard focus; gates ship movement/fire input |
|
| 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 |
|
||||||
| `bot_min_team_size` | int | Tuning constant — min humans+bots per race, see `bots/bot_manager.gd` |
|
| 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 |
|
||||||
| `bot_engage_range` | float | Tuning constant — bot max shoot distance |
|
| Input-focus gates | `chat_focused`, `team_select_focused`, `settings_focused` | Each blocks ship movement/fire input while its overlay owns keyboard focus |
|
||||||
| `bot_stop_distance` | float | Tuning constant — bot stops closing distance below this |
|
| 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) |
|
||||||
| `bot_aim_tolerance_deg` | float | Tuning constant — how precisely a bot must face a target to fire |
|
|
||||||
|
## 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`
|
## Key Autoload — `BotManager`
|
||||||
|
|
||||||
Server-authoritative bot fill for casual (`bots/bot_manager.gd`). Keeps each of the
|
Server-authoritative bot fill for casual (`bots/bot_manager.gd`). Keeps each of the
|
||||||
match's 2 offered races at `GameConfig.bot_min_team_size` total humans+bots,
|
match's 2 offered factions at `GameConfig.bot_min_team_size` total humans+bots,
|
||||||
reacting to `PlayerRegistry.loadout_updated`/`player_removed` and
|
reacting to `PlayerRegistry.loadout_updated`/`race_changed`/`player_removed` and
|
||||||
`World.decide_offered_races()`. See `overview/bots.md`.
|
`World.decide_offered_races()`. Also rolls a `BotPersonality` per bot-slot. See `overview/bots.md`.
|
||||||
|
|
||||||
## Key Autoload — `ChatManager`
|
## Key Autoload — `ChatManager`
|
||||||
|
|
||||||
@@ -177,3 +242,37 @@ Server-relayed chat (`autoload/chat_manager.gd`). `send_chat(text, team_only)` s
|
|||||||
`message_received(sender_name, sender_race, team_only, text)` signal delivers
|
`message_received(sender_name, sender_race, team_only, text)` signal delivers
|
||||||
incoming messages to `ChatBox`. "Team" messages are filtered server-side to
|
incoming messages to `ChatBox`. "Team" messages are filtered server-side to
|
||||||
peers sharing the sender's race (race doubles as team — see `PlayerRegistry`).
|
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.
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Task Log
|
|
||||||
|
|
||||||
Task 1
|
|
||||||
|
|
||||||
I want to build an energy system for each ship. Below the health put an energy of 100/100 for the fighter/ 150/150 for middle ship and big ship 250 energy. Each bullet takes 75 to shoot. Have a blue energy bar that is mirrord top middle appear at the top . Have it take 2.5 seconds to refill 100
|
|
||||||
|
|
||||||
## Completed
|
|
||||||
|
|
||||||
| # | Task | Notes |
|
|
||||||
|---|------|-------|
|
|
||||||
| 0 | Format all design docs as Markdown | Done |
|
|
||||||
| 24 | Energy system (Fighter 100 / Gunner 150 / Tank 250, 75 per shot, blue center-out mirrored bar top-middle, +100 per 2.5s regen) | `GameConfig.ship_max_energy_by_role`/`bullet_energy_cost`/`ship_energy_regen_rate`; server-authoritative in `ships/ship_movement.gd` (broadcast via `_receive_state`, same pattern as health); role threaded through `PlayerRegistry` loadout + bots; text readout `HUD/EnergyLabel` below HP, visual bar `hud/energy_bar.gd`/`.tscn` |
|
|
||||||
| 1 | Design 10 races, pick 3 | Races 1, 4, 5 chosen — see `racesclasses.md` |
|
|
||||||
| 2 | Ranked + casual mode ideas | See `multiplayer.md` |
|
|
||||||
| 3 | Multiplayer engineering doc | See `tech.md` |
|
|
||||||
| 4 | ~~Server browser screen~~ | Superseded by #21's `menu/server_select.gd` |
|
|
||||||
| 5 | ~~Main menu (CASUAL/RANKED tiles)~~ | Superseded by #21's HL2-style nav menu |
|
|
||||||
| 6 | In-game pause menu | ESC / controller Start; game runs behind it; working: RESUME, QUIT TO MENU, QUIT TO DESKTOP |
|
|
||||||
| 7 | Team & ship selection | On world load; 2 random races offered; ship grid with real sprites; player spawns after |
|
|
||||||
| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done |
|
|
||||||
| 9 | Real ship sprites for all 3 races (5 ships each) | Done |
|
|
||||||
| 13 | Multiplayer networking — ENet authoritative server | Done |
|
|
||||||
| 18 | In-game chat (T=all, Y=team, last 10 messages, bottom-left) | `chat/chat_box.gd` + `autoload/chat_manager.gd` — see `chat.md` |
|
|
||||||
| 11 | Bot fill for casual (min 7v7) | `bots/bot_manager.gd` + `bots/bot_ai.gd` — see `bots.md` |
|
|
||||||
| 17 | Select Team in pause menu (live team swap) | Reopens `TeamSelect` mid-match via `PlayerRegistry.submit_local_loadout`'s existing respawn path; bot fill rebalances the vacated race too (`PlayerRegistry.race_changed`) |
|
|
||||||
| 20 | Live headcount/roster + TEAM FULL lock on race select | Shared by the initial pick and #17's reopen; humans only (bots excluded), blocks joining a race >2 humans ahead of the other, exempts your own current race |
|
|
||||||
| 21 | HL2-style main menu + real server select | `main_menu.gd` rebuilt as plain white vertical nav (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT); RANKED removed entirely; new `menu/server_select.gd` lists real servers via `MatchmakingClient.list_servers()` (`GET /servers`) and connects directly, retiring `menu/server_browser.gd` |
|
|
||||||
|
|
||||||
## Up Next
|
|
||||||
|
|
||||||
| # | Task | Priority |
|
|
||||||
|---|------|----------|
|
|
||||||
| 10 | Asteroids + environment hazards | Medium |
|
|
||||||
| 12 | Sound effects (thrust, shoot, explosion, UI clicks) | Medium |
|
|
||||||
| 14 | Galaxy war meta + sector control | Low (post-networking) |
|
|
||||||
| 15 | Ranked matchmaking + MMR | Low (post-networking) |
|
|
||||||
| 16 | Settings screen (audio, controls, display) | Low |
|
|
||||||
| 22 | Options screen (currently a disabled stub on the main menu) | Low |
|
|
||||||
| 23 | Real rank/level backend for the main menu's top-right badge | Low |
|
|
||||||
@@ -75,10 +75,14 @@ diverged from the original plan below:
|
|||||||
then connects directly to the returned server IP/port
|
then connects directly to the returned server IP/port
|
||||||
|
|
||||||
Not yet built: session tokens (server IP/port are handed back unauthenticated
|
Not yet built: session tokens (server IP/port are handed back unauthenticated
|
||||||
— fine for local dev, not for a real deployment), and a real server pool —
|
— fine for local dev, not for a real deployment).
|
||||||
only one dev server is registered right now (auto-seeded on API startup);
|
|
||||||
`POST /servers/register` exists for real servers to self-register/heartbeat
|
Real server pool is now live: `NetworkManager.host_server()` calls
|
||||||
but nothing calls it yet.
|
`POST /servers/register` once on boot and re-heartbeats every 8s, reporting
|
||||||
|
live player counts; a real server registering on the same (ip, port, mode)
|
||||||
|
as one of the 3 demo-seeded rows just takes it over in place. A background
|
||||||
|
sweep marks any server `offline` once its heartbeat goes stale (crash/kill
|
||||||
|
without clean deregister). See `CLAUDE.md` item 16.
|
||||||
|
|
||||||
### Steam Integration
|
### Steam Integration
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
@@ -3,15 +3,15 @@
|
|||||||
importer="texture"
|
importer="texture"
|
||||||
type="CompressedTexture2D"
|
type="CompressedTexture2D"
|
||||||
uid="uid://csymmghcpcpue"
|
uid="uid://csymmghcpcpue"
|
||||||
path="res://.godot/imported/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg-7e809ec090d7c262b7af7abf69bc7fa3.ctex"
|
path="res://.godot/imported/isn_banner.jpeg-8e168a36c4594504af8811aa2c39ac18.ctex"
|
||||||
metadata={
|
metadata={
|
||||||
"vram_texture": false
|
"vram_texture": false
|
||||||
}
|
}
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|
||||||
source_file="res://assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg"
|
source_file="res://assets/images/banners/isn_banner.jpeg"
|
||||||
dest_files=["res://.godot/imported/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg-7e809ec090d7c262b7af7abf69bc7fa3.ctex"]
|
dest_files=["res://.godot/imported/isn_banner.jpeg-8e168a36c4594504af8811aa2c39ac18.ctex"]
|
||||||
|
|
||||||
[params]
|
[params]
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
@@ -3,15 +3,15 @@
|
|||||||
importer="texture"
|
importer="texture"
|
||||||
type="CompressedTexture2D"
|
type="CompressedTexture2D"
|
||||||
uid="uid://do26o8h5m5hb8"
|
uid="uid://do26o8h5m5hb8"
|
||||||
path="res://.godot/imported/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg-c98f9e50e9255c5b98318bf225959231.ctex"
|
path="res://.godot/imported/orc_banner.jpeg-82461efb9d01bc65c8a8282763db5129.ctex"
|
||||||
metadata={
|
metadata={
|
||||||
"vram_texture": false
|
"vram_texture": false
|
||||||
}
|
}
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
|
|
||||||
source_file="res://assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg"
|
source_file="res://assets/images/banners/orc_banner.jpeg"
|
||||||
dest_files=["res://.godot/imported/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg-c98f9e50e9255c5b98318bf225959231.ctex"]
|
dest_files=["res://.godot/imported/orc_banner.jpeg-82461efb9d01bc65c8a8282763db5129.ctex"]
|
||||||
|
|
||||||
[params]
|
[params]
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://rhty40hm2fyb"
|
||||||
|
path="res://.godot/imported/isn_stealth_corvette_obsidian_source.jpeg-5c8ed7ba5712e49bb2acd7d91cd30f81.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/ships/isn/source/isn_stealth_corvette_obsidian_source.jpeg"
|
||||||
|
dest_files=["res://.godot/imported/isn_stealth_corvette_obsidian_source.jpeg-5c8ed7ba5712e49bb2acd7d91cd30f81.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 628 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://oerr3crinmhn"
|
||||||
|
path="res://.godot/imported/stealth_corvette.png-312bfaad4efcc2b2d8e9085e1f5ad90e.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/ships/isn/stealth_corvette.png"
|
||||||
|
dest_files=["res://.godot/imported/stealth_corvette.png-312bfaad4efcc2b2d8e9085e1f5ad90e.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=true
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://cuff012iytrwv"
|
||||||
|
path="res://.godot/imported/5edd9afe-2420-4857-9c2e-c0c246065391.jpeg-b246494d6ac09b218603bfead57cdf05.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/worldsprites/orc/5edd9afe-2420-4857-9c2e-c0c246065391.jpeg"
|
||||||
|
dest_files=["res://.godot/imported/5edd9afe-2420-4857-9c2e-c0c246065391.jpeg-b246494d6ac09b218603bfead57cdf05.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 141 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://dubn3t0v1k14v"
|
||||||
|
path="res://.godot/imported/db3a8115-4e07-4d14-8b3b-aea85663e506.jpeg-943b14a2f6cb2d72afb4dab63d830cfe.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/worldsprites/orc/db3a8115-4e07-4d14-8b3b-aea85663e506.jpeg"
|
||||||
|
dest_files=["res://.godot/imported/db3a8115-4e07-4d14-8b3b-aea85663e506.jpeg-943b14a2f6cb2d72afb4dab63d830cfe.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://5l7x7vmebjq2"
|
||||||
|
path="res://.godot/imported/f9f48964-e6cb-4497-8e5d-70b989809a2b.jpeg-e1218d1a6aa23386a6f26690992990e1.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/worldsprites/orc/f9f48964-e6cb-4497-8e5d-70b989809a2b.jpeg"
|
||||||
|
dest_files=["res://.godot/imported/f9f48964-e6cb-4497-8e5d-70b989809a2b.jpeg-e1218d1a6aa23386a6f26690992990e1.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://df1r8amjmr4n3"
|
||||||
|
path="res://.godot/imported/fc384bc9-bd14-4836-9325-93c0b970fb06.jpeg-923340753940400f25027f71ec4572e6.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://assets/images/worldsprites/orc/fc384bc9-bd14-4836-9325-93c0b970fb06.jpeg"
|
||||||
|
dest_files=["res://.godot/imported/fc384bc9-bd14-4836-9325-93c0b970fb06.jpeg-923340753940400f25027f71ec4572e6.ctex"]
|
||||||
|
|
||||||
|
[params]
|
||||||
|
|
||||||
|
compress/mode=0
|
||||||
|
compress/high_quality=false
|
||||||
|
compress/lossy_quality=0.7
|
||||||
|
compress/uastc_level=0
|
||||||
|
compress/rdo_quality_loss=0.0
|
||||||
|
compress/hdr_compression=1
|
||||||
|
compress/normal_map=0
|
||||||
|
compress/channel_pack=0
|
||||||
|
mipmaps/generate=false
|
||||||
|
mipmaps/limit=-1
|
||||||
|
roughness/mode=0
|
||||||
|
roughness/src_normal=""
|
||||||
|
process/channel_remap/red=0
|
||||||
|
process/channel_remap/green=1
|
||||||
|
process/channel_remap/blue=2
|
||||||
|
process/channel_remap/alpha=3
|
||||||
|
process/fix_alpha_border=true
|
||||||
|
process/premult_alpha=false
|
||||||
|
process/normal_map_invert_y=false
|
||||||
|
process/hdr_as_srgb=false
|
||||||
|
process/hdr_clamp_exposure=false
|
||||||
|
process/size_limit=0
|
||||||
|
detect_3d/compress_to=1
|
||||||
@@ -292,6 +292,15 @@ var ship_nameplate_font_size: int = 18
|
|||||||
# Camera2D only; doesn't affect any other peer's view.
|
# Camera2D only; doesn't affect any other peer's view.
|
||||||
var camera_zoom: float = 0.85
|
var camera_zoom: float = 0.85
|
||||||
|
|
||||||
|
# Slightly wider view swapped in for the MatchManager PRE_MATCH hold (see
|
||||||
|
# ship_movement.gd's _apply_pre_match_hold()/_apply_respawn()) -- just enough
|
||||||
|
# to comfortably frame all 3 flagships' spread (see flagship_spawn_spacing)
|
||||||
|
# at once. Doesn't need to cover flagship_creep_in_distance itself: the
|
||||||
|
# camera actively tracks the formation's centroid every tick while held (see
|
||||||
|
# ship_movement.gd's _follow_flagship_formation()) rather than sitting still
|
||||||
|
# and needing the whole approach path in frame at once.
|
||||||
|
var pre_match_camera_zoom: float = 0.5
|
||||||
|
|
||||||
# Locally-remembered callsign, pre-fills the name field on the menu.
|
# Locally-remembered callsign, pre-fills the name field on the menu.
|
||||||
# Race/ship/speed are per-peer now — see PlayerRegistry.
|
# Race/ship/speed are per-peer now — see PlayerRegistry.
|
||||||
var player_name: String = ""
|
var player_name: String = ""
|
||||||
@@ -299,31 +308,58 @@ var player_name: String = ""
|
|||||||
# Current map's play area, in world coordinates. Set by world.gd on load.
|
# Current map's play area, in world coordinates. Set by world.gd on load.
|
||||||
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
|
||||||
|
|
||||||
# Ships spawn at a random point in an annulus (ring) around a chosen
|
# Ships spawn at a random point in an annulus (ring) around a launch anchor
|
||||||
# team_a_spawn/team_b_spawn marker (see ship_movement.gd's
|
# below the team's flagship formation (see ship_movement.gd's
|
||||||
# _get_spawn_position()) rather than right on top of it -- a plain small-radius
|
# _get_spawn_position()) rather than right on top of it -- a plain small-radius
|
||||||
# square jitter left casual's up-to-25-per-team fill stacking ships nearly on
|
# square jitter left casual's up-to-25-per-team fill stacking ships nearly on
|
||||||
# top of each other. The inner radius also keeps ships mostly clear of the
|
# top of each other.
|
||||||
# flagship formation now sitting on/near the marker (see world.gd's
|
|
||||||
# _spawn_flagships()) instead of spawning inside a hull.
|
|
||||||
var spawn_area_inner_radius: float = 420.0
|
var spawn_area_inner_radius: float = 420.0
|
||||||
var spawn_area_outer_radius: float = 1200.0
|
var spawn_area_outer_radius: float = 1200.0
|
||||||
|
|
||||||
|
# How far past the flagship formation's own outermost hull (see
|
||||||
|
# ship_movement.gd's _flagship_formation_half_extent()) a ship's launch
|
||||||
|
# anchor sits -- clears the southernmost flagship's on-screen footprint (see
|
||||||
|
# flagship_spawn_spacing's own sizing note) so a spawn never lands inside or
|
||||||
|
# overlapping a hull. Kept small deliberately: a fresh spawn's Y offset from
|
||||||
|
# the team's spawn line is a fixed cost every approach to the enemy side
|
||||||
|
# carries the whole way across the map (nothing shrinks it back toward 0
|
||||||
|
# unless the player actively steers), and flagship_defense_radius is what
|
||||||
|
# has to still reach a player who never corrects for it -- see that var's
|
||||||
|
# own comment for the actual worst-case math.
|
||||||
|
var flagship_launch_clearance: float = 250.0
|
||||||
|
|
||||||
|
# Half-width, in degrees, of the downward-facing arc ships launch into around
|
||||||
|
# that anchor (see ship_movement.gd's _get_spawn_position()) -- centered on
|
||||||
|
# straight down (90 deg in Vector2.from_angle() terms). Kept narrow: this is
|
||||||
|
# the angular half-width, so the old 60 deg let spawns land up to 60 deg off
|
||||||
|
# dead-center (radius * sin(60deg), most of a spawn's outer_radius) either
|
||||||
|
# side of the flagships -- reading as spawning off in a corner below-and-
|
||||||
|
# beside the fleet rather than "directly under" it. spawn_area_inner/outer_
|
||||||
|
# radius still varies how far below, so a full team still spreads out
|
||||||
|
# (avoiding stacking) without fanning sideways to do it.
|
||||||
|
var spawn_launch_arc_spread_deg: float = 12.0
|
||||||
|
|
||||||
# Capital-ship backdrop on each team's spawn marker (world.gd's
|
# Capital-ship backdrop on each team's spawn marker (world.gd's
|
||||||
# _spawn_flagships()) -- a small line-abreast formation instead of one hull,
|
# _spawn_flagships()) -- a small line-abreast formation instead of one hull,
|
||||||
# spaced along the axis perpendicular to the two teams' spawn line so the
|
# evenly spaced along the axis perpendicular to the two teams' spawn line
|
||||||
# ships read as a fleet rather than overlapping each other.
|
# (flagship_spawn_spacing, comfortably clear of these hulls' own on-screen
|
||||||
# flagship_spawn_jitter is a per-ship random offset (both axes, see
|
# footprint -- native textures run up to ~535x692 (Colossus) / 516x1019
|
||||||
# _spawn_flagship()) on top of that even spacing -- large relative to the
|
# (Corvette, see CLAUDE.md item 25)) so the formation reads as a fleet rather
|
||||||
# spacing itself (deliberately: a small wobble still reads as "a line", this
|
# than overlapping each other. A clean line, not scattered, every hull
|
||||||
# needs to read as scattered) so ships can end up well out of their nominal
|
# facing straight up (see world.gd's _spawn_flagship()) -- flagship.gd's
|
||||||
# slot order, and flagship_spawn_rotation_jitter_deg randomizes each ship's
|
# play_creep_in() brings the whole formation in together as one rigid group,
|
||||||
# facing too, so the formation reads as a loose defensive cluster instead of
|
# which only reads as "a disciplined fleet" if the destination is a tidy,
|
||||||
# 3 hulls snapped to a ruler and all facing the same way.
|
# uniformly-facing line; per-ship position/rotation jitter was tried and
|
||||||
|
# reverted for exactly that reason.
|
||||||
var flagship_count_per_spawn: int = 3
|
var flagship_count_per_spawn: int = 3
|
||||||
var flagship_spawn_spacing: float = 950.0
|
var flagship_spawn_spacing: float = 950.0
|
||||||
var flagship_spawn_jitter: float = 750.0
|
|
||||||
var flagship_spawn_rotation_jitter_deg: float = 35.0
|
# Cosmetic-only "creep in" distance a flagship tweens from at the start of
|
||||||
|
# every MatchManager PRE_MATCH phase (see flagship.gd's play_creep_in()) --
|
||||||
|
# deliberately large (map_01 spans ~14016x6000) so a capital ship visibly
|
||||||
|
# warps in from well off in deep space over the full 30s countdown, rather
|
||||||
|
# than a small in-place flourish near its own formation slot.
|
||||||
|
var flagship_creep_in_distance: float = 4000.0
|
||||||
|
|
||||||
# Point-defense turret behavior (flagship.gd) -- each formation ship
|
# Point-defense turret behavior (flagship.gd) -- each formation ship
|
||||||
# independently scans for the nearest enemy within flagship_defense_radius
|
# independently scans for the nearest enemy within flagship_defense_radius
|
||||||
@@ -342,7 +378,21 @@ var flagship_spawn_rotation_jitter_deg: float = 35.0
|
|||||||
# bullet_max_range (1875, shared with every other bullet) so a shot fired
|
# bullet_max_range (1875, shared with every other bullet) so a shot fired
|
||||||
# right at the edge of detection range still has enough travel budget left
|
# right at the edge of detection range still has enough travel budget left
|
||||||
# to reach a target that keeps moving away after it's launched.
|
# to reach a target that keeps moving away after it's launched.
|
||||||
var flagship_defense_radius: float = 1200.0
|
#
|
||||||
|
# Also has to clear a second, unrelated constraint: a fresh spawn now always
|
||||||
|
# sits south of the team's own spawn line by flagship_launch_clearance +
|
||||||
|
# spawn_area_outer_radius at worst (up to 1450, see those vars' own comments
|
||||||
|
# on why -- ship_movement.gd's _get_spawn_position()), and nothing shrinks
|
||||||
|
# that Y offset back down unless the player actively steers, so a player who
|
||||||
|
# flies a dead-straight line the entire way across the map arrives at the
|
||||||
|
# enemy's line still carrying it. The nearest enemy flagship to that worst
|
||||||
|
# case is the one at the *south* edge of their own formation
|
||||||
|
# (flagship_formation_half_extent(), 950 the other direction) -- gap between
|
||||||
|
# them is that 1450 leftover, so defense_radius needs to be at least that
|
||||||
|
# just to still be reachable in the worst case, with 1450 itself leaving no
|
||||||
|
# margin. Rounded up to comfortably clear it instead of sitting exactly on
|
||||||
|
# the boundary.
|
||||||
|
var flagship_defense_radius: float = 1600.0
|
||||||
var flagship_fire_rate: float = 1.4
|
var flagship_fire_rate: float = 1.4
|
||||||
var flagship_missile_speed: float = 950.0
|
var flagship_missile_speed: float = 950.0
|
||||||
var flagship_missile_damage: int = 999
|
var flagship_missile_damage: int = 999
|
||||||
@@ -378,6 +428,18 @@ var team_select_focused: bool = false
|
|||||||
# menu underneath it (pause_menu.gd's _input() checks this too).
|
# menu underneath it (pause_menu.gd's _input() checks this too).
|
||||||
var settings_focused: bool = false
|
var settings_focused: bool = false
|
||||||
|
|
||||||
|
# Match structure (autoload/match_manager.gd) — see overview/map1.md.
|
||||||
|
# default_game_mode_id picks a GameMode subclass (world/game_modes/) via
|
||||||
|
# MatchManager's factory; every peer instantiates the same mode independently
|
||||||
|
# off this one shared constant, same as TeamSelect.RACES needing no
|
||||||
|
# replication. match_duration is the "7 minutes" a match's clock counts down
|
||||||
|
# once ships are released; match_pre_match_duration/match_post_match_duration
|
||||||
|
# are the countdown-before and banner-after windows around it.
|
||||||
|
var default_game_mode_id: String = "team_deathmatch"
|
||||||
|
var match_pre_match_duration: float = 30.0
|
||||||
|
var match_duration: float = 420.0
|
||||||
|
var match_post_match_duration: float = 10.0
|
||||||
|
|
||||||
# Bots (casual fill) — see bots/bot_manager.gd and overview/bots.md
|
# Bots (casual fill) — see bots/bot_manager.gd and overview/bots.md
|
||||||
var bot_min_team_size: int = 7 # mirrors matchmaking-api's CASUAL_TEAM_SIZE default
|
var bot_min_team_size: int = 7 # mirrors matchmaking-api's CASUAL_TEAM_SIZE default
|
||||||
var bot_engage_range: float = 500.0
|
var bot_engage_range: float = 500.0
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ func report_kill(victim_peer_id: int, killer_peer_id: int) -> void:
|
|||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
MatchStats.record_kill(victim_peer_id, killer_peer_id)
|
||||||
|
|
||||||
var victim_info := PlayerRegistry.get_info(victim_peer_id)
|
var victim_info := PlayerRegistry.get_info(victim_peer_id)
|
||||||
var victim_name: String = victim_info.get("name", "Player")
|
var victim_name: String = victim_info.get("name", "Player")
|
||||||
var victim_race: int = victim_info.get("race", 0)
|
var victim_race: int = victim_info.get("race", 0)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Server-authoritative match-phase state machine — see overview/map1.md.
|
||||||
|
# PRE_MATCH (ships hidden, capital ships creep into formation, countdown) ->
|
||||||
|
# IN_PROGRESS (ships released, GameMode's clock runs) -> POST_MATCH (winner
|
||||||
|
# banner) -> loops back into a fresh PRE_MATCH, forever, matching this
|
||||||
|
# project's always-on server-pool model (see CLAUDE.md item 16) rather than
|
||||||
|
# kicking players to the menu at match end.
|
||||||
|
#
|
||||||
|
# Follows the exact "decide once on the server, RPC-broadcast the concrete
|
||||||
|
# value, late joiners pull it" pattern world.gd's decide_offered_races()/
|
||||||
|
# _deliver_offered_races already established -- _begin_phase broadcasts a
|
||||||
|
# phase change to every already-connected peer; request_match_state() is the
|
||||||
|
# pull a late joiner uses to catch up.
|
||||||
|
|
||||||
|
enum Phase { PRE_MATCH, IN_PROGRESS, POST_MATCH }
|
||||||
|
|
||||||
|
signal phase_changed(phase: int, duration_sec: float)
|
||||||
|
signal match_ended(winner_race_id: int, is_draw: bool)
|
||||||
|
|
||||||
|
var phase: int = Phase.PRE_MATCH
|
||||||
|
var phase_started_at_msec: int = 0
|
||||||
|
var phase_duration_sec: float = 0.0
|
||||||
|
var current_mode: GameMode = null
|
||||||
|
|
||||||
|
var _world: Node = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Every peer builds the same mode object locally from the same shared
|
||||||
|
# GameConfig constant -- no replication needed, same as TeamSelect.RACES.
|
||||||
|
current_mode = _create_mode(GameConfig.default_game_mode_id)
|
||||||
|
|
||||||
|
|
||||||
|
func _create_mode(mode_id: String) -> GameMode:
|
||||||
|
match mode_id:
|
||||||
|
"team_deathmatch":
|
||||||
|
return TeamDeathmatchMode.new()
|
||||||
|
_:
|
||||||
|
return TeamDeathmatchMode.new()
|
||||||
|
|
||||||
|
|
||||||
|
# Called once by World._ready() (server only), after map/spawn setup.
|
||||||
|
func start(world: Node) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
_world = world
|
||||||
|
_run_match_loop()
|
||||||
|
|
||||||
|
|
||||||
|
func _run_match_loop() -> void:
|
||||||
|
# Lets this frame's deferred flagship spawn (world.gd's decide_offered_races())
|
||||||
|
# land before the first _set_phase(PRE_MATCH) call needs those nodes to exist
|
||||||
|
# (see World._on_match_phase_changed(), which is what actually plays the
|
||||||
|
# creep-in tween now -- see its own comment for why).
|
||||||
|
await get_tree().process_frame
|
||||||
|
while true:
|
||||||
|
await _run_pre_match()
|
||||||
|
await _run_in_progress()
|
||||||
|
var result: Dictionary = current_mode.check_win_condition(_world)
|
||||||
|
await _run_post_match(result)
|
||||||
|
|
||||||
|
|
||||||
|
func _run_pre_match() -> void:
|
||||||
|
MatchStats.reset_for_new_match()
|
||||||
|
_world.hold_all_ships_for_pre_match()
|
||||||
|
var duration: float = GameConfig.match_pre_match_duration
|
||||||
|
# Flagship creep-in is no longer triggered directly from here -- this
|
||||||
|
# function only ever runs on the server (see start()), so a direct call
|
||||||
|
# into World never reached remote clients, leaving their flagships static
|
||||||
|
# the whole match. World now plays it off phase_changed instead (emitted
|
||||||
|
# identically on every peer by the RPC below), so this just needs to
|
||||||
|
# broadcast the phase.
|
||||||
|
_set_phase(Phase.PRE_MATCH, duration)
|
||||||
|
await get_tree().create_timer(duration).timeout
|
||||||
|
|
||||||
|
|
||||||
|
func _run_in_progress() -> void:
|
||||||
|
var duration: float = current_mode.get_match_duration()
|
||||||
|
_set_phase(Phase.IN_PROGRESS, duration)
|
||||||
|
_world.release_held_ships()
|
||||||
|
var elapsed := 0.0
|
||||||
|
while elapsed < duration:
|
||||||
|
await get_tree().create_timer(1.0).timeout
|
||||||
|
elapsed += 1.0
|
||||||
|
if current_mode.should_end_early(_world):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
func _run_post_match(result: Dictionary) -> void:
|
||||||
|
_set_phase(Phase.POST_MATCH, GameConfig.match_post_match_duration)
|
||||||
|
_announce_winner.rpc(result.winner_race_id, result.is_draw)
|
||||||
|
_report_match_result(result)
|
||||||
|
await get_tree().create_timer(GameConfig.match_post_match_duration).timeout
|
||||||
|
|
||||||
|
|
||||||
|
func _set_phase(new_phase: int, duration: float) -> void:
|
||||||
|
_begin_phase.rpc(new_phase, duration)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _begin_phase(new_phase: int, duration: float) -> void:
|
||||||
|
phase = new_phase
|
||||||
|
phase_duration_sec = duration
|
||||||
|
phase_started_at_msec = Time.get_ticks_msec()
|
||||||
|
phase_changed.emit(new_phase, duration)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _announce_winner(winner_race_id: int, is_draw: bool) -> void:
|
||||||
|
match_ended.emit(winner_race_id, is_draw)
|
||||||
|
|
||||||
|
|
||||||
|
func get_remaining_seconds() -> float:
|
||||||
|
var elapsed := (Time.get_ticks_msec() - phase_started_at_msec) / 1000.0
|
||||||
|
return max(0.0, phase_duration_sec - elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func request_match_state() -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var sender_id := multiplayer.get_remote_sender_id()
|
||||||
|
_deliver_match_state.rpc_id(sender_id, phase, get_remaining_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _deliver_match_state(current_phase: int, remaining: float) -> void:
|
||||||
|
phase = current_phase
|
||||||
|
phase_duration_sec = remaining
|
||||||
|
phase_started_at_msec = Time.get_ticks_msec()
|
||||||
|
phase_changed.emit(current_phase, remaining)
|
||||||
|
|
||||||
|
|
||||||
|
# Reports this match's final per-player stats to matchmaking-api so
|
||||||
|
# Player.kills/deaths/wins/losses/hours_played persist across matches (see
|
||||||
|
# overview/map1.md). Bots (negative peer_id) never report -- no backend
|
||||||
|
# identity for them, they're padding, not a player's history.
|
||||||
|
func _report_match_result(result: Dictionary) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var players_payload: Array = []
|
||||||
|
for peer_id in PlayerRegistry.players:
|
||||||
|
if peer_id < 0:
|
||||||
|
continue
|
||||||
|
var info: Dictionary = PlayerRegistry.get_info(peer_id)
|
||||||
|
var stats: Dictionary = MatchStats.get_stats(peer_id)
|
||||||
|
var is_winner: bool = not result.is_draw and info.get("race", 0) == result.winner_race_id
|
||||||
|
players_payload.append({
|
||||||
|
"callsign": info.get("name", ""),
|
||||||
|
"team": info.get("race", 0),
|
||||||
|
"kills": stats.get("kills", 0),
|
||||||
|
"deaths": stats.get("deaths", 0),
|
||||||
|
"is_winner": is_winner,
|
||||||
|
"seconds_played": current_mode.get_match_duration(),
|
||||||
|
})
|
||||||
|
if players_payload.is_empty():
|
||||||
|
return
|
||||||
|
var addr: Dictionary = NetworkManager.get_registered_address()
|
||||||
|
var winner_team: int = -1 if result.is_draw else result.winner_race_id
|
||||||
|
MatchmakingClient.report_match_result(addr.ip, addr.port, "casual", winner_team, players_payload)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dn5i072b3xm4i
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
# Server-authoritative per-match kills/deaths, keyed by peer_id (bots
|
||||||
|
# included — negative peer_id, see bots/bot_manager.gd — since they must
|
||||||
|
# count toward the live Scoreboard and a GameMode's win condition same as any
|
||||||
|
# human). Deliberately a separate autoload from PlayerRegistry: loadout
|
||||||
|
# (identity/ship choice) persists for the whole session, match stats reset
|
||||||
|
# every MatchManager loop — different lifecycles, same one-autoload-per-concern
|
||||||
|
# habit as ChatManager/KillFeedManager/PlayerRegistry already being separate.
|
||||||
|
|
||||||
|
signal stats_updated(peer_id: int)
|
||||||
|
signal stats_reset()
|
||||||
|
|
||||||
|
var _match: Dictionary = {} # peer_id -> {kills: int, deaths: int}
|
||||||
|
|
||||||
|
|
||||||
|
func get_stats(peer_id: int) -> Dictionary:
|
||||||
|
return _match.get(peer_id, {"kills": 0, "deaths": 0})
|
||||||
|
|
||||||
|
|
||||||
|
func get_team_kills(race_id: int) -> int:
|
||||||
|
var total := 0
|
||||||
|
for peer_id in PlayerRegistry.players:
|
||||||
|
if PlayerRegistry.get_info(peer_id).get("race", 0) == race_id:
|
||||||
|
total += get_stats(peer_id).get("kills", 0)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# Server-only, hooked from KillFeedManager.report_kill() — the one place a
|
||||||
|
# death is already detected (see ships/ship_movement.gd's _die()). Reuses
|
||||||
|
# report_kill's own environment-kill convention (killer_peer_id == 0 means no
|
||||||
|
# attacking ship, == victim_peer_id means suicide/hazard) to skip crediting a kill.
|
||||||
|
func record_kill(victim_peer_id: int, killer_peer_id: int) -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
|
||||||
|
var victim: Dictionary = get_stats(victim_peer_id)
|
||||||
|
victim.deaths += 1
|
||||||
|
_match[victim_peer_id] = victim
|
||||||
|
|
||||||
|
var is_environment_kill := killer_peer_id == 0 or killer_peer_id == victim_peer_id
|
||||||
|
var credited_killer := 0
|
||||||
|
var killer_kills := 0
|
||||||
|
if not is_environment_kill:
|
||||||
|
var killer: Dictionary = get_stats(killer_peer_id)
|
||||||
|
killer.kills += 1
|
||||||
|
_match[killer_peer_id] = killer
|
||||||
|
credited_killer = killer_peer_id
|
||||||
|
killer_kills = killer.kills
|
||||||
|
|
||||||
|
_apply_kill_delta.rpc(victim_peer_id, victim.deaths, credited_killer, killer_kills)
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _apply_kill_delta(victim_peer_id: int, victim_deaths: int, killer_peer_id: int, killer_kills: int) -> void:
|
||||||
|
_apply_stat(victim_peer_id, "deaths", victim_deaths)
|
||||||
|
stats_updated.emit(victim_peer_id)
|
||||||
|
if killer_peer_id != 0:
|
||||||
|
_apply_stat(killer_peer_id, "kills", killer_kills)
|
||||||
|
stats_updated.emit(killer_peer_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Named _apply_stat, not _set -- Node already defines a virtual _set(StringName,
|
||||||
|
# Variant) -> bool for custom property setters; shadowing it with an
|
||||||
|
# incompatible signature is a parse error ("function signature doesn't match
|
||||||
|
# the parent"), not just a style clash.
|
||||||
|
func _apply_stat(peer_id: int, key: String, value: int) -> void:
|
||||||
|
var s: Dictionary = get_stats(peer_id)
|
||||||
|
s[key] = value
|
||||||
|
_match[peer_id] = s
|
||||||
|
|
||||||
|
|
||||||
|
# Called by MatchManager at the top of every PRE_MATCH.
|
||||||
|
func reset_for_new_match() -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
_reset_all.rpc()
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _reset_all() -> void:
|
||||||
|
_match.clear()
|
||||||
|
stats_reset.emit()
|
||||||
|
|
||||||
|
|
||||||
|
# Late-joiner pull, same per-entry backfill style as PlayerRegistry.submit_loadout.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func request_match_stats() -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
var sender_id := multiplayer.get_remote_sender_id()
|
||||||
|
for peer_id in _match:
|
||||||
|
var s: Dictionary = _match[peer_id]
|
||||||
|
_receive_stat.rpc_id(sender_id, peer_id, s.get("kills", 0), s.get("deaths", 0))
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _receive_stat(peer_id: int, kills: int, deaths: int) -> void:
|
||||||
|
_match[peer_id] = {"kills": kills, "deaths": deaths}
|
||||||
|
stats_updated.emit(peer_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c1jy8mn3k1o8a
|
||||||
@@ -94,6 +94,25 @@ func register_server(ip: String, port: int, mode: String, player_count: int, max
|
|||||||
return result.get("code", 0) == 200
|
return result.get("code", 0) == 200
|
||||||
|
|
||||||
|
|
||||||
|
# Fire-and-forget, called once by MatchManager (server-only) when a match's
|
||||||
|
# POST_MATCH phase begins (see match_manager.gd's _report_match_result()) --
|
||||||
|
# persists this match's final kills/deaths/win-loss into each reported
|
||||||
|
# player's running backend totals. winner_team < 0 means a draw (mirrors
|
||||||
|
# MatchReportRequest.winner_team being nullable server-side). A fresh
|
||||||
|
# short-lived HTTPRequest node (see _request()) is fine here since this fires
|
||||||
|
# once per ~7-minute match, not a tight repeating loop like register_server()'s
|
||||||
|
# heartbeat, which is the one call site that actually needed _heartbeat_http.
|
||||||
|
func report_match_result(server_ip: String, server_port: int, mode: String, winner_team: int, players: Array) -> bool:
|
||||||
|
var result := await _request("POST", "/matches/report", {
|
||||||
|
"server_ip": server_ip,
|
||||||
|
"server_port": server_port,
|
||||||
|
"mode": mode,
|
||||||
|
"winner_team": null if winner_team < 0 else winner_team,
|
||||||
|
"players": players,
|
||||||
|
})
|
||||||
|
return result.get("code", 0) == 200
|
||||||
|
|
||||||
|
|
||||||
func cancel_matchmaking() -> void:
|
func cancel_matchmaking() -> void:
|
||||||
if not _searching:
|
if not _searching:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ func _process(_delta: float) -> void:
|
|||||||
udp_peer.put_packet(reply.to_utf8_buffer())
|
udp_peer.put_packet(reply.to_utf8_buffer())
|
||||||
|
|
||||||
|
|
||||||
|
# Exposes the private _register_ip/_hosted_port pair -- what this server
|
||||||
|
# actually advertised to the matchmaking API -- so MatchManager can report a
|
||||||
|
# match's results against the same (ip, port) identity (see
|
||||||
|
# match_manager.gd's _report_match_result()).
|
||||||
|
func get_registered_address() -> Dictionary:
|
||||||
|
return {"ip": _register_ip, "port": _hosted_port}
|
||||||
|
|
||||||
|
|
||||||
func join_server(ip: String, port: int = DEFAULT_PORT) -> void:
|
func join_server(ip: String, port: int = DEFAULT_PORT) -> void:
|
||||||
var peer := ENetMultiplayerPeer.new()
|
var peer := ENetMultiplayerPeer.new()
|
||||||
var err := peer.create_client(ip, port)
|
var err := peer.create_client(ip, port)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
# Full-screen winner banner shown for MatchManager's POST_MATCH phase (see
|
||||||
|
# overview/map1.md) -- the losing/winning faction's banner art from
|
||||||
|
# assets/images/banners/ plus "<RACE> WINS" text, or "DRAW" if tied. Highest
|
||||||
|
# layer in the project so it always renders on top regardless of what else
|
||||||
|
# (pause menu, scoreboard, chat) is open when a match ends.
|
||||||
|
|
||||||
|
var _root: Control
|
||||||
|
var _banner_tex: TextureRect
|
||||||
|
var _title_lbl: Label
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 30
|
||||||
|
_build_ui()
|
||||||
|
_root.visible = false
|
||||||
|
MatchManager.match_ended.connect(_on_match_ended)
|
||||||
|
MatchManager.phase_changed.connect(_on_phase_changed)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
_root = Control.new()
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(_root)
|
||||||
|
|
||||||
|
var dim := ColorRect.new()
|
||||||
|
dim.color = Color(0, 0, 0, 0.6)
|
||||||
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.add_child(dim)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
vbox.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||||
|
vbox.add_theme_constant_override("separation", 18)
|
||||||
|
vbox.anchor_left = 0.5
|
||||||
|
vbox.anchor_right = 0.5
|
||||||
|
vbox.anchor_top = 0.5
|
||||||
|
vbox.anchor_bottom = 0.5
|
||||||
|
vbox.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
vbox.grow_vertical = Control.GROW_DIRECTION_BOTH
|
||||||
|
_root.add_child(vbox)
|
||||||
|
|
||||||
|
_banner_tex = TextureRect.new()
|
||||||
|
_banner_tex.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
|
||||||
|
_banner_tex.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
_banner_tex.custom_minimum_size = Vector2(280, 0)
|
||||||
|
_banner_tex.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_banner_tex.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||||
|
vbox.add_child(_banner_tex)
|
||||||
|
|
||||||
|
_title_lbl = Label.new()
|
||||||
|
_title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_title_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_title_lbl.add_theme_font_size_override("font_size", 48)
|
||||||
|
_title_lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||||
|
_title_lbl.add_theme_constant_override("outline_size", 6)
|
||||||
|
vbox.add_child(_title_lbl)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_match_ended(winner_race_id: int, is_draw: bool) -> void:
|
||||||
|
if is_draw:
|
||||||
|
_title_lbl.text = "DRAW"
|
||||||
|
_title_lbl.add_theme_color_override("font_color", Color(0.85, 0.87, 0.92))
|
||||||
|
_banner_tex.texture = null
|
||||||
|
else:
|
||||||
|
var race := _race_by_id(winner_race_id)
|
||||||
|
_title_lbl.text = "%s WINS" % String(race.get("name", ""))
|
||||||
|
_title_lbl.add_theme_color_override("font_color", race.get("color", Color.WHITE))
|
||||||
|
var path: String = race.get("banner_path", "")
|
||||||
|
_banner_tex.texture = load(path) if not path.is_empty() else null
|
||||||
|
_root.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
# Only ever hides the banner -- a late joiner pulling POST_MATCH as their
|
||||||
|
# initial state (MatchManager._deliver_match_state) gets this same signal but
|
||||||
|
# not the one-shot match_ended event that actually fills in the banner's
|
||||||
|
# race/text, so they'd just see nothing for the remainder of that window
|
||||||
|
# rather than a stale/wrong banner. Acceptable gap given how rare a join
|
||||||
|
# during the ~10s POST_MATCH window is.
|
||||||
|
func _on_phase_changed(phase: int, _duration: float) -> void:
|
||||||
|
if phase != MatchManager.Phase.POST_MATCH:
|
||||||
|
_root.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _race_by_id(race_id: int) -> Dictionary:
|
||||||
|
for race in TeamSelect.RACES:
|
||||||
|
if race.id == race_id:
|
||||||
|
return race
|
||||||
|
return {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://hud/match_banner.gd" id="1_script"]
|
||||||
|
|
||||||
|
[node name="MatchBanner" type="CanvasLayer"]
|
||||||
|
script = ExtResource("1_script")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
# Top-center match clock (see overview/map1.md) -- shows PRE_MATCH's
|
||||||
|
# countdown-to-start ("STARTING IN 0:30") and IN_PROGRESS's 7-minute
|
||||||
|
# countdown ("6:59"), driven by MatchManager.get_remaining_seconds() polled
|
||||||
|
# once/sec (same 1s-granularity feel MatchManager itself runs the phase
|
||||||
|
# timers at) rather than a separately-replicated per-frame value.
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL := 1.0
|
||||||
|
|
||||||
|
var _label: Label
|
||||||
|
var _elapsed_since_refresh: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 4
|
||||||
|
_build_ui()
|
||||||
|
MatchManager.phase_changed.connect(_on_phase_changed)
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
_label = Label.new()
|
||||||
|
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_label.add_theme_font_size_override("font_size", 26)
|
||||||
|
_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
||||||
|
_label.add_theme_constant_override("outline_size", 4)
|
||||||
|
_label.anchor_left = 0.5
|
||||||
|
_label.anchor_right = 0.5
|
||||||
|
_label.anchor_top = 0.0
|
||||||
|
_label.anchor_bottom = 0.0
|
||||||
|
_label.offset_left = -200
|
||||||
|
_label.offset_right = 200
|
||||||
|
_label.offset_top = 16
|
||||||
|
_label.grow_horizontal = Control.GROW_DIRECTION_BOTH
|
||||||
|
add_child(_label)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
_elapsed_since_refresh += delta
|
||||||
|
if _elapsed_since_refresh >= REFRESH_INTERVAL:
|
||||||
|
_elapsed_since_refresh = 0.0
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_phase_changed(_phase: int, _duration: float) -> void:
|
||||||
|
_refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh() -> void:
|
||||||
|
var remaining := MatchManager.get_remaining_seconds()
|
||||||
|
var mm := int(remaining) / 60
|
||||||
|
var ss := int(remaining) % 60
|
||||||
|
var clock := "%d:%02d" % [mm, ss]
|
||||||
|
match MatchManager.phase:
|
||||||
|
MatchManager.Phase.PRE_MATCH:
|
||||||
|
_label.text = "MATCH STARTS IN %s" % clock
|
||||||
|
_label.add_theme_color_override("font_color", Color(0.95, 0.85, 0.35))
|
||||||
|
MatchManager.Phase.IN_PROGRESS:
|
||||||
|
_label.text = clock
|
||||||
|
_label.add_theme_color_override("font_color", Color(0.92, 0.95, 1.0))
|
||||||
|
MatchManager.Phase.POST_MATCH:
|
||||||
|
_label.text = "MATCH OVER"
|
||||||
|
_label.add_theme_color_override("font_color", Color(0.92, 0.95, 1.0))
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://hud/match_timer.gd" id="1_script"]
|
||||||
|
|
||||||
|
[node name="MatchTimer" type="CanvasLayer"]
|
||||||
|
script = ExtResource("1_script")
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
extends CanvasLayer
|
||||||
|
|
||||||
|
# CS:GO-style hold-Tab scoreboard (see overview/map1.md) — two team panels
|
||||||
|
# side by side, name/role/kills/deaths per player, sorted by kills within
|
||||||
|
# each team. Autonomous CanvasLayer + signal-driven refresh, same convention
|
||||||
|
# as every other HUD file (player_list.gd/kill_feed.gd) rather than being
|
||||||
|
# imperatively pushed data from world.gd.
|
||||||
|
|
||||||
|
const PANEL_W := 460.0
|
||||||
|
const PANEL_H := 380.0
|
||||||
|
const PANEL_GAP := 24.0
|
||||||
|
|
||||||
|
var _root: Control
|
||||||
|
var _header_a: Label
|
||||||
|
var _header_b: Label
|
||||||
|
var _table_a: RichTextLabel
|
||||||
|
var _table_b: RichTextLabel
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
layer = 6
|
||||||
|
_build_ui()
|
||||||
|
_root.visible = false
|
||||||
|
MatchStats.stats_updated.connect(func(_peer_id): _refresh())
|
||||||
|
MatchStats.stats_reset.connect(_refresh)
|
||||||
|
PlayerRegistry.loadout_updated.connect(func(_peer_id): _refresh())
|
||||||
|
PlayerRegistry.player_removed.connect(func(_peer_id, _info): _refresh())
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if GameConfig.chat_focused or GameConfig.team_select_focused or GameConfig.settings_focused:
|
||||||
|
return
|
||||||
|
if event.is_action_pressed("scoreboard"):
|
||||||
|
_root.visible = true
|
||||||
|
_refresh()
|
||||||
|
elif event.is_action_released("scoreboard"):
|
||||||
|
_root.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
_root = Control.new()
|
||||||
|
_root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(_root)
|
||||||
|
|
||||||
|
# Dim the world behind the scoreboard, same purpose as pause_menu.gd's
|
||||||
|
# own full-screen dim, so the two panels read clearly on top of combat.
|
||||||
|
var dim := ColorRect.new()
|
||||||
|
dim.color = Color(0, 0, 0, 0.45)
|
||||||
|
dim.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_root.add_child(dim)
|
||||||
|
|
||||||
|
var total_w := PANEL_W * 2 + PANEL_GAP
|
||||||
|
_header_a = Label.new()
|
||||||
|
_table_a = RichTextLabel.new()
|
||||||
|
_build_team_panel(-total_w / 2.0, _header_a, _table_a)
|
||||||
|
|
||||||
|
_header_b = Label.new()
|
||||||
|
_table_b = RichTextLabel.new()
|
||||||
|
_build_team_panel(-total_w / 2.0 + PANEL_W + PANEL_GAP, _header_b, _table_b)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_team_panel(x_offset: float, header: Label, table: RichTextLabel) -> void:
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
var ps := StyleBoxFlat.new()
|
||||||
|
ps.bg_color = Color(0.02, 0.03, 0.06, 0.75)
|
||||||
|
ps.set_corner_radius_all(6)
|
||||||
|
panel.add_theme_stylebox_override("panel", ps)
|
||||||
|
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
# Centered anchors (0.5) so the two panels stay centered on the window
|
||||||
|
# regardless of resolution — plain absolute pixel offsets from there,
|
||||||
|
# same push_opposite_anchor-safe pattern as every other HUD file (set
|
||||||
|
# every anchor_* as a direct property assignment, not sequential calls).
|
||||||
|
panel.anchor_left = 0.5
|
||||||
|
panel.anchor_right = 0.5
|
||||||
|
panel.anchor_top = 0.5
|
||||||
|
panel.anchor_bottom = 0.5
|
||||||
|
panel.offset_left = x_offset
|
||||||
|
panel.offset_right = x_offset + PANEL_W
|
||||||
|
panel.offset_top = -PANEL_H / 2.0
|
||||||
|
panel.offset_bottom = PANEL_H / 2.0
|
||||||
|
_root.add_child(panel)
|
||||||
|
|
||||||
|
var vbox := VBoxContainer.new()
|
||||||
|
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
vbox.add_theme_constant_override("separation", 6)
|
||||||
|
panel.add_child(vbox)
|
||||||
|
|
||||||
|
header.add_theme_font_size_override("font_size", 20)
|
||||||
|
header.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
vbox.add_child(header)
|
||||||
|
|
||||||
|
table.bbcode_enabled = true
|
||||||
|
table.scroll_active = true
|
||||||
|
table.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
|
table.add_theme_font_size_override("normal_font_size", 16)
|
||||||
|
table.custom_minimum_size = Vector2(0, PANEL_H - 60.0)
|
||||||
|
vbox.add_child(table)
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh() -> void:
|
||||||
|
var world := get_node_or_null("/root/World")
|
||||||
|
if world == null:
|
||||||
|
return
|
||||||
|
var race_ids: Array = world.decide_offered_races()
|
||||||
|
if race_ids.size() != 2:
|
||||||
|
return
|
||||||
|
_fill_team(race_ids[0], _header_a, _table_a)
|
||||||
|
_fill_team(race_ids[1], _header_b, _table_b)
|
||||||
|
|
||||||
|
|
||||||
|
func _fill_team(race_id: int, header: Label, table: RichTextLabel) -> void:
|
||||||
|
var race := _race_by_id(race_id)
|
||||||
|
var mode: GameMode = MatchManager.current_mode
|
||||||
|
var world := get_node("/root/World")
|
||||||
|
var score: int = mode.get_score_for_team(world, race_id) if mode else 0
|
||||||
|
var label: String = mode.get_score_label() if mode else "KILLS"
|
||||||
|
header.text = "%s — %d %s" % [race.get("name", ""), score, label]
|
||||||
|
header.add_theme_color_override("font_color", race.get("color", Color.WHITE))
|
||||||
|
|
||||||
|
var rows: Array = []
|
||||||
|
for peer_id in PlayerRegistry.players:
|
||||||
|
var info: Dictionary = PlayerRegistry.get_info(peer_id)
|
||||||
|
if info.get("race", 0) != race_id:
|
||||||
|
continue
|
||||||
|
var stats: Dictionary = MatchStats.get_stats(peer_id)
|
||||||
|
rows.append({"peer_id": peer_id, "info": info, "stats": stats})
|
||||||
|
rows.sort_custom(func(a, b): return a.stats.get("kills", 0) > b.stats.get("kills", 0))
|
||||||
|
|
||||||
|
var lines: Array[String] = ["[table=4][cell][b]NAME[/b][/cell][cell][b]ROLE[/b][/cell][cell][b]K[/b][/cell][cell][b]D[/b][/cell]"]
|
||||||
|
for row in rows:
|
||||||
|
var name_text: String = _escape_bbcode(String(row.info.get("name", "")))
|
||||||
|
if row.peer_id < 0:
|
||||||
|
name_text += " (b)"
|
||||||
|
lines.append("[cell]%s[/cell][cell]%s[/cell][cell]%d[/cell][cell]%d[/cell]" % [
|
||||||
|
name_text, String(row.info.get("role", "")), row.stats.get("kills", 0), row.stats.get("deaths", 0)
|
||||||
|
])
|
||||||
|
lines.append("[/table]")
|
||||||
|
table.text = "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
func _race_by_id(race_id: int) -> Dictionary:
|
||||||
|
for race in TeamSelect.RACES:
|
||||||
|
if race.id == race_id:
|
||||||
|
return race
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func _escape_bbcode(s: String) -> String:
|
||||||
|
return s.replace("[", "[lb]")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b523tqi7544b8
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://hud/scoreboard.gd" id="1_script"]
|
||||||
|
|
||||||
|
[node name="Scoreboard" type="CanvasLayer"]
|
||||||
|
script = ExtResource("1_script")
|
||||||
@@ -13,7 +13,18 @@ const RACES := [
|
|||||||
{
|
{
|
||||||
"id": 1, "name": "INNER SPHERE NAVY", "sub": "Gunmetal-gray navy of Earth, Mars, and Mercury — disciplined, frontal, built to hold a line",
|
"id": 1, "name": "INNER SPHERE NAVY", "sub": "Gunmetal-gray navy of Earth, Mars, and Mercury — disciplined, frontal, built to hold a line",
|
||||||
"color": Color(0.80, 0.32, 0.32),
|
"color": Color(0.80, 0.32, 0.32),
|
||||||
|
"banner_path": "res://assets/images/banners/isn_banner.jpeg",
|
||||||
"flagship_path": "res://assets/images/ships/isn/flagship_colossus.png", "flagship_scale": 1.0,
|
"flagship_path": "res://assets/images/ships/isn/flagship_colossus.png", "flagship_scale": 1.0,
|
||||||
|
# Per-slot override for the 3-ship flagship formation (see world.gd's
|
||||||
|
# _spawn_flagship) -- 2 Colossus-class plus 1 Stealth Corvette instead of
|
||||||
|
# 3 identical hulls. scale 0.68 matches the Corvette's much taller native
|
||||||
|
# source resolution (516x1019 vs Colossus's 535x692) to the same
|
||||||
|
# on-screen height as flagship_scale 1.0 gives the Colossus.
|
||||||
|
"flagship_paths": [
|
||||||
|
{"path": "res://assets/images/ships/isn/flagship_colossus.png", "scale": 1.0},
|
||||||
|
{"path": "res://assets/images/ships/isn/flagship_colossus.png", "scale": 1.0},
|
||||||
|
{"path": "res://assets/images/ships/isn/stealth_corvette.png", "scale": 0.68},
|
||||||
|
],
|
||||||
"ships": [
|
"ships": [
|
||||||
{
|
{
|
||||||
"name": "PATRIOT", "role": "Fighter",
|
"name": "PATRIOT", "role": "Fighter",
|
||||||
@@ -41,6 +52,7 @@ const RACES := [
|
|||||||
{
|
{
|
||||||
"id": 2, "name": "OUTER RIM COLLECTIVE", "sub": "Gritty, jury-rigged mining rebels — kinetic weapons scavenged and welded onto anything that flies",
|
"id": 2, "name": "OUTER RIM COLLECTIVE", "sub": "Gritty, jury-rigged mining rebels — kinetic weapons scavenged and welded onto anything that flies",
|
||||||
"color": Color(0.82, 0.52, 0.24),
|
"color": Color(0.82, 0.52, 0.24),
|
||||||
|
"banner_path": "res://assets/images/banners/orc_banner.jpeg",
|
||||||
"flagship_path": "res://assets/images/ships/orc/flagship_rust_titan.png", "flagship_scale": 1.0,
|
"flagship_path": "res://assets/images/ships/orc/flagship_rust_titan.png", "flagship_scale": 1.0,
|
||||||
"ships": [
|
"ships": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ PlayerRegistry="*res://autoload/player_registry.gd"
|
|||||||
MatchmakingClient="*res://autoload/matchmaking_client.gd"
|
MatchmakingClient="*res://autoload/matchmaking_client.gd"
|
||||||
ChatManager="*res://autoload/chat_manager.gd"
|
ChatManager="*res://autoload/chat_manager.gd"
|
||||||
KillFeedManager="*res://autoload/kill_feed_manager.gd"
|
KillFeedManager="*res://autoload/kill_feed_manager.gd"
|
||||||
|
MatchStats="*res://autoload/match_stats.gd"
|
||||||
|
MatchManager="*res://autoload/match_manager.gd"
|
||||||
BotManager="*res://bots/bot_manager.gd"
|
BotManager="*res://bots/bot_manager.gd"
|
||||||
MusicManager="*res://autoload/music_manager.gd"
|
MusicManager="*res://autoload/music_manager.gd"
|
||||||
|
|
||||||
@@ -75,11 +77,16 @@ chat_team={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":89,"key_label":0,"unicode":121,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":89,"key_label":0,"unicode":121,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
scoreboard={
|
||||||
|
"deadzone": 0.2,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
common/physics_interpolation=true
|
|
||||||
3d/physics_engine="Jolt Physics"
|
3d/physics_engine="Jolt Physics"
|
||||||
|
common/physics_interpolation=true
|
||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ func _ready() -> void:
|
|||||||
_spawn_pos = global_position
|
_spawn_pos = global_position
|
||||||
_apply_sprite()
|
_apply_sprite()
|
||||||
rotation = velocity.angle()
|
rotation = velocity.angle()
|
||||||
|
# Physics interpolation (project.godot's physics_interpolation=true)
|
||||||
|
# snapshots each CanvasItem's "previous" transform on tree-entry, which
|
||||||
|
# happens before this _ready() runs -- without these resets, the sprite's
|
||||||
|
# scale (set above, post-entry) interpolates from its stale scene-default
|
||||||
|
# 1.0 baseline, flashing at full native pixel size for a frame before
|
||||||
|
# shrinking to the real size. A reset on the Bullet doesn't propagate to
|
||||||
|
# its Sprite2D child (see world.gd's ship-respawn precedent) -- each
|
||||||
|
# CanvasItem tracks its own transform history, so both need it.
|
||||||
|
reset_physics_interpolation()
|
||||||
|
_sprite.reset_physics_interpolation()
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
body_entered.connect(_on_body_entered)
|
body_entered.connect(_on_body_entered)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ var max_energy: float = 100.0
|
|||||||
var _role: String = "Fighter"
|
var _role: String = "Fighter"
|
||||||
var _dead: bool = false
|
var _dead: bool = false
|
||||||
|
|
||||||
|
# True from server_hold_for_match_start() until server_release_from_hold() --
|
||||||
|
# see the "Match phase gating" section below. Distinguishes a ship parked
|
||||||
|
# waiting for MatchManager's PRE_MATCH countdown to end from a normal death,
|
||||||
|
# even though both reuse the same _dead flag/machinery.
|
||||||
|
var _held_for_pre_match: bool = false
|
||||||
|
|
||||||
# Last-hit attribution for the kill feed (see autoload/kill_feed_manager.gd).
|
# Last-hit attribution for the kill feed (see autoload/kill_feed_manager.gd).
|
||||||
# 0 means "no attacking ship" (hazard/wall collision damage from
|
# 0 means "no attacking ship" (hazard/wall collision damage from
|
||||||
# _apply_collision_bounce()) -- real peer ids are never 0 (server is 1,
|
# _apply_collision_bounce()) -- real peer ids are never 0 (server is 1,
|
||||||
@@ -252,6 +258,8 @@ func _update_collision_shape(texture: Texture2D, total_scale: float) -> void:
|
|||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
if _dead:
|
if _dead:
|
||||||
|
if _is_owner and _held_for_pre_match:
|
||||||
|
_follow_flagship_formation()
|
||||||
return
|
return
|
||||||
|
|
||||||
# INVINCIBILITY BLINK — purely visual, runs once per real tick regardless
|
# INVINCIBILITY BLINK — purely visual, runs once per real tick regardless
|
||||||
@@ -632,9 +640,114 @@ func _spawn_explosion() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _server_respawn() -> void:
|
func _server_respawn() -> void:
|
||||||
|
if MatchManager.phase == MatchManager.Phase.PRE_MATCH:
|
||||||
|
server_hold_for_match_start()
|
||||||
|
return
|
||||||
_apply_respawn.rpc(_get_spawn_position())
|
_apply_respawn.rpc(_get_spawn_position())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Match phase gating — see autoload/match_manager.gd ──────────────────────
|
||||||
|
|
||||||
|
# Called by World (server) at the top of every PRE_MATCH phase, for ships
|
||||||
|
# already flying, AND transitively by _server_respawn() above for any fresh
|
||||||
|
# spawn/death-respawn/team-swap that happens to land while PRE_MATCH is
|
||||||
|
# active -- one phase check in _server_respawn() covers every call site.
|
||||||
|
# Reuses the existing dead-state machinery (invisible, non-colliding,
|
||||||
|
# _physics_process()'s `if _dead: return` early-out) instead of a parallel
|
||||||
|
# state, so prediction/reconciliation are completely untouched by this.
|
||||||
|
# Deliberately does NOT early-return on an already-_dead ship (e.g. one still
|
||||||
|
# mid-respawn-delay from a death right at the previous match's end) --
|
||||||
|
# without also flagging that ship _held_for_pre_match here, its own pending
|
||||||
|
# _server_respawn() call would later see PRE_MATCH and re-enter this function,
|
||||||
|
# but a ship that was already _dead before World ever called this wouldn't
|
||||||
|
# have been caught by that first pass, leaving it stuck dead forever once
|
||||||
|
# IN_PROGRESS starts (release_held_ships() only revives ships flagged here).
|
||||||
|
# Re-sending the RPC to an already-hidden ship is harmless (idempotent).
|
||||||
|
func server_hold_for_match_start() -> void:
|
||||||
|
if not multiplayer.is_server():
|
||||||
|
return
|
||||||
|
_apply_pre_match_hold.rpc(_get_spawn_position())
|
||||||
|
|
||||||
|
|
||||||
|
@rpc("authority", "call_local", "reliable")
|
||||||
|
func _apply_pre_match_hold(pos: Vector2) -> void:
|
||||||
|
# _held_for_pre_match/_dead need to be set here, not just by the server
|
||||||
|
# caller above -- this RPC is what actually reaches every remote client's
|
||||||
|
# own copy of this ship (server_hold_for_match_start() itself early-returns
|
||||||
|
# on a remote client). Without this, a remote client's _dead stayed false,
|
||||||
|
# so _physics_process() never took the held branch at all: WASD kept
|
||||||
|
# driving _owner_tick() normally (invisible but still fully mobile) and
|
||||||
|
# _follow_flagship_formation() never ran, leaving the camera to just go
|
||||||
|
# wherever that unblocked movement happened to take it.
|
||||||
|
_held_for_pre_match = true
|
||||||
|
_dead = true
|
||||||
|
# Not just any position -- this ship's own team spawn point, so the local
|
||||||
|
# camera ends up watching that team's flagship creep-in instead of
|
||||||
|
# sitting wherever the ship happened to die/idle.
|
||||||
|
global_position = pos
|
||||||
|
velocity = Vector2.ZERO
|
||||||
|
visible = false
|
||||||
|
_set_collision_enabled(false)
|
||||||
|
if _nameplate:
|
||||||
|
_nameplate.visible = false
|
||||||
|
reset_physics_interpolation()
|
||||||
|
if _camera:
|
||||||
|
_camera.zoom = Vector2.ONE * GameConfig.pre_match_camera_zoom
|
||||||
|
_camera.reset_physics_interpolation()
|
||||||
|
_camera.global_position = global_position
|
||||||
|
# _follow_flagship_formation() below re-targets this camera's
|
||||||
|
# global_position every tick to chase a fast-moving formation over
|
||||||
|
# flagship_creep_in_distance (thousands of units) across the 30s hold.
|
||||||
|
# Camera2D's own position_smoothing (tuned for normal gameplay's much
|
||||||
|
# smaller frame-to-frame ship movement, see ship.tscn) can't keep up
|
||||||
|
# with that and lags visibly behind -- with the formation's own Tween
|
||||||
|
# easing already providing the smooth motion, stacking the camera's
|
||||||
|
# smoothing on top just means the rendered view trails the target for
|
||||||
|
# most of the hold, which is what read as "the ships are invisible."
|
||||||
|
# Disabled for the hold, restored in _apply_respawn() once normal
|
||||||
|
# ship-following resumes.
|
||||||
|
_camera.position_smoothing_enabled = false
|
||||||
|
|
||||||
|
|
||||||
|
# Called every physics tick while held for PRE_MATCH (see _physics_process()'s
|
||||||
|
# `if _dead` branch) -- rather than sitting fixed on the team spawn marker for
|
||||||
|
# the whole 30s countdown, the camera rides along with its own team's 3
|
||||||
|
# flagships as flagship.gd's play_creep_in() tweens them in, so the player is
|
||||||
|
# always looking straight at the fleet instead of watching it drift into a
|
||||||
|
# static, distant frame. Reads flagship.gd's already-current (mid-tween)
|
||||||
|
# global_position directly off the "flagships" group every tick -- cheap (3
|
||||||
|
# nodes), and correct for free since Flagship isn't networked; every peer
|
||||||
|
# already ticks an identical local Tween off the same MatchManager-broadcast
|
||||||
|
# start moment (see flagship.gd), so there's nothing to reconcile here, same
|
||||||
|
# as ship_movement.gd's own remote-ship interpolation needing no authority
|
||||||
|
# round trip for anything already purely cosmetic.
|
||||||
|
func _follow_flagship_formation() -> void:
|
||||||
|
if not _camera:
|
||||||
|
return
|
||||||
|
var race_id: int = PlayerRegistry.get_info(peer_id).get("race", -1)
|
||||||
|
var center := Vector2.ZERO
|
||||||
|
var count := 0
|
||||||
|
for node in get_tree().get_nodes_in_group("flagships"):
|
||||||
|
var flagship := node as Flagship
|
||||||
|
if flagship == null or flagship.race_id != race_id:
|
||||||
|
continue
|
||||||
|
center += flagship.global_position
|
||||||
|
count += 1
|
||||||
|
if count > 0:
|
||||||
|
_camera.global_position = center / count
|
||||||
|
|
||||||
|
|
||||||
|
# Called by World (server) once, the instant IN_PROGRESS begins. No-op for
|
||||||
|
# ships that joined/respawned after IN_PROGRESS already started (a normal
|
||||||
|
# drop-in join or death-respawn never set _held_for_pre_match).
|
||||||
|
func server_release_from_hold() -> void:
|
||||||
|
if not multiplayer.is_server() or not _held_for_pre_match:
|
||||||
|
return
|
||||||
|
_held_for_pre_match = false
|
||||||
|
_dead = false
|
||||||
|
_server_respawn()
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_local", "reliable")
|
@rpc("authority", "call_local", "reliable")
|
||||||
func _apply_respawn(pos: Vector2) -> void:
|
func _apply_respawn(pos: Vector2) -> void:
|
||||||
global_position = pos
|
global_position = pos
|
||||||
@@ -643,6 +756,14 @@ func _apply_respawn(pos: Vector2) -> void:
|
|||||||
health = GameConfig.ship_max_health
|
health = GameConfig.ship_max_health
|
||||||
energy = max_energy
|
energy = max_energy
|
||||||
_dead = false
|
_dead = false
|
||||||
|
# Same reasoning as _apply_pre_match_hold()'s own _held_for_pre_match
|
||||||
|
# assignment -- this RPC is what reaches every remote client, and this
|
||||||
|
# flag needs to be false there too, not just on the server (which already
|
||||||
|
# cleared its own copy in server_release_from_hold() before sending this).
|
||||||
|
# Otherwise a remote client's stale `true` would silently make a much
|
||||||
|
# later, completely unrelated mid-match death respawn wrongly re-trigger
|
||||||
|
# _follow_flagship_formation() the next time _dead flips true.
|
||||||
|
_held_for_pre_match = false
|
||||||
_last_damage_source = 0
|
_last_damage_source = 0
|
||||||
visible = true
|
visible = true
|
||||||
_set_collision_enabled(true)
|
_set_collision_enabled(true)
|
||||||
@@ -654,6 +775,20 @@ func _apply_respawn(pos: Vector2) -> void:
|
|||||||
# declaration above for why the camera needs its own explicit reset too).
|
# declaration above for why the camera needs its own explicit reset too).
|
||||||
reset_physics_interpolation()
|
reset_physics_interpolation()
|
||||||
if _camera:
|
if _camera:
|
||||||
|
# Undoes _apply_pre_match_hold()'s pulled-back view -- a no-op restore
|
||||||
|
# for every respawn that didn't come from a PRE_MATCH hold (already at
|
||||||
|
# normal zoom), but necessary here since this is the one call site
|
||||||
|
# every hold->release path (server_release_from_hold() -> _server_respawn())
|
||||||
|
# always passes through. position is reset too -- _follow_flagship_
|
||||||
|
# formation() drives the camera's global_position independently of
|
||||||
|
# this ship's own (which never moves during the hold), leaving a
|
||||||
|
# stale local offset baked into the camera's transform that would
|
||||||
|
# otherwise persist into normal play, reading as the camera having
|
||||||
|
# "moved off map" the moment control resumed. position_smoothing is
|
||||||
|
# also re-enabled here, undoing _apply_pre_match_hold()'s disable.
|
||||||
|
_camera.position = Vector2.ZERO
|
||||||
|
_camera.zoom = Vector2.ONE * GameConfig.camera_zoom
|
||||||
|
_camera.position_smoothing_enabled = true
|
||||||
_camera.reset_physics_interpolation()
|
_camera.reset_physics_interpolation()
|
||||||
|
|
||||||
_interp_from_pos = global_position
|
_interp_from_pos = global_position
|
||||||
@@ -697,12 +832,33 @@ func _get_spawn_position() -> Vector2:
|
|||||||
var markers := get_tree().get_nodes_in_group(_team_spawn_group())
|
var markers := get_tree().get_nodes_in_group(_team_spawn_group())
|
||||||
if markers.size() > 0:
|
if markers.size() > 0:
|
||||||
var base: Vector2 = markers[randi() % markers.size()].global_position
|
var base: Vector2 = markers[randi() % markers.size()].global_position
|
||||||
var angle := randf() * TAU
|
# Launch anchor sits below the whole 3-ship flagship formation (see
|
||||||
|
# world.gd's _spawn_flagship(), which lines the formation up
|
||||||
|
# vertically through this same marker -- southernmost hull sits
|
||||||
|
# flagship_formation_half_extent() below it) plus a clearance margin,
|
||||||
|
# so ships spawn visibly emerging from beneath their own fleet at the
|
||||||
|
# start of a match instead of in a full circle around the marker
|
||||||
|
# (which could land a spawn above, behind, or inside the formation).
|
||||||
|
var anchor: Vector2 = base + Vector2(0, _flagship_formation_half_extent() + GameConfig.flagship_launch_clearance)
|
||||||
|
# Full circle would undo the "below the fleet" read for the half of
|
||||||
|
# spawns that rolled an upward angle; constrain to a downward-facing
|
||||||
|
# arc instead, spread wide enough that a full team doesn't spawn in
|
||||||
|
# a single-file line.
|
||||||
|
var angle := deg_to_rad(randf_range(90.0 - GameConfig.spawn_launch_arc_spread_deg, 90.0 + GameConfig.spawn_launch_arc_spread_deg))
|
||||||
var radius := randf_range(GameConfig.spawn_area_inner_radius, GameConfig.spawn_area_outer_radius)
|
var radius := randf_range(GameConfig.spawn_area_inner_radius, GameConfig.spawn_area_outer_radius)
|
||||||
return base + Vector2.from_angle(angle) * radius
|
return anchor + Vector2.from_angle(angle) * radius
|
||||||
return GameConfig.world_bounds.get_center()
|
return GameConfig.world_bounds.get_center()
|
||||||
|
|
||||||
|
|
||||||
|
# Distance from the team spawn marker to the formation's outermost hull
|
||||||
|
# (see world.gd's _spawn_flagship(), which centers the line on that same
|
||||||
|
# marker) -- shared by _get_spawn_position() above so a ship's launch point
|
||||||
|
# stays keyed off the actual formation size instead of a second hardcoded
|
||||||
|
# number that could drift out of sync with it.
|
||||||
|
func _flagship_formation_half_extent() -> float:
|
||||||
|
return (GameConfig.flagship_count_per_spawn - 1) / 2.0 * GameConfig.flagship_spawn_spacing
|
||||||
|
|
||||||
|
|
||||||
# Which side of the map this ship's race spawns on. Without this, spawn
|
# Which side of the map this ship's race spawns on. Without this, spawn
|
||||||
# position was picked at random from *both* groups regardless of race, so
|
# position was picked at random from *both* groups regardless of race, so
|
||||||
# opposite-race ships regularly landed right next to each other at the same
|
# opposite-race ships regularly landed right next to each other at the same
|
||||||
|
|||||||
@@ -219,3 +219,28 @@ func _spawn_damage_number(pos: Vector2, amount: int) -> void:
|
|||||||
number.global_position = pos
|
number.global_position = pos
|
||||||
number.amount = amount
|
number.amount = amount
|
||||||
get_parent().add_child(number)
|
get_parent().add_child(number)
|
||||||
|
|
||||||
|
|
||||||
|
# Cosmetic-only "creep in", replayed at the start of every MatchManager
|
||||||
|
# PRE_MATCH phase. Every peer already independently computed this exact same
|
||||||
|
# formation position (world.gd's _spawn_flagship(), deterministic off
|
||||||
|
# match_seed), so this needs no replication of its own -- each peer just
|
||||||
|
# tweens its own already-placed node from directly below (+Y, this project's
|
||||||
|
# screen-down direction) back up to the jittered formation slot it's already
|
||||||
|
# sitting at. Every ship in both formations uses this same fixed direction/
|
||||||
|
# distance/duration/easing (no per-ship randf()), so the 3-ship group rises
|
||||||
|
# as one cohesive fleet holding its relative formation shape the whole way
|
||||||
|
# in, rather than each hull drifting in from its own independent random
|
||||||
|
# heading. Staying in sync across peers needs no replication of its own
|
||||||
|
# either, for the same reason -- every peer starts the tween off the same
|
||||||
|
# MatchManager-broadcast phase-start moment, and this is now fully
|
||||||
|
# deterministic off that shared target position (see World._spawn_flagship()'s
|
||||||
|
# own PRE_MATCH-race fallback call for the one other place this is invoked).
|
||||||
|
func play_creep_in(duration: float) -> void:
|
||||||
|
var target := global_position
|
||||||
|
var start := target + Vector2(0, GameConfig.flagship_creep_in_distance)
|
||||||
|
global_position = start
|
||||||
|
var tween := create_tween()
|
||||||
|
tween.set_trans(Tween.TRANS_SINE)
|
||||||
|
tween.set_ease(Tween.EASE_IN_OUT)
|
||||||
|
tween.tween_property(self, "global_position", target, duration)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class_name GameMode
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
# Base for a match's ruleset — see MatchManager (autoload/match_manager.gd),
|
||||||
|
# which drives its PRE_MATCH/IN_PROGRESS/POST_MATCH state machine entirely
|
||||||
|
# through these virtual methods rather than hardcoding any one mode's rules.
|
||||||
|
# A new mode (Last Ship Standing, Capture and Hold — see overview/multiplayer.md)
|
||||||
|
# is a new subclass plus one branch in MatchManager's _create_mode() factory;
|
||||||
|
# nothing else (MatchManager's timing, Scoreboard, MatchBanner) needs to change,
|
||||||
|
# since they only ever call get_score_for_team()/get_score_label()/get_mode_name(),
|
||||||
|
# never a mode's underlying stat directly.
|
||||||
|
#
|
||||||
|
# Instantiated identically and independently by every peer (server and every
|
||||||
|
# client) off the same shared GameConfig.default_game_mode_id constant — same
|
||||||
|
# "shared static data, no replication needed" pattern as TeamSelect.RACES.
|
||||||
|
|
||||||
|
|
||||||
|
func get_match_duration() -> float:
|
||||||
|
return GameConfig.match_duration
|
||||||
|
|
||||||
|
|
||||||
|
# Polled by MatchManager once/sec during IN_PROGRESS so a mode can end the
|
||||||
|
# match before the clock runs out (e.g. a future Last Ship Standing mode
|
||||||
|
# ending once one team has zero ships left). The base mode never ends early.
|
||||||
|
func should_end_early(_world: Node) -> bool:
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
# Must return {"winner_race_id": int, "is_draw": bool}. winner_race_id is
|
||||||
|
# meaningless when is_draw is true.
|
||||||
|
func check_win_condition(_world: Node) -> Dictionary:
|
||||||
|
return {"winner_race_id": 0, "is_draw": true}
|
||||||
|
|
||||||
|
|
||||||
|
func get_mode_name() -> String:
|
||||||
|
return "UNKNOWN MODE"
|
||||||
|
|
||||||
|
|
||||||
|
func get_score_label() -> String:
|
||||||
|
return "KILLS"
|
||||||
|
|
||||||
|
|
||||||
|
# The number shown per-team on the Scoreboard/MatchBanner — overridden by a
|
||||||
|
# future mode that scores on something other than kills (e.g. Capture and
|
||||||
|
# Hold's held-sector points), which is what keeps that UI mode-agnostic.
|
||||||
|
func get_score_for_team(_world: Node, race_id: int) -> int:
|
||||||
|
return MatchStats.get_team_kills(race_id)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://lbcxwmv7me36
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
class_name TeamDeathmatchMode
|
||||||
|
extends GameMode
|
||||||
|
|
||||||
|
# The mode described in overview/map1.md: most kills when the 7-minute clock
|
||||||
|
# runs out wins; a tie is a draw. See GameMode for why this is the only file
|
||||||
|
# a future mode needs to add, not a change to MatchManager/Scoreboard/MatchBanner.
|
||||||
|
|
||||||
|
|
||||||
|
func get_mode_name() -> String:
|
||||||
|
return "TEAM DEATHMATCH"
|
||||||
|
|
||||||
|
|
||||||
|
func check_win_condition(world: Node) -> Dictionary:
|
||||||
|
var race_ids: Array = world.decide_offered_races()
|
||||||
|
if race_ids.size() != 2:
|
||||||
|
return {"winner_race_id": 0, "is_draw": true}
|
||||||
|
|
||||||
|
var kills_a := MatchStats.get_team_kills(race_ids[0])
|
||||||
|
var kills_b := MatchStats.get_team_kills(race_ids[1])
|
||||||
|
if kills_a == kills_b:
|
||||||
|
return {"winner_race_id": 0, "is_draw": true}
|
||||||
|
return {
|
||||||
|
"winner_race_id": race_ids[0] if kills_a > kills_b else race_ids[1],
|
||||||
|
"is_draw": false,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dldgufwwd718y
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
extends Node2D
|
extends Node2D
|
||||||
|
|
||||||
const MAP_SCENE: PackedScene = preload("res://world/maps/map_01.tscn")
|
# Only one map exists today; structured as pick-from-list (same static-array
|
||||||
|
# convention as TeamSelect.RACES) so a second .tscn -- and later, per-mode or
|
||||||
|
# rotation selection driven by MatchManager -- is just appending here and
|
||||||
|
# changing _pick_map()'s selection logic. World's own load path never needs
|
||||||
|
# to change again.
|
||||||
|
const MAPS := [
|
||||||
|
{"id": "map_01", "name": "Sector Alpha", "scene_path": "res://world/maps/map_01.tscn"},
|
||||||
|
]
|
||||||
|
|
||||||
const SHIP_SCENE: PackedScene = preload("res://ships/ship.tscn")
|
const SHIP_SCENE: PackedScene = preload("res://ships/ship.tscn")
|
||||||
const BULLET_SCENE: PackedScene = preload("res://ships/bullet.tscn")
|
const BULLET_SCENE: PackedScene = preload("res://ships/bullet.tscn")
|
||||||
const FLAGSHIP_SCENE: PackedScene = preload("res://world/flagship.tscn")
|
const FLAGSHIP_SCENE: PackedScene = preload("res://world/flagship.tscn")
|
||||||
@@ -17,9 +25,32 @@ const FLAGSHIP_SCENE: PackedScene = preload("res://world/flagship.tscn")
|
|||||||
# directly (server's own instance) or requests it over RPC (remote clients).
|
# directly (server's own instance) or requests it over RPC (remote clients).
|
||||||
var _offered_race_ids: Array = []
|
var _offered_race_ids: Array = []
|
||||||
|
|
||||||
|
# Same "decide once on the server, deliver the concrete value to every
|
||||||
|
# client" pattern as _offered_race_ids, for _spawn_flagship()'s
|
||||||
|
# RandomNumberGenerator -- without this, every match landed on the exact
|
||||||
|
# same flagship layout forever, since a fresh RandomNumberGenerator defaults
|
||||||
|
# to a fixed seed (0) unless explicitly randomized, and the old code seeded
|
||||||
|
# it with only spawn_group.hash() (a compile-time-constant string, so always
|
||||||
|
# the same value). randi() here rides Godot 4's auto-seeded-at-boot global
|
||||||
|
# RNG (this project never calls randomize() itself) so it differs each
|
||||||
|
# server process/match, while every peer still converges on the same
|
||||||
|
# concrete _match_seed value via the RPC below, keeping formations
|
||||||
|
# consistent across peers within one match.
|
||||||
|
var _match_seed: int = 0
|
||||||
|
|
||||||
|
# Guards _spawn_flagships() against ever running twice on the same peer
|
||||||
|
# (doubling every formation to 6 hulls per side instead of 3) -- decide_
|
||||||
|
# offered_races() already guards its own call to this against re-firing
|
||||||
|
# from a second local caller, but _deliver_offered_races() (the RPC that
|
||||||
|
# hands this same call to a remote client) has no equivalent guard of its
|
||||||
|
# own, so a client somehow receiving that RPC twice (a duplicate/late
|
||||||
|
# delivery, or manually reconnecting mid-session) would otherwise spawn a
|
||||||
|
# second full set of Flagship nodes right on top of the first.
|
||||||
|
var _flagships_spawned: bool = false
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
var map := MAP_SCENE.instantiate()
|
var map: Node = load(_pick_map().scene_path).instantiate()
|
||||||
_map_container.add_child(map)
|
_map_container.add_child(map)
|
||||||
var background := map.get_node_or_null("Background") as Control
|
var background := map.get_node_or_null("Background") as Control
|
||||||
if background:
|
if background:
|
||||||
@@ -30,11 +61,22 @@ func _ready() -> void:
|
|||||||
_bullet_spawner.spawn_function = _spawn_bullet
|
_bullet_spawner.spawn_function = _spawn_bullet
|
||||||
NetworkManager.peer_joined.connect(_on_peer_joined)
|
NetworkManager.peer_joined.connect(_on_peer_joined)
|
||||||
NetworkManager.peer_left.connect(_on_peer_left)
|
NetworkManager.peer_left.connect(_on_peer_left)
|
||||||
|
MatchManager.phase_changed.connect(_on_match_phase_changed)
|
||||||
|
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
spawn_peer(multiplayer.get_unique_id())
|
spawn_peer(multiplayer.get_unique_id())
|
||||||
for peer_id in multiplayer.get_peers():
|
for peer_id in multiplayer.get_peers():
|
||||||
spawn_peer(peer_id)
|
spawn_peer(peer_id)
|
||||||
|
MatchManager.start(self)
|
||||||
|
else:
|
||||||
|
MatchManager.request_match_state.rpc_id(1)
|
||||||
|
MatchStats.request_match_stats.rpc_id(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Only one map exists today (see MAPS above); this is the seam a future
|
||||||
|
# map/mode-rotation selection hooks into without World's load path changing.
|
||||||
|
func _pick_map() -> Dictionary:
|
||||||
|
return MAPS[0]
|
||||||
|
|
||||||
|
|
||||||
# Picks the match's offered races once and caches them, so every caller
|
# Picks the match's offered races once and caches them, so every caller
|
||||||
@@ -47,12 +89,13 @@ func decide_offered_races() -> Array:
|
|||||||
var ids: Array = TeamSelect.RACES.map(func(race): return race.id)
|
var ids: Array = TeamSelect.RACES.map(func(race): return race.id)
|
||||||
ids.shuffle()
|
ids.shuffle()
|
||||||
_offered_race_ids = ids
|
_offered_race_ids = ids
|
||||||
|
_match_seed = randi()
|
||||||
# Deferred: this can run from TeamSelect._ready(), which (as World's
|
# Deferred: this can run from TeamSelect._ready(), which (as World's
|
||||||
# child) fires before World's own _ready() — before _players/_spawner
|
# child) fires before World's own _ready() — before _players/_spawner
|
||||||
# are assigned. Deferring runs it after the whole scene's _ready
|
# are assigned. Deferring runs it after the whole scene's _ready
|
||||||
# cascade finishes, once World is actually spawn-ready.
|
# cascade finishes, once World is actually spawn-ready.
|
||||||
BotManager.call_deferred("on_match_start", self, _offered_race_ids)
|
BotManager.call_deferred("on_match_start", self, _offered_race_ids)
|
||||||
call_deferred("_spawn_flagships", _offered_race_ids)
|
call_deferred("_spawn_flagships", _offered_race_ids, _match_seed)
|
||||||
return _offered_race_ids
|
return _offered_race_ids
|
||||||
|
|
||||||
|
|
||||||
@@ -61,36 +104,38 @@ func request_offered_races() -> void:
|
|||||||
if not multiplayer.is_server():
|
if not multiplayer.is_server():
|
||||||
return
|
return
|
||||||
var sender_id := multiplayer.get_remote_sender_id()
|
var sender_id := multiplayer.get_remote_sender_id()
|
||||||
_deliver_offered_races.rpc_id(sender_id, decide_offered_races())
|
var race_ids := decide_offered_races()
|
||||||
|
_deliver_offered_races.rpc_id(sender_id, race_ids, _match_seed)
|
||||||
|
|
||||||
|
|
||||||
@rpc("authority", "call_remote", "reliable")
|
@rpc("authority", "call_remote", "reliable")
|
||||||
func _deliver_offered_races(race_ids: Array) -> void:
|
func _deliver_offered_races(race_ids: Array, match_seed: int) -> void:
|
||||||
_team_select.offer_races(race_ids)
|
_team_select.offer_races(race_ids)
|
||||||
call_deferred("_spawn_flagships", race_ids)
|
call_deferred("_spawn_flagships", race_ids, match_seed)
|
||||||
|
|
||||||
|
|
||||||
# Unnetworked capital-ship backdrop sitting on each team's spawn marker --
|
# Unnetworked capital-ship backdrop sitting on each team's spawn marker --
|
||||||
# every peer (server and every remote client alike) derives the same
|
# every peer (server and every remote client alike) derives the same
|
||||||
# placement independently from the same offered race_ids (decided once by
|
# placement independently from the same offered race_ids and match_seed
|
||||||
# the server, RPC'd identically to every client above), so there's no need
|
# (both decided once by the server, RPC'd identically to every client
|
||||||
# to replicate the sprites themselves. Their point-defense fire (see
|
# above), so there's no need to replicate the sprites themselves. Their
|
||||||
# flagship.gd) is server-only decision-making on top of that same
|
# point-defense fire (see flagship.gd) is server-only decision-making on top
|
||||||
# deterministic placement, same as take_damage() already was -- the only
|
# of that same deterministic placement, same as take_damage() already was --
|
||||||
# externally-visible effect (the bullets) rides the already-networked
|
# the only externally-visible effect (the bullets) rides the already-networked
|
||||||
# BulletSpawner, so the Flagship node itself still needs no sync of its own.
|
# BulletSpawner, so the Flagship node itself still needs no sync of its own.
|
||||||
# Deferred at both call sites above since decide_offered_races() can run
|
# Deferred at both call sites above since decide_offered_races() can run
|
||||||
# before the map (and its team_a_spawn/team_b_spawn markers) has been added
|
# before the map (and its team_a_spawn/team_b_spawn markers) has been added
|
||||||
# to the tree -- same ordering hazard BotManager.on_match_start already
|
# to the tree -- same ordering hazard BotManager.on_match_start already
|
||||||
# works around.
|
# works around.
|
||||||
func _spawn_flagships(race_ids: Array) -> void:
|
func _spawn_flagships(race_ids: Array, match_seed: int) -> void:
|
||||||
if race_ids.size() != 2:
|
if race_ids.size() != 2 or _flagships_spawned:
|
||||||
return
|
return
|
||||||
_spawn_flagship(race_ids[0], "team_a_spawn")
|
_flagships_spawned = true
|
||||||
_spawn_flagship(race_ids[1], "team_b_spawn")
|
_spawn_flagship(race_ids[0], "team_a_spawn", match_seed)
|
||||||
|
_spawn_flagship(race_ids[1], "team_b_spawn", match_seed)
|
||||||
|
|
||||||
|
|
||||||
func _spawn_flagship(race_id: int, spawn_group: String) -> void:
|
func _spawn_flagship(race_id: int, spawn_group: String, _match_seed: int) -> void:
|
||||||
var matches: Array = TeamSelect.RACES.filter(func(r): return r.id == race_id)
|
var matches: Array = TeamSelect.RACES.filter(func(r): return r.id == race_id)
|
||||||
var flagship_path: String = matches[0].get("flagship_path", "") if not matches.is_empty() else ""
|
var flagship_path: String = matches[0].get("flagship_path", "") if not matches.is_empty() else ""
|
||||||
if flagship_path.is_empty():
|
if flagship_path.is_empty():
|
||||||
@@ -100,25 +145,24 @@ func _spawn_flagship(race_id: int, spawn_group: String) -> void:
|
|||||||
return
|
return
|
||||||
var base_pos: Vector2 = markers[0].global_position
|
var base_pos: Vector2 = markers[0].global_position
|
||||||
var flagship_scale: float = matches[0].get("flagship_scale", 1.0)
|
var flagship_scale: float = matches[0].get("flagship_scale", 1.0)
|
||||||
# Loosely line-abreast, spread perpendicular to the two teams' spawn axis
|
# Optional per-formation-slot texture/scale override, cycled by slot index --
|
||||||
|
# lets a faction's 3-ship formation mix hull types instead of 3 identical
|
||||||
|
# copies of flagship_path. Races that don't define this (ORC, so far) fall
|
||||||
|
# through to flagship_path/flagship_scale for every slot, unchanged.
|
||||||
|
var flagship_variants: Array = matches[0].get("flagship_paths", []) if not matches.is_empty() else []
|
||||||
|
# Line-abreast, evenly spaced perpendicular to the two teams' spawn axis
|
||||||
# (both team_a_spawn/team_b_spawn markers sit at the same Y, see
|
# (both team_a_spawn/team_b_spawn markers sit at the same Y, see
|
||||||
# map_01.tscn) plus a large random per-ship position jitter on both axes
|
# map_01.tscn) -- a clean vertical line at fixed flagship_spawn_spacing
|
||||||
# and a random facing so the formation reads as ships that fell into a
|
# intervals, every hull facing straight up (rotation 0, this project's
|
||||||
# rough defensive line rather than 3 hulls snapped to a ruler and all
|
# nose-up convention -- Vector2.UP.rotated(rotation) in ship_movement.gd),
|
||||||
# facing the same way. Seeded off spawn_group (not the shared global RNG)
|
# since flagship.gd's play_creep_in() now brings the whole formation in
|
||||||
# so this stays deterministic -- every peer independently building this
|
# together as one rigid group (see its own comment); a scattered/jittered
|
||||||
# same formation (see this function's caller) must land on the exact
|
# formation (position OR facing) doesn't read as "a disciplined fleet"
|
||||||
# same jittered positions, not just the same average spacing; team_a and
|
# once the ships are actually moving in sync toward it.
|
||||||
# team_b get different-looking jitter since their seeds differ, rather
|
|
||||||
# than mirroring each other.
|
|
||||||
var rng := RandomNumberGenerator.new()
|
|
||||||
rng.seed = spawn_group.hash()
|
|
||||||
for i in GameConfig.flagship_count_per_spawn:
|
for i in GameConfig.flagship_count_per_spawn:
|
||||||
var offset_index: float = i - (GameConfig.flagship_count_per_spawn - 1) / 2.0
|
var offset_index: float = i - (GameConfig.flagship_count_per_spawn - 1) / 2.0
|
||||||
var jitter := Vector2(
|
var pos: Vector2 = base_pos + Vector2(0, offset_index * GameConfig.flagship_spawn_spacing)
|
||||||
rng.randf_range(-GameConfig.flagship_spawn_jitter, GameConfig.flagship_spawn_jitter),
|
|
||||||
rng.randf_range(-GameConfig.flagship_spawn_jitter, GameConfig.flagship_spawn_jitter)
|
|
||||||
)
|
|
||||||
var flagship := FLAGSHIP_SCENE.instantiate()
|
var flagship := FLAGSHIP_SCENE.instantiate()
|
||||||
# Named off the spawn group + formation slot, not spawn order, so this
|
# Named off the spawn group + formation slot, not spawn order, so this
|
||||||
# path is identical on every peer regardless of timing -- required for
|
# path is identical on every peer regardless of timing -- required for
|
||||||
@@ -126,9 +170,69 @@ func _spawn_flagship(race_id: int, spawn_group: String) -> void:
|
|||||||
# both ends (see flagship.gd).
|
# both ends (see flagship.gd).
|
||||||
flagship.name = "Flagship_%s_%d" % [spawn_group, i]
|
flagship.name = "Flagship_%s_%d" % [spawn_group, i]
|
||||||
_map_container.add_child(flagship)
|
_map_container.add_child(flagship)
|
||||||
flagship.setup(flagship_path, flagship_scale, race_id)
|
var variant: Dictionary = flagship_variants[i % flagship_variants.size()] if not flagship_variants.is_empty() else {}
|
||||||
flagship.global_position = base_pos + Vector2(0, offset_index * GameConfig.flagship_spawn_spacing) + jitter
|
flagship.setup(variant.get("path", flagship_path), variant.get("scale", flagship_scale), race_id)
|
||||||
flagship.rotation = deg_to_rad(rng.randf_range(-GameConfig.flagship_spawn_rotation_jitter_deg, GameConfig.flagship_spawn_rotation_jitter_deg))
|
flagship.global_position = pos
|
||||||
|
flagship.rotation = 0.0
|
||||||
|
# _on_match_phase_changed() only reaches flagships that already exist
|
||||||
|
# at the moment PRE_MATCH's phase_changed fires -- a flagship built
|
||||||
|
# just after that (e.g. this peer's own _spawn_flagships() deferred
|
||||||
|
# call landing a beat later than the phase RPC, more likely for a
|
||||||
|
# remote client racing its own network round trip) would otherwise
|
||||||
|
# just sit at its final `pos` with no tween ever applied to it. Catch
|
||||||
|
# that case here, once, right as the node is placed. Guarded on
|
||||||
|
# get_remaining_seconds() > 0, not just phase == PRE_MATCH -- phase
|
||||||
|
# defaults to PRE_MATCH from its very declaration, true even before
|
||||||
|
# MatchManager's own match loop has genuinely started that phase for
|
||||||
|
# the first time (phase_started_at_msec/phase_duration_sec both still
|
||||||
|
# 0), which made this fire with duration 0.0 on every fresh server
|
||||||
|
# boot's first-ever flagship spawn. A zero-duration tween doesn't
|
||||||
|
# resolve synchronously -- it needs one process frame -- so the real
|
||||||
|
# 30s tween moments later (from the actual phase broadcast) read
|
||||||
|
# global_position while it was still sitting at that bogus tween's
|
||||||
|
# intermediate (still off in deep space) value, corrupting the
|
||||||
|
# target it computed to tween back to.
|
||||||
|
var remaining := MatchManager.get_remaining_seconds()
|
||||||
|
if MatchManager.phase == MatchManager.Phase.PRE_MATCH and remaining > 0.0:
|
||||||
|
flagship.play_creep_in(remaining)
|
||||||
|
|
||||||
|
|
||||||
|
# Called by MatchManager (server-only) at the top of every PRE_MATCH phase --
|
||||||
|
# hides/freezes every currently-flying ship (see ship_movement.gd's
|
||||||
|
# server_hold_for_match_start()) rather than just gating fresh spawns, so a
|
||||||
|
# ship already in flight when a new match loop rolls over gets hidden too.
|
||||||
|
func hold_all_ships_for_pre_match() -> void:
|
||||||
|
for child in _players.get_children():
|
||||||
|
if child is Ship:
|
||||||
|
child.server_hold_for_match_start()
|
||||||
|
|
||||||
|
|
||||||
|
# Called by MatchManager (server-only) once IN_PROGRESS begins -- releases
|
||||||
|
# every ship still held from the PRE_MATCH phase (a no-op for any ship that
|
||||||
|
# joined/respawned after IN_PROGRESS already started, see
|
||||||
|
# ship_movement.gd's server_release_from_hold()).
|
||||||
|
func release_held_ships() -> void:
|
||||||
|
for child in _players.get_children():
|
||||||
|
if child is Ship:
|
||||||
|
child.server_release_from_hold()
|
||||||
|
|
||||||
|
|
||||||
|
# MatchManager.phase_changed fires identically on every peer (the server via
|
||||||
|
# its own _begin_phase RPC's call_local, remote clients via that same RPC
|
||||||
|
# delivered over the wire, and a late joiner via _deliver_match_state) -- so
|
||||||
|
# this is the one hook every peer needs for its own local flagship tween,
|
||||||
|
# rather than MatchManager._run_pre_match() (server-only code, never runs on
|
||||||
|
# a remote client at all) calling into World directly like it used to.
|
||||||
|
func _on_match_phase_changed(phase: int, duration: float) -> void:
|
||||||
|
if phase == MatchManager.Phase.PRE_MATCH:
|
||||||
|
play_flagship_creep_in(duration)
|
||||||
|
|
||||||
|
|
||||||
|
# Purely cosmetic, see Flagship.play_creep_in().
|
||||||
|
func play_flagship_creep_in(duration: float) -> void:
|
||||||
|
for child in _map_container.get_children():
|
||||||
|
if child is Flagship:
|
||||||
|
child.play_creep_in(duration)
|
||||||
|
|
||||||
|
|
||||||
# Spawns (or returns the already-spawned) ship for a given peer_id — real
|
# Spawns (or returns the already-spawned) ship for a given peer_id — real
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
[ext_resource type="PackedScene" path="res://hud/ping_display.tscn" id="9_ping"]
|
[ext_resource type="PackedScene" path="res://hud/ping_display.tscn" id="9_ping"]
|
||||||
[ext_resource type="PackedScene" path="res://hud/energy_bar.tscn" id="10_ebar"]
|
[ext_resource type="PackedScene" path="res://hud/energy_bar.tscn" id="10_ebar"]
|
||||||
[ext_resource type="PackedScene" uid="uid://csettingspanel1" path="res://menu/settings_panel.tscn" id="11_settings"]
|
[ext_resource type="PackedScene" uid="uid://csettingspanel1" path="res://menu/settings_panel.tscn" id="11_settings"]
|
||||||
|
[ext_resource type="PackedScene" path="res://hud/match_timer.tscn" id="12_mtimer"]
|
||||||
|
[ext_resource type="PackedScene" path="res://hud/scoreboard.tscn" id="13_sboard"]
|
||||||
|
[ext_resource type="PackedScene" path="res://hud/match_banner.tscn" id="14_mbanner"]
|
||||||
|
|
||||||
[node name="World" type="Node2D" unique_id=1962020789]
|
[node name="World" type="Node2D" unique_id=1962020789]
|
||||||
script = ExtResource("4_world")
|
script = ExtResource("4_world")
|
||||||
@@ -49,3 +52,9 @@ fill_color = Color(0.3, 0.85, 0.35, 0.95)
|
|||||||
[node name="EnergyBar" parent="." instance=ExtResource("10_ebar")]
|
[node name="EnergyBar" parent="." instance=ExtResource("10_ebar")]
|
||||||
margin_top = 40.0
|
margin_top = 40.0
|
||||||
fill_color = Color(0.25, 0.55, 1.0, 0.95)
|
fill_color = Color(0.25, 0.55, 1.0, 0.95)
|
||||||
|
|
||||||
|
[node name="MatchTimer" parent="." instance=ExtResource("12_mtimer")]
|
||||||
|
|
||||||
|
[node name="Scoreboard" parent="." instance=ExtResource("13_sboard")]
|
||||||
|
|
||||||
|
[node name="MatchBanner" parent="." instance=ExtResource("14_mbanner")]
|
||||||
|
|||||||