diff --git a/CLAUDE.md b/CLAUDE.md index 9135a5b..6e05d41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//`); 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 diff --git a/matchmaking-api/.env.example b/matchmaking-api/.env.example index 2d6a06b..7c9887d 100644 --- a/matchmaking-api/.env.example +++ b/matchmaking-api/.env.example @@ -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. diff --git a/matchmaking-api/README.md b/matchmaking-api/README.md index 03cecd0..e22e206 100644 --- a/matchmaking-api/README.md +++ b/matchmaking-api/README.md @@ -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. diff --git a/matchmaking-api/app/config.py b/matchmaking-api/app/config.py index f5a9c65..ff3dde8 100644 --- a/matchmaking-api/app/config.py +++ b/matchmaking-api/app/config.py @@ -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() diff --git a/matchmaking-api/app/main.py b/matchmaking-api/app/main.py index 61efad7..d7b9dad 100644 --- a/matchmaking-api/app/main.py +++ b/matchmaking-api/app/main.py @@ -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) diff --git a/matchmaking-api/app/models.py b/matchmaking-api/app/models.py index 715cb5c..d76e256 100644 --- a/matchmaking-api/app/models.py +++ b/matchmaking-api/app/models.py @@ -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): diff --git a/matchmaking-api/app/queue_manager.py b/matchmaking-api/app/queue_manager.py index 71550c9..eff3cb6 100644 --- a/matchmaking-api/app/queue_manager.py +++ b/matchmaking-api/app/queue_manager.py @@ -55,14 +55,7 @@ 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) + tickets.sort(key=lambda t: t.queued_at) return tickets async def _try_form_match(self, mode: Mode, required: int, cap: int, db: AsyncSession) -> None: @@ -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() diff --git a/matchmaking-api/app/routers/matchmaking.py b/matchmaking-api/app/routers/matchmaking.py index 3bb48c7..469cba8 100644 --- a/matchmaking-api/app/routers/matchmaking.py +++ b/matchmaking-api/app/routers/matchmaking.py @@ -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) diff --git a/matchmaking-api/app/routers/servers.py b/matchmaking-api/app/routers/servers.py index 4d6001d..71dd7d5 100644 --- a/matchmaking-api/app/routers/servers.py +++ b/matchmaking-api/app/routers/servers.py @@ -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 diff --git a/matchmaking-api/app/schemas.py b/matchmaking-api/app/schemas.py index 3c3aa92..74172d8 100644 --- a/matchmaking-api/app/schemas.py +++ b/matchmaking-api/app/schemas.py @@ -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): diff --git a/overview/racesclasses.md b/overview/racesclasses.md index 3a18fbe..b93e88a 100644 --- a/overview/racesclasses.md +++ b/overview/racesclasses.md @@ -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//`). 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//` 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. -
-Click to expand all 10 races +### 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 - -
+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. diff --git a/overview/structure.md b/overview/structure.md index 3131a31..3bec2f3 100644 --- a/overview/structure.md +++ b/overview/structure.md @@ -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 diff --git a/overview/task.md b/overview/task.md index da18552..3407f80 100644 --- a/overview/task.md +++ b/overview/task.md @@ -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 | diff --git a/overview/tech.md b/overview/tech.md index c370f29..a791613 100644 --- a/overview/tech.md +++ b/overview/tech.md @@ -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) diff --git a/spacewar/assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg b/spacewar/assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg new file mode 100644 index 0000000..9725014 Binary files /dev/null and b/spacewar/assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg differ diff --git a/spacewar/assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg.import b/spacewar/assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg.import new file mode 100644 index 0000000..6923345 --- /dev/null +++ b/spacewar/assets/images/6a228cb9-6d56-49ca-b1c8-118fd7b1eff3.jpeg.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_01.png b/spacewar/assets/images/effects/explosion/explosion_01.png new file mode 100644 index 0000000..f7ed840 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_01.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_01.png.import b/spacewar/assets/images/effects/explosion/explosion_01.png.import new file mode 100644 index 0000000..2c26da2 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_01.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_02.png b/spacewar/assets/images/effects/explosion/explosion_02.png new file mode 100644 index 0000000..08103eb Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_02.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_02.png.import b/spacewar/assets/images/effects/explosion/explosion_02.png.import new file mode 100644 index 0000000..4ef3f8b --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_02.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_03.png b/spacewar/assets/images/effects/explosion/explosion_03.png new file mode 100644 index 0000000..ea8484e Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_03.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_03.png.import b/spacewar/assets/images/effects/explosion/explosion_03.png.import new file mode 100644 index 0000000..5b18d31 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_03.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_04.png b/spacewar/assets/images/effects/explosion/explosion_04.png new file mode 100644 index 0000000..be13993 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_04.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_04.png.import b/spacewar/assets/images/effects/explosion/explosion_04.png.import new file mode 100644 index 0000000..e27809b --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_04.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_05.png b/spacewar/assets/images/effects/explosion/explosion_05.png new file mode 100644 index 0000000..db1c207 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_05.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_05.png.import b/spacewar/assets/images/effects/explosion/explosion_05.png.import new file mode 100644 index 0000000..9c7312a --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_05.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_06.png b/spacewar/assets/images/effects/explosion/explosion_06.png new file mode 100644 index 0000000..02f3c23 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_06.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_06.png.import b/spacewar/assets/images/effects/explosion/explosion_06.png.import new file mode 100644 index 0000000..b5cbd18 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_06.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_07.png b/spacewar/assets/images/effects/explosion/explosion_07.png new file mode 100644 index 0000000..a8d5f49 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_07.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_07.png.import b/spacewar/assets/images/effects/explosion/explosion_07.png.import new file mode 100644 index 0000000..b6a37e0 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_07.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_08.png b/spacewar/assets/images/effects/explosion/explosion_08.png new file mode 100644 index 0000000..e9b8441 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_08.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_08.png.import b/spacewar/assets/images/effects/explosion/explosion_08.png.import new file mode 100644 index 0000000..e7a05a8 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_08.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_09.png b/spacewar/assets/images/effects/explosion/explosion_09.png new file mode 100644 index 0000000..44cc6ea Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_09.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_09.png.import b/spacewar/assets/images/effects/explosion/explosion_09.png.import new file mode 100644 index 0000000..d7dece4 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_09.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_10.png b/spacewar/assets/images/effects/explosion/explosion_10.png new file mode 100644 index 0000000..33dad11 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_10.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_10.png.import b/spacewar/assets/images/effects/explosion/explosion_10.png.import new file mode 100644 index 0000000..6c22c32 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_10.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_11.png b/spacewar/assets/images/effects/explosion/explosion_11.png new file mode 100644 index 0000000..a725f1b Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_11.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_11.png.import b/spacewar/assets/images/effects/explosion/explosion_11.png.import new file mode 100644 index 0000000..30b7a2c --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_11.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/explosion_12.png b/spacewar/assets/images/effects/explosion/explosion_12.png new file mode 100644 index 0000000..dad13c7 Binary files /dev/null and b/spacewar/assets/images/effects/explosion/explosion_12.png differ diff --git a/spacewar/assets/images/effects/explosion/explosion_12.png.import b/spacewar/assets/images/effects/explosion/explosion_12.png.import new file mode 100644 index 0000000..b5322a5 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/explosion_12.png.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion/source/explosion_sheet.jpeg b/spacewar/assets/images/effects/explosion/source/explosion_sheet.jpeg new file mode 100644 index 0000000..8cde4aa Binary files /dev/null and b/spacewar/assets/images/effects/explosion/source/explosion_sheet.jpeg differ diff --git a/spacewar/assets/images/effects/explosion/source/explosion_sheet.jpeg.import b/spacewar/assets/images/effects/explosion/source/explosion_sheet.jpeg.import new file mode 100644 index 0000000..1895794 --- /dev/null +++ b/spacewar/assets/images/effects/explosion/source/explosion_sheet.jpeg.import @@ -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 diff --git a/spacewar/assets/images/effects/explosion1.png b/spacewar/assets/images/effects/explosion1.png deleted file mode 100644 index 662153c..0000000 Binary files a/spacewar/assets/images/effects/explosion1.png and /dev/null differ diff --git a/spacewar/assets/images/ships/apex/lancet.png b/spacewar/assets/images/ships/apex/lancet.png new file mode 100644 index 0000000..cc808b8 Binary files /dev/null and b/spacewar/assets/images/ships/apex/lancet.png differ diff --git a/spacewar/assets/images/effects/explosion1.png.import b/spacewar/assets/images/ships/apex/lancet.png.import similarity index 73% rename from spacewar/assets/images/effects/explosion1.png.import rename to spacewar/assets/images/ships/apex/lancet.png.import index 45b1dec..c92ca79 100644 --- a/spacewar/assets/images/effects/explosion1.png.import +++ b/spacewar/assets/images/ships/apex/lancet.png.import @@ -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] diff --git a/spacewar/assets/images/ships/apex/pulsar.png b/spacewar/assets/images/ships/apex/pulsar.png new file mode 100644 index 0000000..0f6b0b1 Binary files /dev/null and b/spacewar/assets/images/ships/apex/pulsar.png differ diff --git a/spacewar/assets/images/ships/apex/pulsar.png.import b/spacewar/assets/images/ships/apex/pulsar.png.import new file mode 100644 index 0000000..dd53e66 --- /dev/null +++ b/spacewar/assets/images/ships/apex/pulsar.png.import @@ -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 diff --git a/spacewar/assets/images/ships/apex/source/apex_ships.jpeg b/spacewar/assets/images/ships/apex/source/apex_ships.jpeg new file mode 100644 index 0000000..58cb460 Binary files /dev/null and b/spacewar/assets/images/ships/apex/source/apex_ships.jpeg differ diff --git a/spacewar/assets/images/ships/apex/source/apex_ships.jpeg.import b/spacewar/assets/images/ships/apex/source/apex_ships.jpeg.import new file mode 100644 index 0000000..262ce62 --- /dev/null +++ b/spacewar/assets/images/ships/apex/source/apex_ships.jpeg.import @@ -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 diff --git a/spacewar/assets/images/ships/apex/sovereign.png b/spacewar/assets/images/ships/apex/sovereign.png new file mode 100644 index 0000000..ff39c0d Binary files /dev/null and b/spacewar/assets/images/ships/apex/sovereign.png differ diff --git a/spacewar/assets/images/ships/apex/sovereign.png.import b/spacewar/assets/images/ships/apex/sovereign.png.import new file mode 100644 index 0000000..243ccb6 --- /dev/null +++ b/spacewar/assets/images/ships/apex/sovereign.png.import @@ -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 diff --git a/spacewar/assets/images/ships/isn/barrage.png b/spacewar/assets/images/ships/isn/barrage.png new file mode 100644 index 0000000..7875444 Binary files /dev/null and b/spacewar/assets/images/ships/isn/barrage.png differ diff --git a/spacewar/assets/images/ships/isn/barrage.png.import b/spacewar/assets/images/ships/isn/barrage.png.import new file mode 100644 index 0000000..65acbd3 --- /dev/null +++ b/spacewar/assets/images/ships/isn/barrage.png.import @@ -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 diff --git a/spacewar/assets/images/ships/isn/behemoth.png b/spacewar/assets/images/ships/isn/behemoth.png new file mode 100644 index 0000000..dac35e7 Binary files /dev/null and b/spacewar/assets/images/ships/isn/behemoth.png differ diff --git a/spacewar/assets/images/ships/isn/behemoth.png.import b/spacewar/assets/images/ships/isn/behemoth.png.import new file mode 100644 index 0000000..151f924 --- /dev/null +++ b/spacewar/assets/images/ships/isn/behemoth.png.import @@ -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 diff --git a/spacewar/assets/images/ships/isn/patriot.png b/spacewar/assets/images/ships/isn/patriot.png new file mode 100644 index 0000000..dcbdae5 Binary files /dev/null and b/spacewar/assets/images/ships/isn/patriot.png differ diff --git a/spacewar/assets/images/ships/isn/patriot.png.import b/spacewar/assets/images/ships/isn/patriot.png.import new file mode 100644 index 0000000..e0e3f44 --- /dev/null +++ b/spacewar/assets/images/ships/isn/patriot.png.import @@ -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 diff --git a/spacewar/assets/images/ships/isn/source/isn_ships.jpeg b/spacewar/assets/images/ships/isn/source/isn_ships.jpeg new file mode 100644 index 0000000..ec0e60d Binary files /dev/null and b/spacewar/assets/images/ships/isn/source/isn_ships.jpeg differ diff --git a/spacewar/assets/images/ships/isn/source/isn_ships.jpeg.import b/spacewar/assets/images/ships/isn/source/isn_ships.jpeg.import new file mode 100644 index 0000000..2642d07 --- /dev/null +++ b/spacewar/assets/images/ships/isn/source/isn_ships.jpeg.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/iron_clad.png b/spacewar/assets/images/ships/orc/iron_clad.png new file mode 100644 index 0000000..955e72e Binary files /dev/null and b/spacewar/assets/images/ships/orc/iron_clad.png differ diff --git a/spacewar/assets/images/ships/orc/iron_clad.png.import b/spacewar/assets/images/ships/orc/iron_clad.png.import new file mode 100644 index 0000000..8dfa665 --- /dev/null +++ b/spacewar/assets/images/ships/orc/iron_clad.png.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/rail_jack.png b/spacewar/assets/images/ships/orc/rail_jack.png new file mode 100644 index 0000000..e66600d Binary files /dev/null and b/spacewar/assets/images/ships/orc/rail_jack.png differ diff --git a/spacewar/assets/images/ships/orc/rail_jack.png.import b/spacewar/assets/images/ships/orc/rail_jack.png.import new file mode 100644 index 0000000..2b60597 --- /dev/null +++ b/spacewar/assets/images/ships/orc/rail_jack.png.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/scrap_spitter.png b/spacewar/assets/images/ships/orc/scrap_spitter.png new file mode 100644 index 0000000..fd06599 Binary files /dev/null and b/spacewar/assets/images/ships/orc/scrap_spitter.png differ diff --git a/spacewar/assets/images/ships/orc/scrap_spitter.png.import b/spacewar/assets/images/ships/orc/scrap_spitter.png.import new file mode 100644 index 0000000..e4edff9 --- /dev/null +++ b/spacewar/assets/images/ships/orc/scrap_spitter.png.import @@ -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 diff --git a/spacewar/assets/images/ships/orc/source/orc_ships.jpeg b/spacewar/assets/images/ships/orc/source/orc_ships.jpeg new file mode 100644 index 0000000..8a6aa49 Binary files /dev/null and b/spacewar/assets/images/ships/orc/source/orc_ships.jpeg differ diff --git a/spacewar/assets/images/ships/orc/source/orc_ships.jpeg.import b/spacewar/assets/images/ships/orc/source/orc_ships.jpeg.import new file mode 100644 index 0000000..634a1e9 --- /dev/null +++ b/spacewar/assets/images/ships/orc/source/orc_ships.jpeg.import @@ -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 diff --git a/spacewar/autoload/chat_manager.gd b/spacewar/autoload/chat_manager.gd index 0276c39..850d19e 100644 --- a/spacewar/autoload/chat_manager.gd +++ b/spacewar/autoload/chat_manager.gd @@ -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) diff --git a/spacewar/autoload/game_config.gd b/spacewar/autoload/game_config.gd index 2b2f57d..5a90df2 100644 --- a/spacewar/autoload/game_config.gd +++ b/spacewar/autoload/game_config.gd @@ -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. diff --git a/spacewar/autoload/kill_feed_manager.gd b/spacewar/autoload/kill_feed_manager.gd new file mode 100644 index 0000000..02d90cf --- /dev/null +++ b/spacewar/autoload/kill_feed_manager.gd @@ -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) diff --git a/spacewar/autoload/kill_feed_manager.gd.uid b/spacewar/autoload/kill_feed_manager.gd.uid new file mode 100644 index 0000000..7062701 --- /dev/null +++ b/spacewar/autoload/kill_feed_manager.gd.uid @@ -0,0 +1 @@ +uid://dinxq78rl35lu diff --git a/spacewar/autoload/matchmaking_client.gd b/spacewar/autoload/matchmaking_client.gd index ba9ced6..4d897c7 100644 --- a/spacewar/autoload/matchmaking_client.gd +++ b/spacewar/autoload/matchmaking_client.gd @@ -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 diff --git a/spacewar/autoload/network_manager.gd b/spacewar/autoload/network_manager.gd index b763d06..d157caf 100644 --- a/spacewar/autoload/network_manager.gd +++ b/spacewar/autoload/network_manager.gd @@ -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=` overrides the default join target for clients. +# server; `-- --connect=` overrides the default join target for clients; +# `-- --server-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: diff --git a/spacewar/autoload/player_registry.gd b/spacewar/autoload/player_registry.gd index ce66b77..6fedbbc 100644 --- a/spacewar/autoload/player_registry.gd +++ b/spacewar/autoload/player_registry.gd @@ -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: diff --git a/spacewar/bots/bot_ai.gd b/spacewar/bots/bot_ai.gd index 1e75ec0..1395b00 100644 --- a/spacewar/bots/bot_ai.gd +++ b/spacewar/bots/bot_ai.gd @@ -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 diff --git a/spacewar/bots/bot_manager.gd b/spacewar/bots/bot_manager.gd index 38abaed..3a5215a 100644 --- a/spacewar/bots/bot_manager.gd +++ b/spacewar/bots/bot_manager.gd @@ -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 diff --git a/spacewar/bots/bot_personality.gd b/spacewar/bots/bot_personality.gd new file mode 100644 index 0000000..1baf348 --- /dev/null +++ b/spacewar/bots/bot_personality.gd @@ -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 diff --git a/spacewar/bots/bot_personality.gd.uid b/spacewar/bots/bot_personality.gd.uid new file mode 100644 index 0000000..57560bf --- /dev/null +++ b/spacewar/bots/bot_personality.gd.uid @@ -0,0 +1 @@ +uid://ba0y2016tyc76 diff --git a/spacewar/chat/chat_box.gd b/spacewar/chat/chat_box.gd index df78cb6..0488249 100644 --- a/spacewar/chat/chat_box.gd +++ b/spacewar/chat/chat_box.gd @@ -139,14 +139,17 @@ func _on_text_submitted(text: String) -> void: func _on_message_received(sender_name: String, sender_race: int, team_only: bool, text: String) -> void: - 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 team_only: - line = "[color=#7fdc7f][TEAM][/color] [color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] + if sender_name == ChatManager.ADMIN_NAME: + line = "[color=#e0c341]* %s[/color]" % safe_text else: - line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] + var name_color := _race_color(sender_race).to_html(false) + var safe_name := _escape_bbcode(sender_name) + if team_only: + line = "[color=#7fdc7f][TEAM][/color] [color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] + else: + line = "[color=#%s]%s[/color]: %s" % [name_color, safe_name, safe_text] _lines.append(line) if _lines.size() > MAX_LINES: diff --git a/spacewar/effects/explosion.gd b/spacewar/effects/explosion.gd new file mode 100644 index 0000000..4bd338c --- /dev/null +++ b/spacewar/effects/explosion.gd @@ -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) diff --git a/spacewar/effects/explosion.gd.uid b/spacewar/effects/explosion.gd.uid new file mode 100644 index 0000000..da7ea8a --- /dev/null +++ b/spacewar/effects/explosion.gd.uid @@ -0,0 +1 @@ +uid://dft6gwjocfwx5 diff --git a/spacewar/effects/explosion.tscn b/spacewar/effects/explosion.tscn new file mode 100644 index 0000000..52469f4 --- /dev/null +++ b/spacewar/effects/explosion.tscn @@ -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") diff --git a/spacewar/hud/energy_bar.gd b/spacewar/hud/energy_bar.gd new file mode 100644 index 0000000..6a895f6 --- /dev/null +++ b/spacewar/hud/energy_bar.gd @@ -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) diff --git a/spacewar/hud/energy_bar.tscn b/spacewar/hud/energy_bar.tscn new file mode 100644 index 0000000..ff6c612 --- /dev/null +++ b/spacewar/hud/energy_bar.tscn @@ -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") diff --git a/spacewar/hud/kill_feed.gd b/spacewar/hud/kill_feed.gd new file mode 100644 index 0000000..09ff2e8 --- /dev/null +++ b/spacewar/hud/kill_feed.gd @@ -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]") diff --git a/spacewar/hud/kill_feed.gd.uid b/spacewar/hud/kill_feed.gd.uid new file mode 100644 index 0000000..541faa0 --- /dev/null +++ b/spacewar/hud/kill_feed.gd.uid @@ -0,0 +1 @@ +uid://bp0k0nhyjdvl6 diff --git a/spacewar/hud/kill_feed.tscn b/spacewar/hud/kill_feed.tscn new file mode 100644 index 0000000..7331f97 --- /dev/null +++ b/spacewar/hud/kill_feed.tscn @@ -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") diff --git a/spacewar/hud/mini_map.gd b/spacewar/hud/mini_map.gd new file mode 100644 index 0000000..2ce6286 --- /dev/null +++ b/spacewar/hud/mini_map.gd @@ -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) diff --git a/spacewar/hud/mini_map.gd.uid b/spacewar/hud/mini_map.gd.uid new file mode 100644 index 0000000..6ff0fdd --- /dev/null +++ b/spacewar/hud/mini_map.gd.uid @@ -0,0 +1 @@ +uid://bw5qhf17nlrg diff --git a/spacewar/hud/mini_map.tscn b/spacewar/hud/mini_map.tscn new file mode 100644 index 0000000..f276c81 --- /dev/null +++ b/spacewar/hud/mini_map.tscn @@ -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") diff --git a/spacewar/hud/mini_map_view.gd b/spacewar/hud/mini_map_view.gd new file mode 100644 index 0000000..d68b0ff --- /dev/null +++ b/spacewar/hud/mini_map_view.gd @@ -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) diff --git a/spacewar/hud/mini_map_view.gd.uid b/spacewar/hud/mini_map_view.gd.uid new file mode 100644 index 0000000..561e366 --- /dev/null +++ b/spacewar/hud/mini_map_view.gd.uid @@ -0,0 +1 @@ +uid://b3shd4b2thlml diff --git a/spacewar/hud/ping_display.gd b/spacewar/hud/ping_display.gd new file mode 100644 index 0000000..85cc2c3 --- /dev/null +++ b/spacewar/hud/ping_display.gd @@ -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) diff --git a/spacewar/hud/ping_display.gd.uid b/spacewar/hud/ping_display.gd.uid new file mode 100644 index 0000000..4da5a67 --- /dev/null +++ b/spacewar/hud/ping_display.gd.uid @@ -0,0 +1 @@ +uid://x5kt6q3136cu diff --git a/spacewar/hud/ping_display.tscn b/spacewar/hud/ping_display.tscn new file mode 100644 index 0000000..b9cbd41 --- /dev/null +++ b/spacewar/hud/ping_display.tscn @@ -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") diff --git a/spacewar/hud/player_list.gd b/spacewar/hud/player_list.gd new file mode 100644 index 0000000..bd3be61 --- /dev/null +++ b/spacewar/hud/player_list.gd @@ -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]") diff --git a/spacewar/hud/player_list.gd.uid b/spacewar/hud/player_list.gd.uid new file mode 100644 index 0000000..b03a575 --- /dev/null +++ b/spacewar/hud/player_list.gd.uid @@ -0,0 +1 @@ +uid://jbjhcpnern4x diff --git a/spacewar/hud/player_list.tscn b/spacewar/hud/player_list.tscn new file mode 100644 index 0000000..4af1627 --- /dev/null +++ b/spacewar/hud/player_list.tscn @@ -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") diff --git a/spacewar/hud/stat_bar.gd b/spacewar/hud/stat_bar.gd new file mode 100644 index 0000000..4ac887e --- /dev/null +++ b/spacewar/hud/stat_bar.gd @@ -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 diff --git a/spacewar/menu/main_menu.gd b/spacewar/menu/main_menu.gd index 745e552..bcebe72 100644 --- a/spacewar/menu/main_menu.gd +++ b/spacewar/menu/main_menu.gd @@ -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)}, @@ -14,7 +23,15 @@ const RANK_DATA := [ {"label": "ADMIRAL", "color": Color(0.62, 0.28, 1.0)}, {"label": "LEGEND", "color": Color(1.0, 0.28, 0.10)}, ] -const CURRENT_RANK := 0 # placeholder until backend exists +const CURRENT_RANK := 0 # placeholder until backend exists +const CURRENT_LEVEL := 1 # placeholder until backend exists + +const NAV_ITEMS := ["QUICK PLAY", "SERVER SELECT", "OPTIONS", "PROFILE", "QUIT"] +const NAV_LEFT := 64.0 +const NAV_WIDTH := 360.0 +const NAV_TOP := 250.0 +const NAV_BTN_H := 52.0 +const NAV_GAP := 4.0 func _ready() -> void: @@ -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) - btn.add_theme_stylebox_override("normal", sn) - btn.add_theme_stylebox_override("hover", sh) - btn.add_theme_stylebox_override("pressed", sh) - btn.add_theme_stylebox_override("disabled", sd) + var sn := StyleBoxEmpty.new() + sn.content_margin_left = 20.0 + btn.add_theme_stylebox_override("normal", sn) - 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) + 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 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) + 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) 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() diff --git a/spacewar/menu/pause_menu.gd b/spacewar/menu/pause_menu.gd index 0f3ad65..648d493 100644 --- a/spacewar/menu/pause_menu.gd +++ b/spacewar/menu/pause_menu.gd @@ -10,7 +10,8 @@ func _ready() -> void: func _input(event: InputEvent) -> void: - if event.is_action_pressed("toggle_pause") and not GameConfig.chat_focused: + if event.is_action_pressed("toggle_pause") and not GameConfig.chat_focused \ + and not GameConfig.team_select_focused: _toggle() get_viewport().set_input_as_handled() @@ -75,7 +76,7 @@ func _build_ui() -> void: _add_btn(vb, "RESUME", true).pressed.connect(_toggle) _add_btn(vb, "SETTINGS", false) - _add_btn(vb, "SELECT TEAM", false) + _add_btn(vb, "SELECT TEAM", true).pressed.connect(_on_select_team) vb.add_child(_hsep()) @@ -120,6 +121,13 @@ func _flat(col: Color, radius: int = 0) -> StyleBoxFlat: return s +func _on_select_team() -> void: + _root.visible = false + var team_select := get_node_or_null("/root/World/TeamSelect") as TeamSelect + if team_select: + team_select.reopen() + + func _on_quit_to_menu() -> void: NetworkManager.disconnect_from_game() get_tree().change_scene_to_file("res://menu/main_menu.tscn") diff --git a/spacewar/menu/server_browser.gd b/spacewar/menu/server_browser.gd deleted file mode 100644 index a1817ca..0000000 --- a/spacewar/menu/server_browser.gd +++ /dev/null @@ -1,283 +0,0 @@ -extends Control - -var _selected_race: int = 0 -var _selected_server: String = "" - -var _name_input: LineEdit -var _join_button: Button -var _race_buttons: Array[Button] = [] -var _server_btn: Button - - -func _ready() -> void: - _build_ui() - - -func _build_ui() -> void: - var bg := TextureRect.new() - bg.texture = load("res://assets/images/background/skybox/1.png") - bg.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL - bg.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED - bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - add_child(bg) - - var overlay := ColorRect.new() - overlay.color = Color(0.02, 0.03, 0.1, 0.72) - overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) - add_child(overlay) - - var header := _make_panel(0, 0, 1280, 60) - add_child(header) - var title_lbl := Label.new() - title_lbl.text = "S P A C E W A R — SERVER BROWSER" - title_lbl.anchor_left = 0.0 - title_lbl.anchor_top = 0.0 - title_lbl.anchor_right = 1.0 - title_lbl.anchor_bottom = 1.0 - title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - title_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - title_lbl.add_theme_font_size_override("font_size", 26) - title_lbl.add_theme_color_override("font_color", Color(0.4, 0.85, 1.0)) - header.add_child(title_lbl) - - var left := _make_panel(10, 70, 745, 720) - add_child(left) - _build_server_list(left) - - var right := _make_panel(765, 70, 505, 720) - add_child(right) - _build_player_setup(right) - - -func _make_panel(x: float, y: float, w: float, h: float) -> Panel: - var p := Panel.new() - p.anchor_left = 0.0 - p.anchor_top = 0.0 - p.anchor_right = 0.0 - p.anchor_bottom = 0.0 - p.offset_left = x - p.offset_top = y - p.offset_right = x + w - p.offset_bottom = y + h - var style := StyleBoxFlat.new() - style.bg_color = Color(0.04, 0.07, 0.16, 0.92) - style.set_corner_radius_all(8) - style.set_border_width_all(1) - style.border_color = Color(0.18, 0.38, 0.65, 0.9) - p.add_theme_stylebox_override("panel", style) - return p - - -func _vbox_in(parent: Control, margin: float = 16.0) -> VBoxContainer: - var vb := VBoxContainer.new() - vb.anchor_left = 0.0 - vb.anchor_top = 0.0 - vb.anchor_right = 1.0 - vb.anchor_bottom = 1.0 - vb.offset_left = margin - vb.offset_top = margin - vb.offset_right = -margin - vb.offset_bottom = -margin - vb.add_theme_constant_override("separation", 10) - parent.add_child(vb) - return vb - - -func _section_label(txt: String) -> Label: - var lbl := Label.new() - lbl.text = txt - lbl.add_theme_font_size_override("font_size", 18) - lbl.add_theme_color_override("font_color", Color(0.55, 0.82, 1.0)) - return lbl - - -func _field_label(txt: String) -> Label: - var lbl := Label.new() - lbl.text = txt - lbl.add_theme_font_size_override("font_size", 14) - lbl.add_theme_color_override("font_color", Color(0.65, 0.72, 0.82)) - return lbl - - -func _flat_box(color: Color, radius: int = 0) -> StyleBoxFlat: - var s := StyleBoxFlat.new() - s.bg_color = color - s.set_corner_radius_all(radius) - return s - - -func _build_server_list(parent: Panel) -> void: - var vb := _vbox_in(parent) - - vb.add_child(_section_label("Available Servers")) - vb.add_child(HSeparator.new()) - - var col_hdr := HBoxContainer.new() - vb.add_child(col_hdr) - var h_name := _field_label("Server Name") - h_name.size_flags_horizontal = Control.SIZE_EXPAND_FILL - col_hdr.add_child(h_name) - var h_players := _field_label("Players") - h_players.custom_minimum_size = Vector2(110, 0) - h_players.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - col_hdr.add_child(h_players) - - vb.add_child(HSeparator.new()) - - _server_btn = Button.new() - _server_btn.toggle_mode = true - _server_btn.text = " Trench Wars" - _server_btn.alignment = HORIZONTAL_ALIGNMENT_LEFT - _server_btn.custom_minimum_size = Vector2(0, 54) - _server_btn.add_theme_font_size_override("font_size", 17) - _server_btn.add_theme_color_override("font_color", Color(0.92, 0.95, 1.0)) - - var sn := _flat_box(Color(0.07, 0.13, 0.25, 1.0), 4) - sn.content_margin_left = 12.0 - var sh := _flat_box(Color(0.11, 0.21, 0.4, 1.0), 4) - sh.content_margin_left = 12.0 - var sp := _flat_box(Color(0.09, 0.27, 0.52, 1.0), 4) - sp.set_border_width_all(2) - sp.border_color = Color(0.3, 0.65, 1.0, 1.0) - sp.content_margin_left = 12.0 - _server_btn.add_theme_stylebox_override("normal", sn) - _server_btn.add_theme_stylebox_override("hover", sh) - _server_btn.add_theme_stylebox_override("pressed", sp) - - _server_btn.pressed.connect(_on_server_selected.bind("trench_wars")) - vb.add_child(_server_btn) - - # Player count label floated to the right of the server button row - # (separate non-interactive label overlaid via HBox trick) - var row_hb := HBoxContainer.new() - # We already added the button directly to vb; restructure: remove and re-add inside HBox - vb.remove_child(_server_btn) - vb.add_child(row_hb) - row_hb.add_child(_server_btn) - _server_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - - var pc_lbl := Label.new() - pc_lbl.text = "0 / 32" - pc_lbl.custom_minimum_size = Vector2(80, 0) - pc_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - pc_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - pc_lbl.add_theme_font_size_override("font_size", 14) - pc_lbl.add_theme_color_override("font_color", Color(0.5, 0.65, 0.8)) - row_hb.add_child(pc_lbl) - - -func _build_player_setup(parent: Panel) -> void: - var vb := _vbox_in(parent, 20.0) - vb.add_theme_constant_override("separation", 12) - - vb.add_child(_section_label("Player Setup")) - vb.add_child(HSeparator.new()) - - vb.add_child(_field_label("Player Name:")) - - _name_input = LineEdit.new() - _name_input.placeholder_text = "Enter your callsign..." - _name_input.custom_minimum_size = Vector2(0, 44) - _name_input.add_theme_font_size_override("font_size", 16) - _name_input.text_changed.connect(_on_name_changed) - vb.add_child(_name_input) - - var gap := Control.new() - gap.custom_minimum_size = Vector2(0, 6) - vb.add_child(gap) - - vb.add_child(_field_label("Select Race:")) - - var race_hb := HBoxContainer.new() - race_hb.custom_minimum_size = Vector2(0, 58) - race_hb.add_theme_constant_override("separation", 10) - vb.add_child(race_hb) - - var race_cfg: Array = [ - ["Race 1", Color(0.25, 0.5, 1.0)], - ["Race 2", Color(1.0, 0.45, 0.2)], - ["Race 3", Color(0.25, 0.9, 0.4)], - ] - for i in 3: - var btn := _make_race_button(race_cfg[i][0], race_cfg[i][1]) - btn.pressed.connect(_on_race_selected.bind(i + 1)) - race_hb.add_child(btn) - _race_buttons.append(btn) - - var filler := Control.new() - filler.size_flags_vertical = Control.SIZE_EXPAND_FILL - vb.add_child(filler) - - _join_button = Button.new() - _join_button.text = "JOIN GAME" - _join_button.custom_minimum_size = Vector2(0, 64) - _join_button.disabled = true - _join_button.add_theme_font_size_override("font_size", 22) - _join_button.add_theme_color_override("font_color", Color(0.35, 1.0, 0.45)) - _join_button.add_theme_color_override("font_color_disabled", Color(0.3, 0.35, 0.3, 0.6)) - - var jn := _flat_box(Color(0.07, 0.28, 0.1, 1.0), 7) - var jh := _flat_box(Color(0.1, 0.42, 0.14, 1.0), 7) - var jd := _flat_box(Color(0.06, 0.09, 0.07, 0.5), 7) - _join_button.add_theme_stylebox_override("normal", jn) - _join_button.add_theme_stylebox_override("hover", jh) - _join_button.add_theme_stylebox_override("disabled", jd) - - _join_button.pressed.connect(_on_join_pressed) - vb.add_child(_join_button) - - -func _make_race_button(label_text: String, col: Color) -> Button: - var btn := Button.new() - btn.text = label_text - btn.toggle_mode = true - btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL - btn.custom_minimum_size = Vector2(0, 58) - btn.add_theme_font_size_override("font_size", 15) - btn.add_theme_color_override("font_color", Color.WHITE) - - var dark := Color(col.r * 0.22, col.g * 0.22, col.b * 0.22, 1.0) - var mid := Color(col.r * 0.38, col.g * 0.38, col.b * 0.38, 1.0) - var lite := Color(col.r * 0.5, col.g * 0.5, col.b * 0.5, 1.0) - - var s_n := _flat_box(dark, 6) - var s_h := _flat_box(mid, 6) - var s_p := _flat_box(lite, 6) - s_p.set_border_width_all(2) - s_p.border_color = col - - btn.add_theme_stylebox_override("normal", s_n) - btn.add_theme_stylebox_override("hover", s_h) - btn.add_theme_stylebox_override("pressed", s_p) - return btn - - -func _on_name_changed(_text: String) -> void: - _update_join() - - -func _on_race_selected(race: int) -> void: - _selected_race = race - for i in _race_buttons.size(): - _race_buttons[i].button_pressed = (i + 1 == race) - _update_join() - - -func _on_server_selected(server: String) -> void: - _selected_server = server - _server_btn.button_pressed = true - _update_join() - - -func _update_join() -> void: - _join_button.disabled = ( - _name_input.text.strip_edges().is_empty() - or _selected_race == 0 - or _selected_server.is_empty() - ) - - -func _on_join_pressed() -> void: - GameConfig.player_name = _name_input.text.strip_edges() - GameConfig.player_race = _selected_race - get_tree().change_scene_to_file("res://world/world.tscn") diff --git a/spacewar/menu/server_browser.gd.uid b/spacewar/menu/server_browser.gd.uid deleted file mode 100644 index 8c00144..0000000 --- a/spacewar/menu/server_browser.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dg0sojl1wf8dh diff --git a/spacewar/menu/server_browser.tscn b/spacewar/menu/server_browser.tscn deleted file mode 100644 index 32fe1ea..0000000 --- a/spacewar/menu/server_browser.tscn +++ /dev/null @@ -1,8 +0,0 @@ -[gd_scene format=3 uid="uid://doolmlvruun6o"] - -[ext_resource type="Script" path="res://menu/server_browser.gd" id="1_script"] - -[node name="ServerBrowser" type="Control"] -anchor_right = 1.0 -anchor_bottom = 1.0 -script = ExtResource("1_script") diff --git a/spacewar/menu/server_select.gd b/spacewar/menu/server_select.gd new file mode 100644 index 0000000..a6bac83 --- /dev/null +++ b/spacewar/menu/server_select.gd @@ -0,0 +1,412 @@ +extends Control + +# Replaces the old menu/server_browser.gd, which baked in its own race picker +# from before TeamSelect existed and was never wired to a button. This screen +# only picks a server (backed by the matchmaking API's GET /servers) and +# connects directly — race/ship selection happens in-world via TeamSelect, +# same as the matchmaking-queue join path. + +const STATUS_COLOR := { + "available": Color(0.4, 0.9, 0.5), + "full": Color(0.95, 0.75, 0.3), + "offline": Color(0.55, 0.58, 0.65), +} + +const PING_TIMEOUT_MSEC := 1500 +const PING_COLOR_GOOD := Color(0.4, 0.9, 0.5) +const PING_COLOR_OK := Color(0.95, 0.75, 0.3) +const PING_COLOR_BAD := Color(0.9, 0.4, 0.4) + +var _servers: Array = [] +var _selected_index: int = -1 +var _row_group: ButtonGroup +var _list_vbox: VBoxContainer +var _status_lbl: Label +var _connect_btn: Button + +# Per-row PLAYERS/PING labels, index-aligned with _servers. Ping is measured +# by actively probing each server's UDP query port (game port + 1, see +# NetworkManager) rather than trusting the DB's static fields — the demo +# rows seeded by the matchmaking API aren't real reachable servers, so +# probing also doubles as a live "is this actually up" check for the ones +# that are. +var _players_labels: Array[Label] = [] +var _ping_labels: Array[Label] = [] +var _ping_probes: Array = [] # {udp: PacketPeerUDP, start_msec: int, done: bool} or null + + +func _ready() -> void: + _build_ui() + NetworkManager.connection_succeeded.connect(_on_connected) + NetworkManager.connection_failed.connect(_on_connect_failed) + _refresh_servers() + + +func _process(_delta: float) -> void: + if _ping_probes.is_empty(): + return + var now := Time.get_ticks_msec() + for index in _ping_probes.size(): + var probe = _ping_probes[index] + if probe == null or probe.done: + continue + var udp: PacketPeerUDP = probe.udp + if udp.get_available_packet_count() > 0: + var packet := udp.get_packet() + _apply_ping_result(index, packet.get_string_from_utf8(), now - probe.start_msec) + probe.done = true + elif now - probe.start_msec > PING_TIMEOUT_MSEC: + _apply_ping_result(index, "", -1) + probe.done = true + + +func _build_ui() -> void: + _add_bg() + _add_header() + _add_list_panel() + _add_bottom_bar() + + +func _add_bg() -> void: + var bg := TextureRect.new() + bg.texture = load("res://assets/images/background/skybox/1.png") + bg.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL + bg.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED + bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(bg) + + var ov := ColorRect.new() + ov.color = Color(0.0, 0.0, 0.0, 0.6) + ov.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(ov) + + +func _add_header() -> void: + var title := Label.new() + title.text = "SERVER SELECT" + 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 = 48.0 + title.offset_right = 700.0 + title.offset_bottom = 92.0 + title.add_theme_font_size_override("font_size", 32) + title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) + add_child(title) + + +func _add_list_panel() -> void: + var col_hdr := HBoxContainer.new() + col_hdr.anchor_left = 0.0 + col_hdr.anchor_top = 0.0 + col_hdr.anchor_right = 1.0 + col_hdr.anchor_bottom = 0.0 + col_hdr.offset_left = 60.0 + col_hdr.offset_top = 108.0 + col_hdr.offset_right = -60.0 + col_hdr.offset_bottom = 132.0 + add_child(col_hdr) + + var h_name := _field_label("MODE / ADDRESS") + h_name.size_flags_horizontal = Control.SIZE_EXPAND_FILL + col_hdr.add_child(h_name) + var h_players := _field_label("PLAYERS") + h_players.custom_minimum_size = Vector2(100, 0) + h_players.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + col_hdr.add_child(h_players) + var h_ping := _field_label("PING") + h_ping.custom_minimum_size = Vector2(80, 0) + h_ping.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + col_hdr.add_child(h_ping) + var h_status := _field_label("STATUS") + h_status.custom_minimum_size = Vector2(120, 0) + h_status.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + col_hdr.add_child(h_status) + + var sep := HSeparator.new() + sep.anchor_left = 0.0 + sep.anchor_top = 0.0 + sep.anchor_right = 1.0 + sep.anchor_bottom = 0.0 + sep.offset_left = 60.0 + sep.offset_top = 136.0 + sep.offset_right = -60.0 + sep.offset_bottom = 140.0 + sep.add_theme_color_override("color", Color(1.0, 1.0, 1.0, 0.2)) + add_child(sep) + + var scroll := ScrollContainer.new() + scroll.anchor_left = 0.0 + scroll.anchor_top = 0.0 + scroll.anchor_right = 1.0 + scroll.anchor_bottom = 1.0 + scroll.offset_left = 60.0 + scroll.offset_top = 148.0 + scroll.offset_right = -60.0 + scroll.offset_bottom = -84.0 + add_child(scroll) + + _list_vbox = VBoxContainer.new() + _list_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _list_vbox.add_theme_constant_override("separation", 2) + scroll.add_child(_list_vbox) + + +func _field_label(txt: String) -> Label: + var lbl := Label.new() + lbl.text = txt + lbl.add_theme_font_size_override("font_size", 12) + lbl.add_theme_color_override("font_color", Color(0.6, 0.63, 0.7)) + return lbl + + +func _add_bottom_bar() -> void: + var back_btn := Button.new() + back_btn.text = "BACK" + back_btn.anchor_left = 0.0 + back_btn.anchor_top = 1.0 + back_btn.anchor_right = 0.0 + back_btn.anchor_bottom = 1.0 + back_btn.offset_left = 60.0 + back_btn.offset_top = -60.0 + back_btn.offset_right = 200.0 + back_btn.offset_bottom = -20.0 + back_btn.add_theme_font_size_override("font_size", 15) + back_btn.pressed.connect(_on_back) + add_child(back_btn) + + var refresh_btn := Button.new() + refresh_btn.text = "REFRESH" + refresh_btn.anchor_left = 0.0 + refresh_btn.anchor_top = 1.0 + refresh_btn.anchor_right = 0.0 + refresh_btn.anchor_bottom = 1.0 + refresh_btn.offset_left = 216.0 + refresh_btn.offset_top = -60.0 + refresh_btn.offset_right = 356.0 + refresh_btn.offset_bottom = -20.0 + refresh_btn.add_theme_font_size_override("font_size", 15) + refresh_btn.pressed.connect(_refresh_servers) + add_child(refresh_btn) + + _status_lbl = Label.new() + _status_lbl.text = "" + _status_lbl.anchor_left = 0.0 + _status_lbl.anchor_top = 1.0 + _status_lbl.anchor_right = 1.0 + _status_lbl.anchor_bottom = 1.0 + _status_lbl.offset_left = 372.0 + _status_lbl.offset_top = -60.0 + _status_lbl.offset_right = -216.0 + _status_lbl.offset_bottom = -20.0 + _status_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _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) + + _connect_btn = Button.new() + _connect_btn.text = "CONNECT" + _connect_btn.disabled = true + _connect_btn.anchor_left = 1.0 + _connect_btn.anchor_top = 1.0 + _connect_btn.anchor_right = 1.0 + _connect_btn.anchor_bottom = 1.0 + _connect_btn.offset_left = -200.0 + _connect_btn.offset_top = -60.0 + _connect_btn.offset_right = -60.0 + _connect_btn.offset_bottom = -20.0 + _connect_btn.add_theme_font_size_override("font_size", 16) + _connect_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) + var sd := StyleBoxFlat.new() + sd.bg_color = Color(1.0, 1.0, 1.0, 0.05) + sd.set_corner_radius_all(4) + _connect_btn.add_theme_stylebox_override("normal", sn) + _connect_btn.add_theme_stylebox_override("hover", sh) + _connect_btn.add_theme_stylebox_override("disabled", sd) + _connect_btn.pressed.connect(_on_connect) + add_child(_connect_btn) + + +func _refresh_servers() -> void: + _selected_index = -1 + _connect_btn.disabled = true + _status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86)) + _status_lbl.text = "Loading servers..." + for child in _list_vbox.get_children(): + child.queue_free() + for probe in _ping_probes: + if probe != null: + probe.udp.close() + _players_labels.clear() + _ping_labels.clear() + _ping_probes.clear() + + var result: Dictionary = await MatchmakingClient.list_servers() + if not result.ok: + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "Couldn't reach matchmaking service." + return + + _servers = result.servers + if _servers.is_empty(): + _status_lbl.text = "No servers online right now." + return + + _status_lbl.text = "" + _row_group = ButtonGroup.new() + for i in _servers.size(): + _list_vbox.add_child(_make_row(_servers[i], i)) + _start_ping_probes() + + +func _start_ping_probes() -> void: + for i in _servers.size(): + var server: Dictionary = _servers[i] + var udp := PacketPeerUDP.new() + var query_port := int(server.get("port", 0)) + NetworkManager.QUERY_PORT_OFFSET + if udp.connect_to_host(str(server.get("ip", "")), query_port) != OK: + _ping_probes.append(null) + continue + udp.put_packet(NetworkManager.PING_REQUEST.to_utf8_buffer()) + _ping_probes.append({"udp": udp, "start_msec": Time.get_ticks_msec(), "done": false}) + + +func _apply_ping_result(index: int, response: String, rtt_msec: int) -> void: + if index >= _ping_labels.size(): + return + var ping_lbl := _ping_labels[index] + if response.begins_with("SPACEWAR_PONG:"): + var parts := response.split(":") + if parts.size() >= 3: + _players_labels[index].text = "%s / %s" % [parts[1], parts[2]] + ping_lbl.text = "%d ms" % rtt_msec + if rtt_msec <= 80: + ping_lbl.add_theme_color_override("font_color", PING_COLOR_GOOD) + elif rtt_msec <= 180: + ping_lbl.add_theme_color_override("font_color", PING_COLOR_OK) + else: + ping_lbl.add_theme_color_override("font_color", PING_COLOR_BAD) + else: + ping_lbl.text = "‒‒" + ping_lbl.add_theme_color_override("font_color", Color(0.5, 0.53, 0.6)) + + +func _make_row(server: Dictionary, index: int) -> Button: + var row := Button.new() + row.toggle_mode = true + row.button_group = _row_group + row.text = "" + row.custom_minimum_size = Vector2(0, 46) + row.add_theme_font_size_override("font_size", 16) + + var sn := StyleBoxEmpty.new() + var sh := StyleBoxFlat.new() + sh.bg_color = Color(1.0, 1.0, 1.0, 0.08) + sh.border_width_left = 3 + sh.border_color = Color(1.0, 1.0, 1.0, 0.9) + row.add_theme_stylebox_override("normal", sn) + row.add_theme_stylebox_override("hover", sh) + row.add_theme_stylebox_override("pressed", sh) + + var hb := HBoxContainer.new() + hb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + hb.offset_left = 16.0 + hb.offset_right = -16.0 + hb.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_child(hb) + + var mode: String = str(server.get("mode", "?")).to_upper() + var addr_lbl := Label.new() + addr_lbl.text = "%s %s:%s" % [mode, server.get("ip", "?"), str(server.get("port", "?"))] + addr_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + addr_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + addr_lbl.add_theme_color_override("font_color", Color(0.85, 0.87, 0.92)) + hb.add_child(addr_lbl) + + # Seeded from the DB row immediately; overwritten by the live UDP query + # response in _apply_ping_result if that server actually answers. + var players_lbl := Label.new() + players_lbl.text = "%s / %s" % [str(server.get("player_count", "?")), str(server.get("max_players", "?"))] + players_lbl.custom_minimum_size = Vector2(100, 0) + players_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + players_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + players_lbl.add_theme_font_size_override("font_size", 13) + players_lbl.add_theme_color_override("font_color", Color(0.75, 0.78, 0.85)) + hb.add_child(players_lbl) + _players_labels.append(players_lbl) + + var ping_lbl := Label.new() + ping_lbl.text = "…" + ping_lbl.custom_minimum_size = Vector2(80, 0) + ping_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + ping_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + ping_lbl.add_theme_font_size_override("font_size", 13) + ping_lbl.add_theme_color_override("font_color", Color(0.6, 0.63, 0.7)) + hb.add_child(ping_lbl) + _ping_labels.append(ping_lbl) + + var status: String = str(server.get("status", "offline")) + var col: Color = STATUS_COLOR.get(status, Color(0.6, 0.63, 0.7)) + var status_lbl := Label.new() + status_lbl.text = status.to_upper() + status_lbl.custom_minimum_size = Vector2(120, 0) + status_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + status_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + status_lbl.add_theme_font_size_override("font_size", 13) + status_lbl.add_theme_color_override("font_color", col) + hb.add_child(status_lbl) + + row.pressed.connect(_on_row_selected.bind(index)) + return row + + +func _on_row_selected(index: int) -> void: + _selected_index = index + _connect_btn.disabled = false + + +func _on_connect() -> void: + if _selected_index < 0 or _selected_index >= _servers.size(): + return + if GameConfig.player_name.strip_edges().is_empty(): + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "Set your callsign from the main menu PROFILE first." + return + + var server: Dictionary = _servers[_selected_index] + var status := str(server.get("status", "offline")) + if status == "offline": + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "That server is offline." + return + if status == "full": + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "That server is full." + return + + _connect_btn.disabled = true + _status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86)) + _status_lbl.text = "Connecting..." + NetworkManager.join_server(server.ip, int(server.port)) + + +func _on_connected() -> void: + get_tree().change_scene_to_file("res://world/world.tscn") + + +func _on_connect_failed() -> void: + _connect_btn.disabled = false + _status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4)) + _status_lbl.text = "Connection failed — is that server reachable?" + + +func _on_back() -> void: + get_tree().change_scene_to_file("res://menu/main_menu.tscn") diff --git a/spacewar/menu/server_select.gd.uid b/spacewar/menu/server_select.gd.uid new file mode 100644 index 0000000..d9f1929 --- /dev/null +++ b/spacewar/menu/server_select.gd.uid @@ -0,0 +1 @@ +uid://cofn5g2htyax6 diff --git a/spacewar/menu/server_select.tscn b/spacewar/menu/server_select.tscn new file mode 100644 index 0000000..e64c616 --- /dev/null +++ b/spacewar/menu/server_select.tscn @@ -0,0 +1,8 @@ +[gd_scene format=3 uid="uid://dserverselect01"] + +[ext_resource type="Script" path="res://menu/server_select.gd" id="1_script"] + +[node name="ServerSelect" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_script") diff --git a/spacewar/menu/team_select.gd b/spacewar/menu/team_select.gd index d89bae8..2eba1e8 100644 --- a/spacewar/menu/team_select.gd +++ b/spacewar/menu/team_select.gd @@ -1,107 +1,75 @@ extends CanvasLayer class_name TeamSelect -# Movement speed factor by role, shared across all 3 races so e.g. every -# race's Interceptor moves at the same speed as every other race's. +# Movement speed factor by role, shared across all 3 factions so e.g. every +# faction's Fighter moves at the same speed as every other faction's. const SPEED_BY_ROLE := { - "Interceptor": 1.3, - "Gunship": 1.15, - "Bomber": 1.0, - "Support": 0.9, - "Heavy": 0.7, + "Fighter": 1.3, + "Gunner": 1.15, + "Tank": 0.7, } const RACES := [ { - "id": 1, "name": "TERRAN REPUBLIC", "sub": "Angular gray-blue military ships, standard balanced fighters", - "color": Color(0.40, 0.60, 0.85), + "id": 1, "name": "APEX DYNAMICS", "sub": "Sleek pearlescent-white corporate fleet — precision energy weapons, wins by not being seen", + "color": Color(0.55, 0.85, 1.0), "ships": [ { - "name": "WARFRAME", "role": "Interceptor", - "desc": "Very fast, rapid weak laser bursts. Built to close distance instantly and chip away at anything in range. Low individual hit strength but a relentless rate of fire punishes anyone who stands still.", - "path": "res://assets/images/ships/terran/warframe.png", "scale": 0.229, "speed_factor": SPEED_BY_ROLE.Interceptor, + "name": "LANCET", "role": "Fighter", + "desc": "A single, high-precision bolt of blue coherent light. Built to poke from range and vanish before anything can return fire — the sharpest shot in the fleet, if you can land it.", + "path": "res://assets/images/ships/apex/lancet.png", "scale": 0.296, "speed_factor": SPEED_BY_ROLE.Fighter, }, { - "name": "STRIKER", "role": "Gunship", - "desc": "Super fast fire rate, very low damage lasers. Trades hitting power for volume — the more shots in the air, the more that connect. Best flown aggressively, applying constant pressure rather than picking single shots.", - "path": "res://assets/images/ships/terran/striker.png", "scale": 0.244, "speed_factor": SPEED_BY_ROLE.Gunship, + "name": "PULSAR", "role": "Gunner", + "desc": "A wide, rapid-fire fan of low-damage green plasma pulses. Trades precision for volume — spray enough bolts and something connects.", + "path": "res://assets/images/ships/apex/pulsar.png", "scale": 0.373, "speed_factor": SPEED_BY_ROLE.Gunner, }, { - "name": "DEPLOYER", "role": "Bomber", - "desc": "Fast lasers plus an EMP mine dropped in your wake. Punishes anyone chasing too closely and disables systems on contact. Strong for area denial and covering a retreat.", - "path": "res://assets/images/ships/terran/deployer.png", "scale": 0.265, "speed_factor": SPEED_BY_ROLE.Bomber, - }, - { - "name": "LINK NODE", "role": "Support", - "desc": "Standard lasers plus a small repair pack dropped for allies to pick up. Keeps a team topped off in prolonged fights. Not built to duel alone — stay near the fight, not in the middle of it.", - "path": "res://assets/images/ships/terran/link_node.png", "scale": 0.329, "speed_factor": SPEED_BY_ROLE.Support, - }, - { - "name": "BASTION", "role": "Heavy", - "desc": "Tankier and slower, but its laser hits harder than anything else in the fleet. Built to anchor a push and absorb damage while dealing it back. Discourages anyone from engaging head-on.", - "path": "res://assets/images/ships/terran/bastion.png", "scale": 0.283, "speed_factor": SPEED_BY_ROLE.Heavy, + "name": "SOVEREIGN", "role": "Tank", + "desc": "Homing micro-missiles alongside dual heavy plasma batteries. Slow and telegraphed, but its lock-on missiles punish anyone who tries to just outrun it.", + "path": "res://assets/images/ships/apex/sovereign.png", "scale": 0.348, "speed_factor": SPEED_BY_ROLE.Tank, }, ], }, { - "id": 2, "name": "MECHANOS SOVEREIGNTY", "sub": "Industrial war machines built for brute-force firepower", - "color": Color(0.85, 0.28, 0.28), + "id": 2, "name": "INNER SPHERE NAVY", "sub": "Gunmetal-gray navy of Earth, Mars, and Mercury — disciplined, frontal, built to hold a line", + "color": Color(0.80, 0.32, 0.32), "ships": [ { - "name": "SENTINEL", "role": "Interceptor", - "desc": "A light scout mech with twin rapid cannons. Fast and maneuverable for a Mechanos hull, built to screen the front line and harass before the heavier machines arrive.", - "path": "res://assets/images/ships/mech/sentinel.png", "scale": 0.205, "speed_factor": SPEED_BY_ROLE.Interceptor, + "name": "PATRIOT", "role": "Fighter", + "desc": "A single, hard-hitting high-caliber explosive shell. Standard-issue and dependable — a clean, punishing hit if you land it.", + "path": "res://assets/images/ships/isn/patriot.png", "scale": 0.504, "speed_factor": SPEED_BY_ROLE.Fighter, }, { - "name": "VANGUARD", "role": "Gunship", - "desc": "Dual autocannons built for sustained fire. Sits between the light scouts and the heavy machines — durable enough to hold a line, fast enough to reposition when it breaks.", - "path": "res://assets/images/ships/mech/vanguard.png", "scale": 0.266, "speed_factor": SPEED_BY_ROLE.Gunship, + "name": "BARRAGE", "role": "Gunner", + "desc": "A relentless quad-barrel spray of fast, low-damage light bullets. Built for sustained suppressing fire rather than any one killing blow.", + "path": "res://assets/images/ships/isn/barrage.png", "scale": 0.516, "speed_factor": SPEED_BY_ROLE.Gunner, }, { - "name": "RAVAGER", "role": "Bomber", - "desc": "Drops heavy ordnance and mines from its cargo pods. Slow to turn but devastating to anything caught in the blast radius. Best used to seal off a chokepoint.", - "path": "res://assets/images/ships/mech/ravager.png", "scale": 0.25, "speed_factor": SPEED_BY_ROLE.Bomber, - }, - { - "name": "AEGIS", "role": "Support", - "desc": "Projects a short-range shield and deploys a repair drone for nearby allies. Doesn't hit hard on its own — its job is keeping everyone else alive long enough to win the fight.", - "path": "res://assets/images/ships/mech/aegis.png", "scale": 0.248, "speed_factor": SPEED_BY_ROLE.Support, - }, - { - "name": "COLOSSUS", "role": "Heavy", - "desc": "A walking fortress bristling with cannons. Slow and telegraphed, but absorbs enormous damage and returns it with interest. Usually the last thing standing in an engagement.", - "path": "res://assets/images/ships/mech/colossus.png", "scale": 0.252, "speed_factor": SPEED_BY_ROLE.Heavy, + "name": "BEHEMOTH", "role": "Tank", + "desc": "Heavy tracking torpedoes plus massive physical deck-guns. The navy's flagship-killer — slow to turn, but nothing stands in its way for long.", + "path": "res://assets/images/ships/isn/behemoth.png", "scale": 0.331, "speed_factor": SPEED_BY_ROLE.Tank, }, ], }, { - "id": 3, "name": "VORG SWARM", "sub": "Organic swarm built for chaotic damage-over-time", - "color": Color(0.45, 0.80, 0.35), + "id": 3, "name": "OUTER RIM COLLECTIVE", "sub": "Gritty, jury-rigged mining rebels — kinetic weapons scavenged and welded onto anything that flies", + "color": Color(0.82, 0.52, 0.24), "ships": [ { - "name": "STINGER", "role": "Interceptor", - "desc": "Fast, acid shots with light damage-over-time. Built to swarm a target from range, applying just enough acid to wear an enemy down before backing off.", - "path": "res://assets/images/ships/vorg/stinger.png", "scale": 0.254, "speed_factor": SPEED_BY_ROLE.Interceptor, + "name": "RAIL-JACK", "role": "Fighter", + "desc": "A single, heavy magnetic railgun slug. Repurposed mining hardware — one slug, but it hits like a dropped asteroid.", + "path": "res://assets/images/ships/orc/rail_jack.png", "scale": 0.492, "speed_factor": SPEED_BY_ROLE.Fighter, }, { - "name": "SPITTER", "role": "Gunship", - "desc": "Faster-firing acid with a stronger damage-over-time effect. Trades some speed for sustained corrosive pressure — anything it tags keeps taking damage long after contact.", - "path": "res://assets/images/ships/vorg/spitter.png", "scale": 0.247, "speed_factor": SPEED_BY_ROLE.Gunship, + "name": "SCRAP-SPITTER", "role": "Gunner", + "desc": "A rotary scrap-cannon spraying rapid, low-damage metal shrapnel. Loud, dirty, and built to keep the pressure on.", + "path": "res://assets/images/ships/orc/scrap_spitter.png", "scale": 0.462, "speed_factor": SPEED_BY_ROLE.Gunner, }, { - "name": "BROODLING", "role": "Bomber", - "desc": "Normal acid shots that also spawn a small slowing swarm cloud. The cloud lingers, fouling movement for anyone caught inside it — ideal for locking down a retreat or a chokepoint.", - "path": "res://assets/images/ships/vorg/broodling.png", "scale": 0.404, "speed_factor": SPEED_BY_ROLE.Bomber, - }, - { - "name": "NURSE", "role": "Support", - "desc": "Acid shots plus a small health/regeneration pack dropped for allies. Keeps the swarm alive in extended fights, at the cost of raw offensive power.", - "path": "res://assets/images/ships/vorg/nurse.png", "scale": 0.271, "speed_factor": SPEED_BY_ROLE.Support, - }, - { - "name": "BEHEMOTH", "role": "Heavy", - "desc": "Tankier and slower, with heavy acid shots that carry a large damage-over-time effect. The swarm's apex predator — absorbs punishment while its acid keeps eating away at anyone nearby.", - "path": "res://assets/images/ships/vorg/behemoth.png", "scale": 0.171, "speed_factor": SPEED_BY_ROLE.Heavy, + "name": "IRON-CLAD", "role": "Tank", + "desc": "Unguided explosive rockets plus continuous vulcan machine-gun fire. Bolted-together and brutal — built to grind a fight down, not finesse it.", + "path": "res://assets/images/ships/orc/iron_clad.png", "scale": 0.495, "speed_factor": SPEED_BY_ROLE.Tank, }, ], }, @@ -123,10 +91,29 @@ var _loading_lbl: Label var _offered: Array = [] var _chosen_race: Dictionary = {} +# False until the player's first-ever pick (pre-spawn, mandatory — no cancel +# possible). True afterward, once reopen() below can show this screen again +# for a live swap, at which point ESC should be able to back out of it +# without forcing a re-pick. +var _reopenable: bool = false + +# race_id -> {btn, count_lbl, hint_lbl, roster_lbl, color}, populated by +# _show_race_selection() and cleared the moment that screen isn't showing — +# _refresh_race_boxes() no-ops while empty, so a loadout_updated/player_removed +# signal arriving on the ship-selection screen (or while closed) is harmless. +var _race_boxes: Dictionary = {} + func _ready() -> void: layer = 20 _build_root() + GameConfig.team_select_focused = true + + # Keeps the race-selection screen's live counts/rosters/TEAM FULL state + # in sync as other players join, leave, or swap — both here (pre-spawn) + # and when reopened mid-match from the pause menu. + PlayerRegistry.loadout_updated.connect(_on_registry_changed) + PlayerRegistry.player_removed.connect(_on_registry_player_removed) # The 2 offered races must be the same for every player in the match, not # independently randomized per client — the server decides once (see @@ -140,6 +127,95 @@ func _ready() -> void: world.request_offered_races.rpc_id(1) +func _input(event: InputEvent) -> void: + if _reopenable and _root.visible and event.is_action_pressed("toggle_pause"): + _close() + get_viewport().set_input_as_handled() + + +# Called by PauseMenu's SELECT TEAM button so an already-playing player can +# swap race/ship mid-match. Reuses the match's already-decided _offered pair +# instead of re-requesting it — the server decides that once for everyone +# (World.decide_offered_races()) and it never changes mid-match. +func reopen() -> void: + _root.visible = true + GameConfig.team_select_focused = true + _show_race_selection() + + +func _close() -> void: + _root.visible = false + GameConfig.team_select_focused = false + _race_boxes.clear() + + +func _on_registry_changed(_peer_id: int) -> void: + _refresh_race_boxes() + + +func _on_registry_player_removed(_peer_id: int, _info: Dictionary) -> void: + _refresh_race_boxes() + + +# Recomputes each offered race's human headcount/roster and whether it's +# blocked as "TEAM FULL". Bots don't count — they're padding, not players +# choosing a side, so they'd distort the balance check for no reason. Only +# ever blocks the strictly larger side, and never a race the local player is +# already on (re-picking your own current race shouldn't lock you out of it). +func _refresh_race_boxes() -> void: + if _race_boxes.is_empty(): + return + + var counts: Dictionary = {} + var rosters: Dictionary = {} + for race in _offered: + counts[race.id] = 0 + rosters[race.id] = [] + + for peer_id in PlayerRegistry.players: + if peer_id < 0: + continue + var info: Dictionary = PlayerRegistry.players[peer_id] + var race_id: int = info.get("race", 0) + if not counts.has(race_id): + continue + counts[race_id] += 1 + rosters[race_id].append(String(info.get("name", "?"))) + + var my_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0) + var diff: int = counts[_offered[0].id] - counts[_offered[1].id] + const MAX_ROSTER_SHOWN := 14 + + for race in _offered: + var box: Dictionary = _race_boxes[race.id] + var count: int = counts[race.id] + box.count_lbl.text = "%d PLAYER%s" % [count, "" if count == 1 else "S"] + + var names: Array = rosters[race.id] + if names.is_empty(): + box.roster_lbl.text = "—" + elif names.size() > MAX_ROSTER_SHOWN: + box.roster_lbl.text = "\n".join(names.slice(0, MAX_ROSTER_SHOWN)) + \ + "\n+%d more" % (names.size() - MAX_ROSTER_SHOWN) + else: + box.roster_lbl.text = "\n".join(names) + + # Only one side can ever be "larger" at a time with exactly 2 offered + # races, so there's always at least one joinable side. + # Explicit `bool` types below (not `:=`): `race` comes from the + # untyped `_offered` Array, so `race.id == ...` resolves to Variant, + # and this project treats inferring a var's type from Variant as an + # error (same class of issue as the clamp() gotcha elsewhere). + var is_larger: bool = (race.id == _offered[0].id and diff > 2) or \ + (race.id == _offered[1].id and diff < -2) + var is_full: bool = is_larger and race.id != my_race + box.btn.disabled = is_full + box.hint_lbl.text = "TEAM FULL" if is_full else "CLICK TO SELECT" + var col: Color = box.color + box.hint_lbl.add_theme_color_override("font_color", + Color(0.95, 0.35, 0.35) if is_full else Color(col.r * 0.5, col.g * 0.5, col.b * 0.5)) + + # Called once this client knows which 2 races (by id) the match is offering. func offer_races(race_ids: Array) -> void: if _loading_lbl: @@ -198,19 +274,35 @@ func _show_race_selection() -> void: const BTN_W := 440.0 const HALF_GAP := 50.0 - var left_btn := _make_race_btn(_offered[0]) - _place(left_btn, -(BTN_W + HALF_GAP), 225, -HALF_GAP, 540, 0.5, 0.5) - left_btn.pressed.connect(_on_race_chosen.bind(_offered[0])) - c.add_child(left_btn) + _race_boxes.clear() + _build_race_box(c, _offered[0], -(BTN_W + HALF_GAP), -HALF_GAP) + _build_race_box(c, _offered[1], HALF_GAP, BTN_W + HALF_GAP) + _refresh_race_boxes() - var right_btn := _make_race_btn(_offered[1]) - _place(right_btn, HALF_GAP, 225, BTN_W + HALF_GAP, 540, 0.5, 0.5) - right_btn.pressed.connect(_on_race_chosen.bind(_offered[1])) - c.add_child(right_btn) + +# Builds one race's button + the player roster below it, and registers both +# in _race_boxes under the race's id so _refresh_race_boxes() can update them +# live as PlayerRegistry changes. +func _build_race_box(c: Control, race: Dictionary, x1: float, x2: float) -> void: + var built := _make_race_btn(race) + _place(built.btn, x1, 225, x2, 540, 0.5, 0.5) + built.btn.pressed.connect(_on_race_chosen.bind(race)) + c.add_child(built.btn) + + var roster_lbl := _label(c, "", 12, Color(0.55, 0.65, 0.78), + x1, 550, x2, 760, HORIZONTAL_ALIGNMENT_CENTER, 0.5, 0.5) + roster_lbl.vertical_alignment = VERTICAL_ALIGNMENT_TOP + roster_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + + _race_boxes[race.id] = { + "btn": built.btn, "count_lbl": built.count_lbl, "hint_lbl": built.hint_lbl, + "roster_lbl": roster_lbl, "color": race.color, + } func _on_race_chosen(race: Dictionary) -> void: _chosen_race = race + _race_boxes.clear() _show_ship_selection() @@ -239,14 +331,15 @@ func _show_ship_selection() -> void: func _on_ship_chosen(ship: Dictionary) -> void: PlayerRegistry.submit_local_loadout( - GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor + GameConfig.player_name, _chosen_race.id, ship.path, ship.scale, ship.speed_factor, ship.role ) - queue_free() + _reopenable = true + _close() # ── Button builders ────────────────────────────────────────────────────────── -func _make_race_btn(race: Dictionary) -> Button: +func _make_race_btn(race: Dictionary) -> Dictionary: var btn := Button.new() btn.text = "" var col: Color = race.color @@ -257,9 +350,13 @@ func _make_race_btn(race: Dictionary) -> Button: var sh := _flat(Color(col.r * 0.24, col.g * 0.24, col.b * 0.24, 0.96), 12) sh.set_border_width_all(2) sh.border_color = col + var sd := _flat(Color(0.08, 0.08, 0.09, 0.9), 12) + sd.set_border_width_all(2) + sd.border_color = Color(0.4, 0.15, 0.15) btn.add_theme_stylebox_override("normal", sn) btn.add_theme_stylebox_override("hover", sh) btn.add_theme_stylebox_override("pressed", sh) + btn.add_theme_stylebox_override("disabled", sd) var vb := VBoxContainer.new() vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -290,6 +387,14 @@ func _make_race_btn(race: Dictionary) -> Button: sub_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE vb.add_child(sub_lbl) + var count_lbl := Label.new() + count_lbl.text = "0 PLAYERS" + count_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + count_lbl.add_theme_font_size_override("font_size", 16) + count_lbl.add_theme_color_override("font_color", col) + count_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE + vb.add_child(count_lbl) + var hint := Label.new() hint.text = "CLICK TO SELECT" hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -298,7 +403,7 @@ func _make_race_btn(race: Dictionary) -> Button: hint.mouse_filter = Control.MOUSE_FILTER_IGNORE vb.add_child(hint) - return btn + return {"btn": btn, "count_lbl": count_lbl, "hint_lbl": hint} func _make_ship_row(ship: Dictionary, accent: Color) -> Button: diff --git a/spacewar/project.godot b/spacewar/project.godot index 0bcfba5..b654e71 100644 --- a/spacewar/project.godot +++ b/spacewar/project.godot @@ -22,6 +22,7 @@ NetworkManager="*res://autoload/network_manager.gd" PlayerRegistry="*res://autoload/player_registry.gd" MatchmakingClient="*res://autoload/matchmaking_client.gd" ChatManager="*res://autoload/chat_manager.gd" +KillFeedManager="*res://autoload/kill_feed_manager.gd" BotManager="*res://bots/bot_manager.gd" [display] diff --git a/spacewar/ships/bullet.gd b/spacewar/ships/bullet.gd index f648652..cf4fe06 100644 --- a/spacewar/ships/bullet.gd +++ b/spacewar/ships/bullet.gd @@ -23,5 +23,5 @@ func _on_body_entered(body: Node) -> void: if body.get("peer_id") == source_peer_id: return if body.has_method("take_damage"): - body.take_damage(GameConfig.bullet_damage) + body.take_damage(GameConfig.bullet_damage, source_peer_id) queue_free() diff --git a/spacewar/ships/ship_movement.gd b/spacewar/ships/ship_movement.gd index 144faa2..0bd1e05 100644 --- a/spacewar/ships/ship_movement.gd +++ b/spacewar/ships/ship_movement.gd @@ -1,6 +1,8 @@ extends CharacterBody2D class_name Ship +const EXPLOSION_SCENE: PackedScene = preload("res://effects/explosion.tscn") + # Set by World._spawn_ship() before this node enters the tree. var peer_id: int = 0 @@ -13,8 +15,17 @@ var peer_id: int = 0 var bot_ai: BotAI = null var _fire_cooldown: float = 0.0 +var _turn_hold_time: float = 0.0 var health: int = 0 +var energy: float = 0.0 +var max_energy: float = 100.0 var _dead: bool = false + +# Last-hit attribution for the kill feed (see autoload/kill_feed_manager.gd). +# 0 means "no attacking ship" (hazard/wall collision damage from +# _apply_collision_bounce()) -- real peer ids are never 0 (server is 1, +# clients > 1, bots negative, see bots/bot_manager.gd). +var _last_damage_source: int = 0 var _invincible: bool = false var _invincible_timer: float = 0.0 var _ship_speed_factor: float = 1.0 @@ -81,8 +92,9 @@ func _init_player(info: Dictionary) -> void: var ship_path: String = info.get("ship_path", "") if sprite and not ship_path.is_empty(): sprite.texture = load(ship_path) - sprite.scale = Vector2.ONE * info.get("ship_scale", 1.0) + sprite.scale = Vector2.ONE * info.get("ship_scale", 1.0) * GameConfig.ship_scale_factor _ship_speed_factor = info.get("ship_speed_factor", 1.0) + max_energy = GameConfig.ship_max_energy_by_role.get(info.get("role", "Fighter"), 100.0) set_physics_process(true) if multiplayer.is_server(): _server_respawn() @@ -123,15 +135,17 @@ func _owner_tick(delta: float) -> void: @rpc("authority", "call_remote", "unreliable") -func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, shown: bool) -> void: - # Piggyback current health AND visibility on every state broadcast (not - # just the one-shot _apply_health/_apply_death/_apply_respawn RPCs) so a - # peer that connects after another ship already spawned/respawned still - # converges within a tick, instead of being stuck with that ship's +func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, nrg: float, shown: bool) -> void: + # Piggyback current health/energy AND visibility on every state broadcast + # (not just the one-shot _apply_health/_apply_death/_apply_respawn RPCs) + # so a peer that connects after another ship already spawned/respawned + # still converges within a tick, instead of being stuck with that ship's # defaults (health=0, invisible) forever — those one-shot RPCs only ever # reach peers that were already connected at the moment they fired. - if health != hp: - health = hp + var changed := health != hp or absf(energy - nrg) > 0.01 + health = hp + energy = nrg + if changed: _update_hud() visible = shown @@ -182,9 +196,10 @@ func _server_tick(delta: float) -> void: input = _last_remote_input _simulate_step(delta, input, true) + energy = minf(max_energy, energy + GameConfig.ship_energy_regen_rate * delta) _server_process_shoot(delta, input) - _receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, visible) + _receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, energy, visible) @rpc("any_peer", "call_remote", "unreliable") @@ -210,7 +225,7 @@ func _remote_tick(delta: float) -> void: # ── Shared movement step (deterministic — replayed during reconciliation) ─── func _sample_local_input() -> Dictionary: - if GameConfig.chat_focused: + if GameConfig.chat_focused or GameConfig.team_select_focused: return {"up": false, "down": false, "left": false, "right": false, "shoot": false} return { "up": Input.is_action_pressed("move_up"), @@ -224,10 +239,23 @@ func _sample_local_input() -> Dictionary: # apply_side_effects gates anything non-deterministic-safe (e.g. dealing # damage) so replaying buffered inputs during reconciliation can't double it. func _simulate_step(delta: float, input: Dictionary, apply_side_effects: bool) -> void: - if input.get("right", false): - rotation += GameConfig.ship_rotation_speed * delta - if input.get("left", false): - rotation -= GameConfig.ship_rotation_speed * delta + var turning_right: bool = input.get("right", false) + var turning_left: bool = input.get("left", false) + # _turn_hold_time only advances on a fresh (non-replayed) step, same + # guard as damage above — during reconciliation replay it just stays + # frozen at its last real value instead of double-counting. + if apply_side_effects: + if turning_right or turning_left: + _turn_hold_time += delta + else: + _turn_hold_time = 0.0 + var turn_speed := GameConfig.ship_rotation_speed + if _turn_hold_time >= GameConfig.ship_rotation_ramp_delay: + turn_speed = GameConfig.ship_rotation_speed_held + if turning_right: + rotation += turn_speed * delta + if turning_left: + rotation -= turn_speed * delta var thrust := GameConfig.ship_thrust * _ship_speed_factor var max_speed := GameConfig.ship_max_speed * _ship_speed_factor @@ -276,8 +304,9 @@ func _apply_collision_bounce(previous_velocity: Vector2, apply_side_effects: boo # World spawn the bullet, which replicates it to every peer. func _server_process_shoot(delta: float, input: Dictionary) -> void: _fire_cooldown -= delta - if input.get("shoot", false) and _fire_cooldown <= 0.0: + if input.get("shoot", false) and _fire_cooldown <= 0.0 and energy >= GameConfig.bullet_energy_cost: _fire_cooldown = GameConfig.ship_fire_rate + energy -= GameConfig.bullet_energy_cost var world := get_node_or_null("/root/World") if world: world.request_bullet_spawn( @@ -289,11 +318,12 @@ func _server_process_shoot(delta: float, input: Dictionary) -> void: # ── Health / death — server-authoritative, broadcast to every peer ────────── -func take_damage(amount: int) -> void: +func take_damage(amount: int, source_peer_id: int = 0) -> void: if not multiplayer.is_server(): return if _invincible or _dead: return + _last_damage_source = source_peer_id health = max(0, health - amount) _apply_health.rpc(health) if health == 0: @@ -309,6 +339,7 @@ func _apply_health(new_health: int) -> void: func _die() -> void: if not multiplayer.is_server(): return + KillFeedManager.report_kill(peer_id, _last_damage_source) _apply_death.rpc() await get_tree().create_timer(GameConfig.ship_respawn_delay).timeout _server_respawn() @@ -319,6 +350,16 @@ func _apply_death() -> void: _dead = true visible = false velocity = Vector2.ZERO + _spawn_explosion() + + +# Purely cosmetic, so it's spawned locally by every peer off the already +# broadcast _apply_death RPC above rather than needing its own networked +# spawner (contrast bullets/ships, which are gameplay-authoritative). +func _spawn_explosion() -> void: + var explosion := EXPLOSION_SCENE.instantiate() + explosion.global_position = global_position + get_parent().add_child(explosion) func _server_respawn() -> void: @@ -331,7 +372,9 @@ func _apply_respawn(pos: Vector2) -> void: velocity = Vector2.ZERO rotation = 0.0 health = GameConfig.ship_max_health + energy = max_energy _dead = false + _last_damage_source = 0 visible = true _invincible = true _invincible_timer = GameConfig.ship_invincibility_time @@ -347,15 +390,40 @@ func _apply_respawn(pos: Vector2) -> void: _update_hud() func _get_spawn_position() -> Vector2: - var markers := get_tree().get_nodes_in_group("team_a_spawn") + get_tree().get_nodes_in_group("team_b_spawn") + var markers := get_tree().get_nodes_in_group(_team_spawn_group()) if markers.size() > 0: - return markers[randi() % markers.size()].global_position + var base: Vector2 = markers[randi() % markers.size()].global_position + var offset := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * GameConfig.spawn_scatter_radius + return base + offset return GameConfig.world_bounds.get_center() +# Which side of the map this ship's race spawns on. Without this, spawn +# position was picked at random from *both* groups regardless of race, so +# opposite-race ships regularly landed right next to each other at the same +# marker -- immediate point-blank combat at spawn that, since dying just +# respawns back to that same shared marker, never actually moves away from +# spawn at all (looks identical to the ships-piled-up-and-frozen bug, but +# caused by a standing enemy contact instead of an idle one). +func _team_spawn_group() -> String: + var race_id: int = PlayerRegistry.get_info(peer_id).get("race", 0) + var world := get_node_or_null("/root/World") + if world: + var offered: Array = world.decide_offered_races() + if offered.size() == 2 and race_id == offered[1]: + return "team_b_spawn" + return "team_a_spawn" + + func _update_hud() -> void: if not _is_owner: return - var label = get_node_or_null("/root/World/HUD/HealthLabel") - if label: - label.text = "HP: %d / %d" % [health, GameConfig.ship_max_health] + var health_bar = get_node_or_null("/root/World/HUD/HealthBar") + if health_bar and health_bar.has_method("set_fill"): + health_bar.set_fill(float(health) / GameConfig.ship_max_health if GameConfig.ship_max_health > 0 else 0.0) + var hud_energy_bar = get_node_or_null("/root/World/HUD/EnergyBar") + if hud_energy_bar and hud_energy_bar.has_method("set_fill"): + hud_energy_bar.set_fill(energy / max_energy if max_energy > 0.0 else 0.0) + var energy_bar = get_node_or_null("/root/World/EnergyBar") + if energy_bar and energy_bar.has_method("set_fill"): + energy_bar.set_fill(energy / max_energy if max_energy > 0.0 else 0.0) diff --git a/spacewar/world/world.tscn b/spacewar/world/world.tscn index 7b46fe6..22d3849 100644 --- a/spacewar/world/world.tscn +++ b/spacewar/world/world.tscn @@ -4,6 +4,12 @@ [ext_resource type="PackedScene" uid="uid://dteamselect01" path="res://menu/team_select.tscn" id="3_ts"] [ext_resource type="Script" path="res://world/world.gd" id="4_world"] [ext_resource type="PackedScene" uid="uid://cchatbox01a" path="res://chat/chat_box.tscn" id="5_chat"] +[ext_resource type="PackedScene" uid="uid://cplayerlist01a" path="res://hud/player_list.tscn" id="6_plist"] +[ext_resource type="PackedScene" uid="uid://ckillfeed001" path="res://hud/kill_feed.tscn" id="7_kfeed"] +[ext_resource type="PackedScene" uid="uid://cminimap0001" path="res://hud/mini_map.tscn" id="8_mmap"] +[ext_resource type="PackedScene" path="res://hud/ping_display.tscn" id="9_ping"] +[ext_resource type="PackedScene" path="res://hud/energy_bar.tscn" id="10_ebar"] +[ext_resource type="Script" path="res://hud/stat_bar.gd" id="11_statbar"] [node name="World" type="Node2D" unique_id=1962020789] script = ExtResource("4_world") @@ -22,16 +28,34 @@ spawn_path = NodePath("../Bullets") [node name="HUD" type="CanvasLayer" parent="."] -[node name="HealthLabel" type="Label" parent="HUD"] +[node name="HealthBar" type="Control" parent="HUD"] offset_left = 20.0 offset_top = 20.0 offset_right = 300.0 -offset_bottom = 60.0 -text = "HP: 100 / 100" -theme_override_font_sizes/font_size = 24 +offset_bottom = 44.0 +script = ExtResource("11_statbar") +fill_color = Color(0.3, 0.85, 0.35, 0.95) + +[node name="EnergyBar" type="Control" parent="HUD"] +offset_left = 20.0 +offset_top = 50.0 +offset_right = 300.0 +offset_bottom = 74.0 +script = ExtResource("11_statbar") +fill_color = Color(0.25, 0.55, 1.0, 0.95) [node name="PauseMenu" parent="." instance=ExtResource("2_pm")] [node name="TeamSelect" parent="." instance=ExtResource("3_ts")] [node name="ChatBox" parent="." instance=ExtResource("5_chat")] + +[node name="PlayerList" parent="." instance=ExtResource("6_plist")] + +[node name="KillFeed" parent="." instance=ExtResource("7_kfeed")] + +[node name="MiniMap" parent="." instance=ExtResource("8_mmap")] + +[node name="PingDisplay" parent="." instance=ExtResource("9_ping")] + +[node name="EnergyBar" parent="." instance=ExtResource("10_ebar")]