Add game mode categories, character creation, on-foot ship/hub mode, RAM currency, and in-ship navigation

Bundles several sessions' worth of previously uncommitted work: map
categories with Domination/Conquest/King of the Hill mode stubs and a
server-list mode filter; a procedurally-drawn character-creation screen
replacing the old callsign-only PROFILE overlay; the on-foot groundwork
(walkable station hub, ship interior, character controller) plus the RAM
currency backend; and today's addition, an in-ship Helldivers-2-style
navigation table that QUICK PLAY's queue/connect flow and the Belters/
Military hub travel now live behind, with hub-and-ship return paths and a
context-aware pause menu usable both in-match and inside the ship.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 12:30:22 -04:00
parent be5c41f82f
commit d3bed34c6c
124 changed files with 3347 additions and 159 deletions
+14 -3
View File
@@ -8,7 +8,7 @@
|------|----------|
| `overview.md` | Concept, core loop, game flow, match sizes |
| `racesclasses.md` | 2 chosen factions, 3 ship classes each |
| `multiplayer.md` | Maps, game modes (design ideas — casual's Sector Control etc. not yet built), galaxy war meta |
| `multiplayer.md` | Maps, game modes (4 casual categories exist as `GameMode` subclasses — Team Deathmatch is real, Domination/Conquest/King of the Hill are scoring stubs, see `CLAUDE.md` item 27), galaxy war meta |
| `bots.md` | Bot fill rules for casual matches |
| `tech.md` | Engine, networking architecture, checklist |
| `money.md` | Monetization strategy |
@@ -71,13 +71,24 @@ Added the other half of that same knob: a VSYNC dropdown (Enabled/Adaptive/Disab
25. **ISN flagship formation mixes in a Stealth Corvette hull** — item 24's 3-ship flagship formation was 3 identical `flagship_colossus.png` copies per faction; ISN's formation is now 2 Colossus + 1 `stealth_corvette.png` (a new hull processed from a user-supplied render: background matted out via border flood-fill, a disconnected decorative sparkle dropped, rotated 90° to this project's nose-up convention — see `assets/images/ships/isn/source/isn_stealth_corvette_obsidian_source.jpeg` for the original). `team_select.gd`'s ISN entry gained a `flagship_paths` array (list of `{path, scale}`, cycled by formation-slot index in `world.gd`'s `_spawn_flagship()`); a race with no `flagship_paths` (ORC, so far) falls through to the original singular `flagship_path`/`flagship_scale` for every slot, so this is additive and backward compatible. The Corvette's `scale: 0.68` was calibrated against its native resolution (516×1019, notably taller than Colossus's 535×692) so all 3 formation members read at the same on-screen size despite the size mismatch in source art.
26. **Match structure — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats, reusable GameMode/map framework** (see `overview/map1.md`) — matches now have real phases instead of ships spawning the instant a peer connects. New `MatchManager` autoload (`autoload/match_manager.gd`) runs a server-authoritative `PRE_MATCH` (30s countdown, ships held invisible/uncollidable via `ship_movement.gd`'s `server_hold_for_match_start()`/`server_release_from_hold()` — reuses the existing dead-ship machinery rather than a parallel state, so prediction/reconciliation are untouched; flagships cosmetically tween in from a random offset via `flagship.gd`'s `play_creep_in()`) → `IN_PROGRESS` (the active `GameMode`'s 7-minute clock runs, ships released) → `POST_MATCH` (winner banner, result reported to matchmaking-api) → loops back into a fresh `PRE_MATCH` forever, matching the always-on server-pool model from item 16 rather than kicking players to the menu. Phase state is replicated with the same "decide once on the server, RPC-broadcast, late joiners pull it" pattern `world.gd`'s `decide_offered_races()` already established. Win-condition logic lives entirely in a new `GameMode` abstraction (`world/game_modes/game_mode.gd` base + `team_deathmatch_mode.gd`, the only mode implemented so far — most kills when the clock runs out wins, draw on a tie) built identically on every peer off a shared `GameConfig.default_game_mode_id` constant, so a future mode (`multiplayer.md`'s Flagship Assault/Last Ship Standing/Capture and Hold ideas) is a new subclass plus one factory branch in `MatchManager`, with zero changes to the timer/scoreboard/banner code around it — none of that ever hardcodes "kills," only ever calling `GameMode.get_score_for_team()`/`get_score_label()`. `world.gd`'s hardcoded `MAP_SCENE` const became a `MAPS` array + `_pick_map()` (same static-array convention as `TeamSelect.RACES`) so a second map is an append, not a rewrite — still only `map_01` exists today. New `MatchStats` autoload (`autoload/match_stats.gd`) tracks per-match (not persistent) kills/deaths by peer_id, including bots, hooked into the single existing death-detection call site (`kill_feed_manager.gd`'s `report_kill()`) rather than duplicating it in `ship_movement.gd`. Three new signal-driven HUD `CanvasLayer`s follow every existing HUD file's autonomous-`_build_ui()` convention: `hud/match_timer.gd` (top-center countdown/clock), `hud/scoreboard.gd` (hold-Tab CS:GO-style two-team panel, new `scoreboard` input action bound to Tab), `hud/match_banner.gd` (full-screen winner banner, `layer=30` — highest in the project). Banner art (`assets/images/banners/`) was renamed from opaque UUID filenames to `isn_banner.jpeg`/`orc_banner.jpeg` (the third, Apex Dynamics, stays orphaned per item 19's precedent of leaving unused assets on disk) and wired via a new `banner_path` key on `team_select.gd`'s ISN/ORC `RACES` entries, same pattern as the existing `flagship_path` key. Persistent per-player history (total kills, deaths, wins, losses, hours played) is a new `matchmaking-api` capability: `Player` gained `kills`/`deaths`/`hours_played` columns, a new `POST /matches/report` endpoint (`app/routers/matches.py`) is called once by the hosting server's `MatchManager` (never a client, same server-only trust model as `/servers/register`) at `POST_MATCH`, upserting each real (non-bot) player's totals by callsign via a shared `app/crud.py` helper also now used by the matchmaking-queue join path; `GET /stats/{callsign}` returns the 3 new fields alongside the existing `mmr`/`wins`/`losses`. No migrations tooling exists in this API (schema changes just land in `models.py` for `create_all()` to pick up on a fresh DB) — a `docker compose down -v` is needed once to add the new columns to an already-existing local `players` table, verified end-to-end via a real `docker compose up` round-trip during implementation.
27. **Map categories + 3 new GameMode stubs (Domination, Conquest, King of the Hill)** — maps now declare which game modes they support, the way CS's de_/cs_ prefixes tie a map to a mode, except a map can list more than one category rather than being locked to exactly one. `world.gd`'s `MAPS` entries gained a `"categories"` array; `World.pick_map()` (static, public) filters `MAPS` down to whichever list a given mode id and picks the first match, falling back to the full list if nothing matches (documented as *not* randomized among ties — every caller runs this independently with no shared seed, unlike `_spawn_flagships()`'s `_match_seed`, so a random pick here would desync which map each peer loads the moment a second candidate exists; real map rotation needs that seed-sync treatment first). Three new `GameMode` subclasses (`world/game_modes/domination_mode.gd`/`conquest_mode.gd`/`king_of_the_hill_mode.gd`) round out `MatchManager._create_mode()`'s factory alongside `team_deathmatch_mode.gd` — each only overrides `get_mode_name()` for now and shares a new `GameMode._kills_based_win_condition()` helper (factored out of `TeamDeathmatchMode.check_win_condition()`, which now just calls it) for scoring, since none of the three have real zone/capture-point mechanics yet. `overview/multiplayer.md`'s casual mode-ideas table was renamed to match this session's chosen names (Domination/Conquest/King of the Hill), replacing the old Sector Control/Annihilation/Base Assault/Convoy Escort ideas that had no code behind them either way. **map_01 only lists `"team_deathmatch"`** in its `categories` — Domination/Conquest/King of the Hill exist as `GameMode` code but no map is actually built/tuned for them yet, so map_01 doesn't claim to support modes it can't really run.
**Server list now reports the real game mode + map**`menu/server_select.gd` was previously showing only the matchmaking *queue* mode ("CASUAL", the only value `Mode` has since item 15) with no indication of what's actually being played. `GameServer` (matchmaking-api) gained `game_mode`/`map_name` string columns (plain strings, not a `Mode`-style enum — the API has no reason to know the client's `GameMode` id catalog), set via new fields on `POST /servers/register`'s `ServerRegisterRequest` and returned by `GET /servers`; `NetworkManager._register_with_matchmaking_api()` now calls the static `World.pick_map(GameConfig.default_game_mode_id)` to get these before every heartbeat — safe to call before `world.tscn` even loads (the heartbeat starts on server boot) since it's a pure function of shared static data, same "decide independently, same result on every caller" pattern as `TeamSelect.RACES`. `world.gd` gained a `class_name World` so this static call is reachable from `NetworkManager`. Each server row now reads e.g. `"TEAM DEATHMATCH · Sector Alpha 127.0.0.1:7777"` (`_format_mode_name()` mirrors `GameMode.get_mode_name()`'s formatting so the two never spell a mode differently). Verified end-to-end: rebuilt the local matchmaking-api DB (`docker compose down -v && up -d --build`, needed for the new columns per this API's no-migrations convention — see item 26/`matchmaking-api/README.md`), hosted a real headless server, and confirmed `GET /servers` showed `game_mode: "team_deathmatch"`, `map_name: "Sector Alpha"` for the live row.
**Game mode became its own toggleable filter, not just row text**`server_select.gd` gained an "ALL MODES" + one-per-`GameConfig.GAME_MODE_IDS` toggle bar (radio-style via a `ButtonGroup`, same pattern the server rows below already use) above the list; picking one narrows the rendered rows to that `game_mode` without re-hitting the API. `GameConfig.GAME_MODE_IDS` is a new canonical ordered array (`["team_deathmatch", "domination", "conquest", "king_of_the_hill"]`) backing both this filter bar and `default_game_mode_id`'s valid values. Required splitting what was one `_refresh_servers()` (fetch + render) into `_refresh_servers()` (network fetch only, sets `_all_servers`) and `_apply_filter()` (narrows to `_selected_category`, rebuilds rows into `_servers` — the array rows/ping-probes/CONNECT actually index into) so toggling a filter doesn't need a fresh network round-trip.
28. **Character creation screen (Stardew-Valley-style callsign + portrait picker)** — the main menu's PROFILE overlay (item 14) was just a callsign `LineEdit`; it now also offers a portrait pick, over the existing space-themed starfield background (`main_menu.gd`'s `_add_bg()`, unchanged, already reused here). No portrait art exists in the project (no `assets/images/portraits/` or similar), so portraits are procedurally drawn rather than sourced as new images: new `menu/pilot_portrait.gd` (`class_name PilotPortrait`, `extends Control`) `_draw()`s a simple pilot bust (shoulders + helmet + visor) from a `PRESETS` array of grayscale suit shades paired with a colored visor accent (Ash/Slate/Steel/Iron/Fog/Graphite) — kept monochrome/gray per this feature's art direction, with the visor as the only pop of color per preset. `_add_profile_overlay()`'s panel grew (400×240 → 420×450) to fit a centered portrait frame flanked by `<`/`>` `Button`s (`_on_portrait_cycle()`, cycling `PilotPortrait.preset_index` which wraps via `wrapi()`) above the existing callsign field; title changed from "PROFILE" to "CREATE YOUR PILOT". New `GameConfig.player_portrait_index: int` stores the choice with the same lifetime as the existing (not-persisted-to-disk, resets on relaunch) `player_name` — only written on SAVE, matching the callsign's existing commit-on-save behavior rather than live-updating as the player cycles. The chosen portrait is also now shown as a small icon in the main menu's top-right badge (`_badge_portrait`, refreshed in `_refresh_profile_badge()`) next to the callsign/rank text, so the pick is visible outside the creation screen too, not just while editing it.
29. **On-foot station hub — first sprite import + walkable room (new gameplay mode, groundwork only)** — kicks off a Stardew-Valley-style on-foot mode alongside the ship combat, using 4 AI-generated reference sheets dropped at `assets/images/worldsprites/orc/` (a multi-NPC character roster, a messy "concept + swatches" corridor sheet, a clean labeled tileset reference sheet, and a furniture/props sheet). No `godot4` binary was assumed available per prior session notes, but this sandbox does have one at the `godot4`-aliased path — used here to actually headless-import and screenshot-verify everything below, not just hand-write `.import` files blind. Individual sprites were hand-cropped out of the sheets with ImageMagick (no PIL/pip in this sandbox) since none of the 4 sheets are uniform game-ready spritesheets — grid boundaries were found by overlaying a labeled pixel grid and reading it back with the Read tool, iterating per-crop; backgrounds matted to transparent via corner-seeded flood fill (`-fuzz`/`-draw "alpha X,Y floodfill"`), same technique as the existing ship sprites. New assets: `assets/images/worldsprites/character/soldier_{idle,walk}.png` (one NPC variant's front-idle + side-walking pose — the sheet has no full 4-directional walk cycle, just these two poses per outfit, so `on_foot_character.gd` swaps between them by movement state rather than animating frames, and flips `Sprite2D.flip_h` for left/right, mirroring `ship_movement.gd`'s thrust-sprite-swap pattern from item 18); `assets/images/worldtiles/station_{floor,walls,door}.png` (3 floor variants + 2 wall variants cropped to a uniform 72×72 grid from the clean labeled sheet, plus one door graphic used as a decorative `Sprite2D`, not a tile); `assets/images/worldsprites/furniture/{locker,cabinet,plant,server_rack}.png`. Each folder keeps a `source/` copy of its origin sheet, same convention as the race art in item 17. New `world/station_tileset.tres` (2 `TileSetAtlasSource`s, `tile_size = Vector2i(72, 72)`) follows `world/world_tileset.tres`'s existing walls+asteroids split pattern. New `world/levels/station_hub.tscn` + `station_hub.gd` (`class_name StationHub`) procedurally builds a 12×9 room via `TileMapLayer.set_cell()` in `_ready()` (hand-authoring the raw `tile_data` `PackedInt32Array` format was judged too risky to get right blind, so the layout is code-generated instead of painted) with a door-sized gap in the wall ring, spawns `StaticBody2D` wall colliders per used cell (same "tiles have no collision shapes of their own" approach as `world.gd`'s ship-map collider builder, but a separate implementation local to this scene, not a shared one) and places the 4 furniture sprites as static decoration. New `world/levels/on_foot_character.gd` (`class_name OnFootCharacter`, `CharacterBody2D`) is a 4-directional top-down walker (reuses the existing `move_up/down/left/right` input actions literally instead of the ships' rotate+thrust scheme) with idle/walk sprite swapping and a `Camera2D` using the same `make_current()` fix from the networking memory notes. Verified end-to-end, not just import-clean: launched the real scene headlessly under a throwaway Xvfb display (`:133`, distinct from the user's actual running editor/display — left both untouched), screenshotted the room (floor variety, both wall types, door gap, all 4 furniture pieces, and the player all render correctly), then drove movement via a temporary `HubAutopilot` autoload (removed after) simulating a held `move_right` and confirmed the walk sprite/camera-follow render correctly and that the character stops cleanly at the rock wall's collider instead of clipping through. **This is groundwork only** — no main-menu entry point, no NPC interaction/dialogue, no second room/transition, and the character roster/tileset/furniture sheets still have far more content un-cropped than used (only 1 of ~10 character variants, 5 of ~40 tileset tiles, 4 of ~30 furniture items) — see Current Tasks.
## Current Tasks
- [x] ~~Asteroids and environment hazards~~ — done, undocumented until now: `world.gd`'s collider builder gives the map's `Asteroids` TileMapLayer round hitboxes in an `environment_hazard` group distinct from `environment_wall`, and deals impact damage (`GameConfig.ship_wall_damage`) on top of the bounce every wall/asteroid tile already causes
- [ ] Sound effects — shoot (per-ship, `world.gd`'s `_play_shoot_sound()`) and menu music (`autoload/music_manager.gd`) exist; thrust, explosion, and UI-click sounds are still missing
- [ ] Graphics quality presets / resolution scale in Options (audio, window mode, colorblind, and key rebinding are now done — see item 21)
- [ ] Real rank/level backend — main menu's top-right badge and RANK_DATA are still placeholders (`CURRENT_RANK`/`CURRENT_LEVEL` constants in `main_menu.gd`)
- [ ] Additional game modes (Flagship Assault, Last Ship Standing, Capture and Hold — see `multiplayer.md`) via item 26's new `GameMode` framework (`world/game_modes/`); only Team Deathmatch is implemented so far
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`) — Sector Control specifically would also need its own `GameMode` subclass, see above
- [ ] Real scoring for Domination/Conquest/King of the Hill (see item 27) — all 3 exist as `GameMode` subclasses with map-category tags wired up, but still score by kills like Team Deathmatch until each grows its own zone/point/capture mechanic
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`) — ties most naturally to Domination once it has real per-sector scoring, see above
- [ ] Real account-linked identity — GodotSteam auth + VAC still not started; `callsign` is the only player identity today
- [ ] Lag compensation (basic rewind) — matters once testing moves beyond localhost
- [ ] On-foot station hub (see item 29) — currently a standalone unlinked scene (`world/levels/station_hub.tscn`) with one hand-built room; needs a main-menu entry point, decide whether it's multiplayer (networked like ship combat) or single-player/local, NPC interaction, and cropping more of the still-mostly-unused character/tileset/furniture sheets
+22
View File
@@ -106,6 +106,28 @@ add them to a database that already has a `players` table from before this
change. Run `docker compose down -v` once to pick them up on an existing local
dev database.
## RAM (in-game currency)
See `overview/onfoot.md` for the full design — this is just the backend
piece. `Player.ram_kb` is a single `BigInteger` balance, always stored and
transmitted as kilobytes (the smallest denomination); the Godot client
(`spacewar/autoload/currency.gd`) formats it up into KB/MB/GB/TB for
display, 1000 per step. 100% separate from real-money monetization
(`overview/money.md`) — this is purely an in-game economy.
`POST /matches/report` now also credits RAM to every reported player:
`ram_payout_participation_kb` just for being in the match, plus
`ram_payout_per_kill_kb` per kill, plus `ram_payout_win_bonus_kb` if they
won (all three tunable in `app/config.py`, currently 50/15/200 — placeholder
numbers, not balanced against anything). `GET /stats/{callsign}` returns the
running total as `ram_kb`.
There is no spend endpoint yet — nothing in the game can spend RAM until the
ship interior/hubs from `overview/onfoot.md` exist. `Player.ram_kb` is a new
column on an existing table, same no-migrations caveat as above — covered by
the same `docker compose down -v` if you're picking this up on an existing
local dev database.
## Client integration
The Godot client is wired up (`spacewar/autoload/matchmaking_client.gd`):
+10
View File
@@ -15,5 +15,15 @@ class Settings(BaseSettings):
# to "offline" and back.
server_stale_seconds: int = 20
# RAM payout formula for POST /matches/report (see overview/onfoot.md) --
# every reported player gets ram_payout_participation_kb just for being
# in the match, plus ram_payout_per_kill_kb per kill, plus
# ram_payout_win_bonus_kb if they won. Placeholder numbers, not balanced
# against anything -- tune freely, nothing else in the schema depends on
# the specific values.
ram_payout_participation_kb: int = 50
ram_payout_per_kill_kb: int = 15
ram_payout_win_bonus_kb: int = 200
settings = Settings()
+2
View File
@@ -45,6 +45,8 @@ async def _seed_demo_servers() -> None:
status=ServerStatus.available,
player_count=player_count,
max_players=50,
game_mode="team_deathmatch",
map_name="Sector Alpha",
)
)
await db.commit()
+18 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String
from sqlalchemy import BigInteger, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -38,6 +38,13 @@ class Player(Base):
kills: Mapped[int] = mapped_column(Integer, default=0)
deaths: Mapped[int] = mapped_column(Integer, default=0)
hours_played: Mapped[float] = mapped_column(Float, default=0.0)
# In-game currency, see overview/onfoot.md -- named RAM, stored as a
# single integer count of kilobytes (the smallest denomination); the
# client formats it up into KB/MB/GB/TB (1000 per step, not 1024) for
# display. BigInteger since a long-lived player's total is expected to
# climb well past 32-bit Integer's ~2.1 billion ceiling (2.1 billion KB
# is only ~2.1 TB).
ram_kb: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@@ -57,6 +64,16 @@ class GameServer(Base):
# once that probe answers.
player_count: Mapped[int] = mapped_column(Integer, default=0)
max_players: Mapped[int] = mapped_column(Integer, default=50)
# The actual in-match GameMode id (e.g. "team_deathmatch") and map
# display name (e.g. "Sector Alpha") this server is running -- distinct
# from `mode` above, which is only the matchmaking queue mode ("casual").
# Plain strings, not a Mode-style enum: the set of GameMode ids lives in
# the Godot client (spacewar/world/game_modes/, world.gd's MAPS), which
# this API has no reason to duplicate/validate against. Reported by the
# hosting server on every register/heartbeat call (see
# spacewar/autoload/network_manager.gd's host_server()).
game_mode: Mapped[str] = mapped_column(String(64), default="team_deathmatch")
map_name: Mapped[str] = mapped_column(String(64), default="Sector Alpha")
class Match(Base):
+6
View File
@@ -3,6 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud
from app.config import settings
from app.database import get_db
from app.models import GameServer, Match, MatchPlayer
from app.schemas import MatchReportRequest
@@ -48,6 +49,11 @@ async def report_match(req: MatchReportRequest, db: AsyncSession = Depends(get_d
player.kills += result.kills
player.deaths += result.deaths
player.hours_played += result.seconds_played / 3600.0
player.ram_kb += (
settings.ram_payout_participation_kb
+ result.kills * settings.ram_payout_per_kill_kb
+ (settings.ram_payout_win_bonus_kb if result.is_winner else 0)
)
if result.is_winner:
player.wins += 1
elif req.winner_team is not None:
+6
View File
@@ -34,6 +34,8 @@ async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends
existing.player_count = req.player_count
existing.max_players = req.max_players
existing.last_heartbeat = datetime.utcnow()
existing.game_mode = req.game_mode
existing.map_name = req.map_name
else:
db.add(
GameServer(
@@ -43,6 +45,8 @@ async def register_server(req: ServerRegisterRequest, db: AsyncSession = Depends
status=status,
player_count=req.player_count,
max_players=req.max_players,
game_mode=req.game_mode,
map_name=req.map_name,
)
)
await db.commit()
@@ -80,6 +84,8 @@ async def list_servers(db: AsyncSession = Depends(get_db)) -> list[dict]:
"player_count": s.player_count,
"max_players": s.max_players,
"last_heartbeat": s.last_heartbeat,
"game_mode": s.game_mode,
"map_name": s.map_name,
}
for s in servers
]
+1
View File
@@ -22,4 +22,5 @@ async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> Player
kills=player.kills,
deaths=player.deaths,
hours_played=player.hours_played,
ram_kb=player.ram_kb,
)
+5
View File
@@ -29,6 +29,10 @@ class ServerRegisterRequest(BaseModel):
mode: Mode
player_count: int = 0
max_players: int = 50
# See GameServer.game_mode/map_name in app/models.py -- the in-match
# GameMode id and map display name, distinct from `mode` (queue mode).
game_mode: str = "team_deathmatch"
map_name: str = "Sector Alpha"
class PlayerStatsResponse(BaseModel):
@@ -39,6 +43,7 @@ class PlayerStatsResponse(BaseModel):
kills: int
deaths: int
hours_played: float
ram_kb: int
# Reported by the hosting game server (never a client directly -- see
+15 -13
View File
@@ -41,28 +41,30 @@ Competitive, skill-based, MMR/ELO ladder. Penalty for leaving mid-match. Small
No penalty for leaving. Larger maps. Chaotic and fun — you can drop in mid-match.
> **Status:** **Team Deathmatch** (the first row below) is implemented — a
> 7-minute timed version of Annihilation's "pure kills" idea (most kills when
> the clock runs out wins, not last-team-standing), plus a 30s pre-match
> **Status:** **Team Deathmatch** is implemented for real — a 7-minute timed
> "most kills when the clock runs out wins" mode, plus a 30s pre-match
> countdown, live kill/death tracking, a Tab scoreboard, and a winner banner
> (see `overview/map1.md`, `CLAUDE.md` item 26). It's built through a
> `GameMode` abstraction (`spacewar/world/game_modes/`) specifically so the
> other rows here — Sector Control, King of the Hill, Base Assault, Convoy
> Escort — can each become a new `GameMode` subclass later without changing
> the match-phase/timer/scoreboard/banner machinery around them.
> other 3 rows below — Domination, Conquest, King of the Hill — can each
> become a fully-realized `GameMode` subclass later without changing the
> match-phase/timer/scoreboard/banner machinery around them. As of
> `CLAUDE.md` item 27, all 4 modes below exist as real `GameMode` subclasses
> and are wired up end-to-end via a "category" tag on each map
> (`world.gd`'s `MAPS`, `World._pick_map()`) — but Domination/Conquest/King
> of the Hill are still scoring stubs (kills, same as Team Deathmatch) until
> each grows its own zone/point/capture mechanic.
### Casual Mode Ideas
| Mode | Description |
|------|-------------|
| **Team Deathmatch** *(implemented)* | Most kills when the match timer runs out wins. Timed variant of the Annihilation idea below. |
| **Sector Control** | Map divided into sectors. Hold more sectors at time limit. Ties to galactic war meta. |
| **King of the Hill** | One contested zone in the center. Hold it to rack up points. |
| **Annihilation** | Pure kills. Last team standing or first to X kills. Chaos mode. |
| **Base Assault** | One team attacks a fortified base, one defends. Roles swap each round. |
| **Convoy Escort** | One team escorts a slow-moving freighter across the map, other team destroys it. |
| **Team Deathmatch** *(implemented)* | Most kills when the match timer runs out wins. |
| **Domination** *(category stub)* | Map divided into multiple capture points. Hold more of them than the enemy at the time limit. Ties to galactic war meta. |
| **Conquest** *(category stub)* | Contested capture points bleed the losing team's respawn "tickets"; first team to zero tickets (or fewer tickets at the time limit) loses. |
| **King of the Hill** *(category stub)* | One contested zone in the center. Hold it to rack up points. |
> **Suggestion:** Start with **Sector Control** for casual. It ties into the galaxy meta (Helldivers-style), encourages teamwork without hard requirements, and naturally scales to 25v25.
> **Suggestion:** Build out **Domination** next — it ties into the galaxy meta (Helldivers-style), encourages teamwork without hard requirements, and naturally scales to 25v25.
---
+475
View File
@@ -0,0 +1,475 @@
# On-Foot / Ship & Solar System Layer
Planning doc for the Stardew-Valley-style on-foot mode: a walkable layer
(your ship, solar-system locations, 2 multiplayer hubs) alongside the
existing ship-combat game. This is the working doc for turning the item 29
groundwork (see `CLAUDE.md`) into something actually playable. Sections
below are meant to get filled in / checked off as decisions land — not a
finished spec yet, and the vision itself has already changed shape more than
once, so treat this as a living document rather than a locked design.
## Vision
Two intertwined layers (current thinking, as of this doc's last edit):
1. **Space combat (multiplayer, existing/live)** — the ship-vs-ship battles
already built (`world/world.tscn`, `GameMode` subclasses, matchmaking).
This is the money-earning loop: winning/playing matches pays out
**RAM**, the in-game currency (named after EVE Online's ISK — 100%
separate from `overview/money.md`'s real-world monetization strategy,
confirmed, that doc is about pricing the game itself). Balance is
**server-authoritative**, tracked in `matchmaking-api` alongside the
existing `Player.kills/deaths/wins` stats rather than trusted client-side.
2. **The Stardew side (new, this doc's scope)** — spending RAM and time in a
walkable, on-foot layer:
- **Your ship, walked around inside** — this is the Stardew *farm*
equivalent, and also the **home base** (see "Where does it fit in the
flow" below — space combat and hub travel both launch from here).
Maintenance is broad, not just crops: growing crops (the hydroponics
plant-rack furniture already cropped for `station_hub.tscn` is a
natural seed for this), cosmetic decoration, room-based upgrades that
matter in combat (see "Ship upgrades = new rooms" below — ties the two
layers together mechanically, not just economically), and repairing/
restocking hull, guns, fuel, oxygen, and ammo after combat wear (see
"Ship wear, supplies & repair" below). That's 4 systems, not 1 — see
the phased plan below for suggested sequencing rather than building all
4 at once.
- **2 main hubs** — Belters (ORC) and Military (ISN). Multiplayer: other
players' chosen character sprites (from character creation) are
visible walking around, same as the ship-combat side. Also where you
buy the supplies that repair ship wear (see below) — this is what ties
hub visits into the ship-maintenance loop mechanically, not just
thematically.
- **Solar system locations** — additional single-player/instanced places
to explore beyond the 2 hubs. **This is v2, not v1** — see Version
Scope below.
## Version scope
- **V1** — ship interiors + the 2 main hubs (Belters, Military). Nothing
else on foot. This is the whole on-foot surface area for v1: no solar
system exploration locations.
- **V2** — additional single-player "cool places to explore" solar-system
locations, reachable from the in-ship nav table alongside the 2 hubs (see
"Where does it fit in the flow" below). Not scoped further than that yet —
revisit once v1 is actually playable.
## Multiplayer scope, by location
| Location | Multiplayer? |
|---|---|
| Ship combat (existing) | Yes — already networked |
| Your ship interior | No — private, single-player/instanced |
| Belters hub (`station_hub.tscn`, ORC) | **Yes** — networked, other players' sprites visible |
| Military hub (`military_hub.tscn`, ISN art pending — see phased-plan step 3/4) | **Yes** — same as above |
| Solar system locations (v2, not built) | No — single-player/instanced |
This means `on_foot_character.gd` needs to grow two separate modes rather
than one: a simple local-only controller (ship interior — what exists
today, basically) and a properly networked one for the 2 hub locations
(`MultiplayerSpawner`, position replication, the same predict/reconcile
shape `ship_movement.gd` already has for combat). Worth building the
local-only path first regardless (ship interior), then reusing whatever
interaction/movement code it establishes for the networked hub pass.
**Resolved:** `station_hub.tscn` *is* the first of the 2 main hubs — the
**Belters hub** (Outer Rim Collective's home turf, see
`overview/racesclasses.md`'s ISN-military/ORC-belters split). Its gritty,
industrial, patchwork-metal art direction (see Asset Inventory below) fits
ORC's established visual identity directly. A second hub for **the
military** (ISN) will be built later — likely wants its own, cleaner/more
disciplined tileset+furniture pass to match ISN's gunmetal-gray aesthetic
rather than reusing the Belters hub's art wholesale, though the underlying
room-building/collider/furniture-placement code (`station_hub.gd`'s
approach) should carry over directly.
The **ship interior** (the Stardew-farm equivalent) is a separate, new,
single-player scene — not `station_hub.tscn`, and not built yet.
## Where does it fit in the flow?
**Resolved: the ship is the home base**, and the specific interaction is
settled too. `main_menu.gd` gets a new entry point (name TBD, e.g. "MY
SHIP") that drops the player into their ship interior. Inside, there's a
**navigation table** — Helldivers 2 style: walk up to it, it shows a graph
of the solar system, and picking a node is how you travel. For v1 that
graph has 3 destinations: space combat (queue/connect, today's QUICK
PLAY/SERVER SELECT behavior moves here), the Belters hub, and the Military
hub. V2's additional explorable locations (see Version Scope above) are
just more nodes on the same graph later — the table doesn't need to change
shape, only grow more destinations.
This is a real restructure of the existing menu flow, not an addition
alongside it — QUICK PLAY/SERVER SELECT's current straight-into-a-match
behavior becomes something reached via the nav table, not a main-menu
button. `main_menu.gd`'s QUICK PLAY/SERVER SELECT code itself likely stays
close to as-is functionally (still queues/connects the same way); what
changes is *what calls it* — a nav-table node interaction instead of a nav
button.
## Current implementation state
Everything below predates the "ship interior + solar system + 2 hubs"
framing above — it was built as a generic "station hub" proof of concept
(item 29) and then given a real character-creation entry point in a later
session, before `station_hub.tscn` was pinned down as specifically the
Belters hub (see Vision above). What's actually built and working today
(verified via headless screenshot):
- **Character creation** (`menu/character_preview.gd`,
`menu/character_presets.gd`) — 7 pre-composed archetype sprites (Soldier,
Marine, Engineer, Laborer, Scavenger, Officer, Heavy Trooper), each with
idle + walk frames, picked on the main-menu PROFILE overlay with a live
idle/walk preview loop. Stored in `GameConfig.player_character_index`.
This is the *only* piece with a real menu entry point today.
- **`world/levels/station_hub.tscn`** — one hand-built 12×9 room
(`station_hub.gd`, `class_name StationHub`), procedurally tiled floor +
wall ring with a door gap, 4 static furniture pieces (locker, cabinet,
hydroponics plant rack, server console), wall colliders auto-built from
whatever's painted on the `Walls` `TileMapLayer`.
- **`world/levels/on_foot_character.gd`** (`CharacterBody2D`) — 4-directional
top-down walker, idle/walk sprite swap + `flip_h`, camera follow. Not
networked — single local `CharacterBody2D`, no `MultiplayerSpawner`, no
server authority. Loads whichever archetype was chosen at character
creation via `CharacterPresets.get_preset(GameConfig.player_character_index)`.
- **Not reachable from any menu.** The only way to see this scene today is
launching `station_hub.tscn` directly (editor Play, or the `--hub-test`
throwaway autopilot used to verify it — deleted after use, not in the
repo).
- **No NPCs, no interaction, no dialogue, no second room/transition.**
- **Ship interior (phased-plan step 2 — done)** — `world/levels/ship_interior.tscn`
+ `ship_interior.gd` (`class_name ShipInterior`), a new single-player/
local-only scene distinct from `station_hub.tscn` (which stays spoken for
as the Belters hub, see Vision above). Reuses `on_foot_character.gd`
as-is for the player controller and `station_hub.gd`'s wall-collider
pattern, but not that scene's assets wholesale — no dedicated
ship-interior art exists yet, so it's built from the same ORC station
tileset/furniture as a placeholder (locker, cabinet, server rack; the
plant/hydroponics furniture was deliberately left out, since hydroponics
is a purchasable room per the decisions log below, not present on the
starter ship). A smaller 8×6 room (vs. the hub's 12×9) to read as a
personal ship rather than a public hub. Built procedurally in `_ready()`
(floor/wall `TileMapLayer.set_cell()` calls, one door gap in the wall
ring) rather than hand-authored into the `.tscn`'s `tile_map_data`, since
`station_hub.tscn`'s own layout was originally produced the same
code-generated way (see item 29) rather than painted in an interactive
editor session. Verified via a throwaway autopilot + headless Xvfb
screenshot (same technique as item 29): floor tile variety, the wall
ring's door gap, all 3 furniture pieces, and wall-collision (player stops
cleanly at the east wall instead of clipping through) all confirmed
rendering/working correctly; autopilot and its temporary `project.godot`
autoload entry were removed after. **Now reachable from the main menu**
see the CONTINUE/NEW entry point below (originally slated for step 3,
landed early alongside this step in practice).
- **In-ship navigation table (phased-plan step 3 — done)** —
`world/levels/nav_table.gd` (`class_name NavTable`, an `Area2D` placed in
`ship_interior.tscn`) is a Helldivers-2-style interactable: walking into
its range shows a "PRESS E" prompt (new `interact` input action, E key),
and pressing it opens `world/levels/nav_table_ui.gd`
(`class_name NavTableUI`, `CanvasLayer`, `layer = 20` — same layering
convention as `TeamSelect`/`SettingsPanel`) — a graph of 3 nodes (SPACE
COMBAT, BELTERS HUB, MILITARY HUB) radiating from a center "YOUR SHIP"
marker, connected by `Line2D` routes. A new `GameConfig.nav_table_focused`
flag (same pattern as `chat_focused`/`team_select_focused`/
`settings_focused`) gates `OnFootCharacter` movement while it's open.
**SPACE COMBAT** is QUICK PLAY's old queue/connect flow, moved here
verbatim from `main_menu.gd` (which lost its QUICK PLAY nav button
entirely — `NAV_ITEMS` is now just `["OPTIONS", "PROFILE", "QUIT"]`):
`NavTableUI` owns the `NetworkManager.connection_succeeded/failed` and
`MatchmakingClient.match_found/search_failed` signal wiring and shows
queueing/connecting status inline in the graph panel, then swaps to
`world.tscn` on success. **BELTERS HUB**/**MILITARY HUB** are straight
`change_scene_to_file` calls to `station_hub.tscn`/`military_hub.tscn`.
Caught and fixed one real bug via the autopilot verification below:
`nav_table_focused` was only ever cleared by `close()`, so picking a
destination (which changes scene without closing the panel) left it stuck
`true` forever, silently freezing player movement in every future ship
visit — all 3 destination handlers now clear the flag before changing
scene.
- **Military hub is a functional placeholder, not a stub** (see Decisions
below) — `world/levels/military_hub.tscn` reuses `station_hub.gd`
(`class_name StationHub`, generic — no ORC-specific logic) and the same
ORC tileset/furniture art as `station_hub.tscn` outright, as its own
distinct scene/instance rather than literally reopening the Belters hub.
A real ISN art pass (phased-plan step 4) swaps the art later without
touching the nav-table wiring.
- **Return paths**: a new reusable `world/levels/exit_zone.gd`
(`class_name ExitZone`, `Area2D`, exported `target_scene`) triggers a
scene change the instant the player's body enters it — unlike
`NavTable`, which needs an explicit interact press, this matches walking
through a doorway. Both hub scenes got one positioned at their existing
door sprite, routing back to `ship_interior.tscn`. `menu/pause_menu.gd`'s
"QUIT TO MENU" became **"QUIT TO SHIP"**, changing scene to
`ship_interior.tscn` instead of `main_menu.tscn` (still disconnects via
`NetworkManager.disconnect_from_game()` first) — the ship is now the
only way out of a match, matching its status as home base.
- Verified end-to-end with a throwaway `--navtest`-gated autopilot
autoload (removed after use, same technique as this doc's other
verification notes): a real headless server was hosted and registered
with `matchmaking-api` (confirmed `status: "available"` via
`GET /servers`), then a windowed client under Xvfb was driven through
the full loop — main menu → ship → open nav table (screenshotted, see
below) → Belters hub → back to ship → Military hub → back to ship →
Space Combat → real matchmaking + connect → in `world.tscn` → pause
menu's quit-to-ship → back in the ship interior — with no errors and
the `nav_table_focused` fix confirmed (`focused=false` after each hub
return, where it previously stuck `true`).
- **RAM currency backend (phased-plan step 1 — done)** — `Player.ram_kb`
(BigInteger) in `matchmaking-api`, credited on `POST /matches/report` via
a tunable participation/per-kill/win-bonus formula (`app/config.py`,
currently 50/15/200 KB — placeholder numbers), returned by
`GET /stats/{callsign}`. Client side: `autoload/currency.gd`
(`class_name Currency`) formats KB up into KB/MB/GB/TB for display (1000
per step), `MatchmakingClient.get_stats()` fetches a callsign's balance,
and the main-menu profile badge shows it live next to rank/level as a
working end-to-end proof (verified via real match-report calls + a
headless screenshot — a fresh callsign correctly shows nothing until it
has a stats row, an existing one rolls over KB→MB→GB correctly at the
1000 boundary). No spend path yet — nothing to spend it on until the ship
interior/hubs below exist. See `matchmaking-api/README.md`'s "RAM
(in-game currency)" section for the backend details.
## Ship wear, supplies & repair
Combat leaves your ship **visibly broken** when you're back in the
interior — not just a hidden stat. Two related but distinct systems:
- **Repairable damage** — things that break and need fixing:
- **Hull** — takes damage in combat, needs repair.
- **Guns/weapons** — also take damage in combat, repaired separately from
the hull.
- **Consumable supplies** — things that deplete through play and need
restocking, not "broken" so much as "used up":
- **Fuel**
- **Oxygen**
- **Ammo**
Both repairs and supplies are **bought at the hubs with RAM** — no separate
"supplies" currency, it's a RAM purchase like anything else there. This is
the mechanic that makes hub visits load-bearing rather than optional: you
fight → come home with a damaged hull/guns and depleted fuel/oxygen/ammo →
go to a hub and spend RAM → return and repair/restock. **Repairing and
restocking both take time** — not instant on purchase (consistent with
crops also being a wait → harvest loop, see Vision above; the ship
interior's whole maintenance layer runs on "spend, then wait" pacing, not
"spend, get instantly").
**Damage formula (resolved):** every death in combat randomly drains either
the hull or damages the weapons (one or the other per death, picked
randomly — not both every time). This accumulates across matches; **50
deaths' worth** of accumulated damage fully depletes that system, at which
point RAM has to be spent at a hub to repair it. Purely deaths-driven, not
tied to damage-taken or match outcome.
Still needs, before it can be built:
- How fast fuel/oxygen/ammo deplete (a separate formula from the
deaths-based hull/gun one above — not yet specified), and what happens if
they run out (blocked from queueing another match? a gameplay penalty
mid-match?)
This depends on combat generating *some* persistent-across-matches wear/
consumption state, which does not exist today — `ship_movement.gd`
currently resets health/state fully on every respawn and between matches,
nothing survives to be "broken" or "depleted" afterward, and there's no
existing fuel/oxygen/ammo concept in combat at all (weapons currently cost
*energy*, which already regenerates freely mid-match per `CLAUDE.md` item
23 — that's a separate system from this new persistent ammo-supply concept,
not to be conflated). That plumbing has to be built as part of this, not
assumed already there.
## Ship upgrades = new rooms
Upgrades aren't menu-purchased stat tweaks — they're **new rooms added onto
your ship**, walked into like everything else. This is a meatier build than
a flat stat upgrade: it means the ship interior can't be one fixed
`station_hub.tscn`-style single room — it needs to support **growing its
own floor plan** as rooms get added, which is a real architectural
difference from every other on-foot space in this doc (the 2 hubs and any
v2 solar-system stops are all still just fixed single rooms/scenes).
**Resolved:**
- A room's only function is granting access to the system it houses — it's
a gate, not also a stacking stat/capacity bonus on top of granting access.
- Hydroponics (crops) is not a starter feature. It's a purchasable room like
every other upgrade — the plant-rack furniture already cropped for
`station_hub.tscn` (see Asset Inventory below) is useful reference art for
it, but the starter ship doesn't have it until bought.
**Still open:**
- What a room being "added" looks like mechanically — a new connected room
revealed/unlocked in a pre-built larger ship shell (simpler: author the
full possible floor plan up front, gate rooms behind a
locked-door-until-purchased flag), vs. actually procedurally
attaching/generating new room geometry at purchase time (much harder, and
nothing in the codebase does anything like this today — closest analog is
`station_hub.gd`'s procedural single-room tile painting, which doesn't
generalize to "graft a new room onto an existing layout").
## Asset inventory
Source: `world/levels/spritesheet.png` (a single AI-generated reference
sheet — has a `source/` jpeg convention like the race art, see `CLAUDE.md`
item 17). Cropped into `assets/images/worldsprites/character/`,
`assets/images/worldtiles/`, `assets/images/worldsprites/furniture/`.
| Category | Used | Still on the sheet, uncropped |
|---|---|---|
| Characters | 7 archetypes × (idle+walk) — appears to be the full set the sheet has | — (all 5 character rows are used) |
| Floor tiles | 11 variants → padded/cycled to the 14-slot atlas | — (Flooring block fully used) |
| Wall tiles | 6 clean panel variants → cycled to 14 | rock/asteroid-wall chunks, 2 blend-mask swatches (not directly usable as tiles as-is) |
| Doors | 1 (plain sliding door) | 2 more door variants (open, blast-door), several branded panel doors |
| Furniture | 4 (locker, cabinet, plant rack, server console) | bunks (3 variants), desks + chairs, monitor stations, weapon/gear lockers (cage shelving), crates, workbenches, gas canisters, more potted plants, a fish-tank-style prop |
| Lighting fixtures | 0 | ~10 wall/ceiling light variants, fully uncropped |
Furniture/lighting has a lot of unused headroom for filling out a bigger or
second room later — this is not a blocker for anything above, just noting
it's there.
## Rough phased plan (v1 = ship interior + 2 hubs, nothing else — see Version Scope)
1. ~~Currency: server-authoritative RAM hookup~~**done**, see "Current
implementation state" above and `matchmaking-api/README.md`. Payout
happens via the existing `POST /matches/report` call
(`MatchManager`'s `POST_MATCH` phase, `CLAUDE.md` item 26) rather than a
new endpoint. Note this landed ahead of step 2 (ship interior) in
practice — reasonable since it has no dependency on the ship existing,
just needed *a* balance to exist before anything can spend it later.
2. ~~Ship interior as its own new scene~~**done**, see
`world/levels/ship_interior.tscn`/`ship_interior.gd` and "Current
implementation state" above. Single-player, local-only, no networking —
reused `on_foot_character.gd` and `station_hub.gd`'s room-building/
collider approach as the starting pattern (not the `station_hub.tscn`
instance itself, which is spoken for as the Belters hub — see Vision
above).
**Main-menu entry point into the ship (part of step 3, landed early,
same "no dependency" reasoning as step 1 landing ahead of step 2) —
done.** `main_menu.gd`'s nav gained a dynamic top item, CONTINUE (a
callsign/character already exists) or NEW (none yet), replacing the
old static `NAV_ITEMS` first slot; both drop the player straight into
`ship_interior.tscn` (`_enter_ship()`). NEW reuses the existing PROFILE
overlay (`_open_profile()`'s `pending_action` param generalized from the
old `pending_quick_play` bool to also carry `"enter_ship"`, alongside
the pre-existing `"quick_play"` case) so a fresh player creates their
pilot first, then lands in the ship on Save — same forced-open pattern
QUICK PLAY already used for a missing callsign, just a second pending
action instead of a second overlay. **`SERVER SELECT` was removed from
the main-menu nav entirely** (`menu/server_select.gd`/`.tscn` left on
disk unreferenced, same precedent as the orphaned `terran/mech/vorg`/
`apex` asset folders from items 17/19) — no replacement entry point for
it yet, since server browsing doesn't have a home on the nav table
either until that's built next. QUICK PLAY/OPTIONS/PROFILE/QUIT are
otherwise unchanged. Verified via a throwaway autopilot driving
`main_menu.gd`'s own `_on_continue_pressed()`/`_on_profile_save()`
methods directly (same legitimate `_`-prefixed-access technique as
prior sessions) plus headless Xvfb screenshots: NEW label + profile
overlay opening with an "enter_ship"-specific hint text, landing in the
ship on Save, and the label correctly flipping to CONTINUE back on the
menu once a callsign exists.
3. ~~Navigation table inside the ship~~**done**, see "Current
implementation state" above (`world/levels/nav_table.gd`/
`nav_table_ui.gd`). QUICK PLAY's queue/connect logic now lives behind the
SPACE COMBAT node; Belters/Military hub nodes are the other two. A real
server-browsing entry point to replace the removed SERVER SELECT is still
not built — no node claims that slot yet, it just isn't reachable from
anywhere right now.
4. Military (ISN) hub — **placeholder done, real art pass still open.**
`military_hub.tscn` exists and is reachable today, but reuses the
Belters hub's ORC art/tileset outright rather than a dedicated
gunmetal-gray/disciplined ISN look (see Vision above) — that art pass,
and re-skinning this scene with it, is the remaining work here. The
room-building code (`station_hub.gd`) already carries over directly, as
expected — no code changes needed once the art exists, just new
textures swapped into a cloned `.tscn`.
5. Networked pass on both hubs — `MultiplayerSpawner`, position replication
for on-foot characters, reusing whatever the local-only ship-interior
controller established in step 2.
6. Ship wear, supplies & repair loop (see dedicated section above) — the
mechanic that actually ties hubs into ship maintenance. Needs combat to
generate persistent hull/gun-damage and fuel/oxygen/ammo-consumption
state first (none of which exists today — and is distinct from the
existing per-shot *energy* system, `CLAUDE.md` item 23, which stays
as-is), then the broken/depleted states in the ship interior, then
repairs/supplies for sale at both hubs, then the wait-time pacing on top.
7. Cosmetic decoration — the one maintenance system that doesn't depend on
the room-upgrade architecture (step 8) or the wear loop (step 6). Can be
built any time after the ship interior (step 2) exists.
8. Room-based upgrades (see "Ship upgrades = new rooms" above) — the
heaviest remaining lift, since it needs the ship interior to support a
growable floor plan rather than the fixed-single-room approach every
other on-foot scene uses. Do this after the fixed-room approach has
already shipped for decoration/hubs, so there's a working baseline before
taking on the harder architectural problem. **Crops live here too now**,
not as a separate item — hydroponics is a purchasable room like any other
upgrade (see "Ship upgrades = new rooms" above), not a starter feature,
so it can't ship before the room-upgrade system exists.
9. **V2, not v1:** additional solar-system exploration locations as more
nav-table nodes. NPCs/dialogue, if still wanted — needs a minimal
dialogue/prompt UI that doesn't exist anywhere in the project yet.
## Decisions log
- [x] `station_hub.tscn` is the Belters (ORC) hub, not the ship interior and
not a generic solar-system stop.
- [x] In-game currency is 100% separate from `overview/money.md`'s
real-money monetization.
- [x] Currency is named **RAM** (EVE Online ISK-style), server-authoritative
in `matchmaking-api`.
- [x] Ship is the home base — combat and hub travel launch from inside it,
not straight off the main menu, via an in-ship navigation table.
- [x] The nav table is a Helldivers-2-style solar-system graph; v1 has 3
destinations (combat, Belters hub, Military hub).
- [x] **V1 scope is ship interior + the 2 hubs only.** Solar-system
exploration locations are v2.
- [x] Ship wear covers hull damage and gun damage (repairable), plus fuel,
oxygen, and ammo (consumable supplies) — bought at the hubs, RAM only
(no separate supplies currency), and both repairs and restocking take
time rather than being instant.
- [x] Ship maintenance covers crops + cosmetic decoration + room-based
upgrades + repairs/supplies (all 4 planned for v1, sequenced in the
phased plan above — not simultaneous, and not blocking the
hub/nav-table work).
- [x] Upgrades are new rooms added onto the ship, not a stat menu — the ship
interior needs a growable floor plan, not a fixed single room (see
"Ship upgrades = new rooms" above).
- [x] A room's only function is granting access to the system it houses —
not also a stacking stat/capacity bonus.
- [x] Hydroponics (crops) is a purchasable room, not present on the starter
ship — so it's part of the room-upgrade system (phased-plan step 8),
not shippable before that system exists.
- [x] Hull/gun damage formula: every combat death randomly drains the hull
*or* damages the weapons (one or the other, random per death); 50
deaths' worth fully depletes that system and requires a RAM repair at
a hub. Purely deaths-driven, not tied to damage taken or match result.
- [x] NPCs are deferred past the first playable version.
- [x] Military hub is a functional placeholder (its own scene, reusing the
Belters hub's ORC art/script wholesale) rather than locked/coming-soon
— playable now, real ISN art swapped in later without touching the
nav-table wiring.
- [x] Belters/Military hub exits and a match's pause-menu quit both route
back to `ship_interior.tscn` (not the main menu) — the nav table is a
real hub-and-spoke loop, not a one-way trip. Pause menu's "QUIT TO
MENU" is now "QUIT TO SHIP".
## Still open
- [ ] A dedicated ISN art pass for the Military hub (gunmetal-gray,
disciplined, distinct from the Belters hub's gritty patchwork look) —
`military_hub.tscn` exists and is playable today but is reusing ORC
art as a stand-in (see phased-plan step 4).
- [ ] A real server-browsing entry point (the old, now-removed SERVER
SELECT) — no nav-table node or other menu surface reaches it yet.
- [ ] Fuel/oxygen/ammo depletion rate/formula (separate from the
deaths-based hull/gun formula above), and what happens if they run
out (blocked from queueing? a mid-match penalty?).
- [ ] How a purchased room actually gets added to the ship — pre-built
shell with locked-until-bought rooms, vs. real procedural
attach/generate at purchase time (see "Ship upgrades = new rooms"
above; leaning toward the pre-built/locked-room approach as the
cheaper build, not decided).
- [ ] Exact list of cosmetic decorations for a first pass — not needed until
step 7 of the phased plan.
- [ ] Exact list of purchasable rooms beyond hydroponics (armory for
ammo/guns? a fuel-tank room? an oxygen/life-support room?) — not
needed until step 8 of the phased plan.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 204 KiB

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

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://defa378daeqjh"
path="res://.godot/imported/ae9283e6-0a7c-4a98-a2da-e4d0956a6b8b.jpeg-121eb26e72146fe5f9d2445e45188ecf.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/ae9283e6-0a7c-4a98-a2da-e4d0956a6b8b.jpeg"
dest_files=["res://.godot/imported/ae9283e6-0a7c-4a98-a2da-e4d0956a6b8b.jpeg-121eb26e72146fe5f9d2445e45188ecf.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dfoqqkd4kv3w1"
path="res://.godot/imported/armor_idle.png-2f82efbe1bff7cd562a9173ea0893e3b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/armor_idle.png"
dest_files=["res://.godot/imported/armor_idle.png-2f82efbe1bff7cd562a9173ea0893e3b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cwfl33285ksde"
path="res://.godot/imported/armor_walk.png-5057d2cd82915e9deff684d008ca6fc4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/armor_walk.png"
dest_files=["res://.godot/imported/armor_walk.png-5057d2cd82915e9deff684d008ca6fc4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dgahd7dstvrc4"
path="res://.godot/imported/engineer_idle.png-87ca66b9480bc65728973a02c059059c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/engineer_idle.png"
dest_files=["res://.godot/imported/engineer_idle.png-87ca66b9480bc65728973a02c059059c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdwl43yw80m7m"
path="res://.godot/imported/engineer_walk.png-b5d6ffa913fd64fce8fd2898e6152e44.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/engineer_walk.png"
dest_files=["res://.godot/imported/engineer_walk.png-b5d6ffa913fd64fce8fd2898e6152e44.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://seojffyrfioi"
path="res://.godot/imported/laborer_idle.png-50de51857529f064913edcf5174d3e60.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/laborer_idle.png"
dest_files=["res://.godot/imported/laborer_idle.png-50de51857529f064913edcf5174d3e60.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bm1bxsvjjq5pe"
path="res://.godot/imported/laborer_walk.png-202645da273b382efcffb72df37335da.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/laborer_walk.png"
dest_files=["res://.godot/imported/laborer_walk.png-202645da273b382efcffb72df37335da.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bhvn82imgrrdv"
path="res://.godot/imported/marine_idle.png-decba940b1e070cab7f32d48cd47e614.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/marine_idle.png"
dest_files=["res://.godot/imported/marine_idle.png-decba940b1e070cab7f32d48cd47e614.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bnlivrk518dxg"
path="res://.godot/imported/marine_walk.png-f42f087095ba6b04b0f2e990676140eb.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/marine_walk.png"
dest_files=["res://.godot/imported/marine_walk.png-f42f087095ba6b04b0f2e990676140eb.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://5ve0wtgcmqo6"
path="res://.godot/imported/officer_idle.png-2be41b8a497f2290de12d9f2ef404925.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/officer_idle.png"
dest_files=["res://.godot/imported/officer_idle.png-2be41b8a497f2290de12d9f2ef404925.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://djpyvqnbs5nm3"
path="res://.godot/imported/officer_walk.png-4cc5308e92afd612845edb4aefa4970f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/officer_walk.png"
dest_files=["res://.godot/imported/officer_walk.png-4cc5308e92afd612845edb4aefa4970f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://os8r8wgciiax"
path="res://.godot/imported/scavenger_idle.png-8132b845cfa209806000561b8cc987f2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/scavenger_idle.png"
dest_files=["res://.godot/imported/scavenger_idle.png-8132b845cfa209806000561b8cc987f2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c0g2587hjurhl"
path="res://.godot/imported/scavenger_walk.png-474d74921a3b5034f6c9bf4d7d8d2471.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/scavenger_walk.png"
dest_files=["res://.godot/imported/scavenger_walk.png-474d74921a3b5034f6c9bf4d7d8d2471.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dok0oj2avem2g"
path="res://.godot/imported/soldier_idle.png-4709623b32bb1409bf1d754ddcc1e291.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/soldier_idle.png"
dest_files=["res://.godot/imported/soldier_idle.png-4709623b32bb1409bf1d754ddcc1e291.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://u415d3kddeh0"
path="res://.godot/imported/soldier_walk.png-ab5b08a73427300f5908eabf04977357.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/character/soldier_walk.png"
dest_files=["res://.godot/imported/soldier_walk.png-ab5b08a73427300f5908eabf04977357.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://beiyomi67nvln"
path="res://.godot/imported/cabinet.png-81b9230125498c42eff4c5e2a89664f8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/furniture/cabinet.png"
dest_files=["res://.godot/imported/cabinet.png-81b9230125498c42eff4c5e2a89664f8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c5m6vma51lbxd"
path="res://.godot/imported/locker.png-8cca49f3e3733cf983c09e978919473a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/furniture/locker.png"
dest_files=["res://.godot/imported/locker.png-8cca49f3e3733cf983c09e978919473a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dtvfg5q2o0xnx"
path="res://.godot/imported/plant.png-a10ef1c7abb27caf4d26dd43a1187291.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/furniture/plant.png"
dest_files=["res://.godot/imported/plant.png-a10ef1c7abb27caf4d26dd43a1187291.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://br7bwwcuaikdb"
path="res://.godot/imported/server_rack.png-14c0c511f56b8aa64850b6c93eaeda9a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/furniture/server_rack.png"
dest_files=["res://.godot/imported/server_rack.png-14c0c511f56b8aa64850b6c93eaeda9a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c3lroomgvih1i"
path="res://.godot/imported/orc_character_roster_source.jpeg-4a9ed72bb7d862488a0185bc8d540042.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/orc/orc_character_roster_source.jpeg"
dest_files=["res://.godot/imported/orc_character_roster_source.jpeg-4a9ed72bb7d862488a0185bc8d540042.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bi6720yj2f5im"
path="res://.godot/imported/orc_furniture_sheet_source.jpeg-7ddc98842928a9efcb921df3e9a65438.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/orc/orc_furniture_sheet_source.jpeg"
dest_files=["res://.godot/imported/orc_furniture_sheet_source.jpeg-7ddc98842928a9efcb921df3e9a65438.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c4pv2och0ovpu"
path="res://.godot/imported/spritesheet.png-47f1727966668bc132dd8ee0982756e6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldsprites/orc/spritesheet.png"
dest_files=["res://.godot/imported/spritesheet.png-47f1727966668bc132dd8ee0982756e6.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ciu4tyyv8h3i1"
path="res://.godot/imported/0ac1a9f7-311e-4e64-8e76-8e8a6903ed32.jpeg-3c3e1a0cb799bac42f4bd3cb72ca8f25.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/source/0ac1a9f7-311e-4e64-8e76-8e8a6903ed32.jpeg"
dest_files=["res://.godot/imported/0ac1a9f7-311e-4e64-8e76-8e8a6903ed32.jpeg-3c3e1a0cb799bac42f4bd3cb72ca8f25.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ce7eu3mgcow85"
path="res://.godot/imported/4b196425-afc8-407a-aed3-bec051324333.jpeg-e562b7030f906c9cf524497417cda608.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/source/4b196425-afc8-407a-aed3-bec051324333.jpeg"
dest_files=["res://.godot/imported/4b196425-afc8-407a-aed3-bec051324333.jpeg-e562b7030f906c9cf524497417cda608.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b336dmxpyg2gh"
path="res://.godot/imported/f4ad5065-9e36-4c9e-a0a8-2aa0d3ea0352.jpeg-3aecddbe3da5ada37efd3b3ddfb1a2f4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/source/f4ad5065-9e36-4c9e-a0a8-2aa0d3ea0352.jpeg"
dest_files=["res://.godot/imported/f4ad5065-9e36-4c9e-a0a8-2aa0d3ea0352.jpeg-3aecddbe3da5ada37efd3b3ddfb1a2f4.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dlvc4kixq56mt"
path="res://.godot/imported/orc_station_tileset_reference_source.jpeg-957d05efb5712c2f55eec8156a465142.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/source/orc_station_tileset_reference_source.jpeg"
dest_files=["res://.godot/imported/orc_station_tileset_reference_source.jpeg-957d05efb5712c2f55eec8156a465142.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bpdb1ou43elae"
path="res://.godot/imported/station_door.png-d50d127a344c0b29259c58e6ff93180f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/station_door.png"
dest_files=["res://.godot/imported/station_door.png-d50d127a344c0b29259c58e6ff93180f.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bndn236w4pj0x"
path="res://.godot/imported/station_floor.png-4efe9ad5dc167e01b6f1ce970a24cfa0.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/station_floor.png"
dest_files=["res://.godot/imported/station_floor.png-4efe9ad5dc167e01b6f1ce970a24cfa0.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dvb5ni8hxhrmk"
path="res://.godot/imported/station_walls.png-3b0fea72982af3446ef23cc9f1e1d700.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/worldtiles/station_walls.png"
dest_files=["res://.godot/imported/station_walls.png-3b0fea72982af3446ef23cc9f1e1d700.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
+21
View File
@@ -0,0 +1,21 @@
extends Node
class_name Currency
# RAM is the in-game currency (see overview/onfoot.md) -- server-authoritative,
# stored and transmitted as a single integer count of kilobytes
# (matchmaking-api's Player.ram_kb). This class is purely the client-side
# display formatter: KB -> KB/MB/GB/TB, 1000 per step (not 1024).
const STEP := 1000.0
const UNITS := ["KB", "MB", "GB", "TB"]
static func format_ram(kb: int) -> String:
var value := float(kb)
var unit_index := 0
while value >= STEP and unit_index < UNITS.size() - 1:
value /= STEP
unit_index += 1
if unit_index == 0:
return "%d %s" % [kb, UNITS[0]]
return "%.2f %s" % [value, UNITS[unit_index]]
+1
View File
@@ -0,0 +1 @@
uid://ch23k14liwu2i
+22
View File
@@ -305,6 +305,12 @@ var pre_match_camera_zoom: float = 0.5
# Race/ship/speed are per-peer now — see PlayerRegistry.
var player_name: String = ""
# Index into CharacterPresets.PRESETS, chosen on the character-creation
# screen (main_menu.gd's PROFILE overlay). Drives both the menu badge and the
# on-foot walker's sprite (world/levels/on_foot_character.gd); same
# not-persisted lifetime as player_name above.
var player_character_index: int = 0
# Current map's play area, in world coordinates. Set by world.gd on load.
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
@@ -428,6 +434,12 @@ var team_select_focused: bool = false
# menu underneath it (pause_menu.gd's _input() checks this too).
var settings_focused: bool = false
# True while NavTableUI is open (world/levels/nav_table_ui.gd) — same purpose
# as chat_focused/team_select_focused/settings_focused, gates
# OnFootCharacter movement so walking into the console doesn't also drive
# the character underneath the graph overlay.
var nav_table_focused: bool = false
# Match structure (autoload/match_manager.gd) — see overview/map1.md.
# default_game_mode_id picks a GameMode subclass (world/game_modes/) via
# MatchManager's factory; every peer instantiates the same mode independently
@@ -435,6 +447,16 @@ var settings_focused: bool = false
# replication. match_duration is the "7 minutes" a match's clock counts down
# once ships are released; match_pre_match_duration/match_post_match_duration
# are the countdown-before and banner-after windows around it.
#
# GAME_MODE_IDS is the canonical ordered catalog behind default_game_mode_id
# (see MatchManager._create_mode()) — these double as the category tags on
# world.gd's MAPS entries, which is what a map's "categories" list is
# filtered against in World.pick_map(), and as the toggle filters on
# menu/server_select.gd's mode-category bar. Only Team Deathmatch has real
# scoring so far; the other three are stub GameMode subclasses that score
# like Team Deathmatch until they grow real zone/point mechanics — see
# CLAUDE.md item 27.
const GAME_MODE_IDS := ["team_deathmatch", "domination", "conquest", "king_of_the_hill"]
var default_game_mode_id: String = "team_deathmatch"
var match_pre_match_duration: float = 30.0
var match_duration: float = 420.0
+6
View File
@@ -36,6 +36,12 @@ func _create_mode(mode_id: String) -> GameMode:
match mode_id:
"team_deathmatch":
return TeamDeathmatchMode.new()
"domination":
return DominationMode.new()
"conquest":
return ConquestMode.new()
"king_of_the_hill":
return KingOfTheHillMode.new()
_:
return TeamDeathmatchMode.new()
+22 -5
View File
@@ -67,9 +67,9 @@ func start_matchmaking(callsign: String, mode: String) -> void:
# 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.
# (ip, port, mode, status, player_count, max_players, last_heartbeat,
# game_mode, map_name). "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:
@@ -82,14 +82,19 @@ func list_servers() -> Dictionary:
# 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:
# callers can fire-and-forget this. game_mode/map_name are the actual
# in-match GameMode id (e.g. "team_deathmatch") and map display name (e.g.
# "Sector Alpha") — distinct from `mode`, which is the matchmaking queue
# mode ("casual"); see menu/server_select.gd, which shows both.
func register_server(ip: String, port: int, mode: String, player_count: int, max_players: int, game_mode: String, map_name: String) -> bool:
var result := await _request("POST", "/servers/register", {
"ip": ip,
"port": port,
"mode": mode,
"player_count": player_count,
"max_players": max_players,
"game_mode": game_mode,
"map_name": map_name,
}, _heartbeat_http)
return result.get("code", 0) == 200
@@ -113,6 +118,18 @@ func report_match_result(server_ip: String, server_port: int, mode: String, winn
return result.get("code", 0) == 200
# Returns {"ok": bool, "stats": Dictionary} -- "stats" has callsign/mmr/
# wins/losses/kills/deaths/hours_played/ram_kb (GET /stats/{callsign}).
# "ok" is false if the API couldn't be reached OR the callsign has no
# recorded stats yet (a brand-new player who hasn't queued/finished a match
# returns 404 -- not an error, just "nothing to show yet").
func get_stats(callsign: String) -> Dictionary:
var result := await _request("GET", "/stats/" + callsign.uri_encode(), null)
if result.get("code", 0) != 200:
return {"ok": false, "stats": {}}
return {"ok": true, "stats": result.body}
func cancel_matchmaking() -> void:
if not _searching:
return
+8 -1
View File
@@ -111,8 +111,15 @@ func _stop_heartbeat() -> void:
# 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.
# World.pick_map() is called statically here (world.tscn hasn't even loaded
# yet at boot, when the first heartbeat fires) rather than read off a live
# World instance -- it's a pure function of GameConfig.default_game_mode_id
# and World.MAPS, so this independently lands on the exact same map World
# itself picks once it loads, with no replication needed (same pattern as
# TeamSelect.RACES).
func _register_with_matchmaking_api() -> void:
await MatchmakingClient.register_server(_register_ip, _hosted_port, "casual", PlayerRegistry.players.size(), MAX_PLAYERS)
var map_info: Dictionary = World.pick_map(GameConfig.default_game_mode_id)
await MatchmakingClient.register_server(_register_ip, _hosted_port, "casual", PlayerRegistry.players.size(), MAX_PLAYERS, GameConfig.default_game_mode_id, map_info.name)
func _start_query_responder(query_port: int) -> void:
+35
View File
@@ -0,0 +1,35 @@
extends CanvasLayer
# Top-right static readout of this match's map name. World.pick_map() is a
# pure function of GameConfig.default_game_mode_id (see world/world.gd's
# MAPS), so every peer resolves the same value independently with no RPC
# needed -- same trick NetworkManager's server registration and
# menu/server_select.gd's mode filter already rely on. Sits directly above
# ping_display.gd's readout, which starts lower to leave room for this.
const MARGIN_X := 16.0
const MARGIN_TOP := 20.0
const WIDTH := 220.0
const HEIGHT := 26.0
func _ready() -> void:
layer = 4
_build_ui()
func _build_ui() -> void:
var 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)
label.add_theme_color_override("font_color", Color(0.85, 0.87, 0.92))
label.text = World.pick_map(GameConfig.default_game_mode_id).name.to_upper()
add_child(label)
+1
View File
@@ -0,0 +1 @@
uid://dcs037f6tynnw
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://hud/map_name_display.gd" id="1_script"]
[node name="MapNameDisplay" type="CanvasLayer"]
script = ExtResource("1_script")
+1
View File
@@ -0,0 +1 @@
uid://bbf4sob5sgkqt
+1
View File
@@ -0,0 +1 @@
uid://cor1re65vyu2t
+3 -2
View File
@@ -5,10 +5,11 @@ extends CanvasLayer
# 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.
# there. MARGIN_TOP starts below map_name_display.gd's readout, which owns
# the top of this same corner.
const MARGIN_X := 16.0
const MARGIN_TOP := 20.0
const MARGIN_TOP := 54.0
const WIDTH := 100.0
const HEIGHT := 30.0
const UPDATE_INTERVAL := 0.5
+24
View File
@@ -0,0 +1,24 @@
extends Node
class_name CharacterPresets
# On-foot character archetypes, cropped from the unified station spritesheet
# (world/levels/spritesheet.png -> assets/images/worldsprites/character/).
# Each is a pre-composed idle+walk sprite pair, not separate skin/hair/clothing
# layers, so "character creation" here means picking one of these rather than
# a Stardew-style layered build-a-look. Shared by the main-menu creation
# screen (menu/pilot_portrait.gd) and the on-foot walker
# (world/levels/on_foot_character.gd), same pattern as TeamSelect.RACES.
const PRESETS := [
{"name": "Soldier", "idle": "res://assets/images/worldsprites/character/soldier_idle.png", "walk": "res://assets/images/worldsprites/character/soldier_walk.png"},
{"name": "Marine", "idle": "res://assets/images/worldsprites/character/marine_idle.png", "walk": "res://assets/images/worldsprites/character/marine_walk.png"},
{"name": "Engineer", "idle": "res://assets/images/worldsprites/character/engineer_idle.png", "walk": "res://assets/images/worldsprites/character/engineer_walk.png"},
{"name": "Laborer", "idle": "res://assets/images/worldsprites/character/laborer_idle.png", "walk": "res://assets/images/worldsprites/character/laborer_walk.png"},
{"name": "Scavenger", "idle": "res://assets/images/worldsprites/character/scavenger_idle.png", "walk": "res://assets/images/worldsprites/character/scavenger_walk.png"},
{"name": "Officer", "idle": "res://assets/images/worldsprites/character/officer_idle.png", "walk": "res://assets/images/worldsprites/character/officer_walk.png"},
{"name": "Heavy Trooper", "idle": "res://assets/images/worldsprites/character/armor_idle.png", "walk": "res://assets/images/worldsprites/character/armor_walk.png"},
]
static func get_preset(index: int) -> Dictionary:
return PRESETS[wrapi(index, 0, PRESETS.size())]
+1
View File
@@ -0,0 +1 @@
uid://d0xf7c88agixx
+52
View File
@@ -0,0 +1,52 @@
extends Control
class_name CharacterPreview
# Sprite-based stand-in for the old procedurally-drawn helmet bust: shows the
# actual idle/walk archetype sprite from CharacterPresets, with a gentle
# auto-alternating idle<->walk loop so the creation screen (and the menu
# badge) reads as "alive" rather than a static thumbnail.
const STEP_INTERVAL := 0.6
var _texture_rect: TextureRect
var _idle_tex: Texture2D
var _walk_tex: Texture2D
var _step_elapsed: float = 0.0
var _showing_walk: bool = false
var preset_index: int = 0:
set(v):
preset_index = wrapi(v, 0, CharacterPresets.PRESETS.size())
_load_textures()
func _ready() -> void:
_texture_rect = TextureRect.new()
_texture_rect.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_texture_rect.expand_mode = TextureRect.EXPAND_FIT_HEIGHT_PROPORTIONAL
_texture_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
_texture_rect.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
add_child(_texture_rect)
_load_textures()
func _process(delta: float) -> void:
_step_elapsed += delta
if _step_elapsed >= STEP_INTERVAL:
_step_elapsed = 0.0
_showing_walk = not _showing_walk
_texture_rect.texture = _walk_tex if _showing_walk else _idle_tex
func preset_name() -> String:
return CharacterPresets.get_preset(preset_index).name
func _load_textures() -> void:
var preset: Dictionary = CharacterPresets.get_preset(preset_index)
_idle_tex = load(preset.idle)
_walk_tex = load(preset.walk)
_showing_walk = false
_step_elapsed = 0.0
if _texture_rect:
_texture_rect.texture = _idle_tex
+1
View File
@@ -0,0 +1 @@
uid://cg8sr5iuw0gai
+158 -88
View File
@@ -1,22 +1,29 @@
extends Control
# 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.
# space backdrop. Combat queueing (formerly QUICK PLAY here) now lives behind
# the in-ship nav table's SPACE COMBAT node, see world/levels/nav_table_ui.gd
# and overview/onfoot.md's "Where does it fit in the flow" section.
var _status_lbl: Label
var _name_lbl: Label
var _rank_lbl: Label
var _ram_lbl: Label
var _profile_overlay: Control
var _profile_input: LineEdit
var _profile_hint_lbl: Label
var _profile_character: CharacterPreview
var _profile_character_name_lbl: Label
var _badge_character: CharacterPreview
var _continue_btn: Button
const SETTINGS_PANEL_SCENE := preload("res://menu/settings_panel.tscn")
var _settings_panel: SettingsPanel
var _connecting: bool = false
var _pending_quick_play: bool = false # profile overlay was forced open by Quick Play
# "" (no pending action) or "enter_ship" -- which action to resume once the
# profile overlay's forced-open Save completes.
var _pending_action: String = ""
const RANK_DATA := [
{"label": "CADET", "color": Color(0.72, 0.48, 0.22)},
@@ -29,7 +36,7 @@ const RANK_DATA := [
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_ITEMS := ["OPTIONS", "PROFILE", "QUIT"]
const NAV_LEFT := 64.0
const NAV_WIDTH := 360.0
const NAV_TOP := 250.0
@@ -40,10 +47,6 @@ const NAV_GAP := 4.0
func _ready() -> void:
_build_ui()
MusicManager.play_menu_music()
NetworkManager.connection_succeeded.connect(_on_connected)
NetworkManager.connection_failed.connect(_on_connect_failed)
MatchmakingClient.match_found.connect(_on_match_found)
MatchmakingClient.search_failed.connect(_on_search_failed)
func _build_ui() -> void:
@@ -51,7 +54,6 @@ func _build_ui() -> void:
_add_logo()
_add_nav_menu()
_add_profile_badge()
_add_status_label()
_add_version_label()
_add_profile_overlay()
_add_settings_panel()
@@ -97,24 +99,22 @@ func _add_logo() -> void:
func _add_nav_menu() -> void:
# Dynamic top item: CONTINUE (character already created) or NEW
# (no callsign/character yet) - both drop the player straight into the
# ship interior, see _on_continue_pressed(). Positioned as slot 0, ahead
# of the static NAV_ITEMS below.
_continue_btn = _make_nav_btn(_continue_label(), false)
_position_nav_btn(_continue_btn, 0)
_continue_btn.pressed.connect(_on_continue_pressed)
add_child(_continue_btn)
for i in NAV_ITEMS.size():
var label_text: String = NAV_ITEMS[i]
var btn := _make_nav_btn(label_text, false)
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
_position_nav_btn(btn, i + 1)
add_child(btn)
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":
@@ -123,6 +123,25 @@ func _add_nav_menu() -> void:
btn.pressed.connect(_on_quit)
func _position_nav_btn(btn: Button, index: int) -> void:
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 + index * (NAV_BTN_H + NAV_GAP)
btn.offset_bottom = btn.offset_top + NAV_BTN_H
func _continue_label() -> String:
return "CONTINUE" if not GameConfig.player_name.strip_edges().is_empty() else "NEW"
func _refresh_continue_button() -> void:
_continue_btn.text = _continue_label()
func _make_nav_btn(label_text: String, stub: bool) -> Button:
var btn := Button.new()
btn.text = label_text
@@ -154,6 +173,17 @@ func _make_nav_btn(label_text: String, stub: bool) -> Button:
func _add_profile_badge() -> void:
_badge_character = CharacterPreview.new()
_badge_character.anchor_left = 1.0
_badge_character.anchor_top = 0.0
_badge_character.anchor_right = 1.0
_badge_character.anchor_bottom = 0.0
_badge_character.offset_left = -396.0
_badge_character.offset_top = 12.0
_badge_character.offset_right = -348.0
_badge_character.offset_bottom = 76.0
add_child(_badge_character)
_name_lbl = Label.new()
_name_lbl.anchor_left = 1.0
_name_lbl.anchor_top = 0.0
@@ -181,35 +211,52 @@ func _add_profile_badge() -> void:
_rank_lbl.add_theme_font_size_override("font_size", 12)
add_child(_rank_lbl)
_ram_lbl = Label.new()
_ram_lbl.anchor_left = 1.0
_ram_lbl.anchor_top = 0.0
_ram_lbl.anchor_right = 1.0
_ram_lbl.anchor_bottom = 0.0
_ram_lbl.offset_left = -340.0
_ram_lbl.offset_top = 68.0
_ram_lbl.offset_right = -24.0
_ram_lbl.offset_bottom = 86.0
_ram_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_ram_lbl.add_theme_font_size_override("font_size", 12)
_ram_lbl.add_theme_color_override("font_color", Color(0.4, 0.85, 0.6))
add_child(_ram_lbl)
func _refresh_profile_badge() -> void:
_refresh_continue_button()
_badge_character.preset_index = GameConfig.player_character_index
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))
_ram_lbl.text = ""
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)
_ram_lbl.text = "..."
_refresh_ram_balance()
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)
# Async, fire-and-forget from _refresh_profile_badge() -- a brand-new
# callsign that hasn't queued/finished a match yet has no stats row at all
# (get_stats() returns ok=false, a 404, not an error), so the label just
# stays blank rather than showing a wrong/misleading value.
func _refresh_ram_balance() -> void:
var result := await MatchmakingClient.get_stats(GameConfig.player_name)
if GameConfig.player_name.strip_edges().is_empty():
return # profile was cleared while the request was in flight
if not result.ok:
_ram_lbl.text = ""
return
_ram_lbl.text = Currency.format_ram(int(result.stats.ram_kb)) + " RAM"
func _add_version_label() -> void:
@@ -242,15 +289,15 @@ func _add_profile_overlay() -> void:
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
panel.offset_left = -210
panel.offset_top = -225
panel.offset_right = 210
panel.offset_bottom = 225
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.05, 0.05, 0.07, 0.97)
ps.bg_color = Color(0.09, 0.09, 0.11, 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)
ps.border_color = Color(0.6, 0.62, 0.68, 0.4)
panel.add_theme_stylebox_override("panel", ps)
_profile_overlay.add_child(panel)
@@ -264,7 +311,7 @@ func _add_profile_overlay() -> void:
panel.add_child(vb)
var title := Label.new()
title.text = "PROFILE"
title.text = "CREATE YOUR PILOT"
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)
@@ -279,6 +326,40 @@ func _add_profile_overlay() -> void:
_profile_hint_lbl.visible = false
vb.add_child(_profile_hint_lbl)
var portrait_row := HBoxContainer.new()
portrait_row.alignment = BoxContainer.ALIGNMENT_CENTER
portrait_row.add_theme_constant_override("separation", 18)
portrait_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vb.add_child(portrait_row)
var prev_btn := _make_portrait_arrow_btn("<")
prev_btn.pressed.connect(_on_portrait_cycle.bind(-1))
portrait_row.add_child(prev_btn)
var portrait_frame := Panel.new()
portrait_frame.custom_minimum_size = Vector2(120, 140)
var pfs := StyleBoxFlat.new()
pfs.bg_color = Color(0.03, 0.03, 0.04, 0.6)
pfs.set_corner_radius_all(4)
pfs.set_border_width_all(1)
pfs.border_color = Color(0.5, 0.52, 0.58, 0.5)
portrait_frame.add_theme_stylebox_override("panel", pfs)
portrait_row.add_child(portrait_frame)
_profile_character = CharacterPreview.new()
_profile_character.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
portrait_frame.add_child(_profile_character)
var next_btn := _make_portrait_arrow_btn(">")
next_btn.pressed.connect(_on_portrait_cycle.bind(1))
portrait_row.add_child(next_btn)
_profile_character_name_lbl = Label.new()
_profile_character_name_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_profile_character_name_lbl.add_theme_font_size_override("font_size", 13)
_profile_character_name_lbl.add_theme_color_override("font_color", Color(0.75, 0.78, 0.84))
vb.add_child(_profile_character_name_lbl)
var lbl := Label.new()
lbl.text = "CALLSIGN"
lbl.add_theme_font_size_override("font_size", 12)
@@ -325,11 +406,28 @@ func _add_profile_overlay() -> void:
btn_row.add_child(save_btn)
func _open_profile(pending_quick_play: bool) -> void:
_pending_quick_play = pending_quick_play
func _make_portrait_arrow_btn(label_text: String) -> Button:
var btn := Button.new()
btn.text = label_text
btn.custom_minimum_size = Vector2(36, 36)
btn.add_theme_font_size_override("font_size", 18)
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))
return btn
func _on_portrait_cycle(delta: int) -> void:
_profile_character.preset_index += delta
_profile_character_name_lbl.text = _profile_character.preset_name().to_upper()
func _open_profile(pending_action: String) -> void:
_pending_action = pending_action
_profile_input.text = GameConfig.player_name
_profile_hint_lbl.visible = pending_quick_play
_profile_hint_lbl.text = "Enter a callsign to play."
_profile_hint_lbl.visible = not pending_action.is_empty()
_profile_hint_lbl.text = "Create your pilot to continue."
_profile_character.preset_index = GameConfig.player_character_index
_profile_character_name_lbl.text = _profile_character.preset_name().to_upper()
_profile_overlay.visible = true
_profile_input.grab_focus()
@@ -339,36 +437,29 @@ func _on_profile_save() -> void:
if new_name.is_empty():
return
GameConfig.player_name = new_name
GameConfig.player_character_index = _profile_character.preset_index
_refresh_profile_badge()
_profile_overlay.visible = false
if _pending_quick_play:
_pending_quick_play = false
_start_quick_play()
var pending := _pending_action
_pending_action = ""
if pending == "enter_ship":
_enter_ship()
func _on_profile_cancel() -> void:
_pending_quick_play = false
_pending_action = ""
_profile_overlay.visible = false
func _on_quick_play() -> void:
if _connecting:
return
func _on_continue_pressed() -> void:
if GameConfig.player_name.strip_edges().is_empty():
_open_profile(true)
_open_profile("enter_ship")
return
_start_quick_play()
_enter_ship()
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..."
MatchmakingClient.start_matchmaking(GameConfig.player_name, "casual")
func _on_server_select() -> void:
get_tree().change_scene_to_file("res://menu/server_select.tscn")
func _enter_ship() -> void:
get_tree().change_scene_to_file("res://world/levels/ship_interior.tscn")
func _on_options() -> void:
@@ -381,29 +472,8 @@ func _add_settings_panel() -> void:
func _on_profile() -> void:
_open_profile(false)
_open_profile("")
func _on_quit() -> void:
get_tree().quit()
func _on_match_found(server_ip: String, server_port: int) -> void:
_status_lbl.text = "Match found — connecting..."
NetworkManager.join_server(server_ip, server_port)
func _on_connected() -> void:
get_tree().change_scene_to_file("res://world/world.tscn")
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?"
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
+24 -6
View File
@@ -1,17 +1,27 @@
extends CanvasLayer
# Shared between world.tscn (in-match) and ship_interior.tscn (home base) —
# the two contexts differ only in whether SELECT TEAM makes sense and where
# "quit" goes, so this reads its own parent scene rather than needing two
# separate menu scripts. Ship interior isn't networked (see
# overview/onfoot.md), so its quit path skips NetworkManager entirely.
const SHIP_SCENE := "res://world/levels/ship_interior.tscn"
var _root: Control
var _in_ship: bool = false
func _ready() -> void:
layer = 10
_in_ship = get_tree().current_scene.scene_file_path == SHIP_SCENE
_build_ui()
_root.visible = false
func _input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_pause") and not GameConfig.chat_focused \
and not GameConfig.team_select_focused and not GameConfig.settings_focused:
and not GameConfig.team_select_focused and not GameConfig.settings_focused \
and not GameConfig.nav_table_focused:
_toggle()
get_viewport().set_input_as_handled()
@@ -76,11 +86,15 @@ func _build_ui() -> void:
_add_btn(vb, "RESUME", true).pressed.connect(_toggle)
_add_btn(vb, "SETTINGS", true).pressed.connect(_on_settings)
_add_btn(vb, "SELECT TEAM", true).pressed.connect(_on_select_team)
if not _in_ship:
_add_btn(vb, "SELECT TEAM", true).pressed.connect(_on_select_team)
vb.add_child(_hsep())
_add_btn(vb, "QUIT TO MENU", true).pressed.connect(_on_quit_to_menu)
if _in_ship:
_add_btn(vb, "QUIT TO MAIN MENU", true).pressed.connect(_on_quit_to_main_menu)
else:
_add_btn(vb, "QUIT TO SHIP", true).pressed.connect(_on_quit_to_ship)
_add_btn(vb, "QUIT TO DESKTOP", true).pressed.connect(_on_quit_to_desktop)
@@ -122,20 +136,24 @@ func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
func _on_settings() -> void:
var settings := get_node_or_null("/root/World/SettingsPanel") as SettingsPanel
var settings := get_tree().current_scene.get_node_or_null("SettingsPanel") as SettingsPanel
if settings:
settings.open()
func _on_select_team() -> void:
_root.visible = false
var team_select := get_node_or_null("/root/World/TeamSelect") as TeamSelect
var team_select := get_tree().current_scene.get_node_or_null("TeamSelect") as TeamSelect
if team_select:
team_select.reopen()
func _on_quit_to_menu() -> void:
func _on_quit_to_ship() -> void:
NetworkManager.disconnect_from_game()
get_tree().change_scene_to_file(SHIP_SCENE)
func _on_quit_to_main_menu() -> void:
get_tree().change_scene_to_file("res://menu/main_menu.tscn")
+120 -17
View File
@@ -17,7 +17,10 @@ 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 _all_servers: Array = [] # every row from the last GET /servers, unfiltered
var _servers: Array = [] # _all_servers narrowed to _selected_category -- what's actually rendered/connectable
var _selected_category: String = "" # "" = ALL MODES; otherwise one of GameConfig.GAME_MODE_IDS
var _category_group: ButtonGroup
var _selected_index: int = -1
var _row_group: ButtonGroup
var _list_vbox: VBoxContainer
@@ -64,10 +67,78 @@ func _process(_delta: float) -> void:
func _build_ui() -> void:
_add_bg()
_add_header()
_add_category_filters()
_add_list_panel()
_add_bottom_bar()
# Game-mode toggle bar (ALL MODES + one per GameConfig.GAME_MODE_IDS) -- a
# separate, filterable category from the map/address text each row already
# shows, so a player can narrow the list to e.g. just Team Deathmatch servers
# instead of reading mode off every row by eye. A ButtonGroup makes these
# mutually exclusive (radio-style), same pattern _row_group already uses for
# the server rows below.
func _add_category_filters() -> void:
var bar := HBoxContainer.new()
bar.anchor_left = 0.0
bar.anchor_top = 0.0
bar.anchor_right = 1.0
bar.anchor_bottom = 0.0
bar.offset_left = 60.0
bar.offset_top = 100.0
bar.offset_right = -60.0
bar.offset_bottom = 130.0
bar.add_theme_constant_override("separation", 8)
add_child(bar)
_category_group = ButtonGroup.new()
var categories: Array = [""]
categories.append_array(GameConfig.GAME_MODE_IDS)
for cat_id in categories:
bar.add_child(_make_category_button(cat_id))
func _make_category_button(cat_id: String) -> Button:
var btn := Button.new()
btn.text = "ALL MODES" if cat_id == "" else _format_mode_name(cat_id)
btn.toggle_mode = true
btn.button_pressed = cat_id == _selected_category
btn.button_group = _category_group
btn.custom_minimum_size = Vector2(0, 30)
btn.add_theme_font_size_override("font_size", 13)
var sn := StyleBoxFlat.new()
sn.bg_color = Color(1.0, 1.0, 1.0, 0.06)
sn.set_corner_radius_all(4)
sn.content_margin_left = 12.0
sn.content_margin_right = 12.0
var sh := StyleBoxFlat.new()
sh.bg_color = Color(1.0, 1.0, 1.0, 0.14)
sh.set_corner_radius_all(4)
sh.content_margin_left = 12.0
sh.content_margin_right = 12.0
var sp := StyleBoxFlat.new()
sp.bg_color = Color(1.0, 1.0, 1.0, 0.9)
sp.set_corner_radius_all(4)
sp.content_margin_left = 12.0
sp.content_margin_right = 12.0
btn.add_theme_stylebox_override("normal", sn)
btn.add_theme_stylebox_override("hover", sh)
btn.add_theme_stylebox_override("pressed", sp)
btn.add_theme_color_override("font_color", Color(0.85, 0.87, 0.92))
btn.add_theme_color_override("font_color_pressed", Color(0.05, 0.05, 0.08))
btn.pressed.connect(_on_category_selected.bind(cat_id))
return btn
func _on_category_selected(cat_id: String) -> void:
if _selected_category == cat_id:
return
_selected_category = cat_id
_apply_filter()
func _add_bg() -> void:
var bg := TextureRect.new()
bg.texture = load("res://assets/images/background/skybox/1.png")
@@ -105,9 +176,9 @@ func _add_list_panel() -> void:
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_top = 142.0
col_hdr.offset_right = -60.0
col_hdr.offset_bottom = 132.0
col_hdr.offset_bottom = 166.0
add_child(col_hdr)
var h_name := _field_label("MODE / ADDRESS")
@@ -132,9 +203,9 @@ func _add_list_panel() -> void:
sep.anchor_right = 1.0
sep.anchor_bottom = 0.0
sep.offset_left = 60.0
sep.offset_top = 136.0
sep.offset_top = 170.0
sep.offset_right = -60.0
sep.offset_bottom = 140.0
sep.offset_bottom = 174.0
sep.add_theme_color_override("color", Color(1.0, 1.0, 1.0, 0.2))
add_child(sep)
@@ -144,7 +215,7 @@ func _add_list_panel() -> void:
scroll.anchor_right = 1.0
scroll.anchor_bottom = 1.0
scroll.offset_left = 60.0
scroll.offset_top = 148.0
scroll.offset_top = 182.0
scroll.offset_right = -60.0
scroll.offset_bottom = -84.0
add_child(scroll)
@@ -236,11 +307,28 @@ func _add_bottom_bar() -> void:
add_child(_connect_btn)
# Fetches the full unfiltered server list from the API. Re-rendering for a
# category toggle (_on_category_selected()) doesn't need to hit the network
# again -- that's _apply_filter()'s job, working off _all_servers.
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..."
var result: Dictionary = await MatchmakingClient.list_servers()
if not result.ok:
_all_servers = []
_clear_rows()
_status_lbl.add_theme_color_override("font_color", Color(0.85, 0.4, 0.4))
_status_lbl.text = "Couldn't reach matchmaking service."
return
_all_servers = result.servers
_apply_filter()
func _clear_rows() -> void:
_selected_index = -1
_connect_btn.disabled = true
for child in _list_vbox.get_children():
child.queue_free()
for probe in _ping_probes:
@@ -250,15 +338,22 @@ func _refresh_servers() -> void:
_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
# Narrows _all_servers down to _selected_category ("" = no filter, show
# everything) and rebuilds the row list from that -- the only place _servers
# (what rows/ping-probes/CONNECT actually index into) gets assigned.
func _apply_filter() -> void:
_clear_rows()
_status_lbl.add_theme_color_override("font_color", Color(0.6, 0.72, 0.86))
_servers = _all_servers.filter(
func(s): return _selected_category == "" or str(s.get("game_mode", "")) == _selected_category
)
if _servers.is_empty():
_status_lbl.text = "No servers online right now."
_status_lbl.text = (
"No servers online right now." if _selected_category == ""
else "No %s servers online right now." % _format_mode_name(_selected_category)
)
return
_status_lbl.text = ""
@@ -300,6 +395,13 @@ func _apply_ping_result(index: int, response: String, rtt_msec: int) -> void:
ping_lbl.add_theme_color_override("font_color", Color(0.5, 0.53, 0.6))
# "team_deathmatch" -> "TEAM DEATHMATCH" -- matches GameMode.get_mode_name()'s
# own formatting (world/game_modes/*.gd) so the server list and the in-match
# scoreboard/banner never show two different spellings of the same mode.
func _format_mode_name(mode_id: String) -> String:
return mode_id.replace("_", " ").to_upper()
func _make_row(server: Dictionary, index: int) -> Button:
var row := Button.new()
row.toggle_mode = true
@@ -324,9 +426,10 @@ func _make_row(server: Dictionary, index: int) -> Button:
hb.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(hb)
var mode: String = str(server.get("mode", "?")).to_upper()
var game_mode: String = _format_mode_name(str(server.get("game_mode", "team_deathmatch")))
var map_name: String = str(server.get("map_name", "?"))
var addr_lbl := Label.new()
addr_lbl.text = "%s %s:%s" % [mode, server.get("ip", "?"), str(server.get("port", "?"))]
addr_lbl.text = "%s · %s %s:%s" % [game_mode, map_name, 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))
+5
View File
@@ -82,6 +82,11 @@ scoreboard={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194306,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
interact={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
]
}
[physics]
@@ -0,0 +1,19 @@
class_name ConquestMode
extends GameMode
# Category-tagged stub -- see world.gd's MAPS "categories" field and
# CLAUDE.md item 27. Conquest's real capture/ticket scoring isn't built yet,
# so this scores identically to TeamDeathmatchMode (kills) for now; a map
# tagging "conquest" among its supported categories is already playable
# under this mode id today. Give this its own check_win_condition()/
# get_score_for_team() when that mechanic exists -- GameMode's contract is
# what keeps MatchManager/Scoreboard/MatchBanner unaware of the difference
# either way.
func get_mode_name() -> String:
return "CONQUEST"
func check_win_condition(world: Node) -> Dictionary:
return _kills_based_win_condition(world)
@@ -0,0 +1 @@
uid://mchyeuh3sq2d
@@ -0,0 +1,19 @@
class_name DominationMode
extends GameMode
# Category-tagged stub -- see world.gd's MAPS "categories" field and
# CLAUDE.md item 27. Domination's real hold-multiple-zones scoring isn't
# built yet, so this scores identically to TeamDeathmatchMode (kills) for
# now; a map tagging "domination" among its supported categories is already
# playable under this mode id today. Give this its own zone-hold
# check_win_condition()/get_score_for_team() when that mechanic exists --
# GameMode's contract is what keeps MatchManager/Scoreboard/MatchBanner
# unaware of the difference either way.
func get_mode_name() -> String:
return "DOMINATION"
func check_win_condition(world: Node) -> Dictionary:
return _kills_based_win_condition(world)
@@ -0,0 +1 @@
uid://cn2darjojw0gq
+20
View File
@@ -45,3 +45,23 @@ func get_score_label() -> String:
# Hold's held-sector points), which is what keeps that UI mode-agnostic.
func get_score_for_team(_world: Node, race_id: int) -> int:
return MatchStats.get_team_kills(race_id)
# Shared by TeamDeathmatchMode and by the category-tagged stub modes
# (domination_mode.gd/conquest_mode.gd/king_of_the_hill_mode.gd) that don't
# have real zone/point scoring yet -- see world.gd's MAPS "categories" field
# and CLAUDE.md item 27. Once a mode grows its own scoring, it overrides
# check_win_condition()/get_score_for_team() instead of calling this.
func _kills_based_win_condition(world: Node) -> Dictionary:
var race_ids: Array = world.decide_offered_races()
if race_ids.size() != 2:
return {"winner_race_id": 0, "is_draw": true}
var kills_a := MatchStats.get_team_kills(race_ids[0])
var kills_b := MatchStats.get_team_kills(race_ids[1])
if kills_a == kills_b:
return {"winner_race_id": 0, "is_draw": true}
return {
"winner_race_id": race_ids[0] if kills_a > kills_b else race_ids[1],
"is_draw": false,
}
@@ -0,0 +1,19 @@
class_name KingOfTheHillMode
extends GameMode
# Category-tagged stub -- see world.gd's MAPS "categories" field and
# CLAUDE.md item 27. King of the Hill's real contested-zone scoring isn't
# built yet, so this scores identically to TeamDeathmatchMode (kills) for
# now; a map tagging "king_of_the_hill" among its supported categories is
# already playable under this mode id today. Give this its own
# check_win_condition()/get_score_for_team() when that mechanic exists --
# GameMode's contract is what keeps MatchManager/Scoreboard/MatchBanner
# unaware of the difference either way.
func get_mode_name() -> String:
return "KING OF THE HILL"
func check_win_condition(world: Node) -> Dictionary:
return _kills_based_win_condition(world)
@@ -0,0 +1 @@
uid://bsl7r3s8oey64
@@ -11,15 +11,4 @@ func get_mode_name() -> String:
func check_win_condition(world: Node) -> Dictionary:
var race_ids: Array = world.decide_offered_races()
if race_ids.size() != 2:
return {"winner_race_id": 0, "is_draw": true}
var kills_a := MatchStats.get_team_kills(race_ids[0])
var kills_b := MatchStats.get_team_kills(race_ids[1])
if kills_a == kills_b:
return {"winner_race_id": 0, "is_draw": true}
return {
"winner_race_id": race_ids[0] if kills_a > kills_b else race_ids[1],
"is_draw": false,
}
return _kills_based_win_condition(world)
+18
View File
@@ -0,0 +1,18 @@
extends Area2D
class_name ExitZone
# Walk-through scene transition, e.g. a hub's door back to the ship interior
# (see overview/onfoot.md's "Where does it fit in the flow" section) --
# unlike NavTable, which requires an explicit interact press, this triggers
# the instant the player's body enters it, matching a doorway rather than a
# console.
@export var target_scene: String
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(_body: Node2D) -> void:
get_tree().change_scene_to_file(target_scene)

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