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
1. **Server browser** — player name input, race toggles, server list (Trench Wars 0/32), JOIN button; stores name + race in `GameConfig` autoload
2. **Main menu** CASUAL / RANKED (disabled) buttons, callsign input, rank badge, QUIT; clears player state on load
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
1. ~~Server browser~~ — superseded by item 14's `menu/server_select.gd`
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 (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
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`)
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`)
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
- [ ] Asteroids and environment hazards
- [ ] 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`)
- [ ] 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
+4 -5
View File
@@ -1,11 +1,10 @@
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).
# CASUAL_TEAM_SIZE no longer gates when a casual match forms (bot fill means
# 1 real player is enough — see queue_manager.py); it only caps how many real
# players group into one casual match.
# Real-value target per overview/bots.md (casual min 7v7). CASUAL_TEAM_SIZE
# no longer gates when a casual match forms (bot fill means 1 real player is
# enough — see queue_manager.py); it only caps how many real players group
# into one casual match.
CASUAL_TEAM_SIZE=7
RANKED_TEAM_SIZE=5
# Registered on API startup so local matchmaking has somewhere to assign
# 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
- `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`.
- `GET /matchmaking/queue/status/{ticket_id}` — poll this; once matched it
returns the assigned server's `server_ip`/`server_port`/`team`.
- `POST /matchmaking/queue/leave?ticket_id=...` — cancel a queued ticket.
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
and assigns the first available `GameServer` for that mode. Casual and
ranked differ here: casual has bot fill (`spacewar/bots/bot_manager.gd`
pads both teams up to `GameConfig.bot_min_team_size` in-game), so 1 queued
player is enough to form a casual match — `CASUAL_TEAM_SIZE` just caps how
many real players can group into one match, it no longer gates formation.
Ranked has no bots, so it still waits for the full `RANKED_TEAM_SIZE × 2`
real players before forming.
once enough players are queued for casual, then splits them into two teams
and assigns the first available `GameServer`. Casual has bot fill
(`spacewar/bots/bot_manager.gd` pads both teams up to
`GameConfig.bot_min_team_size` in-game), so 1 queued player is enough to
form a match — `CASUAL_TEAM_SIZE` just caps how many real players can group
into one match, it no longer gates formation. Ranked mode has been removed
entirely (was never implemented past matchmaking-queue scaffolding) — the
`Mode` enum only has `casual` now.
There's no real game-server pool yet — one dev server is auto-seeded on API
startup from `DEV_SEED_SERVER_IP`/`DEV_SEED_SERVER_PORT` (defaults to
`127.0.0.1:7777`, i.e. a Godot server run on the host via
`godot4 --headless --path spacewar -- --server`). That IP is handed straight
Real servers self-register: `spacewar/autoload/network_manager.gd`'s
`host_server()` calls `POST /servers/register` once on boot and then every
`HEARTBEAT_INTERVAL` (8s) as a heartbeat, reporting live player count. The
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
*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
isn't a docker-internal address. Real servers should eventually call
`POST /servers/register` on boot and periodically as a heartbeat — that
endpoint exists but nothing calls it yet.
isn't a docker-internal address (same constraint `DEV_SEED_SERVER_IP` has).
`player_count`/`max_players` from `GET /servers` are just this DB-stored
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
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`
connects `NetworkManager` directly to the returned `server_ip`/`server_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
client's current state (no accounts yet). Real identity arrives with the
GodotSteam auth checklist item in `overview/tech.md`.
- **Ranked MMR-window widening / bot-fill timeouts**: current matching is a
simple threshold (enough players queued → form a match). Refine once
there's real queue volume to tune against.
- **Bot-fill timeouts**: current matching is a simple threshold (enough
players queued → form a match). Refine once there's real queue volume to
tune against.
+6 -1
View File
@@ -6,9 +6,14 @@ class Settings(BaseSettings):
database_url: str
casual_team_size: int = 7
ranked_team_size: int = 5
dev_seed_server_ip: str = "127.0.0.1"
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()
+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.queue_manager import queue_manager
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:
for mode in (Mode.casual, Mode.ranked):
for i, player_count in enumerate(_DEMO_PLAYER_COUNTS):
port = settings.dev_seed_server_port + i
existing = (
await db.execute(
select(GameServer).where(
GameServer.ip == settings.dev_seed_server_ip,
GameServer.port == settings.dev_seed_server_port,
GameServer.mode == mode,
GameServer.port == port,
GameServer.mode == Mode.casual,
)
)
).scalars().first()
@@ -27,22 +40,33 @@ async def _seed_dev_server() -> None:
db.add(
GameServer(
ip=settings.dev_seed_server_ip,
port=settings.dev_seed_server_port,
mode=mode,
port=port,
mode=Mode.casual,
status=ServerStatus.available,
player_count=player_count,
max_players=50,
)
)
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
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
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())
sweep_task = asyncio.create_task(_run_stale_sweep_loop())
yield
matching_task.cancel()
sweep_task.cancel()
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):
casual = "casual"
ranked = "ranked"
class ServerStatus(str, enum.Enum):
@@ -43,6 +42,13 @@ class GameServer(Base):
mode: Mapped[Mode] = mapped_column(SAEnum(Mode))
status: Mapped[ServerStatus] = mapped_column(SAEnum(ServerStatus), default=ServerStatus.available)
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):
+5 -20
View File
@@ -55,13 +55,6 @@ class QueueManager:
def _waiting(self, mode: Mode) -> list[Ticket]:
tickets = [t for t in self._tickets.values() if t.mode == mode and t.status == "queued"]
if mode == Mode.ranked:
# 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
@@ -98,20 +91,12 @@ class QueueManager:
while True:
await asyncio.sleep(2)
async with async_session() as db:
# Casual has bot fill (spacewar/bots/bot_manager.gd) — bots pad
# both teams up to GameConfig.bot_min_team_size, so a single
# queued player is enough to form a match. Still group in
# anyone else who queues in the same tick, up to the real
# casual_team_size*2 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.
# Bot fill (spacewar/bots/bot_manager.gd) pads both teams up
# to GameConfig.bot_min_team_size, so a single queued player
# is enough to form a match. Still group in anyone else who
# queues in the same tick, up to the real casual_team_size*2
# target, rather than capping at 1v1.
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()
+2 -3
View File
@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.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)
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
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)
+48 -7
View File
@@ -1,9 +1,10 @@
from datetime import datetime
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models import GameServer, ServerStatus
from app.schemas import ServerRegisterRequest
@@ -11,24 +12,62 @@ from app.schemas import ServerRegisterRequest
router = APIRouter(prefix="/servers", tags=["servers"])
# Real game servers should call this on boot and periodically (heartbeat) once
# NetworkManager grows that integration. Until then, a dev server is seeded
# on API startup (see main.py) from DEV_SEED_SERVER_IP/PORT so matchmaking is
# testable end-to-end without it.
def _status_for(player_count: int, max_players: int) -> ServerStatus:
return ServerStatus.full if player_count >= max_players else ServerStatus.available
# 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")
async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict:
existing = (
await db.execute(select(GameServer).where(GameServer.ip == req.ip, GameServer.port == req.port, GameServer.mode == req.mode))
).scalars().first()
status = _status_for(req.player_count, req.max_players)
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()
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()
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("")
async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
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,
"mode": s.mode,
"status": s.status,
"player_count": s.player_count,
"max_players": s.max_players,
"last_heartbeat": s.last_heartbeat,
}
for s in servers
+2 -1
View File
@@ -8,7 +8,6 @@ from app.models import Mode
class QueueJoinRequest(BaseModel):
callsign: str
mode: Mode
mmr: int | None = None # ignored for casual; defaults to the player's stored mmr for ranked
class QueueJoinResponse(BaseModel):
@@ -28,6 +27,8 @@ class ServerRegisterRequest(BaseModel):
ip: str
port: int
mode: Mode
player_count: int = 0
max_players: int = 50
class PlayerStatsResponse(BaseModel):
+107 -60
View File
@@ -1,84 +1,131 @@
# 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
**Weapon type:** Projectile / Ballistic
**Visual:** Angular gray-blue military ships, recognizable fighter jet silhouettes, clean livery
**Feel:** Standard military. Balanced and familiar. Easiest to learn.
### Apex Dynamics — The Corporate Syndicate
**Weapon type:** High-tech energy (coherent light / plasma)
**Visual:** Sleek, pearlescent-white hulls with razor-sharp geometric lines and
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 |
|---|------|------|-------------|
| 1 | **Warframe** | Interceptor | Very fast, rapid weak laser bursts. Built to close distance instantly and chip away at anything in range. |
| 2 | **Striker** | Gunship | Super fast fire rate, very low damage lasers. Trades hitting power for volume of fire. |
| 3 | **Deployer** | Bomber | Fast lasers plus an EMP mine dropped in your wake. Strong for area denial and covering a retreat. |
| 4 | **Link Node** | Support | Lasers plus a small repair pack dropped for allies to pick up. Keeps a team topped off in prolonged fights. |
| 5 | **Bastion** | Heavy | Tankier and slower, but its laser hits harder than anything else in the fleet. Anchors a push. |
### Inner Sphere Navy (ISN) — The Military
**Weapon type:** Standard-issue ballistics / ordnance
**Visual:** Gunmetal-gray, wedge-shaped hulls with hard militaristic angles, dense
protective plating, and prominent forward-facing gun turrets.
**Feel:** Disciplined, heavy, frontal. Wins by tanking a choke point and forcing
the fight.
**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
**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 Classes
| # | Ship | Role | Description |
|---|------|------|-------------|
| 1 | **Sentinel** | Interceptor | A light scout mech with twin rapid cannons. Fast and maneuverable for a Mechanos hull. |
| 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. |
| 4 | **Aegis** | Support | Projects a short-range shield and deploys a repair drone for nearby allies. |
| 5 | **Colossus** | Heavy | A walking fortress bristling with cannons. Slow and telegraphed, but absorbs enormous damage. |
Every faction fields the same 3-ship structure, re-skinned to its own theme:
| Class | Role |
|-------|------|
| **Fighter** | Single, high-precision, high-damage shot |
| **Gunner** | Rapid-fire spray/burst, more shots for less damage each |
| **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
**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.
## Faction Abilities (Universal System)
| # | Ship | Role | Description |
|---|------|------|-------------|
| 1 | **Stinger** | Interceptor | Fast, acid shots with light damage-over-time. Swarms a target from range. |
| 2 | **Spitter** | Gunship | Faster-firing acid with a stronger damage-over-time effect. Sustained corrosive pressure. |
| 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. |
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
off a ship's faction regardless of which of the 3 hulls it's flying. Same energy
cost and cooldown across factions; duration/impact scales with the hull:
---
| 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>
<summary>Click to expand all 10 races</summary>
### Inner Sphere Navy — Overcharged Aegis
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
**Weapon type:** Laser / Crystal Energy | **Feel:** Alien elegance, glass cannon
### Outer Rim Collective — Proximity Scrap-Mine
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
**Weapon type:** Dark Energy / Gravity / Phase | **Feel:** Terrifying and unpredictable, gravity wells and phase attacks, hard to pin down
### The Rock-Paper-Scissors Loop
### Race 6 — Solari Imperium
**Weapon type:** Plasma / Solar Fire | **Feel:** Ancient empire, slow and devastating
- **ORC mines counter Apex cloak** — a cloaked ship blundering through a mined
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
**Weapon type:** Swarm Missiles / Acid | **Feel:** Insectoid swarm, volume of fire
### 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>
No ability wins a fight by itself — it depends on the map and the moment it's
used, same as a smoke/flash/molotov in CS:GO.
+17 -6
View File
@@ -9,8 +9,8 @@ spacewar/ ← repo root
├── autoload/
│ └── game_config.gd ← autoload singleton (tuning values, player state, signals)
├── menu/
│ ├── main_menu.tscn/gd ← entry point / main scene
│ ├── server_browser.tscn/gd ← server browser UI (built, not in active flow yet)
│ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav
│ ├── 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)
│ └── team_select.tscn/gd ← race + ship selection overlay (shown on world load)
├── ships/
@@ -19,6 +19,8 @@ spacewar/ ← repo root
│ └── bullet.tscn/gd ← projectile
├── chat/
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
├── hud/
│ └── player_list.tscn/gd ← top-left player roster (white=team, yellow=enemy, "(b)"=bot), added to world.tscn
├── bots/
│ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation)
│ ├── 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()
```
### `menu/server_select.tscn` — server list
```
ServerSelect (Control) ← server_select.gd builds all UI in _ready(), fetches GET /servers
```
### `world/world.tscn` — game world
```
World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready()
@@ -56,7 +63,8 @@ World (Node2D) ← world.gd instances MAP_SCENE into MapContainer o
│ └── HealthLabel
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
├── 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
@@ -95,14 +103,17 @@ Player (CharacterBody2D) ← ship_movement.gd
```
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 ship
│ └── 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
+12 -3
View File
@@ -1,15 +1,20 @@
# Task Log
Task 1
I want to build an energy system for each ship. Below the health put an energy of 100/100 for the fighter/ 150/150 for middle ship and big ship 250 energy. Each bullet takes 75 to shoot. Have a blue energy bar that is mirrord top middle appear at the top . Have it take 2.5 seconds to refill 100
## Completed
| # | Task | Notes |
|---|------|-------|
| 0 | Format all design docs as Markdown | Done |
| 24 | Energy system (Fighter 100 / Gunner 150 / Tank 250, 75 per shot, blue center-out mirrored bar top-middle, +100 per 2.5s regen) | `GameConfig.ship_max_energy_by_role`/`bullet_energy_cost`/`ship_energy_regen_rate`; server-authoritative in `ships/ship_movement.gd` (broadcast via `_receive_state`, same pattern as health); role threaded through `PlayerRegistry` loadout + bots; text readout `HUD/EnergyLabel` below HP, visual bar `hud/energy_bar.gd`/`.tscn` |
| 1 | Design 10 races, pick 3 | Races 1, 4, 5 chosen — see `racesclasses.md` |
| 2 | Ranked + casual mode ideas | See `multiplayer.md` |
| 3 | Multiplayer engineering doc | See `tech.md` |
| 4 | Server browser screen | Player name, race buttons, server list, JOIN — stores in `GameConfig` |
| 5 | Main menu | CASUAL / RANKED (disabled), callsign input, rank badge, quit button |
| 4 | ~~Server browser screen~~ | Superseded by #21's `menu/server_select.gd` |
| 5 | ~~Main menu (CASUAL/RANKED tiles)~~ | Superseded by #21's HL2-style nav menu |
| 6 | In-game pause menu | ESC / controller Start; game runs behind it; working: RESUME, QUIT TO MENU, QUIT TO DESKTOP |
| 7 | Team & ship selection | On world load; 2 random races offered; ship grid with real sprites; player spawns after |
| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done |
@@ -17,6 +22,9 @@
| 13 | Multiplayer networking — ENet authoritative server | Done |
| 18 | In-game chat (T=all, Y=team, last 10 messages, bottom-left) | `chat/chat_box.gd` + `autoload/chat_manager.gd` — see `chat.md` |
| 11 | Bot fill for casual (min 7v7) | `bots/bot_manager.gd` + `bots/bot_ai.gd` — see `bots.md` |
| 17 | Select Team in pause menu (live team swap) | Reopens `TeamSelect` mid-match via `PlayerRegistry.submit_local_loadout`'s existing respawn path; bot fill rebalances the vacated race too (`PlayerRegistry.race_changed`) |
| 20 | Live headcount/roster + TEAM FULL lock on race select | Shared by the initial pick and #17's reopen; humans only (bots excluded), blocks joining a race >2 humans ahead of the other, exempts your own current race |
| 21 | HL2-style main menu + real server select | `main_menu.gd` rebuilt as plain white vertical nav (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT); RANKED removed entirely; new `menu/server_select.gd` lists real servers via `MatchmakingClient.list_servers()` (`GET /servers`) and connects directly, retiring `menu/server_browser.gd` |
## Up Next
@@ -27,4 +35,5 @@
| 14 | Galaxy war meta + sector control | Low (post-networking) |
| 15 | Ranked matchmaking + MMR | Low (post-networking) |
| 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] Ship class registration per peer
- [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
- [ ] 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"
type="CompressedTexture2D"
uid="uid://ded6ek6l7n8gb"
path="res://.godot/imported/explosion1.png-dbaabd892c7c9e8bfd179ebbad20e6c6.ctex"
uid="uid://b8tlmag3nkye1"
path="res://.godot/imported/lancet.png-205173e7177ad4865682a3d270a3a688.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/explosion1.png"
dest_files=["res://.godot/imported/explosion1.png-dbaabd892c7c9e8bfd179ebbad20e6c6.ctex"]
source_file="res://assets/images/ships/apex/lancet.png"
dest_files=["res://.godot/imported/lancet.png-205173e7177ad4865682a3d270a3a688.ctex"]
[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
# 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:
var text := raw_text.strip_edges().left(MAX_MESSAGE_LENGTH)
+60 -3
View File
@@ -3,7 +3,12 @@ extends Node
# Ship movement
var ship_thrust: float = 250.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
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 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
var ship_bounce_restitution: float = 0.45
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.
# Race/ship/speed are per-peer now — see PlayerRegistry.
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.
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
# input so typing (e.g. the letter "w") doesn't also move the ship.
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
var bot_min_team_size: int = 7 # mirrors matchmaking-api's CASUAL_TEAM_SIZE default
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()
# 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:
if not _searching:
return
+92 -1
View File
@@ -8,9 +8,39 @@ signal peer_left(peer_id: int)
const DEFAULT_PORT := 7777
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 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:
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
# 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:
var args := OS.get_cmdline_user_args()
for arg in args:
if arg.begins_with("--connect="):
target_ip = arg.substr("--connect=".length())
elif arg.begins_with("--server-ip="):
_register_ip = arg.substr("--server-ip=".length())
if args.has("--server"):
host_server(DEFAULT_PORT)
await get_tree().process_frame
@@ -43,6 +77,9 @@ func host_server(port: int = DEFAULT_PORT) -> void:
return
multiplayer.multiplayer_peer = peer
is_server = true
_hosted_port = port
_start_query_responder(port + QUERY_PORT_OFFSET)
_start_heartbeat()
print("[NetworkManager] Hosting on port %d" % port)
@@ -51,6 +88,60 @@ func disconnect_from_game() -> void:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = null
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:
+30 -14
View File
@@ -6,8 +6,18 @@ extends Node
signal loadout_updated(peer_id: int)
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:
@@ -22,18 +32,18 @@ func get_info(peer_id: int) -> Dictionary:
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:
submit_loadout.rpc(player_name, race, ship_path, ship_scale, ship_speed_factor)
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, role)
# 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
# learn), broadcasting to every connected peer so bots render identically to
# 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():
_receive_loadout.rpc_id(pid, 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)
_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, role)
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
# else's already-known loadout.
@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()
if sender_id == 0:
sender_id = multiplayer.get_unique_id()
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
for peer_id in multiplayer.get_peers():
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:
if peer_id != sender_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")
func _receive_loadout(peer_id: int, player_name: String, race: int, ship_path: String, ship_scale: float, ship_speed_factor: float) -> void:
_store(peer_id, player_name, race, ship_path, ship_scale, ship_speed_factor)
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, 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] = {
"name": player_name,
"race": race,
"ship_path": ship_path,
"ship_scale": ship_scale,
"ship_speed_factor": ship_speed_factor,
"role": role,
}
print("[PlayerRegistry] peer %d loadout: %s" % [peer_id, players[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:
+82 -17
View File
@@ -1,41 +1,104 @@
class_name BotAI
extends RefCounted
# Simplified "seek and shoot" bot AI (see overview/bots.md): thrust toward
# the nearest living enemy, turn to face it, fire once roughly aimed and in
# range. Kept intentionally simple — casual bots are filler, not a challenge
# — and isolated from ship_movement.gd's networking code entirely so future
# difficulty/behavior tweaks only ever touch this file.
# "Seek and shoot" bot AI (see overview/bots.md), personality-driven by
# BotPersonality (bots/bot_personality.gd, rolled once per bot-slot by
# BotManager). Five independent traits reshape the same base behavior instead
# of branching into separate hand-written behavior trees per archetype:
# 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 _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
_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()
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
# _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).
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 aim_tolerance: float = deg_to_rad(GameConfig.bot_aim_tolerance_deg)
return {
"up": distance > GameConfig.bot_stop_distance,
"up": fleeing or true_distance > _stop_distance,
"down": false,
"left": diff < -aim_tolerance,
"right": diff > aim_tolerance,
"shoot": absf(diff) <= aim_tolerance and distance <= GameConfig.bot_engage_range,
"left": diff < -_aim_tolerance_rad,
"right": diff > _aim_tolerance_rad,
"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:
continue
var dist: float = _ship.global_position.distance_squared_to(candidate.global_position)
if dist > _awareness_range_sq:
continue
if dist < best_dist:
best_dist = dist
best = candidate
+11 -1
View File
@@ -19,6 +19,7 @@ var _next_bot_id: int = -1
func _ready() -> void:
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
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
@@ -45,6 +46,15 @@ func _on_player_removed(peer_id: int, info: Dictionary) -> void:
_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:
if race_id <= 0:
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)
PlayerRegistry.register_bot(
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
+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
+5 -2
View File
@@ -139,10 +139,13 @@ func _on_text_submitted(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 line: String
if sender_name == ChatManager.ADMIN_NAME:
line = "[color=#e0c341]* %s[/color]" % safe_text
else:
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:
+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
+295 -281
View File
@@ -1,10 +1,19 @@
extends Control
var _name_input: LineEdit
var _casual_btn: Button
var _ranked_btn: Button
# HL2-style menu: no big mode tiles, just a plain vertical text nav over a
# space backdrop. Quick Play always queues casual — ranked is gone until the
# matchmaking/MMR work in overview/task.md lands.
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 _pending_quick_play: bool = false # profile overlay was forced open by Quick Play
const RANK_DATA := [
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
@@ -15,6 +24,14 @@ const RANK_DATA := [
{"label": "LEGEND", "color": Color(1.0, 0.28, 0.10)},
]
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:
@@ -27,11 +44,13 @@ func _ready() -> void:
func _build_ui() -> void:
_add_bg()
_add_title()
_add_play_section()
_add_rank_badge()
_add_quit_btn()
_add_logo()
_add_nav_menu()
_add_profile_badge()
_add_status_label()
_add_version_label()
_add_profile_overlay()
_refresh_profile_badge()
func _add_bg() -> void:
@@ -43,330 +62,328 @@ func _add_bg() -> void:
add_child(bg)
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)
add_child(ov)
func _add_title() -> void:
func _add_logo() -> void:
var title := Label.new()
title.text = "S P A C E W A R"
title.set_anchors_preset(Control.PRESET_TOP_WIDE)
title.offset_top = 72.0
title.offset_bottom = 148.0
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 56)
title.add_theme_color_override("font_color", Color(0.45, 0.88, 1.0))
title.anchor_left = 0.0
title.anchor_top = 0.0
title.anchor_right = 0.0
title.anchor_bottom = 0.0
title.offset_left = 60.0
title.offset_top = 56.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)
var sub := Label.new()
sub.text = "FIGHT FOR THE GALAXY"
sub.set_anchors_preset(Control.PRESET_TOP_WIDE)
sub.offset_top = 152.0
sub.offset_bottom = 178.0
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
sub.anchor_left = 0.0
sub.anchor_top = 0.0
sub.anchor_right = 0.0
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_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)
func _add_play_section() -> void:
const BTN_W := 480.0
const BTN_H := 230.0
const GAP := 40.0
const TOP_Y := 220.0
# The whole row (both buttons + gap) is centered on the actual screen
# width via anchor 0.5, not a hardcoded 1280px design width — offsets
# below are all relative to that center point, not the left edge.
const HALF_GAP := GAP / 2.0
const ROW_HALF_W := (BTN_W * 2.0 + GAP) / 2.0
func _add_nav_menu() -> void:
for i in NAV_ITEMS.size():
var label_text: String = NAV_ITEMS[i]
var stub := label_text == "OPTIONS"
var btn := _make_nav_btn(label_text, stub)
btn.anchor_left = 0.0
btn.anchor_top = 0.0
btn.anchor_right = 0.0
btn.anchor_bottom = 0.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(
-ROW_HALF_W, TOP_Y, -HALF_GAP, TOP_Y + BTN_H,
"CASUAL",
"25 vs 25",
"Drop in, drop out anytime.\nNo rank at stake.",
Color(0.06, 0.22, 0.08),
Color(0.30, 0.90, 0.40),
)
_casual_btn.pressed.connect(_on_play_casual)
add_child(_casual_btn)
_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()
match label_text:
"QUICK PLAY":
btn.pressed.connect(_on_quick_play)
"SERVER SELECT":
btn.pressed.connect(_on_server_select)
"OPTIONS":
btn.pressed.connect(_on_options)
"PROFILE":
btn.pressed.connect(_on_profile)
"QUIT":
btn.pressed.connect(_on_quit)
func _make_mode_btn(
left: float, top: float, right: float, bottom: float,
mode: String, fmt: String, desc: String,
bg: Color, accent: Color) -> Button:
func _make_nav_btn(label_text: String, stub: bool) -> Button:
var btn := Button.new()
btn.text = ""
_place(btn, 0.5, left, 0.5, right, top, bottom)
btn.text = label_text
btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
btn.add_theme_font_size_override("font_size", 22)
var sn := _flat(bg.lightened(0.05), 12)
sn.set_border_width_all(2)
sn.border_color = accent.darkened(0.45)
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)
var sn := StyleBoxEmpty.new()
sn.content_margin_left = 20.0
btn.add_theme_stylebox_override("normal", sn)
if stub:
btn.add_theme_color_override("font_color", Color(0.4, 0.42, 0.48, 0.8))
btn.add_theme_stylebox_override("hover", sn)
btn.add_theme_stylebox_override("pressed", sn)
else:
btn.add_theme_color_override("font_color", Color(0.82, 0.84, 0.9))
btn.add_theme_color_override("font_hover_color", Color(1.0, 1.0, 1.0))
btn.add_theme_color_override("font_pressed_color", Color(1.0, 1.0, 1.0))
var sh := StyleBoxFlat.new()
sh.bg_color = Color(1.0, 1.0, 1.0, 0.06)
sh.content_margin_left = 20.0
sh.border_width_left = 3
sh.border_color = Color(1.0, 1.0, 1.0, 0.9)
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()
vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vb.offset_left = 24.0
vb.offset_top = 20.0
vb.offset_right = -24.0
vb.offset_bottom = -20.0
vb.alignment = BoxContainer.ALIGNMENT_CENTER
vb.add_theme_constant_override("separation", 12)
vb.mouse_filter = Control.MOUSE_FILTER_IGNORE
btn.add_child(vb)
var mode_lbl := Label.new()
mode_lbl.text = mode
mode_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
mode_lbl.add_theme_font_size_override("font_size", 40)
mode_lbl.add_theme_color_override("font_color", accent)
mode_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
vb.add_child(mode_lbl)
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
func _add_rank_badge() -> void:
const W := 210.0
const H := 60.0
const MARGIN := 16.0
func _add_profile_badge() -> void:
_name_lbl = Label.new()
_name_lbl.anchor_left = 1.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()
panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
panel.offset_left = -(W + MARGIN)
panel.offset_top = MARGIN
panel.offset_right = -MARGIN
panel.offset_bottom = MARGIN + H
var ps := _flat(Color(0.04, 0.07, 0.16, 0.92), 8)
ps.set_border_width_all(1)
ps.border_color = Color(0.18, 0.36, 0.62, 0.85)
panel.add_theme_stylebox_override("panel", ps)
add_child(panel)
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)
_rank_lbl = Label.new()
_rank_lbl.anchor_left = 1.0
_rank_lbl.anchor_top = 0.0
_rank_lbl.anchor_right = 1.0
_rank_lbl.anchor_bottom = 0.0
_rank_lbl.offset_left = -340.0
_rank_lbl.offset_top = 50.0
_rank_lbl.offset_right = -24.0
_rank_lbl.offset_bottom = 68.0
_rank_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_rank_lbl.add_theme_font_size_override("font_size", 12)
add_child(_rank_lbl)
func _add_quit_btn() -> void:
const W := 110.0
const H := 38.0
const MARGIN := 20.0
func _refresh_profile_badge() -> void:
if GameConfig.player_name.strip_edges().is_empty():
_name_lbl.text = "GUEST"
_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)
sn.set_border_width_all(1)
sn.border_color = Color(0.18, 0.22, 0.33, 0.70)
var sh := _flat(Color(0.10, 0.12, 0.20, 0.90), 6)
sh.set_border_width_all(1)
sh.border_color = Color(0.32, 0.38, 0.52)
btn.add_theme_stylebox_override("normal", sn)
btn.add_theme_stylebox_override("hover", sh)
btn.pressed.connect(func(): get_tree().quit())
add_child(btn)
func _add_status_label() -> void:
_status_lbl = Label.new()
_status_lbl.text = ""
_status_lbl.anchor_left = 0.0
_status_lbl.anchor_top = 1.0
_status_lbl.anchor_right = 0.0
_status_lbl.anchor_bottom = 1.0
_status_lbl.offset_left = NAV_LEFT
_status_lbl.offset_right = NAV_LEFT + 460.0
_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:
var lbl := Label.new()
lbl.text = "v0.1-dev"
lbl.set_anchor_and_offset(SIDE_LEFT, 0.0, 14.0)
lbl.set_anchor_and_offset(SIDE_TOP, 1.0, -30.0)
lbl.set_anchor_and_offset(SIDE_RIGHT, 0.0, 100.0)
lbl.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -10.0)
lbl.anchor_left = 0.0
lbl.anchor_top = 1.0
lbl.anchor_right = 0.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_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)
# Sets all 4 anchors then all 4 offsets as plain property assignments —
# NOT via sequential set_anchor_and_offset() calls, whose default
# push_opposite_anchor=true drags the opposite side's anchor/offset along
# whenever the two calls momentarily disagree (e.g. left set to 0.5 while
# right is still its 0.0 default), corrupting layout for any non-0.0 anchor
# (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
# instead of centered until this fix.
func _place(node: Control, anchor_left: float, left: float, anchor_right: float, right: float, top: float, bottom: float) -> void:
node.anchor_left = anchor_left
node.anchor_right = anchor_right
node.anchor_top = 0.0
node.anchor_bottom = 0.0
node.offset_left = left
node.offset_right = right
node.offset_top = top
node.offset_bottom = bottom
func _add_profile_overlay() -> void:
_profile_overlay = Control.new()
_profile_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_profile_overlay.visible = false
add_child(_profile_overlay)
var dim := ColorRect.new()
dim.color = Color(0.0, 0.0, 0.0, 0.6)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
_profile_overlay.add_child(dim)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.offset_left = -200
panel.offset_top = -120
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:
var s := StyleBoxFlat.new()
s.bg_color = col
s.set_corner_radius_all(radius)
return s
func _open_profile(pending_quick_play: bool) -> void:
_pending_quick_play = pending_quick_play
_profile_input.text = GameConfig.player_name
_profile_hint_lbl.visible = pending_quick_play
_profile_hint_lbl.text = "Enter a callsign to play."
_profile_overlay.visible = true
_profile_input.grab_focus()
func _on_name_changed(_text: String) -> void:
_update_play_buttons()
func _on_profile_save() -> void:
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:
var ok := not _name_input.text.strip_edges().is_empty() and not _connecting
_casual_btn.disabled = not ok
_ranked_btn.disabled = true
func _on_profile_cancel() -> void:
_pending_quick_play = false
_profile_overlay.visible = false
func _on_play_casual() -> void:
_start_match("casual")
func _on_play_ranked() -> void:
_start_match("ranked")
func _start_match(mode: String) -> void:
func _on_quick_play() -> void:
if _connecting:
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
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
_status_lbl.text = "Searching for a match..."
_update_play_buttons()
MatchmakingClient.start_matchmaking(GameConfig.player_name, mode)
MatchmakingClient.start_matchmaking(GameConfig.player_name, "casual")
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:
@@ -382,12 +399,9 @@ func _on_connect_failed() -> void:
_connecting = false
_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?"
_update_play_buttons()
func _on_search_failed(reason: String) -> void:
_connecting = false
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
_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