Add real server pool, race roster overhaul, and ship energy system

Server browsing & matchmaking:
- HL2-style main menu (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT)
  replaces the old CASUAL/RANKED tiles; RANKED removed end-to-end
  (menu, matchmaking-api's Mode.ranked queue path, MMR matching) until
  ranked is real.
- New menu/server_select.gd lists real servers from the matchmaking
  API's GET /servers and connects directly, retiring the old
  unreachable menu/server_browser.gd.
- Real game-server pool: NetworkManager.host_server() self-registers on
  boot and heartbeats every 8s with live player counts; API computes
  available/full status from player_count, and a background sweep marks
  any server offline once its heartbeat goes stale (catches a crashed
  server that never deregistered).
- Server-select rows get a live UDP ping probe (query port = game port
  + 10000) instead of trusting stale DB numbers; post-connect HUD shows
  live RTT off ENet's own peer stats.

Race roster overhaul:
- Swapped Terran/Mechanos/Vorg for the pivoted roster — Apex Dynamics,
  Inner Sphere Navy, Outer Rim Collective — each with a 3-ship
  Fighter/Gunner/Tank lineup, art cropped from concept sheets with
  background removal + orientation fixes per sheet.
- Live headcount + roster + "TEAM FULL" lock on the race-select screen,
  shared between the initial pre-spawn pick and the pause menu's live
  SELECT TEAM swap.
- Bot personalities (bots/bot_personality.gd): aggression/caution/
  accuracy/reaction/awareness traits rolled per bot instead of one
  fixed AI profile.

HUD additions:
- Player list (roster, teammates white/enemies yellow, bots flagged),
  kill feed, minimap, and explosion VFX on death.
- Health and energy now render as bars (hud/stat_bar.gd) instead of
  text in the top-left HUD.

Ship energy system:
- Per-role max energy (Fighter 100 / Gunner 150 / Tank 250), 75 energy
  per shot, flat regen (100 per 2.5s), fully server-authoritative and
  piggybacked on the existing per-tick state broadcast alongside health.
- New blue "mirrored" bar top-middle of the screen (hud/energy_bar.gd)
  whose fill drains from both edges toward the center instead of
  left-to-right.

Ship handling tuning:
- Turn rate reduced (4.0 -> 1.0 rad/s) so a quick tap no longer
  over-rotates; holding past 0.15s ramps to double speed (2.0 rad/s) for
  fast full turns, gated the same way damage already is so replay during
  reconciliation can't double-count the hold timer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:56:33 -04:00
parent c0b5f421c5
commit 7b44b2f2dc
112 changed files with 3349 additions and 872 deletions
+14 -6
View File
@@ -20,22 +20,30 @@ README for how to run/extend it.
## Completed ## Completed
1. **Server browser** — player name input, race toggles, server list (Trench Wars 0/32), JOIN button; stores name + race in `GameConfig` autoload 1. ~~Server browser~~ — superseded by item 14's `menu/server_select.gd`
2. **Main menu** CASUAL / RANKED (disabled) buttons, callsign input, rank badge, QUIT; clears player state on load 2. ~~Main menu CASUAL/RANKED tiles~~ — superseded by item 14's HL2-style nav menu
3. **In-game pause menu** — ESC / controller Start button toggles overlay; game keeps running; RESUME, SETTINGS (stub), SELECT TEAM (stub), QUIT TO MENU, QUIT TO DESKTOP 3. **In-game pause menu** — ESC / controller Start button toggles overlay; game keeps running; RESUME, SETTINGS (stub), SELECT TEAM (live team swap — see item 12), QUIT TO MENU, QUIT TO DESKTOP
4. **Team & ship selection** — shows on world load before player spawns; 2 races randomly offered per match, decided once by the server so every player sees the same pair; ship grid with sprites; player spawns with chosen ship after selection 4. **Team & ship selection** — shows on world load before player spawns; 2 races randomly offered per match, decided once by the server so every player sees the same pair; ship grid with sprites; player spawns with chosen ship after selection
5. **Real race art wired in** — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (`assets/images/ships/<race>/`); placeholder `example_ships` removed 5. **Real race art wired in** — Terran Republic, Mechanos Sovereignty, and Vorg Swarm each have their own 5-ship roster (Interceptor/Gunship/Bomber/Support/Heavy) with real sprites cropped from uploaded concept sheets (`assets/images/ships/<race>/`); placeholder `example_ships` removed
6. **Multiplayer networking (movement + combat)** — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see `overview/tech.md`) 6. **Multiplayer networking (movement + combat)** — ENet authoritative server; networked ship spawning with client-side prediction + server reconciliation for movement; server-authoritative bullets, health, death/respawn all broadcast to every peer (see `overview/tech.md`)
7. **Matchmaking API + client wiring** — FastAPI + Postgres service (`matchmaking-api/`) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks 7. **Matchmaking API + client wiring** — FastAPI + Postgres service (`matchmaking-api/`) for casual/ranked queueing; main menu CASUAL/RANKED buttons queue via the API, poll for a match, then connect to the assigned server. Currently only one dev server is registered (auto-seeded on API startup) — see Current Tasks
8. **In-game chat** — T focuses "all" chat (whole match, both teams), Y focuses "team" chat (peers sharing the sender's race, since race doubles as team); Enter sends, Esc cancels; last 10 messages shown bottom-left, WoW-style translucent log + input line (`chat/chat_box.gd`); server-relayed and team-filtered via `ChatManager` autoload (`autoload/chat_manager.gd`) 8. **In-game chat** — T focuses "all" chat (whole match, both teams), Y focuses "team" chat (peers sharing the sender's race, since race doubles as team); Enter sends, Esc cancels; last 10 messages shown bottom-left, WoW-style translucent log + input line (`chat/chat_box.gd`); server-relayed and team-filtered via `ChatManager` autoload (`autoload/chat_manager.gd`)
9. **Bot fill for casual** — each of the match's 2 offered races is kept at a minimum of `GameConfig.bot_min_team_size` (7) total humans+bots; bots spawn/despawn reactively as players join/leave (`bots/bot_manager.gd`, server-authoritative autoload). Bots always fly their race's fighter/Interceptor (`race.ships[0]`) and use negative peer_ids, which lets them ride every existing networked-ship system (spawning, loadout replication, position/health sync, bullet attribution) with zero special-casing. AI (`bots/bot_ai.gd`, `class_name BotAI`) is a simple seek-nearest-enemy-and-shoot brain, deliberately isolated from ship networking code so future tuning/behavior changes stay contained to that one file. See `bots.md`. Casual matchmaking now forms with just 1 real player queued (bots pad the rest) — see `matchmaking-api/README.md`. 9. **Bot fill for casual** — each of the match's 2 offered races is kept at a minimum of `GameConfig.bot_min_team_size` (7) total humans+bots; bots spawn/despawn reactively as players join/leave (`bots/bot_manager.gd`, server-authoritative autoload). Bots always fly their race's fighter/Interceptor (`race.ships[0]`) and use negative peer_ids, which lets them ride every existing networked-ship system (spawning, loadout replication, position/health sync, bullet attribution) with zero special-casing. AI (`bots/bot_ai.gd`, `class_name BotAI`) is a simple seek-nearest-enemy-and-shoot brain, deliberately isolated from ship networking code so future tuning/behavior changes stay contained to that one file. See `bots.md`. Casual matchmaking now forms with just 1 real player queued (bots pad the rest) — see `matchmaking-api/README.md`.
10. **Borderless fullscreen, no UI/world scaling** — launches at the real screen resolution with stretch mode disabled (1 game pixel = 1 screen pixel), instead of scaling the Steam-Deck-matched 1280×800 design canvas up to fill PC monitors (which looked zoomed in). PC monitors now just reveal more world/HUD instead. Every menu screen (`main_menu.gd`, `team_select.gd`, `chat/chat_box.gd`) was reworked to position itself via anchors relative to the real window instead of hardcoded 1280×800 pixel coordinates — see `structure.md`'s Display section, including a real `push_opposite_anchor` footgun hit along the way. `menu/server_browser.gd` was NOT updated (still hardcoded, but unreachable in the live flow). 10. **Borderless fullscreen, no UI/world scaling** — launches at the real screen resolution with stretch mode disabled (1 game pixel = 1 screen pixel), instead of scaling the Steam-Deck-matched 1280×800 design canvas up to fill PC monitors (which looked zoomed in). PC monitors now just reveal more world/HUD instead. Every menu screen (`main_menu.gd`, `team_select.gd`, `chat/chat_box.gd`) was reworked to position itself via anchors relative to the real window instead of hardcoded 1280×800 pixel coordinates — see `structure.md`'s Display section, including a real `push_opposite_anchor` footgun hit along the way. `menu/server_browser.gd` was NOT updated at the time (still hardcoded, but unreachable in the live flow) — it has since been deleted, see item 14.
11. **Player list HUD** — top-left roster of every connected player, reactive to `PlayerRegistry`'s `loadout_updated`/`player_removed` signals rather than polled (`hud/player_list.gd`, added to `world.tscn`). White entries are the local player's team (same race), yellow are the enemy team; bots (negative peer_id) get a trailing " (b)". Teammates are sorted to the top.
12. **Select Team in pause menu (live team swap)** — pause menu's SELECT TEAM reopens `TeamSelect` mid-match instead of only offering it once pre-spawn; re-picking a race/ship reuses the existing `PlayerRegistry.submit_local_loadout``loadout_updated``Ship._init_player`/`_server_respawn` path, so the swap gets a fresh respawn + invincibility window for free. `GameConfig.team_select_focused` (mirrors `chat_focused`) blocks ship input while the picker is open, and Esc cancels back out without swapping (only once `TeamSelect` has already been used to reopen — the original mandatory pre-spawn pick still can't be cancelled). Fixed a bot-fill gap this surfaced: `PlayerRegistry.race_changed` now fires alongside `loadout_updated` so `BotManager` rebalances the race the player *left*, not just the one they joined.
13. **Live headcounts + roster + team-full lock on the race-selection screen** — each race box on `TeamSelect`'s "CHOOSE YOUR FACTION" screen now shows a live human player count and the roster of names below it, reactive to `PlayerRegistry.loadout_updated`/`player_removed` (bots excluded from both — they're padding, not a player's actual choice). If one race has more than 2 more humans than the other, the larger side is disabled and reads "TEAM FULL" (except for a player already on that race, so reopening the picker to pick a different ship never locks you out of your own team). This is one shared code path (`_show_race_selection()`/`_refresh_race_boxes()`), so it applies identically to the initial pre-spawn pick and to item 12's mid-match SELECT TEAM reopen — no separate implementation needed for the two.
14. **HL2-style main menu + real server select**`main_menu.gd` rebuilt as a plain white-on-space vertical nav (QUICK PLAY, SERVER SELECT, OPTIONS stub, PROFILE, QUIT) instead of the old big CASUAL/RANKED tiles; RANKED is gone entirely (not just disabled) until ranked matchmaking is real. Top-right shows callsign/rank/level (rank/level still placeholders, same as before). Callsign entry moved into a PROFILE overlay, auto-opened the first time QUICK PLAY is pressed with no callsign set. New `menu/server_select.gd`/`.tscn` replaces the retired `menu/server_browser.gd` — lists real servers from the matchmaking API's `GET /servers`, lets the player pick one and connect directly via `NetworkManager.join_server` (race/ship selection still happens in-world via TeamSelect, same as the matchmaking-queue path). `MatchmakingClient.list_servers()` added as the public wrapper other screens should use for this — don't call `_request` directly from outside the autoload.
15. **Ranked mode removed backend-side + server population/ping** — item 14 already dropped ranked from the menu UI, but the matchmaking API still had a `Mode.ranked` queue path (MMR-sorted matching, `RANKED_TEAM_SIZE`) with no client ever hitting it; that's now deleted (`app/models.py`/`config.py`/`queue_manager.py`/`schemas.py` in `matchmaking-api/`) — `Mode` only has `casual`. Note this is distinct from the CADET/PILOT/.../LEGEND rank-tier *display* system (`Player.mmr`, `/ranks`, `/stats`) backing the main menu's profile badge, which stays (still placeholder-driven, see Current Tasks). `GameServer` gained `player_count`/`max_players` columns, returned by `GET /servers` and settable via `POST /servers/register`; API startup now seeds 3 demo servers (`127.0.0.1:7777/7778/7779`, `player_count` 40/20/10 of 50) instead of one. `menu/server_select.gd` shows these as DB-fallback PLAYERS/PING columns, then actively overrides them per-row with a live probe: `NetworkManager` now runs a `UDPServer` on game-port+10000 (`_start_query_responder`) that answers a raw `"SPACEWAR_PING"` datagram with live `PlayerRegistry.players.size()`/`MAX_PLAYERS`, separate from the ENet game port since ENet won't answer arbitrary UDP itself — same game-port/query-port split classic server browsers use (offset is large, not +1, since local dev runs servers on sequential ports and a +1 query port would alias onto the next server's actual game port). Only `:7777` is realistically reachable without running extra local servers, so the other two demo rows show DB numbers with ping timing out until real servers register there. Post-connect, `hud/ping_display.gd` shows the local client's live RTT top-right using ENet's own `ENetPacketPeer.get_statistic(PEER_ROUND_TRIP_TIME)` on peer 1 (the server) — no custom protocol needed once already connected, unlike the pre-connect server-select probe.
16. **Real game-server pool (self-registration + heartbeat + stale sweep)**`NetworkManager.host_server()` now calls the new `MatchmakingClient.register_server()` once on boot and then every `HEARTBEAT_INTERVAL` (8s) via a `Timer`, reporting live `PlayerRegistry.players.size()`/`MAX_PLAYERS` — item 15's demo-seeded rows are no longer the only thing populating `GET /servers`; a real server registering on the same `(ip, port, mode)` as a demo row just takes it over in place, no special-casing needed. `--server-ip=` is a new cmdline arg for the IP a hosted server advertises (defaults to loopback for local dev — see the reachability note already in `matchmaking-api/README.md` about `DEV_SEED_SERVER_IP`, same constraint applies here). API-side, `POST /servers/register` now computes `status` from `player_count`/`max_players` (`full` vs `available`) instead of always writing `available`, and a new background loop (`sweep_stale_servers` in `app/routers/servers.py`, run every 5s from `main.py`'s lifespan) marks any server `offline` once its `last_heartbeat` exceeds `server_stale_seconds` (20s) — catches a crashed/killed server that never got to deregister cleanly, so a dead server doesn't sit in the list looking joinable forever. `menu/server_select.gd`'s connect button now also blocks (with a message) on `status == "full"`, not just `"offline"`.
17. **Race roster replaced (Terran/Mechanos/Vorg → Apex Dynamics/Inner Sphere Navy/Outer Rim Collective)**`team_select.gd`'s `RACES` now points at the 3 factions from `overview/racesclasses.md`'s pivot, each with a 3-ship Fighter/Gunner/Tank roster (Lancet/Pulsar/Sovereign, Patriot/Barrage/Behemoth, Rail-Jack/Scrap-Spitter/Iron-Clad) instead of the old 5-ship Interceptor/Gunship/Bomber/Support/Heavy set; `SPEED_BY_ROLE` shrunk to match. Art was extracted from 3 user-provided concept sheets (originally dropped at `assets/images/ship/`, now cropped per-ship into `assets/images/ships/apex|isn|orc/` with a `source/` copy of each sheet, same convention as the old race folders) — background removed via flood-fill + largest-connected-component matting (plain background for Apex, starfield for ORC, grid-lined UI panels for ISN, each needing different thresholding). ISN and ORC's source art was drawn nose-*sideways*; both were rotated 90° before saving since this project's ship rotation convention is nose-up at `rotation = 0` (`Vector2.UP.rotated(rotation)` in `ship_movement.gd`) — Apex's source art was already nose-up. `scale` values were computed with the same per-role target-on-screen-height normalization the old roster used (Fighter/Gunner/Tank targets reuse the old Interceptor/Gunship/Heavy heights, ~52.5/58.5/82.5px) so ship sizes read consistently across factions. Verified by hosting a real server and screenshotting bot fill flying with the new sprites — no load errors, transparency and nose-up orientation both correct. The old `terran/mech/vorg` asset folders are left on disk but unreferenced (no code points at them); not deleted since that wasn't asked for.
## Current Tasks ## Current Tasks
- [ ] Asteroids and environment hazards - [ ] Asteroids and environment hazards
- [ ] Sound effects (thrust, shoot, explosion, UI) - [ ] Sound effects (thrust, shoot, explosion, UI)
- [ ] Real game-server pool — servers self-register with the matchmaking API (`POST /servers/register` already exists, nothing calls it yet) instead of one hardcoded dev entry - [ ] Options screen — currently just a disabled stub button on the main menu
- [ ] 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`) - [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
- [ ] Ranked matchmaking refinement — MMR-window widening, real account-linked MMR (currently a naive nearest-neighbor sort on a per-callsign stub); GodotSteam auth + VAC still not started - [ ] 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
+4 -5
View File
@@ -1,11 +1,10 @@
DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking DATABASE_URL=postgresql+asyncpg://matchmaking:matchmaking@postgres:5432/matchmaking
# Real-value targets per overview/bots.md (casual min 7v7) and overview/racesclasses.md (ranked 5v5). # Real-value target per overview/bots.md (casual min 7v7). CASUAL_TEAM_SIZE
# CASUAL_TEAM_SIZE no longer gates when a casual match forms (bot fill means # no longer gates when a casual match forms (bot fill means 1 real player is
# 1 real player is enough — see queue_manager.py); it only caps how many real # enough — see queue_manager.py); it only caps how many real players group
# players group into one casual match. # into one casual match.
CASUAL_TEAM_SIZE=7 CASUAL_TEAM_SIZE=7
RANKED_TEAM_SIZE=5
# Registered on API startup so local matchmaking has somewhere to assign # Registered on API startup so local matchmaking has somewhere to assign
# players to before real game servers self-register via POST /servers/register. # players to before real game servers self-register via POST /servers/register.
+42 -20
View File
@@ -27,37 +27,59 @@ rebuilding the image. Rebuild (`docker compose up -d --build`) only when
## How matchmaking works right now ## How matchmaking works right now
- `POST /matchmaking/queue/join` `{callsign, mode, mmr?}` — creates the - `POST /matchmaking/queue/join` `{callsign, mode}` — creates the
player row if it doesn't exist yet, returns a `ticket_id`. player row if it doesn't exist yet, returns a `ticket_id`.
- `GET /matchmaking/queue/status/{ticket_id}` — poll this; once matched it - `GET /matchmaking/queue/status/{ticket_id}` — poll this; once matched it
returns the assigned server's `server_ip`/`server_port`/`team`. returns the assigned server's `server_ip`/`server_port`/`team`.
- `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket. - `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket.
A background loop (`app/queue_manager.py`) runs every 2s and forms a match A background loop (`app/queue_manager.py`) runs every 2s and forms a match
once enough players are queued for a mode, then splits them into two teams once enough players are queued for casual, then splits them into two teams
and assigns the first available `GameServer` for that mode. Casual and and assigns the first available `GameServer`. Casual has bot fill
ranked differ here: casual has bot fill (`spacewar/bots/bot_manager.gd` (`spacewar/bots/bot_manager.gd` pads both teams up to
pads both teams up to `GameConfig.bot_min_team_size` in-game), so 1 queued `GameConfig.bot_min_team_size` in-game), so 1 queued player is enough to
player is enough to form a casual match — `CASUAL_TEAM_SIZE` just caps how form a match — `CASUAL_TEAM_SIZE` just caps how many real players can group
many real players can group into one match, it no longer gates formation. into one match, it no longer gates formation. Ranked mode has been removed
Ranked has no bots, so it still waits for the full `RANKED_TEAM_SIZE × 2` entirely (was never implemented past matchmaking-queue scaffolding) — the
real players before forming. `Mode` enum only has `casual` now.
There's no real game-server pool yet — one dev server is auto-seeded on API Real servers self-register: `spacewar/autoload/network_manager.gd`'s
startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to `host_server()` calls `POST /servers/register` once on boot and then every
`127.0.0.1:7777`, i.e. a Godot server run on the host via `HEARTBEAT_INTERVAL` (8s) as a heartbeat, reporting live player count. The
`godot4 --headless --path spacewar -- --server`). That IP is handed straight endpoint upserts on `(ip, port, mode)` — an unrecognized combo creates a
row, a recognized one just refreshes it — and now also computes `status`
from `player_count` vs `max_players` (`full` once at capacity) instead of
always writing `available`. A background loop (`sweep_stale_servers` in
`app/routers/servers.py`, run every 5s from `main.py`'s lifespan) marks a
server `offline` once its `last_heartbeat` is older than
`server_stale_seconds` (20s default) — catches a server that crashed or was
`kill -9`'d instead of shutting down cleanly, so it doesn't sit in the list
looking joinable forever.
Three demo `GameServer` rows are still auto-seeded on API startup
(`_seed_demo_servers` in `main.py`) at `127.0.0.1:7777/7778/7779` with
`player_count` 40/20/10 out of `max_players` 50, so `menu/server_select.gd`
has something to list even with no real server running — and since
registration is a plain upsert, running a real Godot server on one of those
same `(ip, port)` pairs (`godot4 --headless --path spacewar -- --server`)
just takes that row over with live data, no special-casing needed. A demo
row with nothing real backing it goes stale and flips to `offline` within
`server_stale_seconds` of API startup, same as any other server. `--server-ip=`
(new `network_manager.gd` cmdline arg) controls what IP a hosted server
advertises — defaults to loopback for local dev. That IP is handed straight
to game clients to connect to, so it must be reachable from wherever the to game clients to connect to, so it must be reachable from wherever the
*client* runs, not the API container — `host.docker.internal` would resolve *client* runs, not the API container — `host.docker.internal` would resolve
inside the api container but not on the client's machine, which is why this inside the api container but not on the client's machine, which is why this
isn't a docker-internal address. Real servers should eventually call isn't a docker-internal address (same constraint `DEV_SEED_SERVER_IP` has).
`POST /servers/register` on boot and periodically as a heartbeat — that `player_count`/`max_players` from `GET /servers` are just this DB-stored
endpoint exists but nothing calls it yet. 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
RTT, overriding the DB value in the UI once that probe answers.
## 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`):
CASUAL/RANKED on the main menu calls `POST /matchmaking/queue/join`, polls QUICK PLAY on the main menu calls `POST /matchmaking/queue/join`, polls
`GET /matchmaking/queue/status/{ticket_id}` every 1.5s, and once `matched` `GET /matchmaking/queue/status/{ticket_id}` every 1.5s, and once `matched`
connects `NetworkManager` directly to the returned `server_ip`/`server_port`. connects `NetworkManager` directly to the returned `server_ip`/`server_port`.
Point it at a non-default API with `godot4 ... -- --matchmaking-api=http://host:port`. Point it at a non-default API with `godot4 ... -- --matchmaking-api=http://host:port`.
@@ -81,6 +103,6 @@ support, in this same service.
- **Auth**: `callsign` is the only player identity, matching the game - **Auth**: `callsign` is the only player identity, matching the game
client's current state (no accounts yet). Real identity arrives with the client's current state (no accounts yet). Real identity arrives with the
GodotSteam auth checklist item in `overview/tech.md`. GodotSteam auth checklist item in `overview/tech.md`.
- **Ranked MMR-window widening / bot-fill timeouts**: current matching is a - **Bot-fill timeouts**: current matching is a simple threshold (enough
simple threshold (enough players queued → form a match). Refine once players queued → form a match). Refine once there's real queue volume to
there's real queue volume to tune against. tune against.
+6 -1
View File
@@ -6,9 +6,14 @@ class Settings(BaseSettings):
database_url: str database_url: str
casual_team_size: int = 7 casual_team_size: int = 7
ranked_team_size: int = 5
dev_seed_server_ip: str = "127.0.0.1" dev_seed_server_ip: str = "127.0.0.1"
dev_seed_server_port: int = 7777 dev_seed_server_port: int = 7777
# Real/demo servers are considered offline once their last heartbeat is
# older than this. Godot's NetworkManager heartbeats every 8s (see
# spacewar/autoload/network_manager.gd's HEARTBEAT_INTERVAL), so this
# needs enough slack for one missed beat without flapping a live server
# to "offline" and back.
server_stale_seconds: int = 20
settings = Settings() settings = Settings()
+31 -7
View File
@@ -9,17 +9,30 @@ 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 matchmaking, ranks, servers, stats
from app.routers.servers import sweep_stale_servers
async def _seed_dev_server() -> None: # Three fake rows so server_select.gd has a realistic-looking list before a
# real server pool exists (see README's "Not set up yet"). Only the first
# port matches DEV_SEED_SERVER_IP/PORT, so it's the only one of the three
# that's actually reachable by running a real Godot server locally — the
# other two are display-only until real servers self-register on those
# ports. player_count here is just the DB fallback; a live server overrides
# it via the UDP query responder the client probes directly (see
# spacewar/autoload/network_manager.gd).
_DEMO_PLAYER_COUNTS = [40, 20, 10]
async def _seed_demo_servers() -> None:
async with async_session() as db: async with async_session() as db:
for mode in (Mode.casual, Mode.ranked): for i, player_count in enumerate(_DEMO_PLAYER_COUNTS):
port = settings.dev_seed_server_port + i
existing = ( existing = (
await db.execute( await db.execute(
select(GameServer).where( select(GameServer).where(
GameServer.ip == settings.dev_seed_server_ip, GameServer.ip == settings.dev_seed_server_ip,
GameServer.port == settings.dev_seed_server_port, GameServer.port == port,
GameServer.mode == mode, GameServer.mode == Mode.casual,
) )
) )
).scalars().first() ).scalars().first()
@@ -27,22 +40,33 @@ async def _seed_dev_server() -> None:
db.add( db.add(
GameServer( GameServer(
ip=settings.dev_seed_server_ip, ip=settings.dev_seed_server_ip,
port=settings.dev_seed_server_port, port=port,
mode=mode, mode=Mode.casual,
status=ServerStatus.available, status=ServerStatus.available,
player_count=player_count,
max_players=50,
) )
) )
await db.commit() await db.commit()
async def _run_stale_sweep_loop() -> None:
while True:
await asyncio.sleep(5)
async with async_session() as db:
await sweep_stale_servers(db)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await _seed_dev_server() await _seed_demo_servers()
matching_task = asyncio.create_task(queue_manager.run_matching_loop()) matching_task = asyncio.create_task(queue_manager.run_matching_loop())
sweep_task = asyncio.create_task(_run_stale_sweep_loop())
yield yield
matching_task.cancel() matching_task.cancel()
sweep_task.cancel()
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan) app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
+7 -1
View File
@@ -11,7 +11,6 @@ from app.database import Base
class Mode(str, enum.Enum): class Mode(str, enum.Enum):
casual = "casual" casual = "casual"
ranked = "ranked"
class ServerStatus(str, enum.Enum): class ServerStatus(str, enum.Enum):
@@ -43,6 +42,13 @@ class GameServer(Base):
mode: Mapped[Mode] = mapped_column(SAEnum(Mode)) mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
status: Mapped[ServerStatus] = mapped_column(SAEnum(ServerStatus), default=ServerStatus.available) status: Mapped[ServerStatus] = mapped_column(SAEnum(ServerStatus), default=ServerStatus.available)
last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_heartbeat: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# DB-stored fallback shown immediately in the server list; a live server
# is queried directly for its real-time count (see
# spacewar/autoload/network_manager.gd's UDP query responder and
# menu/server_select.gd's ping probe), which overrides this in the UI
# once that probe answers.
player_count: Mapped[int] = mapped_column(Integer, default=0)
max_players: Mapped[int] = mapped_column(Integer, default=50)
class Match(Base): class Match(Base):
+6 -21
View File
@@ -55,14 +55,7 @@ class QueueManager:
def _waiting(self, mode: Mode) -> list[Ticket]: def _waiting(self, mode: Mode) -> list[Ticket]:
tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"] tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"]
if mode == Mode.ranked: tickets.sort(key=lambda t: t.queued_at)
# Simple MMR-sorted grouping. No widening-window-by-wait-time yet
# (real ranked matchmaking will want that) — nearest-neighbor by
# mmr is a reasonable placeholder until ranked queue volume
# exists to tune against.
tickets.sort(key=lambda t: t.mmr)
else:
tickets.sort(key=lambda t: t.queued_at)
return tickets return tickets
async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None: async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None:
@@ -98,20 +91,12 @@ class QueueManager:
while True: while True:
await asyncio.sleep(2) await asyncio.sleep(2)
async with async_session() as db: async with async_session() as db:
# Casual has bot fill (spacewar/bots/bot_manager.gd) — bots pad # Bot fill (spacewar/bots/bot_manager.gd) pads both teams up
# both teams up to GameConfig.bot_min_team_size, so a single # to GameConfig.bot_min_team_size, so a single queued player
# queued player is enough to form a match. Still group in # is enough to form a match. Still group in anyone else who
# anyone else who queues in the same tick, up to the real # queues in the same tick, up to the real casual_team_size*2
# casual_team_size*2 target, rather than capping at 1v1. # target, rather than capping at 1v1.
# Ranked has no bots, so it still needs the full
# ranked_team_size*2 real players queued before forming.
await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db) await self._try_form_match(Mode.casual, required=1, cap=settings.casual_team_size * 2, db=db)
await self._try_form_match(
Mode.ranked,
required=settings.ranked_team_size * 2,
cap=settings.ranked_team_size * 2,
db=db,
)
queue_manager = QueueManager() queue_manager = QueueManager()
+2 -3
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import Mode, Player 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
@@ -25,9 +25,8 @@ async def _get_or_create_player(db: AsyncSession, callsign: str) -> 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 _get_or_create_player(db, req.callsign)
mmr = req.mmr if req.mode == Mode.ranked and req.mmr is not None else player.mmr
ticket = await queue_manager.join(player.id, player.callsign, req.mode, 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)
+48 -7
View File
@@ -1,9 +1,10 @@
from datetime import datetime from datetime import datetime, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db from app.database import get_db
from app.models import GameServer, ServerStatus from app.models import GameServer, ServerStatus
from app.schemas import ServerRegisterRequest from app.schemas import ServerRegisterRequest
@@ -11,24 +12,62 @@ from app.schemas import ServerRegisterRequest
router = APIRouter(prefix="/servers", tags=["servers"]) router = APIRouter(prefix="/servers", tags=["servers"])
# Real game servers should call this on boot and periodically (heartbeat) once def _status_for(player_count: int, max_players: int) -> ServerStatus:
# NetworkManager grows that integration. Until then, a dev server is seeded return ServerStatus.full if player_count >= max_players else ServerStatus.available
# on API startup (see main.py) from DEV_SEED_SERVER_IP/PORT so matchmaking is
# testable end-to-end without it.
# spacewar/autoload/network_manager.gd calls this once on server boot and
# then repeatedly as a heartbeat (every HEARTBEAT_INTERVAL, currently 8s),
# via MatchmakingClient.register_server(). Same endpoint for both — an
# unrecognized (ip, port, mode) creates a new row, a recognized one just
# refreshes it. sweep_stale_servers() (below) is what flips a server back to
# offline if those heartbeats stop, e.g. the process crashed or was killed
# without a clean shutdown.
@router.post("/register") @router.post("/register")
async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict: async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict:
existing = ( existing = (
await db.execute(select(GameServer).where(GameServer.ip == req.ip, GameServer.port == req.port, GameServer.mode == req.mode)) await db.execute(select(GameServer).where(GameServer.ip == req.ip, GameServer.port == req.port, GameServer.mode == req.mode))
).scalars().first() ).scalars().first()
status = _status_for(req.player_count, req.max_players)
if existing: if existing:
existing.status = ServerStatus.available existing.status = status
existing.player_count = req.player_count
existing.max_players = req.max_players
existing.last_heartbeat = datetime.utcnow() existing.last_heartbeat = datetime.utcnow()
else: else:
db.add(GameServer(ip=req.ip, port=req.port, mode=req.mode, status=ServerStatus.available)) db.add(
GameServer(
ip=req.ip,
port=req.port,
mode=req.mode,
status=status,
player_count=req.player_count,
max_players=req.max_players,
)
)
await db.commit() await db.commit()
return {"status": "registered"} return {"status": "registered"}
# Run periodically from main.py's lifespan (same shape as
# queue_manager.run_matching_loop()). Without this, a server that's killed
# instead of cleanly shut down (crash, host reboot, `kill -9`) would stay
# listed as available/full forever — server_select.gd's own UDP ping probe
# catches that live per-row, but this keeps the DB's status column honest
# too, e.g. for any future consumer that just reads GET /servers.
async def sweep_stale_servers(db: AsyncSession) -> None:
cutoff = datetime.utcnow() - timedelta(seconds=settings.server_stale_seconds)
stale = (
await db.execute(
select(GameServer).where(GameServer.last_heartbeat < cutoff, GameServer.status != ServerStatus.offline)
)
).scalars().all()
for server in stale:
server.status = ServerStatus.offline
if stale:
await db.commit()
@router.get("") @router.get("")
async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]: async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
servers = (await db.execute(select(GameServer))).scalars().all() servers = (await db.execute(select(GameServer))).scalars().all()
@@ -38,6 +77,8 @@ async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
"port": s.port, "port": s.port,
"mode": s.mode, "mode": s.mode,
"status": s.status, "status": s.status,
"player_count": s.player_count,
"max_players": s.max_players,
"last_heartbeat": s.last_heartbeat, "last_heartbeat": s.last_heartbeat,
} }
for s in servers for s in servers
+2 -1
View File
@@ -8,7 +8,6 @@ from app.models import Mode
class QueueJoinRequest(BaseModel): class QueueJoinRequest(BaseModel):
callsign: str callsign: str
mode: Mode mode: Mode
mmr: int | None = None # ignored for casual; defaults to the player's stored mmr for ranked
class QueueJoinResponse(BaseModel): class QueueJoinResponse(BaseModel):
@@ -28,6 +27,8 @@ class ServerRegisterRequest(BaseModel):
ip: str ip: str
port: int port: int
mode: Mode mode: Mode
player_count: int = 0
max_players: int = 50
class PlayerStatsResponse(BaseModel): class PlayerStatsResponse(BaseModel):
+107 -60
View File
@@ -1,84 +1,131 @@
# Races & Ship Classes # Races & Ship Classes
## Chosen Races: 1, 4, 3 ## Setting
From the 10 concepts below, races **1 (Terran Republic)**, **4 (Mechanos Sovereignty)**, and **3 (Vorg Swarm)** were selected. Three factions fight inside the massive shielding, pipes, and superstructure of
refineries, shipyards, and mining stations — not open void. Grounded, blue-collar
sci-fi in the vein of *The Expanse* (corporate hegemony vs. disciplined inner-planet
military vs. gritty outer-belt miners), but built as a fast, arcade 2D tactical
arena shooter rather than a hard-sci-fi sim. Tight factory corridors and choke
points are the map language — see `multiplayer.md`.
> **Current in-game status:** Team selection now uses the real race names and real ship art for all 3 chosen races (`assets/images/ships/<race>/`). Each race has its own 5-ship roster — Interceptor, Gunship, Bomber, Support, Heavy — matching the tables below. See `team_select.gd`. > **Status:** This replaces the previous 10-alien-race concept pool and the
> Terran Republic / Mechanos Sovereignty / Vorg Swarm roster. Doc-only for now —
> game code (`team_select.gd` RACES data, `GameConfig`, existing
> `assets/images/ships/<race>/` art) still reflects the old races until that work
> is scheduled.
--- ---
## The 3 Chosen Races ## The 3 Factions
### Race 1 — Terran Republic ✅ Chosen ### Apex Dynamics — The Corporate Syndicate
**Weapon type:** Projectile / Ballistic **Weapon type:** High-tech energy (coherent light / plasma)
**Visual:** Angular gray-blue military ships, recognizable fighter jet silhouettes, clean livery **Visual:** Sleek, pearlescent-white hulls with razor-sharp geometric lines and
**Feel:** Standard military. Balanced and familiar. Easiest to learn. glowing neon-blue engine trails — looks more like a high-end luxury smartphone
than a military weapon.
**Feel:** High-tech and precise. Wins by not being seen until it's too late.
**Tactical ability:** **Active Camouflage** — see [Faction Abilities](#faction-abilities-universal-system)
| # | Ship | Role | Description | ### Inner Sphere Navy (ISN) — The Military
|---|------|------|-------------| **Weapon type:** Standard-issue ballistics / ordnance
| 1 | **Warframe** | Interceptor | Very fast, rapid weak laser bursts. Built to close distance instantly and chip away at anything in range. | **Visual:** Gunmetal-gray, wedge-shaped hulls with hard militaristic angles, dense
| 2 | **Striker** | Gunship | Super fast fire rate, very low damage lasers. Trades hitting power for volume of fire. | protective plating, and prominent forward-facing gun turrets.
| 3 | **Deployer** | Bomber | Fast lasers plus an EMP mine dropped in your wake. Strong for area denial and covering a retreat. | **Feel:** Disciplined, heavy, frontal. Wins by tanking a choke point and forcing
| 4 | **Link Node** | Support | Lasers plus a small repair pack dropped for allies to pick up. Keeps a team topped off in prolonged fights. | the fight.
| 5 | **Bastion** | Heavy | Tankier and slower, but its laser hits harder than anything else in the fleet. Anchors a push. | **Tactical ability:** **Overcharged Aegis** — see [Faction Abilities](#faction-abilities-universal-system)
### Outer Rim Collective (ORC) — The Belters
**Weapon type:** Kinetic / repurposed mining hardware
**Visual:** Blocky, gritty patchwork of mismatched metal plating and exposed
hydraulic wiring, dominated by external fuel tanks and heavy industrial hardware
bolted onto the hull.
**Feel:** Scrappy and improvised. Wins by controlling terrain and denying space.
**Tactical ability:** **Proximity Scrap-Mine** — see [Faction Abilities](#faction-abilities-universal-system)
--- ---
### Race 4 — Mechanos Sovereignty ✅ Chosen ## Ship Classes
**Weapon type:** Cannon / Missile
**Visual:** Heavy industrial war machines, chrome-and-gunmetal plating, red accent lighting. Modular, interchangeable parts. Clearly machine-made.
**Feel:** Brute-force industrial muscle. Durable, hard-hitting, built to grind a fight down rather than outmaneuver it.
| # | Ship | Role | Description | Every faction fields the same 3-ship structure, re-skinned to its own theme:
|---|------|------|-------------|
| 1 | **Sentinel** | Interceptor | A light scout mech with twin rapid cannons. Fast and maneuverable for a Mechanos hull. | | Class | Role |
| 2 | **Vanguard** | Gunship | Dual autocannons built for sustained fire. Durable enough to hold a line, fast enough to reposition. | |-------|------|
| 3 | **Ravager** | Bomber | Drops heavy ordnance and mines from its cargo pods. Best used to seal off a chokepoint. | | **Fighter** | Single, high-precision, high-damage shot |
| 4 | **Aegis** | Support | Projects a short-range shield and deploys a repair drone for nearby allies. | | **Gunner** | Rapid-fire spray/burst, more shots for less damage each |
| 5 | **Colossus** | Heavy | A walking fortress bristling with cannons. Slow and telegraphed, but absorbs enormous damage. | | **Tank** | Bigger and slower; fires both bullets and missiles |
### Apex Dynamics
| Ship | Class | Weapon |
|------|-------|--------|
| **Lancet** | Fighter | A single, high-precision bolt of blue coherent light |
| **Pulsar** | Gunner | A wide, rapid-fire fan of low-damage green plasma pulses |
| **Sovereign** | Tank | Homing micro-missiles alongside dual heavy plasma batteries |
### Inner Sphere Navy (ISN)
| Ship | Class | Weapon |
|------|-------|--------|
| **Patriot** | Fighter | A single, hard-hitting high-caliber explosive shell |
| **Barrage** | Gunner | A relentless quad-barrel spray of fast, low-damage light bullets |
| **Behemoth** | Tank | Heavy tracking torpedoes plus massive physical deck-guns |
### Outer Rim Collective (ORC)
| Ship | Class | Weapon |
|------|-------|--------|
| **Rail-Jack** | Fighter | A single, heavy magnetic railgun slug |
| **Scrap-Spitter** | Gunner | A rotary scrap-cannon spraying rapid, low-damage metal shrapnel |
| **Iron-Clad** | Tank | Unguided explosive rockets plus continuous vulcan machine-gun fire |
--- ---
### Race 3 — Vorg Swarm ✅ Chosen ## Faction Abilities (Universal System)
**Weapon type:** Bio / Acid (DoT)
**Visual:** Organic insectoid creatures rather than built ships — chitin, wings, bioluminescent glow. No two look mechanically alike.
**Feel:** Chaotic organic swarm. Corrosive damage-over-time chips away at anyone who lingers nearby.
| # | Ship | Role | Description | Each faction has exactly one tactical ability. Every ship in that faction gets
|---|------|------|-------------| it — what changes per class is scale, not kind, so the ability reads instantly
| 1 | **Stinger** | Interceptor | Fast, acid shots with light damage-over-time. Swarms a target from range. | off a ship's faction regardless of which of the 3 hulls it's flying. Same energy
| 2 | **Spitter** | Gunship | Faster-firing acid with a stronger damage-over-time effect. Sustained corrosive pressure. | cost and cooldown across factions; duration/impact scales with the hull:
| 3 | **Broodling** | Bomber | Normal acid shots that also spawn a small slowing swarm cloud. Locks down a retreat or chokepoint. |
| 4 | **Nurse** | Support | Acid shots plus a small health/regeneration pack dropped for allies. |
| 5 | **Behemoth** | Heavy | Tankier and slower, with heavy acid shots that carry a large damage-over-time effect. The swarm's apex predator. |
--- | Class | Scaling rule |
|-------|--------------|
| Fighter | Fast cooldown, short/light effect |
| Gunner | Baseline cooldown and effect |
| Tank | Long cooldown, largest/heaviest effect |
## All 10 Race Concepts (Reference) ### Apex Dynamics — Active Camouflage
Ship turns 90% transparent and disappears from radar. Firing or taking damage
breaks it instantly. Fighter gets a quick, short slip; Tank's lasts longest but
is riskiest to commit a slow hull to.
**Use:** Slip past a defender in a narrow pipe, flank, or break a missile lock.
<details> ### Inner Sphere Navy — Overcharged Aegis
<summary>Click to expand all 10 races</summary> A frontal hard-light barrier absorbs 100% of incoming damage from the front for
a few seconds. Sides and rear stay exposed, turn rate drops, and the ship can't
fire while it's up. Tank's barrier is wide enough to shield teammates trailing
behind it; Fighter's is a narrow, quick poke.
**Use:** Push straight through a defended choke point, soaking fire so teammates
can follow.
### Race 2 — Xel'Nara Collective ### Outer Rim Collective — Proximity Scrap-Mine
**Weapon type:** Laser / Crystal Energy | **Feel:** Alien elegance, glass cannon Ejects a magnetized mine that arms after ~1 second and goes dark on radar.
Detonates on enemy proximity for heavy damage in a small radius plus knockback;
alert enemies can shoot it from a distance to trigger it safely. Only one mine
per ship at a time — dropping a second detonates the first. Tank's mine has a
larger blast radius; Fighter's arms faster but hits smaller.
**Use:** Cover a retreat down a corridor, or seed a choke point/objective before
a push.
### Race 5 — Void Wraiths ### The Rock-Paper-Scissors Loop
**Weapon type:** Dark Energy / Gravity / Phase | **Feel:** Terrifying and unpredictable, gravity wells and phase attacks, hard to pin down
### Race 6 — Solari Imperium - **ORC mines counter Apex cloak** — a cloaked ship blundering through a mined
**Weapon type:** Plasma / Solar Fire | **Feel:** Ancient empire, slow and devastating corridor eats the blast, which also breaks the cloak.
- **ISN shield counters ORC mines** — the frontal barrier absorbs a mine
detonation, clearing the path for the team behind it.
- **Apex cloak counters ISN shield** — a slow, frontal-shielded push leaves the
ISN ship's flank and engines open for a cloaked ship to slip around and
punish.
### Race 7 — Kryx Hivemind No ability wins a fight by itself — it depends on the map and the moment it's
**Weapon type:** Swarm Missiles / Acid | **Feel:** Insectoid swarm, volume of fire used, same as a smoke/flash/molotov in CS:GO.
### Race 8 — Nebulon Drifters
**Weapon type:** Gas Clouds / Toxin | **Feel:** Environmental control, zone denial
### Race 9 — Iron Covenant
**Weapon type:** EMP / Railgun / Hacking | **Feel:** Disruptor faction, breaks enemy systems
### Race 10 — Eldari Ascendancy
**Weapon type:** Psionic / Reality Distortion | **Feel:** Psychic manipulation, bullets that curve
</details>
+17 -6
View File
@@ -9,8 +9,8 @@ spacewar/ ← repo root
├── autoload/ ├── autoload/
│ └── game_config.gd ← autoload singleton (tuning values, player state, signals) │ └── game_config.gd ← autoload singleton (tuning values, player state, signals)
├── menu/ ├── menu/
│ ├── main_menu.tscn/gd ← entry point / main scene │ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav
│ ├── server_browser.tscn/gd ← server browser UI (built, not in active flow yet) │ ├── server_select.tscn/gd ← server list UI, backed by matchmaking-api's GET /servers
│ ├── 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) │ └── team_select.tscn/gd ← race + ship selection overlay (shown on world load)
├── ships/ ├── ships/
@@ -19,6 +19,8 @@ spacewar/ ← repo root
│ └── 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/
│ └── player_list.tscn/gd ← top-left player roster (white=team, yellow=enemy, "(b)"=bot), added to world.tscn
├── 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
@@ -47,6 +49,11 @@ spacewar/ ← repo root
MainMenu (Control) ← main_menu.gd builds all UI in _ready() MainMenu (Control) ← main_menu.gd builds all UI in _ready()
``` ```
### `menu/server_select.tscn` — server list
```
ServerSelect (Control) ← server_select.gd builds all UI in _ready(), fetches GET /servers
```
### `world/world.tscn` — game world ### `world/world.tscn` — game world
``` ```
World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready() World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready()
@@ -56,7 +63,8 @@ World (Node2D) ← world.gd instances MAP_SCENE into MapContainer o
│ └── HealthLabel │ └── HealthLabel
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn ├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection ├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn ── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
└── PlayerList (CanvasLayer, layer=4) ← hud/player_list.tscn
``` ```
### `world/maps/map_01.tscn` — hand-painted map ### `world/maps/map_01.tscn` — hand-painted map
@@ -95,14 +103,17 @@ Player (CharacterBody2D) ← ship_movement.gd
``` ```
main_menu.tscn main_menu.tscn
├── CASUAL → world.tscn ├── QUICK PLAY → matchmaking queue (casual) → world.tscn
│ ├── TeamSelect overlay: pick race (2 random of 3 offered) │ ├── TeamSelect overlay: pick race (2 random of 3 offered)
│ ├── TeamSelect overlay: pick ship │ ├── TeamSelect overlay: pick ship
│ └── Player spawns → game live │ └── Player spawns → game live
── RANKED → disabled (coming soon) ── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
├── OPTIONS → stub, coming soon
├── PROFILE → callsign-edit overlay (also auto-opens from QUICK PLAY if no callsign is set)
└── QUIT
``` ```
> **Note:** `server_browser.tscn` was built (Task 1) and is functional, but the current flow bypasses it — CASUAL goes directly to the world and TeamSelect handles name/race/ship. The server browser will be re-integrated when real multiplayer server listing is built. It still uses the old hardcoded-1280px layout style (see Display below) — not yet updated since it isn't reachable in the live flow. Ranked matchmaking is removed from the menu entirely (not just disabled) until it's real — see `CLAUDE.md` Current Tasks.
## Display ## Display
+12 -3
View File
@@ -1,15 +1,20 @@
# Task Log # 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 ## Completed
| # | Task | Notes | | # | Task | Notes |
|---|------|-------| |---|------|-------|
| 0 | Format all design docs as Markdown | Done | | 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` | | 1 | Design 10 races, pick 3 | Races 1, 4, 5 chosen — see `racesclasses.md` |
| 2 | Ranked + casual mode ideas | See `multiplayer.md` | | 2 | Ranked + casual mode ideas | See `multiplayer.md` |
| 3 | Multiplayer engineering doc | See `tech.md` | | 3 | Multiplayer engineering doc | See `tech.md` |
| 4 | Server browser screen | Player name, race buttons, server list, JOIN — stores in `GameConfig` | | 4 | ~~Server browser screen~~ | Superseded by #21's `menu/server_select.gd` |
| 5 | Main menu | CASUAL / RANKED (disabled), callsign input, rank badge, quit button | | 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 | | 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 | | 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 | | 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done |
@@ -17,6 +22,9 @@
| 13 | Multiplayer networking — ENet authoritative server | 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` | | 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` | | 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 ## Up Next
@@ -27,4 +35,5 @@
| 14 | Galaxy war meta + sector control | Low (post-networking) | | 14 | Galaxy war meta + sector control | Low (post-networking) |
| 15 | Ranked matchmaking + MMR | Low (post-networking) | | 15 | Ranked matchmaking + MMR | Low (post-networking) |
| 16 | Settings screen (audio, controls, display) | Low | | 16 | Settings screen (audio, controls, display) | Low |
| 17 | Select Team in pause menu (live team swap) | 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 |
+1 -1
View File
@@ -95,6 +95,6 @@ but nothing calls it yet.
- [x] Position sync with interpolation - [x] Position sync with interpolation
- [x] Ship class registration per peer - [x] Ship class registration per peer
- [x] Server-authoritative health/death - [x] Server-authoritative health/death
- [x] Matchmaking API (queue + lobby assignment) — client wired end-to-end; real server pool and ranked MMR-window widening still open, see Current Tasks in `CLAUDE.md` - [x] Matchmaking API (queue + lobby assignment) — client wired end-to-end for casual; ranked mode implementation removed until it's real (see `CLAUDE.md`); real server pool still open, see Current Tasks in `CLAUDE.md`
- [ ] GodotSteam auth + VAC - [ ] GodotSteam auth + VAC
- [ ] Lag compensation (basic rewind) - [ ] Lag compensation (basic rewind)
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dguubg5diklxk"
path="res://.godot/imported/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg-f0cdd8e07e962d7ee4a3798785dc7c62.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg"
dest_files=["res://.godot/imported/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg-f0cdd8e07e962d7ee4a3798785dc7c62.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://qlamialwad8x"
path="res://.godot/imported/explosion_01.png-4c53e5953b371103554a8dd07eede195.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_01.png"
dest_files=["res://.godot/imported/explosion_01.png-4c53e5953b371103554a8dd07eede195.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dm6ng2eudva3n"
path="res://.godot/imported/explosion_02.png-0e50681433ba5e47b7e2eb897c21f876.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_02.png"
dest_files=["res://.godot/imported/explosion_02.png-0e50681433ba5e47b7e2eb897c21f876.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bx5fwswqtolwf"
path="res://.godot/imported/explosion_03.png-2edccd48705b215af7cd78a8c2fa9c21.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_03.png"
dest_files=["res://.godot/imported/explosion_03.png-2edccd48705b215af7cd78a8c2fa9c21.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c0hjd6i5aq8o7"
path="res://.godot/imported/explosion_04.png-908ea26c3b0aba8f98e0c73226999853.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_04.png"
dest_files=["res://.godot/imported/explosion_04.png-908ea26c3b0aba8f98e0c73226999853.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dnlm5tu7i3oj8"
path="res://.godot/imported/explosion_05.png-0d8dbeaabed098318c1db7a1eb18d746.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_05.png"
dest_files=["res://.godot/imported/explosion_05.png-0d8dbeaabed098318c1db7a1eb18d746.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqflkg8vy0ntw"
path="res://.godot/imported/explosion_06.png-8f7bb7be1ccca8a8508b2c1a818ff9b1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_06.png"
dest_files=["res://.godot/imported/explosion_06.png-8f7bb7be1ccca8a8508b2c1a818ff9b1.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://chkgsyufrx3kk"
path="res://.godot/imported/explosion_07.png-c3e0ab76fd3a34d4ebeef527ae6b74a6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_07.png"
dest_files=["res://.godot/imported/explosion_07.png-c3e0ab76fd3a34d4ebeef527ae6b74a6.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bmlawa6tytlet"
path="res://.godot/imported/explosion_08.png-8517f51a72fc7aa47bfbd1d58d86f54b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_08.png"
dest_files=["res://.godot/imported/explosion_08.png-8517f51a72fc7aa47bfbd1d58d86f54b.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ce3pmj845ihtn"
path="res://.godot/imported/explosion_09.png-a26440b2a6f5b50a561e92c564a69fe6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_09.png"
dest_files=["res://.godot/imported/explosion_09.png-a26440b2a6f5b50a561e92c564a69fe6.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cte31rb8efu72"
path="res://.godot/imported/explosion_10.png-1727ee912d3fba21a1357a2b67b4c150.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_10.png"
dest_files=["res://.godot/imported/explosion_10.png-1727ee912d3fba21a1357a2b67b4c150.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://fc1rqxscnw8f"
path="res://.godot/imported/explosion_11.png-81f0f24665c9d3d8d5e58503f8831d24.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_11.png"
dest_files=["res://.godot/imported/explosion_11.png-81f0f24665c9d3d8d5e58503f8831d24.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://76gv4wm32p7l"
path="res://.godot/imported/explosion_12.png-dfe87e1d89d69bec2c167437163c7444.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/explosion_12.png"
dest_files=["res://.godot/imported/explosion_12.png-dfe87e1d89d69bec2c167437163c7444.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cxr88dcqaulit"
path="res://.godot/imported/explosion_sheet.jpeg-d3eff2ea5e3c0e5b20184dd6e9e60baf.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion/source/explosion_sheet.jpeg"
dest_files=["res://.godot/imported/explosion_sheet.jpeg-d3eff2ea5e3c0e5b20184dd6e9e60baf.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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -2,16 +2,16 @@
importer="texture" importer="texture"
type="CompressedTexture2D" type="CompressedTexture2D"
uid="uid://ded6ek6l7n8gb" uid="uid://b8tlmag3nkye1"
path="res://.godot/imported/explosion1.png-dbaabd892c7c9e8bfd179ebbad20e6c6.ctex" path="res://.godot/imported/lancet.png-205173e7177ad4865682a3d270a3a688.ctex"
metadata={ metadata={
"vram_texture": false "vram_texture": false
} }
[deps] [deps]
source_file="res://assets/images/effects/explosion1.png" source_file="res://assets/images/ships/apex/lancet.png"
dest_files=["res://.godot/imported/explosion1.png-dbaabd892c7c9e8bfd179ebbad20e6c6.ctex"] dest_files=["res://.godot/imported/lancet.png-205173e7177ad4865682a3d270a3a688.ctex"]
[params] [params]
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b1d5536o6asen"
path="res://.godot/imported/pulsar.png-540998692d6787c6418d7a09a4174132.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/apex/pulsar.png"
dest_files=["res://.godot/imported/pulsar.png-540998692d6787c6418d7a09a4174132.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bybcq2rus582u"
path="res://.godot/imported/apex_ships.jpeg-0ec570ae56f16d08d6f035d1183b6e44.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/apex/source/apex_ships.jpeg"
dest_files=["res://.godot/imported/apex_ships.jpeg-0ec570ae56f16d08d6f035d1183b6e44.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bykx3457gpph0"
path="res://.godot/imported/sovereign.png-cabfebc1fb08153369b73ec967b3f2d9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/apex/sovereign.png"
dest_files=["res://.godot/imported/sovereign.png-cabfebc1fb08153369b73ec967b3f2d9.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://co1mqbhl0uyxn"
path="res://.godot/imported/barrage.png-a8fd1168c9dbf90adbf522771b397e6a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/barrage.png"
dest_files=["res://.godot/imported/barrage.png-a8fd1168c9dbf90adbf522771b397e6a.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cvfko6k4gria8"
path="res://.godot/imported/behemoth.png-4d8f9559d83bf436fa79a4f9bc89b85d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/behemoth.png"
dest_files=["res://.godot/imported/behemoth.png-4d8f9559d83bf436fa79a4f9bc89b85d.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ny5lxa253x2l"
path="res://.godot/imported/patriot.png-e50347577a9cfbf01e487858016c3f0b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/patriot.png"
dest_files=["res://.godot/imported/patriot.png-e50347577a9cfbf01e487858016c3f0b.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bu3mx4q42yjt3"
path="res://.godot/imported/isn_ships.jpeg-bc1ba3af07741827f797cccb15cc8285.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/source/isn_ships.jpeg"
dest_files=["res://.godot/imported/isn_ships.jpeg-bc1ba3af07741827f797cccb15cc8285.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c81ebvx34vj6c"
path="res://.godot/imported/iron_clad.png-fae857e77dcbb343670adfb9236cbae9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/iron_clad.png"
dest_files=["res://.godot/imported/iron_clad.png-fae857e77dcbb343670adfb9236cbae9.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cpe81feymft3l"
path="res://.godot/imported/rail_jack.png-5cf2e7c1664f7d1d3c3cbcf08acfc3ce.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/rail_jack.png"
dest_files=["res://.godot/imported/rail_jack.png-5cf2e7c1664f7d1d3c3cbcf08acfc3ce.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bkula1evuj8td"
path="res://.godot/imported/scrap_spitter.png-36960733faf816166f6cb9624422a331.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/scrap_spitter.png"
dest_files=["res://.godot/imported/scrap_spitter.png-36960733faf816166f6cb9624422a331.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://r812qvf8wvnj"
path="res://.godot/imported/orc_ships.jpeg-3c93ef705a05d2faa073a21bbfddf048.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/source/orc_ships.jpeg"
dest_files=["res://.godot/imported/orc_ships.jpeg-3c93ef705a05d2faa073a21bbfddf048.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
+34
View File
@@ -8,6 +8,40 @@ signal message_received(sender_name: String, sender_race: int, team_only: bool,
const MAX_MESSAGE_LENGTH := 140 const MAX_MESSAGE_LENGTH := 140
# Virtual system sender for join/leave announcements below. Not a real peer,
# so chat_box.gd special-cases this name to render it distinctly from player
# chat instead of trying to look up a race color for it.
const ADMIN_NAME := "Admin"
func _ready() -> void:
# PlayerRegistry's join/removal state is already replicated identically to
# every peer (loadout relay + ENet's own disconnect broadcast), so these
# fire in sync on every client already — no extra RPC needed to announce
# them, same as send_chat's local echo below needing no round trip.
PlayerRegistry.player_joined.connect(_on_player_joined)
PlayerRegistry.player_removed.connect(_on_player_removed)
# Bots (negative peer_id) are matchmaking padding, not real players — the
# race-selection headcount already excludes them for the same reason (see
# team_select.gd), so admin chat doesn't announce bot fill churn either.
func _on_player_joined(peer_id: int) -> void:
if peer_id < 0:
return
var player_name: String = PlayerRegistry.get_info(peer_id).get("name", "A player")
_announce("%s has joined the match." % player_name)
func _on_player_removed(peer_id: int, info: Dictionary) -> void:
if peer_id < 0:
return
_announce("%s has left the match." % info.get("name", "A player"))
func _announce(text: String) -> void:
message_received.emit(ADMIN_NAME, 0, false, text)
func send_chat(raw_text: String, team_only: bool) -> void: func send_chat(raw_text: String, team_only: bool) -> void:
var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH) var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH)
+60 -3
View File
@@ -3,7 +3,12 @@ extends Node
# Ship movement # Ship movement
var ship_thrust: float = 250.0 var ship_thrust: float = 250.0
var ship_max_speed: float = 300.0 var ship_max_speed: float = 300.0
var ship_rotation_speed: float = 4.0 var ship_rotation_speed: float = 1.0
# Continuing to hold left/right past this many seconds ramps to
# ship_rotation_speed_held (double the tap rate) — a quick tap stays precise,
# a held turn goes faster. See ship_movement.gd's _simulate_step().
var ship_rotation_ramp_delay: float = 0.15
var ship_rotation_speed_held: float = 2.0
# Shooting # Shooting
var ship_fire_rate: float = 0.15 var ship_fire_rate: float = 0.15
@@ -15,10 +20,34 @@ var ship_respawn_delay: float = 3.0
var ship_invincibility_time: float = 2.0 var ship_invincibility_time: float = 2.0
var bullet_damage: int = 40 var bullet_damage: int = 40
# Energy (see ships/ship_movement.gd) — max energy scales by ship role
# (Fighter/Gunner/Tank, see menu/team_select.gd's RACES ship "role" field);
# regen is a flat rate regardless of role, so bigger ships take longer to
# top off from empty than smaller ones.
var ship_max_energy_by_role: Dictionary = {"Fighter": 100.0, "Gunner": 150.0, "Tank": 250.0}
var bullet_energy_cost: float = 75.0
var ship_energy_regen_rate: float = 100.0 / 2.5 # 100 energy per 2.5s
# Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed # Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed
var ship_bounce_restitution: float = 0.45 var ship_bounce_restitution: float = 0.45
var ship_collision_damage_scale: float = 0.064 var ship_collision_damage_scale: float = 0.064
# One shared knob to resize every ship's in-world sprite at once, applied on
# top of each ship's own per-role "scale" in team_select.gd's RACES (see
# ship_movement.gd's _init_player()) — doesn't touch those individually
# balanced per-role values, just scales the end result uniformly.
var ship_scale_factor: float = 0.75
# Explosion VFX (effects/explosion.gd) — one shared knob to resize every
# explosion in the game at once; explosion_fps controls playback speed of the
# 12-frame animation (see assets/images/effects/explosion/). Each frame is
# held for 5 ticks (explosion.tscn's SpriteFrames "duration": 5.0 per frame,
# so mostly-transparent/dark frames like the opening spark and the frame 6
# ember cloud have time to register instead of reading as a flicker-to-black
# gap between the brighter frames) — total time is (12 * 5) / explosion_fps.
var explosion_scale: float = 0.75
var explosion_fps: float = 24.0 # 60 ticks / 24.0 fps = 2.5s total
# 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 = ""
@@ -26,12 +55,40 @@ 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)
# Random offset applied around a chosen team_a_spawn/team_b_spawn marker
# (see ship_movement.gd's _get_spawn_position()) so multiple ships spawning
# off the same single marker -- e.g. a 7-bot casual-fill team -- don't stack
# exactly on top of each other.
var spawn_scatter_radius: float = 150.0
# True while the chat input box has keyboard focus — gates ship movement/fire # True while the chat input box has keyboard focus — gates ship movement/fire
# input so typing (e.g. the letter "w") doesn't also move the ship. # input so typing (e.g. the letter "w") doesn't also move the ship.
var chat_focused: bool = false var chat_focused: bool = false
# True while TeamSelect is reopened mid-match (pause menu "SELECT TEAM") —
# same purpose as chat_focused, so clicking ship rows doesn't also fly the
# ship underneath. Not needed for the initial pre-spawn pick since the ship
# has no physics processing yet at that point.
var team_select_focused: bool = false
# 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
var bot_stop_distance: float = 160.0
var bot_aim_tolerance_deg: float = 6.0 # Personality trait ranges (bots/bot_personality.gd) — each bot rolls all 5
# traits 0..1 independently once at spawn; these ranges convert that roll
# into the concrete numbers BotAI actually flies with.
var bot_stop_distance_min: float = 70.0 # aggression 1: dogfights up close
var bot_stop_distance_max: float = 260.0 # aggression 0: hangs back at range
var bot_retreat_health_frac_min: float = 0.10 # caution 0: fights to the death
var bot_retreat_health_frac_max: float = 0.65 # caution 1: breaks off early
var bot_aim_tolerance_best_deg: float = 3.0 # accuracy 1: pinpoint gate
var bot_aim_tolerance_worst_deg: float = 14.0 # accuracy 0: sloppy gate
var bot_aim_jitter_max_px: float = 220.0 # accuracy 0: aims this far off the real spot
var bot_reaction_update_best_sec: float = 0.05 # reaction 1: re-aims almost every tick
var bot_reaction_update_worst_sec: float = 0.6 # reaction 0: laggy, stale tracking
var bot_awareness_range_min: float = 900.0 # awareness 0: near-sighted
var bot_awareness_range_max: float = 16000.0 # awareness 1: sees across the whole map
# map_01 spawns the two teams ~10608 units apart (see world/maps/map_01.tscn),
# on a ~14016x6000 map (diagonal ~15247) — the max must clear that or every
# bot spawns permanently unable to detect the enemy team and never advances.
+33
View File
@@ -0,0 +1,33 @@
extends Node
# Server-authoritative kill feed. Unlike ChatManager, a death is only ever
# decided on the server (see ships/ship_movement.gd's _die()), so there's no
# client -> server leg here -- just one broadcast RPC per kill.
signal kill_reported(victim_name: String, victim_race: int, killer_name: String, killer_race: int, is_environment_kill: bool)
# source_peer_id == 0 means no attacking ship (hazard/wall collision damage,
# see ship_movement.gd's take_damage()/_apply_collision_bounce()).
func report_kill(victim_peer_id: int, killer_peer_id: int) -> void:
if not multiplayer.is_server():
return
var victim_info := PlayerRegistry.get_info(victim_peer_id)
var victim_name: String = victim_info.get("name", "Player")
var victim_race: int = victim_info.get("race", 0)
var is_environment_kill := killer_peer_id == 0 or killer_peer_id == victim_peer_id
var killer_name := ""
var killer_race := 0
if not is_environment_kill:
var killer_info := PlayerRegistry.get_info(killer_peer_id)
killer_name = killer_info.get("name", "Player")
killer_race = killer_info.get("race", 0)
_receive_kill.rpc(victim_name, victim_race, killer_name, killer_race, is_environment_kill)
@rpc("authority", "call_local", "reliable")
func _receive_kill(victim_name: String, victim_race: int, killer_name: String, killer_race: int, is_environment_kill: bool) -> void:
kill_reported.emit(victim_name, victim_race, killer_name, killer_race, is_environment_kill)
@@ -0,0 +1 @@
uid://dinxq78rl35lu
+28
View File
@@ -52,6 +52,34 @@ func start_matchmaking(callsign: String, mode: String) -> void:
_poll_timer.start() _poll_timer.start()
# Returns {"ok": bool, "servers": Array} — one dict per row from GET /servers
# (ip, port, mode, status, player_count, max_players, last_heartbeat). "ok"
# is false if the API couldn't be reached at all, distinct from a reachable
# API returning an empty list.
func list_servers() -> Dictionary:
var result := await _request("GET", "/servers", null)
if result.get("code", 0) != 200:
return {"ok": false, "servers": []}
return {"ok": true, "servers": result.body}
# Called by NetworkManager once on server boot and then repeatedly as a
# heartbeat — same endpoint both times, an unrecognized (ip, port, mode)
# creates a row, a recognized one refreshes it. Returns true on success;
# NetworkManager doesn't hard-fail on a missed heartbeat (the API's own
# stale-server sweep just marks it offline until the next one lands), so
# callers can fire-and-forget this.
func register_server(ip: String, port: int, mode: String, player_count: int, max_players: int) -> bool:
var result := await _request("POST", "/servers/register", {
"ip": ip,
"port": port,
"mode": mode,
"player_count": player_count,
"max_players": max_players,
})
return result.get("code", 0) == 200
func cancel_matchmaking() -> void: func cancel_matchmaking() -> void:
if not _searching: if not _searching:
return return
+92 -1
View File
@@ -8,9 +8,39 @@ signal peer_left(peer_id: int)
const DEFAULT_PORT := 7777 const DEFAULT_PORT := 7777
const MAX_PLAYERS := 50 # covers 25v25 casual const MAX_PLAYERS := 50 # covers 25v25 casual
# Lightweight ping/population query, answered on a dedicated UDP port
# (game port + a large offset, same "game port vs. query port" split classic
# server browsers use) rather than the ENet game port itself, since ENet's
# own protocol won't respond to arbitrary raw datagrams. Lets
# menu/server_select.gd measure real RTT and live player count for a server
# *before* actually joining it, without touching PlayerRegistry or ENet peer
# state at all. The offset must be big enough that it can't land on another
# server's game port — local dev runs several servers on sequential ports
# (7777, 7778, 7779, ...), so a +1 offset would alias server N's query port
# onto server N+1's actual game port.
const QUERY_PORT_OFFSET := 10000
const PING_REQUEST := "SPACEWAR_PING"
# How often a hosted server re-registers with the matchmaking API as a
# heartbeat. app/config.py's server_stale_seconds (20s) needs enough slack
# over this for one missed beat without flapping a live server to "offline".
const HEARTBEAT_INTERVAL := 8.0
var is_server: bool = false var is_server: bool = false
var target_ip: String = "127.0.0.1" var target_ip: String = "127.0.0.1"
# IP advertised to the matchmaking API for other players to connect to —
# not necessarily the same as the interface ENet binds to. Defaults to
# loopback for local dev; a real deployment sets --server-ip= to whatever
# address is actually reachable from a player's machine (matches the
# DEV_SEED_SERVER_IP note in matchmaking-api/README.md about this same
# reachability requirement).
var _register_ip: String = "127.0.0.1"
var _hosted_port: int = DEFAULT_PORT
var _heartbeat_timer: Timer
var _query_udp: UDPServer
func _ready() -> void: func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_connected.connect(_on_peer_connected)
@@ -23,12 +53,16 @@ func _ready() -> void:
# Dev-only bootstrap: `-- --server` runs this instance as a dedicated headless # Dev-only bootstrap: `-- --server` runs this instance as a dedicated headless
# server; `-- --connect=<ip>` overrides the default join target for clients. # server; `-- --connect=<ip>` overrides the default join target for clients;
# `-- --server-ip=<ip>` overrides the IP a hosted server advertises to the
# matchmaking API (see _register_ip).
func _apply_cmdline_args() -> void: func _apply_cmdline_args() -> void:
var args := OS.get_cmdline_user_args() var args := OS.get_cmdline_user_args()
for arg in args: for arg in args:
if arg.begins_with("--connect="): if arg.begins_with("--connect="):
target_ip = arg.substr("--connect=".length()) target_ip = arg.substr("--connect=".length())
elif arg.begins_with("--server-ip="):
_register_ip = arg.substr("--server-ip=".length())
if args.has("--server"): if args.has("--server"):
host_server(DEFAULT_PORT) host_server(DEFAULT_PORT)
await get_tree().process_frame await get_tree().process_frame
@@ -43,6 +77,9 @@ func host_server(port: int = DEFAULT_PORT) -> void:
return return
multiplayer.multiplayer_peer = peer multiplayer.multiplayer_peer = peer
is_server = true is_server = true
_hosted_port = port
_start_query_responder(port + QUERY_PORT_OFFSET)
_start_heartbeat()
print("[NetworkManager] Hosting on port %d" % port) print("[NetworkManager] Hosting on port %d" % port)
@@ -51,6 +88,60 @@ func disconnect_from_game() -> void:
multiplayer.multiplayer_peer.close() multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null multiplayer.multiplayer_peer = null
is_server = false is_server = false
_stop_query_responder()
_stop_heartbeat()
func _start_heartbeat() -> void:
_register_with_matchmaking_api()
_heartbeat_timer = Timer.new()
_heartbeat_timer.wait_time = HEARTBEAT_INTERVAL
_heartbeat_timer.one_shot = false
_heartbeat_timer.timeout.connect(_register_with_matchmaking_api)
add_child(_heartbeat_timer)
_heartbeat_timer.start()
func _stop_heartbeat() -> void:
if _heartbeat_timer != null:
_heartbeat_timer.queue_free()
_heartbeat_timer = null
# Fire-and-forget: a missed heartbeat just leaves the API's last_heartbeat
# stale, which its own sweep turns into "offline" after server_stale_seconds
# (see matchmaking-api/app/routers/servers.py) — no retry logic needed here.
func _register_with_matchmaking_api() -> void:
await MatchmakingClient.register_server(_register_ip, _hosted_port, "casual", PlayerRegistry.players.size(), MAX_PLAYERS)
func _start_query_responder(query_port: int) -> void:
_query_udp = UDPServer.new()
var err := _query_udp.listen(query_port)
if err != OK:
push_error("[NetworkManager] Failed to start ping responder on port %d: %s" % [query_port, err])
_query_udp = null
return
print("[NetworkManager] Ping responder listening on port %d" % query_port)
func _stop_query_responder() -> void:
if _query_udp != null:
_query_udp.stop()
_query_udp = null
func _process(_delta: float) -> void:
if _query_udp == null:
return
_query_udp.poll()
while _query_udp.is_connection_available():
var udp_peer := _query_udp.take_connection()
var packet := udp_peer.get_packet()
if packet.get_string_from_utf8() == PING_REQUEST:
var player_count: int = PlayerRegistry.players.size()
var reply := "SPACEWAR_PONG:%d:%d" % [player_count, MAX_PLAYERS]
udp_peer.put_packet(reply.to_utf8_buffer())
func join_server(ip: String, port: int = DEFAULT_PORT) -> void: func join_server(ip: String, port: int = DEFAULT_PORT) -> void:
+30 -14
View File
@@ -6,8 +6,18 @@ extends Node
signal loadout_updated(peer_id: int) signal loadout_updated(peer_id: int)
signal player_removed(peer_id: int, info: Dictionary) signal player_removed(peer_id: int, info: Dictionary)
# Fired once, the first time a peer ever registers a loadout (as opposed to
# loadout_updated, which also fires on every later ship re-pick) — lets
# ChatManager's admin announcer say "X has joined" exactly once per peer.
signal player_joined(peer_id: int)
# Fired only when an already-registered peer's race actually changes (e.g. a
# live team swap via the pause menu's SELECT TEAM), not on a fresh peer's
# first-ever loadout — lets BotManager rebalance the race the player left,
# which loadout_updated alone can't do since by the time it fires the old
# race is already overwritten in `players`.
signal race_changed(peer_id: int, old_race: int, new_race: int)
var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor} var players: Dictionary = {} # peer_id -> {name, race, ship_path, ship_scale, ship_speed_factor, role}
func _ready() -> void: func _ready() -> void:
@@ -22,18 +32,18 @@ func get_info(peer_id: int) -> Dictionary:
return players.get(peer_id, {}) return players.get(peer_id, {})
func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: func submit_local_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor) submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor, role)
# Server-only: registers a bot's loadout the same way a real player's # Server-only: registers a bot's loadout the same way a real player's
# submit_loadout would (minus the backfill, since a fresh bot has nothing to # submit_loadout would (minus the backfill, since a fresh bot has nothing to
# learn), broadcasting to every connected peer so bots render identically to # learn), broadcasting to every connected peer so bots render identically to
# human ships on every client. See bots/bot_manager.gd. # human ships on every client. See bots/bot_manager.gd.
func register_bot(peer_id: int, bot_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: func register_bot(peer_id: int, bot_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
for pid in multiplayer.get_peers(): for pid in multiplayer.get_peers():
_receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor) _receive_loadout.rpc_id(pid, peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role)
_store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor) _store(peer_id, bot_name, race, ship_path, ship_scale, ship_speed_factor, role)
func unregister_bot(peer_id: int) -> void: func unregister_bot(peer_id: int) -> void:
@@ -44,41 +54,47 @@ func unregister_bot(peer_id: int) -> void:
# the new loadout to everyone else, and backfills the newcomer with everyone # the new loadout to everyone else, and backfills the newcomer with everyone
# else's already-known loadout. # else's already-known loadout.
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: func submit_loadout(player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
var sender_id := multiplayer.get_remote_sender_id() var sender_id := multiplayer.get_remote_sender_id()
if sender_id == 0: if sender_id == 0:
sender_id = multiplayer.get_unique_id() sender_id = multiplayer.get_unique_id()
if not multiplayer.is_server(): if not multiplayer.is_server():
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor) _store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
return return
for peer_id in multiplayer.get_peers(): for peer_id in multiplayer.get_peers():
if peer_id != sender_id: if peer_id != sender_id:
_receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor) _receive_loadout.rpc_id(peer_id, sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
for peer_id in players: for peer_id in players:
if peer_id != sender_id: if peer_id != sender_id:
var info: Dictionary = players[peer_id] var info: Dictionary = players[peer_id]
_receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor) _receive_loadout.rpc_id(sender_id, peer_id, info.name, info.race, info.ship_path, info.ship_scale, info.ship_speed_factor, info.role)
_store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor) _store(sender_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
@rpc("authority", "call_remote", "reliable") @rpc("authority", "call_remote", "reliable")
func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
_store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor) _store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor, role)
func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void: func _store(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float, role: String) -> void:
var old_race: int = players.get(peer_id, {}).get("race", 0)
players[peer_id] = { players[peer_id] = {
"name": player_name, "name": player_name,
"race": race, "race": race,
"ship_path": ship_path, "ship_path": ship_path,
"ship_scale": ship_scale, "ship_scale": ship_scale,
"ship_speed_factor": ship_speed_factor, "ship_speed_factor": ship_speed_factor,
"role": role,
} }
print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]]) print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[peer_id]])
loadout_updated.emit(peer_id) loadout_updated.emit(peer_id)
if old_race == 0:
player_joined.emit(peer_id)
elif old_race != race:
race_changed.emit(peer_id, old_race, race)
func _on_peer_left(peer_id: int) -> void: func _on_peer_left(peer_id: int) -> void:
+82 -17
View File
@@ -1,41 +1,104 @@
class_name BotAI class_name BotAI
extends RefCounted extends RefCounted
# Simplified "seek and shoot" bot AI (see overview/bots.md): thrust toward # "Seek and shoot" bot AI (see overview/bots.md), personality-driven by
# the nearest living enemy, turn to face it, fire once roughly aimed and in # BotPersonality (bots/bot_personality.gd, rolled once per bot-slot by
# range. Kept intentionally simple — casual bots are filler, not a challenge # BotManager). Five independent traits reshape the same base behavior instead
# — and isolated from ship_movement.gd's networking code entirely so future # of branching into separate hand-written behavior trees per archetype:
# difficulty/behavior tweaks only ever touch this file. # aggression -> how close it presses in before it's satisfied with range
# caution -> health fraction at which it breaks off and flees
# accuracy -> how tightly it tracks the target's true position
# reaction -> how often it refreshes its aim point (lag on movers)
# awareness -> detection radius for picking a target at all
# Kept isolated from ship_movement.gd's networking code entirely so future
# tuning only ever touches this file and game_config.gd's bot_* ranges.
var _ship: Ship var _ship: Ship
var _race_id: int var _race_id: int
var personality: BotPersonality
var _stop_distance: float
var _retreat_health_frac: float
var _aim_tolerance_rad: float
var _reaction_interval: float
var _awareness_range_sq: float
var _current_target: Ship = null
var _tracked_pos: Vector2 = Vector2.ZERO
var _reaction_timer: float = 0.0
func _init(ship: Ship, race_id: int) -> void: func _init(ship: Ship, race_id: int, bot_personality: BotPersonality = null) -> void:
_ship = ship _ship = ship
_race_id = race_id _race_id = race_id
personality = bot_personality if bot_personality != null else BotPersonality.random()
_stop_distance = lerp(GameConfig.bot_stop_distance_max, GameConfig.bot_stop_distance_min, personality.aggression)
_retreat_health_frac = lerp(GameConfig.bot_retreat_health_frac_min, GameConfig.bot_retreat_health_frac_max, personality.caution)
_aim_tolerance_rad = deg_to_rad(lerp(GameConfig.bot_aim_tolerance_worst_deg, GameConfig.bot_aim_tolerance_best_deg, personality.accuracy))
_reaction_interval = lerp(GameConfig.bot_reaction_update_worst_sec, GameConfig.bot_reaction_update_best_sec, personality.reaction)
var awareness_range: float = lerp(GameConfig.bot_awareness_range_min, GameConfig.bot_awareness_range_max, personality.awareness)
_awareness_range_sq = awareness_range * awareness_range
_reaction_timer = randf() * _reaction_interval # desync bots so they don't all re-aim on the same tick
func compute_input(_delta: float) -> Dictionary: func compute_input(delta: float) -> Dictionary:
var target := _find_nearest_enemy() var target := _find_nearest_enemy()
if target == null: if target == null:
return {"up": false, "down": false, "left": false, "right": false, "shoot": false} _current_target = null
return _compute_advance_input()
if target != _current_target:
_current_target = target
_reaction_timer = 0.0 # snap to a fresh target immediately, don't lag onto it
_reaction_timer -= delta
if _reaction_timer <= 0.0:
_reaction_timer = _reaction_interval
var jitter_radius: float = (1.0 - personality.accuracy) * GameConfig.bot_aim_jitter_max_px
var jitter := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * jitter_radius
_tracked_pos = target.global_position + jitter
var fleeing: bool = float(_ship.health) / float(GameConfig.ship_max_health) < _retreat_health_frac
var true_distance: float = _ship.global_position.distance_to(target.global_position)
var to_target: Vector2 = target.global_position - _ship.global_position
var distance: float = to_target.length()
# Ship forward is Vector2.UP.rotated(rotation) (see ship_movement.gd # Ship forward is Vector2.UP.rotated(rotation) (see ship_movement.gd
# _simulate_step), so facing `to_target` requires offsetting by the angle # _simulate_step), so facing a point requires offsetting by the angle
# between Vector2.UP and the zero-rotation forward vector (PI/2). # between Vector2.UP and the zero-rotation forward vector (PI/2).
var desired_rot: float = to_target.angle() + PI / 2.0 var aim_at: Vector2 = _ship.global_position - target.global_position if fleeing else _tracked_pos - _ship.global_position
var desired_rot: float = aim_at.angle() + PI / 2.0
var diff: float = angle_difference(_ship.rotation, desired_rot) var diff: float = angle_difference(_ship.rotation, desired_rot)
var aim_tolerance: float = deg_to_rad(GameConfig.bot_aim_tolerance_deg)
return { return {
"up": distance > GameConfig.bot_stop_distance, "up": fleeing or true_distance > _stop_distance,
"down": false, "down": false,
"left": diff < -aim_tolerance, "left": diff < -_aim_tolerance_rad,
"right": diff > aim_tolerance, "right": diff > _aim_tolerance_rad,
"shoot": absf(diff) <= aim_tolerance and distance <= GameConfig.bot_engage_range, "shoot": not fleeing and absf(diff) <= _aim_tolerance_rad and true_distance <= GameConfig.bot_engage_range,
}
# No enemy within awareness range yet (common right after match start: two
# 7-bot teams spawn on opposite sides of the map, farther apart than most
# personality rolls' awareness_range — see GameConfig.bot_awareness_range_min
# comment). Without this, those bots would just sit still at spawn forever,
# waiting for someone to wander into range. Advance toward the map's center
# instead, closing the distance until awareness (or GameConfig.bot_engage_range)
# picks up an enemy.
func _compute_advance_input() -> Dictionary:
var center: Vector2 = GameConfig.world_bounds.get_center()
var to_center: Vector2 = center - _ship.global_position
if to_center.length_squared() < 2500.0: # ~50px: already at the rally point
return {"up": false, "down": false, "left": false, "right": false, "shoot": false}
var desired_rot: float = to_center.angle() + PI / 2.0
var diff: float = angle_difference(_ship.rotation, desired_rot)
return {
"up": true,
"down": false,
"left": diff < -_aim_tolerance_rad,
"right": diff > _aim_tolerance_rad,
"shoot": false,
} }
@@ -50,6 +113,8 @@ func _find_nearest_enemy() -> Ship:
if info.get("race", -1) == _race_id: if info.get("race", -1) == _race_id:
continue continue
var dist: float = _ship.global_position.distance_squared_to(candidate.global_position) var dist: float = _ship.global_position.distance_squared_to(candidate.global_position)
if dist > _awareness_range_sq:
continue
if dist < best_dist: if dist < best_dist:
best_dist = dist best_dist = dist
best = candidate best = candidate
+11 -1
View File
@@ -19,6 +19,7 @@ var _next_bot_id: int = -1
func _ready() -> void: func _ready() -> void:
PlayerRegistry.loadout_updated.connect(_on_loadout_updated) PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
PlayerRegistry.player_removed.connect(_on_player_removed) PlayerRegistry.player_removed.connect(_on_player_removed)
PlayerRegistry.race_changed.connect(_on_race_changed)
# Called once by World.decide_offered_races() when the match's 2 races are # Called once by World.decide_offered_races() when the match's 2 races are
@@ -45,6 +46,15 @@ func _on_player_removed(peer_id: int, info: Dictionary) -> void:
_reconcile_race(info.get("race", 0)) _reconcile_race(info.get("race", 0))
# A live team swap (pause menu SELECT TEAM) fires loadout_updated for the
# *new* race, but that alone leaves the race the player just left one human
# short — this backfills it. _on_loadout_updated handles the new race.
func _on_race_changed(peer_id: int, old_race: int, _new_race: int) -> void:
if not multiplayer.is_server() or _world == null or peer_id < 0:
return
_reconcile_race(old_race)
func _reconcile_race(race_id: int) -> void: func _reconcile_race(race_id: int) -> void:
if race_id <= 0: if race_id <= 0:
return return
@@ -74,7 +84,7 @@ func _spawn_bot(race_id: int) -> int:
var ship_def: Dictionary = race.ships[0] # bots always fly the race's fighter (Interceptor) var ship_def: Dictionary = race.ships[0] # bots always fly the race's fighter (Interceptor)
PlayerRegistry.register_bot( PlayerRegistry.register_bot(
bot_id, BotNames.random_name(), race_id, bot_id, BotNames.random_name(), race_id,
ship_def.path, ship_def.scale, ship_def.speed_factor ship_def.path, ship_def.scale, ship_def.speed_factor, ship_def.role
) )
var ship_node := _world.spawn_peer(bot_id) as Ship var ship_node := _world.spawn_peer(bot_id) as Ship
+25
View File
@@ -0,0 +1,25 @@
class_name BotPersonality
extends RefCounted
# Rolled once per bot-slot when BotManager spawns a bot, then handed to that
# bot's BotAI and kept for the rest of the match (even across death/respawn)
# so a player can learn "that bot" and plan around it — see overview/bots.md.
# Each trait is independent (0.0-1.0), so e.g. a bot can be aggressive AND
# accurate, or aggressive and a terrible shot; BotAI converts these into
# concrete numbers via the bot_* ranges in GameConfig.
var aggression: float # higher = presses in closer before it's satisfied with range
var caution: float # higher = flees earlier when hurt instead of trading hits
var accuracy: float # higher = tracks the target's true position tightly
var reaction: float # higher = updates its aim point more often (less lag on movers)
var awareness: float # higher = notices enemies farther away
static func random() -> BotPersonality:
var p := BotPersonality.new()
p.aggression = randf()
p.caution = randf()
p.accuracy = randf()
p.reaction = randf()
p.awareness = randf()
return p
+1
View File
@@ -0,0 +1 @@
uid://ba0y2016tyc76
+8 -5
View File
@@ -139,14 +139,17 @@ func _on_text_submitted(text: String) -> void:
func _on_message_received(sender_name: String, sender_race: int, team_only: bool, text: String) -> void: func _on_message_received(sender_name: String, sender_race: int, team_only: bool, text: String) -> void:
var name_color := _race_color(sender_race).to_html(false)
var safe_name := _escape_bbcode(sender_name)
var safe_text := _escape_bbcode(text) var safe_text := _escape_bbcode(text)
var line: String var line: String
if team_only: if sender_name == ChatManager.ADMIN_NAME:
line = "[color=#7fdc7f][TEAM][/color] [color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] line = "[color=#e0c341]* %s[/color]" % safe_text
else: else:
line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] var name_color := _race_color(sender_race).to_html(false)
var safe_name := _escape_bbcode(sender_name)
if team_only:
line = "[color=#7fdc7f][TEAM][/color] [color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text]
else:
line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text]
_lines.append(line) _lines.append(line)
if _lines.size() > MAX_LINES: if _lines.size() > MAX_LINES:
+14
View File
@@ -0,0 +1,14 @@
extends AnimatedSprite2D
class_name Explosion
# Per-instance override; leave at 1.0 to use GameConfig.explosion_scale (the
# one shared knob for resizing every explosion in the game at once).
@export var scale_override: float = 0.0
func _ready() -> void:
var s: float = scale_override if scale_override > 0.0 else GameConfig.explosion_scale
scale = Vector2.ONE * s
sprite_frames.set_animation_speed("explode", GameConfig.explosion_fps)
play("explode")
animation_finished.connect(queue_free)
+1
View File
@@ -0,0 +1 @@
uid://dft6gwjocfwx5
+65
View File
@@ -0,0 +1,65 @@
[gd_scene load_steps=15 format=3 uid="uid://cexplosion01a"]
[ext_resource type="Script" uid="uid://dft6gwjocfwx5" path="res://effects/explosion.gd" id="1_expl01"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_01.png" id="2_expl01"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_02.png" id="3_expl02"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_03.png" id="4_expl03"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_04.png" id="5_expl04"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_05.png" id="6_expl05"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_06.png" id="7_expl06"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_07.png" id="8_expl07"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_08.png" id="9_expl08"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_09.png" id="10_expl09"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_10.png" id="11_expl10"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_11.png" id="12_expl11"]
[ext_resource type="Texture2D" path="res://assets/images/effects/explosion/explosion_12.png" id="13_expl12"]
[sub_resource type="SpriteFrames" id="SpriteFrames_expl01"]
animations = [{
"frames": [{
"duration": 5.0,
"texture": ExtResource("2_expl01")
}, {
"duration": 5.0,
"texture": ExtResource("3_expl02")
}, {
"duration": 5.0,
"texture": ExtResource("4_expl03")
}, {
"duration": 5.0,
"texture": ExtResource("5_expl04")
}, {
"duration": 5.0,
"texture": ExtResource("6_expl05")
}, {
"duration": 5.0,
"texture": ExtResource("7_expl06")
}, {
"duration": 5.0,
"texture": ExtResource("8_expl07")
}, {
"duration": 5.0,
"texture": ExtResource("9_expl08")
}, {
"duration": 5.0,
"texture": ExtResource("10_expl09")
}, {
"duration": 5.0,
"texture": ExtResource("11_expl10")
}, {
"duration": 5.0,
"texture": ExtResource("12_expl11")
}, {
"duration": 5.0,
"texture": ExtResource("13_expl12")
}],
"loop": false,
"name": &"explode",
"speed": 20.0
}]
[node name="Explosion" type="AnimatedSprite2D"]
sprite_frames = SubResource("SpriteFrames_expl01")
animation = &"explode"
autoplay = ""
script = ExtResource("1_expl01")
+60
View File
@@ -0,0 +1,60 @@
extends CanvasLayer
# Blue "mirrored" energy bar, top-middle of the screen: the filled region is
# centered in the bar's full extent, so it shrinks toward the middle from
# both edges as energy drains, instead of draining left-to-right like a
# normal bar. Driven by Ship._update_hud() calling set_fill() with the local
# player's energy/max_energy fraction (see ships/ship_movement.gd).
const BAR_WIDTH := 300.0
const BAR_HEIGHT := 14.0
const MARGIN_TOP := 20.0
const COLOR_BG := Color(0.15, 0.18, 0.25, 0.6)
const COLOR_FILL := Color(0.25, 0.55, 1.0, 0.95)
var _fill: ColorRect
func _ready() -> void:
layer = 4
_build_ui()
set_fill(0.0)
func _build_ui() -> void:
var container := Control.new()
container.anchor_left = 0.5
container.anchor_right = 0.5
container.anchor_top = 0.0
container.anchor_bottom = 0.0
container.offset_left = -BAR_WIDTH / 2.0
container.offset_right = BAR_WIDTH / 2.0
container.offset_top = MARGIN_TOP
container.offset_bottom = MARGIN_TOP + BAR_HEIGHT
container.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(container)
var bg := ColorRect.new()
bg.color = COLOR_BG
bg.anchor_left = 0.0
bg.anchor_right = 1.0
bg.anchor_top = 0.0
bg.anchor_bottom = 1.0
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(bg)
_fill = ColorRect.new()
_fill.color = COLOR_FILL
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
container.add_child(_fill)
# frac in [0, 1]; the fill rect stays centered in the bar's full extent so it
# shrinks toward the middle from both edges as energy drains.
func set_fill(frac: float) -> void:
if _fill == null:
return
var f := clampf(frac, 0.0, 1.0)
var w := BAR_WIDTH * f
_fill.position = Vector2((BAR_WIDTH - w) / 2.0, 0.0)
_fill.size = Vector2(w, BAR_HEIGHT)
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://hud/energy_bar.gd" id="1_script"]
[node name="EnergyBar" type="CanvasLayer"]
script = ExtResource("1_script")
+86
View File
@@ -0,0 +1,86 @@
extends CanvasLayer
# Last MAX_LINES deaths, server-relayed via KillFeedManager. Stacked directly
# above ChatBox's history panel (same left column, off-center rather than
# top-dead-center) so both overlays read as one HUD block.
const MAX_LINES := 4
const WIDTH := 460.0 # matches chat/chat_box.gd's HISTORY_W for column alignment
const HEIGHT := 100.0
const MARGIN_X := 16.0
const GAP := 4.0
# chat/chat_box.gd's history panel spans y -210 to -50 off the window's
# bottom edge (anchor_bottom 1.0) -- see that script's _build_ui() for the
# margin/gap math behind those numbers. Stack this panel on top of it using
# the same GAP chat uses internally between its own history and input line.
const CHAT_HISTORY_TOP := -210.0
var _list: RichTextLabel
var _lines: Array[String] = []
func _ready() -> void:
layer = 5
_build_ui()
KillFeedManager.kill_reported.connect(_on_kill_reported)
func _build_ui() -> void:
var panel := PanelContainer.new()
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.02, 0.03, 0.06, 0.55)
ps.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", ps)
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Same anchor pattern as chat_box.gd/player_list.gd: set every
# anchor_* as a plain property assignment (not sequential
# set_anchor_and_offset() calls) to avoid the push_opposite_anchor
# footgun described in team_select.gd's _place().
panel.anchor_left = 0.0
panel.anchor_right = 0.0
panel.anchor_top = 1.0
panel.anchor_bottom = 1.0
panel.offset_left = MARGIN_X
panel.offset_right = MARGIN_X + WIDTH
panel.offset_bottom = CHAT_HISTORY_TOP - GAP
panel.offset_top = panel.offset_bottom - HEIGHT
add_child(panel)
_list = RichTextLabel.new()
_list.bbcode_enabled = true
_list.scroll_active = false
_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
_list.add_theme_font_size_override("normal_font_size", 15)
_list.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_list.offset_left = 8
_list.offset_top = 4
_list.offset_right = -8
_list.offset_bottom = -4
panel.add_child(_list)
func _on_kill_reported(victim_name: String, victim_race: int, killer_name: String, killer_race: int, is_environment_kill: bool) -> void:
var victim_text := "[color=#%s]%s[/color]" % [_race_color(victim_race).to_html(false), _escape_bbcode(victim_name)]
var line: String
if is_environment_kill:
line = "%s killed by the environment" % victim_text
else:
var killer_text := "[color=#%s]%s[/color]" % [_race_color(killer_race).to_html(false), _escape_bbcode(killer_name)]
line = "%s killed by %s" % [victim_text, killer_text]
_lines.append(line)
if _lines.size() > MAX_LINES:
_lines.pop_front()
_list.text = "\n".join(_lines)
func _race_color(race_id: int) -> Color:
for race in TeamSelect.RACES:
if race.id == race_id:
return race.color
return Color(0.85, 0.87, 0.92)
func _escape_bbcode(s: String) -> String:
return s.replace("[", "[lb]")
+1
View File
@@ -0,0 +1 @@
uid://bp0k0nhyjdvl6
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://ckillfeed001"]
[ext_resource type="Script" path="res://hud/kill_feed.gd" id="1_script"]
[node name="KillFeed" type="CanvasLayer"]
script = ExtResource("1_script")
+100
View File
@@ -0,0 +1,100 @@
extends CanvasLayer
# Bottom-right minimap: green translucent background, gray dots for every
# wall/asteroid tile, white dots for teammates, yellow dots for enemies.
# Drawing itself lives in mini_map_view.gd (a Control, since CanvasLayer
# can't _draw()) — this script only polls game state and feeds it in.
const MARGIN := 16.0
const WIDTH := 260.0
const HEIGHT := 180.0
const REFRESH_INTERVAL := 0.1 # ~10Hz is plenty for a minimap dot
const TEAM_COLOR := Color(1.0, 1.0, 1.0)
const ENEMY_COLOR := Color(0.95, 0.85, 0.25)
const SELF_COLOR := Color(0.3, 0.55, 1.0)
var _view: MiniMapView
var _refresh_timer: float = 0.0
func _ready() -> void:
layer = 4
_build_ui()
# Deferred: MiniMap is a child of World and so (like every other World
# child, see World.decide_offered_races()'s comment) has its own _ready()
# fire *before* World._ready() — before the map has even been instanced
# into MapContainer. Deferring runs this after the whole _ready cascade
# finishes, once the map (and GameConfig.world_bounds) actually exist.
call_deferred("_cache_hazard_points")
func _build_ui() -> void:
_view = MiniMapView.new()
_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Anchored fully to the bottom-right corner (all anchors 1.0), so offsets
# are negative pixel distances from that corner that stay correct
# regardless of window size — see structure.md's Display section on why
# every anchor here must be set directly rather than via sequential
# set_anchor_and_offset() calls (push_opposite_anchor footgun).
_view.anchor_left = 1.0
_view.anchor_right = 1.0
_view.anchor_top = 1.0
_view.anchor_bottom = 1.0
_view.offset_left = -MARGIN - WIDTH
_view.offset_right = -MARGIN
_view.offset_top = -MARGIN - HEIGHT
_view.offset_bottom = -MARGIN
add_child(_view)
func _process(delta: float) -> void:
_refresh_timer -= delta
if _refresh_timer > 0.0:
return
_refresh_timer = REFRESH_INTERVAL
_update_players()
_view.queue_redraw()
func _cache_hazard_points() -> void:
var map_container := get_node_or_null("/root/World/MapContainer")
if not map_container:
return
var bounds := GameConfig.world_bounds
var points := PackedVector2Array()
for tile_layer in map_container.find_children("*", "TileMapLayer", true, false):
for cell in tile_layer.get_used_cells():
var world_pos: Vector2 = tile_layer.to_global(tile_layer.map_to_local(cell))
points.append(_normalize(world_pos, bounds))
_view.hazard_points = points
_view.queue_redraw()
func _update_players() -> void:
var players_node := get_node_or_null("/root/World/Players")
if not players_node:
return
var bounds := GameConfig.world_bounds
var local_id := PlayerRegistry.get_local_id()
var local_race: int = PlayerRegistry.get_info(local_id).get("race", 0)
var points: Array = []
for peer_id in PlayerRegistry.players.keys():
var ship := players_node.get_node_or_null(str(peer_id))
if not ship or not ship.visible:
continue
var color: Color
if peer_id == local_id:
color = SELF_COLOR
else:
var info: Dictionary = PlayerRegistry.players[peer_id]
var is_teammate: bool = info.get("race", 0) == local_race
color = TEAM_COLOR if is_teammate else ENEMY_COLOR
points.append({"pos": _normalize(ship.global_position, bounds), "color": color})
_view.player_points = points
func _normalize(world_pos: Vector2, bounds: Rect2) -> Vector2:
var frac := (world_pos - bounds.position) / bounds.size
return frac.clamp(Vector2.ZERO, Vector2.ONE)
+1
View File
@@ -0,0 +1 @@
uid://bw5qhf17nlrg
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://cminimap0001"]
[ext_resource type="Script" path="res://hud/mini_map.gd" id="1_script"]
[node name="MiniMap" type="CanvasLayer"]
script = ExtResource("1_script")
+34
View File
@@ -0,0 +1,34 @@
extends Control
class_name MiniMapView
# Pure drawing surface for hud/mini_map.gd — split out because CanvasLayer
# (which the rest of the HUD overlays use for their `layer` property, see
# player_list.gd/kill_feed.gd) isn't a CanvasItem and can't override _draw().
# mini_map.gd owns all game-state polling and just pokes these two arrays
# before calling queue_redraw().
# Normalized (0..1 within world_bounds) positions of every wall/asteroid
# tile, cached once by mini_map.gd since the map layout never changes.
var hazard_points: PackedVector2Array = PackedVector2Array()
# [{pos: Vector2 (normalized 0..1), color: Color}], rebuilt every refresh.
var player_points: Array = []
const BG_COLOR := Color(0.04, 0.28, 0.08, 0.6)
const BORDER_COLOR := Color(0.5, 0.85, 0.5, 0.8)
const HAZARD_COLOR := Color(0.65, 0.65, 0.65, 0.9)
const HAZARD_RADIUS := 1.5
const PLAYER_RADIUS := 3.0
func _draw() -> void:
var rect := Rect2(Vector2.ZERO, size)
draw_rect(rect, BG_COLOR, true)
for p in hazard_points:
draw_circle(p * size, HAZARD_RADIUS, HAZARD_COLOR)
for entry in player_points:
draw_circle(entry.pos * size, PLAYER_RADIUS, entry.color)
draw_rect(rect, BORDER_COLOR, false, 1.5)
+1
View File
@@ -0,0 +1 @@
uid://b3shd4b2thlml
+75
View File
@@ -0,0 +1,75 @@
extends CanvasLayer
# Top-right live latency readout: this client's RTT to the server, read
# straight from ENet's own peer statistic (no custom protocol needed, unlike
# menu/server_select.gd's pre-connect probe, which has no ENet connection
# yet to read a statistic from). Meaningless for the server itself (peer id
# 1 is the local server, RTT to itself is always ~0), so it stays hidden
# there.
const MARGIN_X := 16.0
const MARGIN_TOP := 20.0
const WIDTH := 100.0
const HEIGHT := 30.0
const UPDATE_INTERVAL := 0.5
const COLOR_GOOD := Color(0.4, 0.9, 0.5)
const COLOR_OK := Color(0.95, 0.75, 0.3)
const COLOR_BAD := Color(0.9, 0.4, 0.4)
var _label: Label
var _timer: float = 0.0
func _ready() -> void:
layer = 4
if NetworkManager.is_server:
queue_free()
return
_build_ui()
_refresh()
func _build_ui() -> void:
_label = Label.new()
_label.anchor_left = 1.0
_label.anchor_right = 1.0
_label.anchor_top = 0.0
_label.anchor_bottom = 0.0
_label.offset_left = -(MARGIN_X + WIDTH)
_label.offset_right = -MARGIN_X
_label.offset_top = MARGIN_TOP
_label.offset_bottom = MARGIN_TOP + HEIGHT
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_label.add_theme_font_size_override("font_size", 15)
add_child(_label)
func _process(delta: float) -> void:
_timer += delta
if _timer < UPDATE_INTERVAL:
return
_timer = 0.0
_refresh()
func _refresh() -> void:
var peer := multiplayer.multiplayer_peer
if peer == null or not (peer is ENetMultiplayerPeer):
_label.text = ""
return
var enet_peer: ENetPacketPeer = (peer as ENetMultiplayerPeer).get_peer(1)
if enet_peer == null:
_label.text = "PING: --"
_label.add_theme_color_override("font_color", Color(0.5, 0.53, 0.6))
return
var rtt := int(enet_peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME))
_label.text = "PING: %d ms" % rtt
if rtt <= 80:
_label.add_theme_color_override("font_color", COLOR_GOOD)
elif rtt <= 180:
_label.add_theme_color_override("font_color", COLOR_OK)
else:
_label.add_theme_color_override("font_color", COLOR_BAD)
+1
View File
@@ -0,0 +1 @@
uid://x5kt6q3136cu
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://hud/ping_display.gd" id="1_script"]
[node name="PingDisplay" type="CanvasLayer"]
script = ExtResource("1_script")
+98
View File
@@ -0,0 +1,98 @@
extends CanvasLayer
# Top-left roster of every connected player (human + bot). White entries are
# on the local player's team (same race — races double as teams, see
# TeamSelect), yellow entries are the enemy team. Bots (negative peer_id, see
# bots/bot_manager.gd) get a trailing " (b)". Rebuilt whenever
# PlayerRegistry's roster changes rather than polled every frame.
const MARGIN_X := 16.0
const MARGIN_TOP := 90.0 # below HUD/HealthBar + HUD/EnergyBar, which occupy y 20-74
const WIDTH := 220.0
const HEIGHT := 260.0
const TEAM_COLOR := Color(0.92, 0.95, 1.0)
const ENEMY_COLOR := Color(0.95, 0.85, 0.25)
var _list: RichTextLabel
func _ready() -> void:
layer = 4
_build_ui()
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
PlayerRegistry.player_removed.connect(_on_player_removed)
_refresh()
func _build_ui() -> void:
var panel := PanelContainer.new()
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.02, 0.03, 0.06, 0.55)
ps.set_corner_radius_all(6)
panel.add_theme_stylebox_override("panel", ps)
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
# Anchored fully to the top-left corner (all anchors 0.0), so offsets are
# plain absolute pixel coordinates that stay correct regardless of window
# size — no push_opposite_anchor risk since every anchor here agrees.
panel.anchor_left = 0.0
panel.anchor_right = 0.0
panel.anchor_top = 0.0
panel.anchor_bottom = 0.0
panel.offset_left = MARGIN_X
panel.offset_top = MARGIN_TOP
panel.offset_right = MARGIN_X + WIDTH
panel.offset_bottom = MARGIN_TOP + HEIGHT
add_child(panel)
_list = RichTextLabel.new()
_list.bbcode_enabled = true
_list.scroll_active = true
_list.scroll_following = false
_list.mouse_filter = Control.MOUSE_FILTER_IGNORE
_list.add_theme_font_size_override("normal_font_size", 15)
_list.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_list.offset_left = 8
_list.offset_top = 4
_list.offset_right = -8
_list.offset_bottom = -4
panel.add_child(_list)
func _on_loadout_updated(_peer_id: int) -> void:
_refresh()
func _on_player_removed(_peer_id: int, _info: Dictionary) -> void:
_refresh()
func _refresh() -> void:
var local_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0)
var peer_ids := PlayerRegistry.players.keys()
peer_ids.sort_custom(func(a, b):
var info_a: Dictionary = PlayerRegistry.players[a]
var info_b: Dictionary = PlayerRegistry.players[b]
var a_team: bool = info_a.get("race", 0) == local_race
var b_team: bool = info_b.get("race", 0) == local_race
if a_team != b_team:
return a_team # teammates first
return String(info_a.get("name", "")).naturalnocasecmp_to(info_b.get("name", "")) < 0
)
var lines: Array[String] = []
for peer_id in peer_ids:
var info: Dictionary = PlayerRegistry.players[peer_id]
var is_teammate: bool = info.get("race", 0) == local_race
var color := TEAM_COLOR if is_teammate else ENEMY_COLOR
var name_text := _escape_bbcode(String(info.get("name", "")))
if peer_id < 0:
name_text += " (b)"
lines.append("[color=#%s]%s[/color]" % [color.to_html(false), name_text])
_list.text = "\n".join(lines)
func _escape_bbcode(s: String) -> String:
return s.replace("[", "[lb]")
+1
View File
@@ -0,0 +1 @@
uid://jbjhcpnern4x
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://cplayerlist01a"]
[ext_resource type="Script" path="res://hud/player_list.gd" id="1_script"]
[node name="PlayerList" type="CanvasLayer"]
script = ExtResource("1_script")
+39
View File
@@ -0,0 +1,39 @@
extends Control
# Simple left-to-right stat bar for the top-left HUD (HealthBar/EnergyBar in
# world.tscn's HUD node) — fill shrinks from the right as the stat depletes.
# Contrast hud/energy_bar.gd's top-middle bar, which drains from both edges
# toward the center instead.
@export var fill_color: Color = Color(0.3, 0.85, 0.35, 0.95)
@export var bg_color: Color = Color(0.12, 0.14, 0.18, 0.6)
var _fill: ColorRect
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
var bg := ColorRect.new()
bg.color = bg_color
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
_fill = ColorRect.new()
_fill.color = fill_color
_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(_fill)
set_fill(1.0)
# frac in [0, 1]; anchor_right (not an explicit pixel width) so the fill
# tracks this control's actual size regardless of window width.
func set_fill(frac: float) -> void:
if _fill == null:
return
_fill.anchor_left = 0.0
_fill.anchor_right = clampf(frac, 0.0, 1.0)
_fill.offset_left = 0.0
_fill.offset_right = 0.0
+297 -283
View File
@@ -1,10 +1,19 @@
extends Control extends Control
var _name_input: LineEdit # HL2-style menu: no big mode tiles, just a plain vertical text nav over a
var _casual_btn: Button # space backdrop. Quick Play always queues casual — ranked is gone until the
var _ranked_btn: Button # matchmaking/MMR work in overview/task.md lands.
var _status_lbl: Label var _status_lbl: Label
var _name_lbl: Label
var _rank_lbl: Label
var _profile_overlay: Control
var _profile_input: LineEdit
var _profile_hint_lbl: Label
var _connecting: bool = false var _connecting: bool = false
var _pending_quick_play: bool = false # profile overlay was forced open by Quick Play
const RANK_DATA := [ const RANK_DATA := [
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)}, {"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
@@ -14,7 +23,15 @@ const RANK_DATA := [
{"label": "ADMIRAL", "color": Color(0.62, 0.28, 1.0)}, {"label": "ADMIRAL", "color": Color(0.62, 0.28, 1.0)},
{"label": "LEGEND", "color": Color(1.0, 0.28, 0.10)}, {"label": "LEGEND", "color": Color(1.0, 0.28, 0.10)},
] ]
const CURRENT_RANK := 0 # placeholder until backend exists const CURRENT_RANK := 0 # placeholder until backend exists
const CURRENT_LEVEL := 1 # placeholder until backend exists
const NAV_ITEMS := ["QUICK PLAY", "SERVER SELECT", "OPTIONS", "PROFILE", "QUIT"]
const NAV_LEFT := 64.0
const NAV_WIDTH := 360.0
const NAV_TOP := 250.0
const NAV_BTN_H := 52.0
const NAV_GAP := 4.0
func _ready() -> void: func _ready() -> void:
@@ -27,11 +44,13 @@ func _ready() -> void:
func _build_ui() -> void: func _build_ui() -> void:
_add_bg() _add_bg()
_add_title() _add_logo()
_add_play_section() _add_nav_menu()
_add_rank_badge() _add_profile_badge()
_add_quit_btn() _add_status_label()
_add_version_label() _add_version_label()
_add_profile_overlay()
_refresh_profile_badge()
func _add_bg() -> void: func _add_bg() -> void:
@@ -43,330 +62,328 @@ func _add_bg() -> void:
add_child(bg) add_child(bg)
var ov := ColorRect.new() var ov := ColorRect.new()
ov.color = Color(0.01, 0.02, 0.09, 0.80) ov.color = Color(0.0, 0.0, 0.0, 0.55)
ov.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) ov.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(ov) add_child(ov)
func _add_title() -> void: func _add_logo() -> void:
var title := Label.new() var title := Label.new()
title.text = "S P A C E W A R" title.text = "S P A C E W A R"
title.set_anchors_preset(Control.PRESET_TOP_WIDE) title.anchor_left = 0.0
title.offset_top = 72.0 title.anchor_top = 0.0
title.offset_bottom = 148.0 title.anchor_right = 0.0
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.anchor_bottom = 0.0
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER title.offset_left = 60.0
title.add_theme_font_size_override("font_size", 56) title.offset_top = 56.0
title.add_theme_color_override("font_color", Color(0.45, 0.88, 1.0)) title.offset_right = 700.0
title.offset_bottom = 118.0
title.add_theme_font_size_override("font_size", 44)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
add_child(title) add_child(title)
var sub := Label.new() var sub := Label.new()
sub.text = "FIGHT FOR THE GALAXY" sub.text = "FIGHT FOR THE GALAXY"
sub.set_anchors_preset(Control.PRESET_TOP_WIDE) sub.anchor_left = 0.0
sub.offset_top = 152.0 sub.anchor_top = 0.0
sub.offset_bottom = 178.0 sub.anchor_right = 0.0
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER sub.anchor_bottom = 0.0
sub.offset_left = 64.0
sub.offset_top = 116.0
sub.offset_right = 500.0
sub.offset_bottom = 138.0
sub.add_theme_font_size_override("font_size", 13) sub.add_theme_font_size_override("font_size", 13)
sub.add_theme_color_override("font_color", Color(0.42, 0.54, 0.70)) sub.add_theme_color_override("font_color", Color(0.7, 0.72, 0.78, 0.85))
add_child(sub) add_child(sub)
func _add_play_section() -> void: func _add_nav_menu() -> void:
const BTN_W := 480.0 for i in NAV_ITEMS.size():
const BTN_H := 230.0 var label_text: String = NAV_ITEMS[i]
const GAP := 40.0 var stub := label_text == "OPTIONS"
const TOP_Y := 220.0 var btn := _make_nav_btn(label_text, stub)
# The whole row (both buttons + gap) is centered on the actual screen btn.anchor_left = 0.0
# width via anchor 0.5, not a hardcoded 1280px design width — offsets btn.anchor_top = 0.0
# below are all relative to that center point, not the left edge. btn.anchor_right = 0.0
const HALF_GAP := GAP / 2.0 btn.anchor_bottom = 0.0
const ROW_HALF_W := (BTN_W * 2.0 + GAP) / 2.0 btn.offset_left = NAV_LEFT
btn.offset_right = NAV_LEFT + NAV_WIDTH
btn.offset_top = NAV_TOP + i * (NAV_BTN_H + NAV_GAP)
btn.offset_bottom = btn.offset_top + NAV_BTN_H
add_child(btn)
_casual_btn = _make_mode_btn( match label_text:
-ROW_HALF_W, TOP_Y, -HALF_GAP, TOP_Y + BTN_H, "QUICK PLAY":
"CASUAL", btn.pressed.connect(_on_quick_play)
"25 vs 25", "SERVER SELECT":
"Drop in, drop out anytime.\nNo rank at stake.", btn.pressed.connect(_on_server_select)
Color(0.06, 0.22, 0.08), "OPTIONS":
Color(0.30, 0.90, 0.40), btn.pressed.connect(_on_options)
) "PROFILE":
_casual_btn.pressed.connect(_on_play_casual) btn.pressed.connect(_on_profile)
add_child(_casual_btn) "QUIT":
btn.pressed.connect(_on_quit)
_ranked_btn = _make_mode_btn(
HALF_GAP, TOP_Y, ROW_HALF_W, TOP_Y + BTN_H,
"RANKED",
"5 vs 5",
"Competitive ladder.\nYour rank is on the line.",
Color(0.06, 0.10, 0.26),
Color(0.40, 0.72, 1.00),
)
_ranked_btn.disabled = true
_ranked_btn.pressed.connect(_on_play_ranked)
add_child(_ranked_btn)
var coming_soon := Label.new()
coming_soon.text = "— COMING SOON —"
_place(coming_soon, 0.5, HALF_GAP, 0.5, ROW_HALF_W, TOP_Y + BTN_H - 36.0, TOP_Y + BTN_H - 8.0)
coming_soon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
coming_soon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
coming_soon.add_theme_font_size_override("font_size", 11)
coming_soon.add_theme_color_override("font_color", Color(0.30, 0.36, 0.48, 0.70))
coming_soon.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(coming_soon)
# Callsign label
var lbl := Label.new()
lbl.text = "CALLSIGN"
_place(lbl, 0.5, -ROW_HALF_W, 0.5, -ROW_HALF_W + 95.0, TOP_Y + BTN_H + 26.0, TOP_Y + BTN_H + 62.0)
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.add_theme_font_size_override("font_size", 12)
lbl.add_theme_color_override("font_color", Color(0.45, 0.55, 0.70))
add_child(lbl)
_name_input = LineEdit.new()
_name_input.placeholder_text = "Enter your callsign..."
_place(_name_input, 0.5, -ROW_HALF_W + 100.0, 0.5, ROW_HALF_W, TOP_Y + BTN_H + 26.0, TOP_Y + BTN_H + 62.0)
_name_input.add_theme_font_size_override("font_size", 15)
if GameConfig.player_name != "":
_name_input.text = GameConfig.player_name
_name_input.text_changed.connect(_on_name_changed)
add_child(_name_input)
_status_lbl = Label.new()
_status_lbl.text = ""
_place(_status_lbl, 0.5, -ROW_HALF_W, 0.5, ROW_HALF_W, TOP_Y + BTN_H + 68.0, TOP_Y + BTN_H + 92.0)
_status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_status_lbl.add_theme_font_size_override("font_size", 13)
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
add_child(_status_lbl)
_update_play_buttons()
func _make_mode_btn( func _make_nav_btn(label_text: String, stub: bool) -> Button:
left: float, top: float, right: float, bottom: float,
mode: String, fmt: String, desc: String,
bg: Color, accent: Color) -> Button:
var btn := Button.new() var btn := Button.new()
btn.text = "" btn.text = label_text
_place(btn, 0.5, left, 0.5, right, top, bottom) btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.add_theme_font_size_override("font_size", 22)
var sn := _flat(bg.lightened(0.05), 12) var sn := StyleBoxEmpty.new()
sn.set_border_width_all(2) sn.content_margin_left = 20.0
sn.border_color = accent.darkened(0.45) btn.add_theme_stylebox_override("normal", sn)
var sh := _flat(bg.lightened(0.18), 12)
sh.set_border_width_all(2)
sh.border_color = accent
var sd := _flat(Color(0.04, 0.04, 0.06, 0.5), 12)
sd.set_border_width_all(1)
sd.border_color = Color(0.2, 0.22, 0.28, 0.5)
btn.add_theme_stylebox_override("normal", sn)
btn.add_theme_stylebox_override("hover", sh)
btn.add_theme_stylebox_override("pressed", sh)
btn.add_theme_stylebox_override("disabled", sd)
var vb := VBoxContainer.new() if stub:
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) btn.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48, 0.8))
vb.offset_left = 24.0 btn.add_theme_stylebox_override("hover", sn)
vb.offset_top = 20.0 btn.add_theme_stylebox_override("pressed", sn)
vb.offset_right = -24.0 else:
vb.offset_bottom = -20.0 btn.add_theme_color_override("font_color", Color(0.82, 0.84, 0.9))
vb.alignment = BoxContainer.ALIGNMENT_CENTER btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 1.0))
vb.add_theme_constant_override("separation", 12) btn.add_theme_color_override("font_pressed_color", Color(1.0, 1.0, 1.0))
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(vb)
var mode_lbl := Label.new() var sh := StyleBoxFlat.new()
mode_lbl.text = mode sh.bg_color = Color(1.0, 1.0, 1.0, 0.06)
mode_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER sh.content_margin_left = 20.0
mode_lbl.add_theme_font_size_override("font_size", 40) sh.border_width_left = 3
mode_lbl.add_theme_color_override("font_color", accent) sh.border_color = Color(1.0, 1.0, 1.0, 0.9)
mode_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE btn.add_theme_stylebox_override("hover", sh)
vb.add_child(mode_lbl) btn.add_theme_stylebox_override("pressed", sh)
var fmt_lbl := Label.new()
fmt_lbl.text = fmt
fmt_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
fmt_lbl.add_theme_font_size_override("font_size", 22)
fmt_lbl.add_theme_color_override("font_color", Color(0.85, 0.90, 0.96))
fmt_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(fmt_lbl)
var sep := HSeparator.new()
sep.add_theme_color_override("color", accent.darkened(0.5))
sep.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(sep)
var desc_lbl := Label.new()
desc_lbl.text = desc
desc_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
desc_lbl.add_theme_font_size_override("font_size", 13)
desc_lbl.add_theme_color_override("font_color", Color(0.55, 0.63, 0.76))
desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(desc_lbl)
return btn return btn
func _add_rank_badge() -> void: func _add_profile_badge() -> void:
const W := 210.0 _name_lbl = Label.new()
const H := 60.0 _name_lbl.anchor_left = 1.0
const MARGIN := 16.0 _name_lbl.anchor_top = 0.0
_name_lbl.anchor_right = 1.0
_name_lbl.anchor_bottom = 0.0
_name_lbl.offset_left = -340.0
_name_lbl.offset_top = 28.0
_name_lbl.offset_right = -24.0
_name_lbl.offset_bottom = 50.0
_name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_name_lbl.add_theme_font_size_override("font_size", 17)
_name_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
add_child(_name_lbl)
var panel := Panel.new() _rank_lbl = Label.new()
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT) _rank_lbl.anchor_left = 1.0
panel.offset_left = -(W + MARGIN) _rank_lbl.anchor_top = 0.0
panel.offset_top = MARGIN _rank_lbl.anchor_right = 1.0
panel.offset_right = -MARGIN _rank_lbl.anchor_bottom = 0.0
panel.offset_bottom = MARGIN + H _rank_lbl.offset_left = -340.0
var ps := _flat(Color(0.04, 0.07, 0.16, 0.92), 8) _rank_lbl.offset_top = 50.0
ps.set_border_width_all(1) _rank_lbl.offset_right = -24.0
ps.border_color = Color(0.18, 0.36, 0.62, 0.85) _rank_lbl.offset_bottom = 68.0
panel.add_theme_stylebox_override("panel", ps) _rank_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
add_child(panel) _rank_lbl.add_theme_font_size_override("font_size", 12)
add_child(_rank_lbl)
var rank := RANK_DATA[CURRENT_RANK]
# Colored badge block on the left
var badge := Panel.new()
badge.set_anchor_and_offset(SIDE_LEFT, 0.0, 8.0)
badge.set_anchor_and_offset(SIDE_TOP, 0.0, 8.0)
badge.set_anchor_and_offset(SIDE_RIGHT, 0.0, 50.0)
badge.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -8.0)
var bs := _flat(rank.color.darkened(0.45), 5)
bs.set_border_width_all(2)
bs.border_color = rank.color
badge.add_theme_stylebox_override("panel", bs)
panel.add_child(badge)
var init_lbl := Label.new()
init_lbl.text = rank.label.left(1)
init_lbl.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
init_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
init_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
init_lbl.add_theme_font_size_override("font_size", 20)
init_lbl.add_theme_color_override("font_color", rank.color)
badge.add_child(init_lbl)
# Rank name
var rank_lbl := Label.new()
rank_lbl.text = rank.label
rank_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 58.0)
rank_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, 0.0)
rank_lbl.set_anchor_and_offset(SIDE_RIGHT, 1.0, -8.0)
rank_lbl.set_anchor_and_offset(SIDE_BOTTOM, 0.0, H / 2.0 + 2.0)
rank_lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
rank_lbl.add_theme_font_size_override("font_size", 15)
rank_lbl.add_theme_color_override("font_color", rank.color)
panel.add_child(rank_lbl)
var rating_lbl := Label.new()
rating_lbl.text = "Rating: —"
rating_lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 58.0)
rating_lbl.set_anchor_and_offset(SIDE_TOP, 0.0, H / 2.0)
rating_lbl.set_anchor_and_offset(SIDE_RIGHT, 1.0, -8.0)
rating_lbl.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -6.0)
rating_lbl.vertical_alignment = VERTICAL_ALIGNMENT_TOP
rating_lbl.add_theme_font_size_override("font_size", 11)
rating_lbl.add_theme_color_override("font_color", Color(0.45, 0.54, 0.68))
panel.add_child(rating_lbl)
func _add_quit_btn() -> void: func _refresh_profile_badge() -> void:
const W := 110.0 if GameConfig.player_name.strip_edges().is_empty():
const H := 38.0 _name_lbl.text = "GUEST"
const MARGIN := 20.0 _name_lbl.add_theme_color_override("font_color", Color(0.7, 0.72, 0.78))
_rank_lbl.text = "SET A CALLSIGN IN PROFILE"
_rank_lbl.add_theme_color_override("font_color", Color(0.85, 0.55, 0.3))
else:
_name_lbl.text = GameConfig.player_name.to_upper()
_name_lbl.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
var rank: Dictionary = RANK_DATA[CURRENT_RANK]
_rank_lbl.text = "%s • LEVEL %d" % [rank.label, CURRENT_LEVEL]
_rank_lbl.add_theme_color_override("font_color", rank.color)
var btn := Button.new()
btn.text = "QUIT"
btn.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
btn.offset_left = -(W + MARGIN)
btn.offset_top = -(H + MARGIN)
btn.offset_right = -MARGIN
btn.offset_bottom = -MARGIN
btn.add_theme_font_size_override("font_size", 13)
btn.add_theme_color_override("font_color", Color(0.48, 0.54, 0.65))
var sn := _flat(Color(0.05, 0.06, 0.11, 0.80), 6) func _add_status_label() -> void:
sn.set_border_width_all(1) _status_lbl = Label.new()
sn.border_color = Color(0.18, 0.22, 0.33, 0.70) _status_lbl.text = ""
var sh := _flat(Color(0.10, 0.12, 0.20, 0.90), 6) _status_lbl.anchor_left = 0.0
sh.set_border_width_all(1) _status_lbl.anchor_top = 1.0
sh.border_color = Color(0.32, 0.38, 0.52) _status_lbl.anchor_right = 0.0
btn.add_theme_stylebox_override("normal", sn) _status_lbl.anchor_bottom = 1.0
btn.add_theme_stylebox_override("hover", sh) _status_lbl.offset_left = NAV_LEFT
btn.pressed.connect(func(): get_tree().quit()) _status_lbl.offset_right = NAV_LEFT + 460.0
add_child(btn) _status_lbl.offset_top = -64.0
_status_lbl.offset_bottom = -36.0
_status_lbl.add_theme_font_size_override("font_size", 13)
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
add_child(_status_lbl)
func _add_version_label() -> void: func _add_version_label() -> void:
var lbl := Label.new() var lbl := Label.new()
lbl.text = "v0.1-dev" lbl.text = "v0.1-dev"
lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 14.0) lbl.anchor_left = 0.0
lbl.set_anchor_and_offset(SIDE_TOP, 1.0, -30.0) lbl.anchor_top = 1.0
lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, 100.0) lbl.anchor_right = 0.0
lbl.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -10.0) lbl.anchor_bottom = 1.0
lbl.offset_left = NAV_LEFT
lbl.offset_top = -30.0
lbl.offset_right = NAV_LEFT + 100.0
lbl.offset_bottom = -10.0
lbl.add_theme_font_size_override("font_size", 11) lbl.add_theme_font_size_override("font_size", 11)
lbl.add_theme_color_override("font_color", Color(0.3, 0.35, 0.45)) lbl.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48))
add_child(lbl) add_child(lbl)
# Sets all 4 anchors then all 4 offsets as plain property assignments — func _add_profile_overlay() -> void:
# NOT via sequential set_anchor_and_offset() calls, whose default _profile_overlay = Control.new()
# push_opposite_anchor=true drags the opposite side's anchor/offset along _profile_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# whenever the two calls momentarily disagree (e.g. left set to 0.5 while _profile_overlay.visible = false
# right is still its 0.0 default), corrupting layout for any non-0.0 anchor add_child(_profile_overlay)
# (0.5-centered elements hit this hardest). See _make_mode_btn's git history
# for how this was found — buttons rendered flush against the left edge var dim := ColorRect.new()
# instead of centered until this fix. dim.color = Color(0.0, 0.0, 0.0, 0.6)
func _place(node: Control, anchor_left: float, left: float, anchor_right: float, right: float, top: float, bottom: float) -> void: dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
node.anchor_left = anchor_left dim.mouse_filter = Control.MOUSE_FILTER_STOP
node.anchor_right = anchor_right _profile_overlay.add_child(dim)
node.anchor_top = 0.0
node.anchor_bottom = 0.0 var panel := Panel.new()
node.offset_left = left panel.set_anchors_preset(Control.PRESET_CENTER)
node.offset_right = right panel.offset_left = -200
node.offset_top = top panel.offset_top = -120
node.offset_bottom = bottom panel.offset_right = 200
panel.offset_bottom = 120
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.05, 0.05, 0.07, 0.97)
ps.set_corner_radius_all(6)
ps.set_border_width_all(1)
ps.border_color = Color(1.0, 1.0, 1.0, 0.25)
panel.add_theme_stylebox_override("panel", ps)
_profile_overlay.add_child(panel)
var vb := VBoxContainer.new()
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vb.offset_left = 26
vb.offset_top = 22
vb.offset_right = -26
vb.offset_bottom = -22
vb.add_theme_constant_override("separation", 10)
panel.add_child(vb)
var title := Label.new()
title.text = "PROFILE"
title.add_theme_font_size_override("font_size", 20)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
vb.add_child(title)
vb.add_child(HSeparator.new())
_profile_hint_lbl = Label.new()
_profile_hint_lbl.text = ""
_profile_hint_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD
_profile_hint_lbl.add_theme_font_size_override("font_size", 12)
_profile_hint_lbl.add_theme_color_override("font_color", Color(0.85, 0.55, 0.3))
_profile_hint_lbl.visible = false
vb.add_child(_profile_hint_lbl)
var lbl := Label.new()
lbl.text = "CALLSIGN"
lbl.add_theme_font_size_override("font_size", 12)
lbl.add_theme_color_override("font_color", Color(0.65, 0.68, 0.75))
vb.add_child(lbl)
_profile_input = LineEdit.new()
_profile_input.placeholder_text = "Enter your callsign..."
_profile_input.custom_minimum_size = Vector2(0, 40)
_profile_input.add_theme_font_size_override("font_size", 15)
_profile_input.text_submitted.connect(func(_t): _on_profile_save())
vb.add_child(_profile_input)
var gap := Control.new()
gap.custom_minimum_size = Vector2(0, 4)
gap.size_flags_vertical = Control.SIZE_EXPAND_FILL
vb.add_child(gap)
var btn_row := HBoxContainer.new()
btn_row.add_theme_constant_override("separation", 10)
vb.add_child(btn_row)
var cancel_btn := Button.new()
cancel_btn.text = "CANCEL"
cancel_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
cancel_btn.custom_minimum_size = Vector2(0, 42)
cancel_btn.pressed.connect(_on_profile_cancel)
btn_row.add_child(cancel_btn)
var save_btn := Button.new()
save_btn.text = "SAVE"
save_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
save_btn.custom_minimum_size = Vector2(0, 42)
save_btn.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
var sn := StyleBoxFlat.new()
sn.bg_color = Color(1.0, 1.0, 1.0, 0.12)
sn.set_corner_radius_all(4)
var sh := StyleBoxFlat.new()
sh.bg_color = Color(1.0, 1.0, 1.0, 0.22)
sh.set_corner_radius_all(4)
save_btn.add_theme_stylebox_override("normal", sn)
save_btn.add_theme_stylebox_override("hover", sh)
save_btn.pressed.connect(_on_profile_save)
btn_row.add_child(save_btn)
func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: func _open_profile(pending_quick_play: bool) -> void:
var s := StyleBoxFlat.new() _pending_quick_play = pending_quick_play
s.bg_color = col _profile_input.text = GameConfig.player_name
s.set_corner_radius_all(radius) _profile_hint_lbl.visible = pending_quick_play
return s _profile_hint_lbl.text = "Enter a callsign to play."
_profile_overlay.visible = true
_profile_input.grab_focus()
func _on_name_changed(_text: String) -> void: func _on_profile_save() -> void:
_update_play_buttons() var new_name := _profile_input.text.strip_edges()
if new_name.is_empty():
return
GameConfig.player_name = new_name
_refresh_profile_badge()
_profile_overlay.visible = false
if _pending_quick_play:
_pending_quick_play = false
_start_quick_play()
func _update_play_buttons() -> void: func _on_profile_cancel() -> void:
var ok := not _name_input.text.strip_edges().is_empty() and not _connecting _pending_quick_play = false
_casual_btn.disabled = not ok _profile_overlay.visible = false
_ranked_btn.disabled = true
func _on_play_casual() -> void: func _on_quick_play() -> void:
_start_match("casual")
func _on_play_ranked() -> void:
_start_match("ranked")
func _start_match(mode: String) -> void:
if _connecting: if _connecting:
return return
GameConfig.player_name = _name_input.text.strip_edges() if GameConfig.player_name.strip_edges().is_empty():
_open_profile(true)
return
_start_quick_play()
func _start_quick_play() -> void:
_connecting = true _connecting = true
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86)) _status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
_status_lbl.text = "Searching for a match..." _status_lbl.text = "Searching for a match..."
_update_play_buttons() MatchmakingClient.start_matchmaking(GameConfig.player_name, "casual")
MatchmakingClient.start_matchmaking(GameConfig.player_name, mode)
func _on_server_select() -> void:
get_tree().change_scene_to_file("res://menu/server_select.tscn")
func _on_options() -> void:
_status_lbl.add_theme_color_override("font_color", Color(0.5, 0.54, 0.62))
_status_lbl.text = "Options — coming soon."
func _on_profile() -> void:
_open_profile(false)
func _on_quit() -> void:
get_tree().quit()
func _on_match_found(server_ip: String, server_port: int) -> void: func _on_match_found(server_ip: String, server_port: int) -> void:
@@ -382,12 +399,9 @@ func _on_connect_failed() -> void:
_connecting = false _connecting = false
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
_status_lbl.text = "Connection failed — is the game server reachable?" _status_lbl.text = "Connection failed — is the game server reachable?"
_update_play_buttons()
func _on_search_failed(reason: String) -> void: func _on_search_failed(reason: String) -> void:
_connecting = false _connecting = false
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
_status_lbl.text = "Matchmaking failed: %s" % reason _status_lbl.text = "Matchmaking failed: %s" % reason
_update_play_buttons()
_update_play_buttons()

Some files were not shown because too many files have changed in this diff Show More