Compare commits

...

5 Commits

Author SHA1 Message Date
anekdotin d3bed34c6c 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>
2026-07-19 12:30:22 -04:00
anekdotin be5c41f82f Add match structure (countdown, timer, scoreboard, banner, persistent stats) and capital-ship fleet polish
Match phases (autoload/match_manager.gd, world/game_modes/):
- New server-authoritative MatchManager loops PRE_MATCH (30s countdown,
  ships held invisible/uncollidable, flagships creep into formation) ->
  IN_PROGRESS (active GameMode's clock runs) -> POST_MATCH (winner
  banner, result reported to matchmaking-api) -> back to a fresh
  PRE_MATCH forever, matching the always-on server-pool model instead
  of kicking players to the menu at match end.
- Win-condition logic lives in a new GameMode abstraction (game_mode.gd
  base + team_deathmatch_mode.gd, the only mode so far) so a future
  mode is a new subclass plus one factory branch, no timer/scoreboard/
  banner code changes needed.
- Three new HUD pieces: match_timer.gd (countdown/clock), scoreboard.gd
  (hold-Tab two-team panel), match_banner.gd (winner banner).
- New MatchStats autoload tracks per-match kills/deaths by peer_id
  (including bots), hooked into kill_feed_manager's existing
  report_kill() call site.

Persistent stats (matchmaking-api/):
- Player gains kills/deaths/hours_played columns; new
  POST /matches/report endpoint (server-only, called once at
  POST_MATCH) upserts each real player's totals by callsign via a
  shared app/crud.py helper also used by the matchmaking-queue join
  path. GET /stats/{callsign} returns the new fields alongside mmr/
  wins/losses.

Capital-ship fleet polish (world/flagship.gd, world/world.gd,
ships/ship_movement.gd, autoload/game_config.gd):
- Flagship formations are now a clean vertical line (no per-ship
  position/rotation jitter) so play_creep_in()'s rigid-group tween
  reads as one disciplined fleet arriving together, rising from
  directly below (not a random compass direction) over the full 30s
  countdown.
- Fixed a real bug where the creep-in tween only ever played on the
  server -- MatchManager._run_pre_match() called straight into World,
  server-only code a remote client's own process never runs, leaving
  their flagships static all match. World now triggers it off
  MatchManager.phase_changed instead, which fires identically on every
  peer.
- Fixed a second bug (only reachable on a fresh server boot's very
  first spawn): a phase==PRE_MATCH check that's true even before the
  match loop has genuinely started that phase for the first time fired
  play_creep_in() with a bogus zero-duration tween, corrupting the
  target the real 30s tween read moments later -- flagships would
  settle 4000 units off from their intended formation slot and fire
  from there instead. Guarded on get_remaining_seconds() > 0 too.
- The held ship's camera now actively tracks its own team's flagship
  centroid every tick during the countdown (position_smoothing
  disabled for the hold, since it fights a manually-driven target and
  was the reason the fleet read as invisible) instead of sitting fixed
  and wide-angle; local offset/zoom/smoothing are explicitly reset on
  release so control handback doesn't inherit a stale camera transform.
- _apply_pre_match_hold()/_apply_respawn() now set _dead/
  _held_for_pre_match inside the RPC itself, not just in the
  server-only caller -- those flags never reached remote clients
  before, so WASD wasn't actually blocked for them during the hold.
- Ships now launch from a narrow point directly beneath their own
  flagship formation instead of a full-circle scatter around the spawn
  marker (which could land a spawn behind/inside a hull). Bumped
  flagship_defense_radius so it still comfortably reaches a player who
  flies a straight line to the enemy side without correcting for that
  new offset.

ISN gets a Stealth Corvette hull mixed into its flagship formation
(assets/images/ships/isn/), banner art renamed off opaque UUID
filenames to isn_banner.jpeg/orc_banner.jpeg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:40:15 -04:00
anekdotin a94bccc57b Add capital ships, and settings screen with smooth-motion fixes
Capital ships & support art:
- New Flagship capital ships (world/flagship.gd) spawn a small
  point-defense formation at each team's spawn marker, independently
  targeting the nearest enemy in range with a slow unmissable-looking
  missile and a faster bullet stream -- keeps a team from parking on
  the enemy spawn and farming respawns.
- Per-race/role bullet sprites for turret fire (assets/images/effects/
  bullets/), flagship art for both factions, minimap now draws
  flagship blips, bot_ai awareness tuned to notice them.

Settings screen (items 20-21 in CLAUDE.md) -- was a stub, now five
real sections shared between the main menu and the in-game pause menu:
- Diagnosed and fixed a "movement looks blurry/laggy" report down to
  three independent causes: physics-tick vs. display-refresh judder
  (fixed via Godot's built-in physics interpolation, plus a
  frame-rate-cap + VSync dropdown so each player can match their own
  monitor), missing mipmaps on every minified ship texture (real GPU
  sampling shimmer, unrelated to frame timing), and a periodic hitch
  from the matchmaking heartbeat spinning up a new HTTPRequest thread
  every 8 seconds instead of reusing one.
- Nameplates get their own manual per-frame interpolation, since
  Godot's physics interpolation only covers Node2D/Node3D, not the
  Control-based Label they're built from.
- Audio: Master/Music/SFX volume sliders backed by a real bus layout
  (default_bus_layout.tres) -- the project had no volume control at
  all before this.
- Window mode (Fullscreen/Exclusive Fullscreen/Windowed), a
  colorblind-friendly enemy-color toggle (also fixes the minimap's own
  separate, inconsistent yellow enemy color), and keyboard rebinding
  for every action with a live capture UI.
- New GameConfig.settings_focused flag (same pattern as chat_focused/
  team_select_focused) so a key-rebind capture can't also move the
  ship or toggle the pause menu underneath the panel.

Spawn intro: a ship's first-ever appearance now eases in from a
random direction (fast off the start, decelerating into its landing
spot) instead of popping into place -- purely a cosmetic sprite
offset, so collision/camera/networking are untouched and every peer
(including bots) sees the same warp-in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:02:31 -04:00
anekdotin 1ebcca3807 Replace tiled skybox background with a procedural starfield shader
The old background tiled a single non-seamless 1024x1024 skybox render
across the map, producing visible seams and stars too faint/small to
survive minification. Replace it with a canvas shader (starfield.gdshader)
that draws layered depth stars + soft nebula tint directly, so there's
no repeating tile (no seams) and stars stay pixel-crisp at any zoom.
Applied to both the in-game map background and the main menu backdrop.

Also fixes a bug the swap introduced: world.gd cast the map's
Background node to TextureRect to read world bounds, which silently
failed once Background became a ColorRect, leaving GameConfig.world_bounds
on its tiny fallback default and hard-clamping every ship into an
invisible box near the map center. Cast to Control instead.

Also clears leftover placeholder Walls/Asteroids tile data in
map_01.tscn that sat directly in the spawn-to-spawn flight corridor —
harmless once bounds were fixed, but still untuned test content per
CLAUDE.md's own Current Tasks list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 21:41:47 -04:00
anekdotin 5fe7c01052 Remove Apex Dynamics faction, retune bullet speed/size
Drop Apex Dynamics from the playable roster (2 races now: Inner Sphere
Navy, Outer Rim Collective) to simplify the 2D art pipeline; World's
race-offer logic just shuffles both remaining races instead of picking
2 of N. Also fix bullets using a constant muzzle velocity instead of
adding the shooter's own velocity, and shrink the bullet hitbox/sprite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 21:41:35 -04:00
223 changed files with 7205 additions and 437 deletions
+50 -7
View File
@@ -1,18 +1,24 @@
# Spacewar
2D multiplayer space shooter inspired by Subspace Continuum. Built in **Godot 4.7** (GDScript). Target platform: Steam Deck + PC. Three races fight for galaxy control across casual (25v25) and ranked (5v5) modes.
2D multiplayer space shooter inspired by Subspace Continuum. Built in **Godot 4.7** (GDScript). Target platform: Steam Deck + PC. Two human factions (Inner Sphere Navy, Outer Rim Collective) fight for galaxy control in casual (25v25, bots fill to 7v7 minimum) matches — ranked is removed until it's real, see Current Tasks.
## Design Docs (`overview/`)
| File | Contents |
|------|----------|
| `overview.md` | Concept, core loop, game flow, match sizes |
| `racesclasses.md` | 3 chosen races, 5 ship classes each |
| `multiplayer.md` | Maps, game modes, galaxy war meta |
| `racesclasses.md` | 2 chosen factions, 3 ship classes each |
| `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 |
| `structure.md` | Project file tree, scene graph, input map |
| `networking_implementation_plan.md` | Historical — the phased plan that took the project from zero networking to item 6/16's real implementation; all phases done |
| `sprite-tileset-brief.md` | Art-request brief for ship/tileset needs, derived from `racesclasses.md` |
| `chat.md` | Original chat-system spec (implemented, item 8) |
| `map1.md` | Original match-structure spec — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats (implemented, item 26) |
| `match_framework_progress.md` | Implementation checklist for item 26 — historical, all boxes checked |
| `issue.md` | Dev notes (first Godot project, UI built in code, Steam Deck target) |
Matchmaking API (`matchmaking-api/`, separate FastAPI + Postgres service, own
`README.md`) is a second codebase alongside the Godot project — see its
@@ -40,12 +46,49 @@ README for how to run/extend it.
18. **Engine-flame sprite swap on thrust** — 6 of item 17's 9 ships (ISN's Patriot/Barrage/Behemoth, ORC's Rail-Jack/Scrap-Spitter/Iron-Clad) gained a second `_flame.png` sprite alongside their idle one, cropped from the same concept sheets' flame-frame columns (ISN's sheet has a clean idle/flame pair per ship; ORC's only unambiguous case was Rail-Jack's brighter 3rd-column flame, so Scrap-Spitter/Iron-Clad's flame PNGs are just copies of their idle sprite for now — their sheet's other columns are damage/weapon-fire poses, not more exhaust). Behemoth's flame was composited from two separate sheet crops (clean idle hull + a flame-only crop of just its thrusters) rather than cropped directly, since the sheet's flame frame has a large red shield-ability graphic overlapping the hull that isn't exhaust. Apex's 3 ships (Lancet/Pulsar/Sovereign) have no flame art — their concept sheet never drew a thrust pose — so they're untouched and always show their one sprite. Wired into gameplay via a new optional `flame_path` per ship in `team_select.gd`'s `RACES`, threaded through `PlayerRegistry` (`submit_local_loadout`/`register_bot`/the loadout RPCs, new `ship_flame_path` field, default `""`) so bots and remote peers all get it too, not just the local owner. `ship_movement.gd`'s `_set_thrust_sprite()` swaps the `Sprite2D` texture based on whether the up/down thrust key is *currently held*, not current speed — this game's movement has no drag, so a first cut keyed off `velocity.length()` left the flame on long after letting off the gas since a released ship keeps drifting at speed. The owner predicts this locally the instant it samples input (`_owner_tick`); the server does the same for bots/itself (`_server_tick`) and broadcasts its authoritative value as a new `thrusting` bool on the existing `_receive_state` RPC so every other peer's view of a ship — which never sees raw input, only replicated state — stays in sync too, with no separate RPC needed. Ships with no `flame_path` (Apex) just skip the check entirely (`_flame_texture == null` short-circuits).
19. **Apex Dynamics faction removed** — cut down to 2 playable races (Inner Sphere Navy, Outer Rim Collective) to simplify the 2D art pipeline. `team_select.gd`'s `RACES` array lost its Apex Dynamics block (Lancet/Pulsar/Sovereign), with ISN/ORC renumbered to `id` 1/2. `world.gd`'s `decide_offered_races()` no longer picks "2 of N" races — with only 2 total, that logic was vestigial, so it now just shuffles the order of both remaining races (kept for spawn-side/left-right variety, since `team_select.gd`/`ship_movement.gd` still key off `offered[0]`/`offered[1]`). `overview/racesclasses.md`'s faction-ability Rock-Paper-Scissors loop (which structurally needed 3 legs) is rewritten as a single ISN-shield-vs-ORC-mines counter relationship; `overview.md`/`bots.md`/`structure.md`'s "2 of 3 races" language updated to reflect both races always being fielded. `assets/images/ships/apex/` and `assets/sound/ships/apex/` are left on disk but unreferenced, same as the older unused `terran/mech/vorg` folders from item 17 — not deleted since that wasn't asked for.
20. **Options/Settings screen — max frame-rate cap** — diagnosed a "moving forward feels blurry/laggy" report as physics-tick (fixed 60Hz, see `ships/ship_movement.gd`) vs. display-refresh-rate judder, made far more visible by item's-ago `starfield.gdshader` switching from a blurry tiled skybox to pixel-crisp procedural stars — the right fix depends on each player's own monitor, so it's now a user-facing setting rather than a hardcoded value. New `menu/settings_panel.gd`/`.tscn` (`class_name SettingsPanel`, `extends CanvasLayer`, `layer = 25` — above `PauseMenu`'s 10 and `TeamSelect`'s 20, same layering convention, so it still renders/receives input on top when opened from inside the already-paused pause menu) offers a wide spread of common refresh rates (30 up to 360, plus Unlimited) that set `Engine.max_fps` via a new `GameConfig.set_max_fps()`, persisted to `user://settings.cfg` (`GameConfig._ready()` loads and applies it on boot) so the choice survives restarts. One `SettingsPanel` instance lives in `world.tscn` (opened by the pause menu's now-live SETTINGS button) and a second is instantiated directly by `main_menu.gd` (opened by OPTIONS, no longer a stub) — both just read/write the same `GameConfig` state, so a choice made in one is already in effect if the other is opened afterward. The FPS cap alone turned out to only be a partial fix -- it only removes judder if the chosen rate happens to be a clean multiple of the physics tick (e.g. a 165Hz-monitor player capping to 165 sees *worse* judder than capping to 60, since 165/60 isn't an integer ratio and every physics update ends up displayed for an uneven number of frames). The actual fix, layered underneath so it helps regardless of which cap a player picks, is Godot's built-in physics interpolation (`physics/common/physics_interpolation=true` in `project.godot`), which blends each node's last two 60Hz physics-tick transforms for rendering instead of holding a position static between ticks. Costs no netcode bandwidth (physics_ticks_per_second is untouched, so `submit_input`/`_receive_state`'s RPC rate in `ship_movement.gd` doesn't change) — it only smooths what's already being simulated. Needed two explicit `reset_physics_interpolation()` calls in `_apply_respawn()` (on the ship, and on its owner's `Camera2D` child, now kept as `_camera` instead of a `_ready()`-local var) so a spawn/respawn's instant position jump snaps cleanly instead of visibly sliding across the map over one interpolation window — a reset on a parent doesn't propagate to children, since each `CanvasItem` tracks its own transform history independently. Prediction/reconciliation (`_reconcile()`) and remote-ship snapshot interpolation (`_remote_tick()`) were deliberately left untouched: both already produce a fresh authoritative-or-predicted position every physics tick, so physics interpolation layers on top for free as an additional render-side smoothing pass, with small reconciliation corrections now smoothed away instead of visibly popping. Tried removing `ship.tscn`'s `Camera2D` built-in `position_smoothing` (speed 8.0) next, on a theory that it was fighting physics interpolation (two independent lag/smoothing systems stacked) — made things feel worse, not better, so it was put back (`position_smoothing_enabled = true` again) rather than keep an unproven regression.
The actual remaining culprit: every one of the 9 in-use ISN/ORC ship textures (18 counting `_flame` variants, plus both flagships) had `mipmaps/generate=false` in their `.import` file, and every ship is drawn shrunk to roughly 20-35% of native texture size on screen (per-role sprite `scale` in `team_select.gd`'s `RACES` × `GameConfig.ship_scale_factor` (0.75) × `GameConfig.camera_zoom` (0.85)). Minifying a texture that far with no mipmap chain forces the GPU into raw bilinear point-sampling, which visibly shimmers/blurs as the sub-pixel sampling phase shifts every frame during motion — a purely spatial/GPU-sampling artifact with zero relationship to physics tick rate, frame cap, or camera smoothing, which is exactly why none of those changes touched it (and why disabling camera smoothing, which had incidentally been low-pass-filtering the shimmer, made it more visible instead of less). Fixed by flipping `mipmaps/generate=false``true` in all 14 affected `.import` files under `assets/images/ships/isn/` and `assets/images/ships/orc/` and letting Godot re-import (`godot4 --headless --path . --import --quit` regenerates the cached `.ctex` files with a proper mip chain baked in — needed once after this kind of `.import` edit, same as the class-cache-rebuild step needed after adding a new `class_name`, see the headless-testing memory notes).
Even after that, a *periodic* (roughly every 8s) stutter remained — smooth for a stretch, then a hitch, invisible while sitting still but very visible while panning (a dropped/delayed frame just doesn't register on an unchanging screen). Root cause: `autoload/network_manager.gd`'s hosting heartbeat (`HEARTBEAT_INTERVAL = 8.0`, re-registers a hosted server with the matchmaking API for as long as it stays up — see item 16) went through `MatchmakingClient._request()`, which spun up a brand-new `HTTPRequest` node (and its background thread) per call and `queue_free()`'d it right after. Every other caller of `_request()` fires rarely enough (once, or for a short pre-match queueing window) that this per-call churn is fine; the heartbeat is the one endpoint called on a tight, indefinitely-repeating cadence for a hosted server's entire uptime, so it's the one that needed a persistent, reused `HTTPRequest` instead. Added `MatchmakingClient._heartbeat_http` (created once in `_ready()`) and an optional `reuse_http` param on `_request()` that `register_server()` now passes — every other call site is untouched, still short-lived per-call nodes so concurrent unrelated requests (e.g. matchmaking queue polling) can't collide on a shared node mid-flight.
Last remaining piece: the bottom-right nameplate (`ship_movement.gd`'s `_nameplate`, a `top_level` `Label` manually repositioned every `_physics_process()` tick) stayed juddery/blurry in motion even after everything else smoothed out, because Godot's built-in physics interpolation only covers `Node2D`/`Node3D` transforms — `Label`'s base class is `Control`, which isn't part of that system at all, so it was still snapping at the raw 60Hz tick rate while the ship sprite and camera (both `Node2D`) rode the engine's interpolation for free. Fixed the same way this codebase already smooths a remote ship's position between snapshots (`_remote_tick()`'s lerp pattern): added `_nameplate_from_pos`/`_nameplate_to_pos`/`_nameplate_interp_elapsed`, set once per physics tick, consumed by a new `_process()` that lerps the nameplate's `global_position` across render frames the same `elapsed / _fixed_delta` way `_remote_tick()` does.
Confirmed there was one more variable left: a 165 FPS cap (`menu/settings_panel.gd`) still stuttered badly in a repeating smooth/bad cycle even after all of the above — not a code bug, a vsync mismatch (the player's monitor isn't natively 165Hz, so `Engine.max_fps=165` fights vsync's own throttling, causing an alternating catch-up/backlog pattern). Capping to exactly 60 fixed it completely. Takeaway for anyone hitting this again: physics interpolation + a correct mipmap/heartbeat/nameplate setup makes *any* cap that actually matches the display look smooth, but the frame-rate-cap dropdown is still a "match your real monitor" knob, not a free "bigger number is smoother" one — 60 is the one value guaranteed to be judder-free regardless of the player's actual display, since it's an exact match to the fixed physics tick.
Added the other half of that same knob: a VSYNC dropdown (Enabled/Adaptive/Disabled, `DisplayServer.window_set_vsync_mode()`) next to the frame-rate cap in `SettingsPanel`, backed by a new `GameConfig.vsync_mode` (persisted the same way as `max_fps`, both now written together by a shared `GameConfig._save_settings()`). Not every display/driver combination will land cleanly on Enabled vsync at a given cap the way 60 did here — Adaptive (only syncs when the frame rate would otherwise exceed the display's refresh) and Disabled (no sync, lowest input lag, tearing possible) are the standard fallbacks for a player who's still stuttering at the right cap.
21. **Options/Settings screen expanded — audio, window mode, colorblind mode, key rebinding** — item 20 only ever had a frame-rate cap; `menu/settings_panel.gd` now has four more sections, all following the same pattern (a `GameConfig` var + setter that applies live and persists to `user://settings.cfg`, read back by both `SettingsPanel` instances via `_refresh_selected()`). Body is now a fixed-size panel + `ScrollContainer` instead of growing per section, so it still fits Steam Deck's 800px-tall screen. `_add_section()`/`_add_hint()`/`_add_dropdown()`/`_add_slider()` helpers factor out the repeated per-section boilerplate now that there are 5 sections instead of 2.
- **Audio** — the project had *zero* volume control before this (every sound used a hardcoded `volume_db`). Added `default_bus_layout.tres` (Master/Music/SFX, Music and SFX both routed to Master) and pointed `autoload/music_manager.gd`'s player and `world.gd`'s `_play_shoot_sound()`'s per-shot `AudioStreamPlayer2D` at their respective buses. `GameConfig.master_volume`/`music_volume`/`sfx_volume` are linear 0..1 (`HSlider`'s native range), converted to dB only in `_apply_bus_volume()` (0.0 explicitly mutes the bus rather than relying on `linear_to_db(0)`'s `-inf` edge case). The volume setters deliberately don't call `save_settings()` themselves — `HSlider.value_changed` fires continuously through a drag, and writing the config file to disk on every one of those would reintroduce exactly the per-event hitch item 20 already hunted down once (the matchmaking heartbeat). `SettingsPanel` applies live on every `value_changed` and only persists once on the slider's `drag_ended`.
- **Window mode** — `GameConfig.window_mode` (`DisplayServer.WindowMode`), default `WINDOW_MODE_FULLSCREEN` matching `project.godot`'s existing boot default (item 10) so nothing changes until a player picks something else. Exclusive Fullscreen and Windowed are the other two options.
- **Colorblind-friendly enemy color** — `GameConfig.colorblind_mode` swaps `enemy_color` between the existing red (`ENEMY_COLOR_DEFAULT`) and a high-contrast orange (`ENEMY_COLOR_COLORBLIND`) that stays distinct from `team_color` (white, unchanged in both palettes) and from the minimap's own blue self-marker across effectively all forms of color vision deficiency. `hud/mini_map.gd` had its own separate hardcoded `TEAM_COLOR`/`ENEMY_COLOR` consts (white/yellow, inconsistent with the red used everywhere else) — replaced with reads of `GameConfig.team_color`/`enemy_color` so the toggle (and the color scheme generally) is consistent across nameplates, the top-left roster, and the minimap instead of just the first two.
- **Key rebinding** — keyboard-only (the only non-keyboard binding in the project, `toggle_pause`'s joypad Start button for Steam Deck, is deliberately left alone). `GameConfig.KEYBIND_ACTIONS`/`DEFAULT_KEYCODES` mirror `project.godot`'s `[input]` section, needed because `InputMap` resets to those compiled-in defaults on every engine boot — `GameConfig._apply_single_keybind()` reapplies any persisted override on top at `_ready()`, and only ever erases/re-adds an action's `InputEventKey` entries specifically (not the whole action), so rebinding e.g. `toggle_pause`'s keyboard key can never silently wipe its separate joypad binding. `SettingsPanel`'s CONTROLS section shows one row per action with a button reading its current key (`InputEventKey.as_text_physical_keycode()`); clicking it sets `_rebinding_action` and the panel's own `_input()` captures the next physical key (Esc cancels instead of binding). New `GameConfig.settings_focused` flag (same pattern as `chat_focused`/`team_select_focused`) gates ship movement input and `pause_menu.gd`'s own Escape-toggles-pause handling while the panel is open — without it, a rebind capture pressing W/A/S/D/Space would also thrust/turn/fire the ship if unpaused behind the panel, and pressing Esc to cancel a rebind would also close the pause menu underneath it.
22. **Spawn "fly in" intro** — a ship's first-ever appearance now eases in from a random direction instead of just popping into place. `ship_movement.gd`'s `_play_spawn_intro()` is purely a cosmetic `Sprite2D.position` tween (`TRANS_EXPO`/`EASE_OUT` — fast off the start, tailing off into the landing spot) layered on top of the already-correct `global_position`; collision, camera, and every other peer's replicated view of this ship are untouched; the sprite is what everyone (including bots) visibly sees warp in, since `_apply_respawn()` runs identically on every peer via its `call_local` RPC. Gated by a new `_played_spawn_intro` bool that's never reset, so only the ship's true first spawn plays it — a later death/respawn or mid-match team swap (`_apply_respawn()`'s other two call sites) don't repeat it, matching "when the match starts" rather than every respawn.
23. **Per-role energy system** — each ship has an energy pool alongside health, spent on firing: Fighter 100 max / 75 per shot, Gunner 150 max / 25 per shot, Tank 250 max / 50 per shot (`GameConfig.ship_max_energy_by_role`/`ship_bullet_energy_cost_by_role`), regenerating at a flat 100-per-2.5s rate (`ship_energy_regen_rate`) regardless of role. Server-authoritative in `ships/ship_movement.gd`, same pattern as health — a shot is only fired if the server's copy of the ship has enough energy, and current energy broadcasts to every peer over the existing `_receive_state` RPC. Role is threaded through `PlayerRegistry`'s loadout (so remote peers and bots all get the right pool/cost, not just the local owner) and `bots/bot_manager.gd`'s bot registration. Shown as a text readout (`HUD/EnergyLabel`, below the health label) plus a blue center-out mirrored bar top-middle of screen (`hud/energy_bar.gd`/`.tscn`).
24. **Capital ships (Flagships) + kill feed + bot personalities**`world/flagship.gd` (`class_name Flagship`) spawns a 3-ship point-defense formation at each team's spawn marker (`world.gd`'s `_spawn_flagships()`, called with the same server-decided race_ids/match_seed every peer already uses for map/race setup, so every client builds an identical formation locally with no replication needed). Each flagship independently scans for the nearest enemy ship in `GameConfig.flagship_defense_radius` and fires a lead-aimed (with jitter, so it can miss) slow missile plus a faster bullet stream, both riding the existing networked `BulletSpawner` — deters a team from parking on the enemy spawn and farming respawns. 5000 HP, shootable for damage-number feedback but no destroy/respawn logic yet (that's future Flagship Assault mode, see `multiplayer.md`'s ranked-mode ideas). Collision/detection hulls are traced from each sprite's actual alpha channel via `Geometry2D.convex_hull`, not a bounding box, since the hulls taper sharply at the nose/tail. A ship flying under a flagship's hull ghosts (reuses `ship_movement.gd`'s existing explosion-cover ghost mechanism) rather than colliding. New per-race/role bullet sprites for turret fire live in `assets/images/effects/bullets/`. `hud/mini_map.gd`/`mini_map_view.gd` draw flagships as larger team-colored blips (`_update_flagships()`), and `bots/bot_ai.gd`'s awareness was tuned to notice them. Alongside this: a server-authoritative **kill feed** (`autoload/kill_feed_manager.gd` broadcasts victim/killer name+race on every death from `ship_movement.gd`'s `_die()`; `hud/kill_feed.gd` shows the last 4 lines stacked directly above ChatBox's history panel) and **bot personalities** (`bots/bot_personality.gd`, `class_name BotPersonality` — 5 independent 0.0-1.0 traits: aggression/caution/accuracy/reaction/awareness, rolled once per bot-slot by `BotManager` and kept for the bot's lifetime so a player can learn and counter a specific bot's behavior, see `bots.md`).
25. **ISN flagship formation mixes in a Stealth Corvette hull** — item 24's 3-ship flagship formation was 3 identical `flagship_colossus.png` copies per faction; ISN's formation is now 2 Colossus + 1 `stealth_corvette.png` (a new hull processed from a user-supplied render: background matted out via border flood-fill, a disconnected decorative sparkle dropped, rotated 90° to this project's nose-up convention — see `assets/images/ships/isn/source/isn_stealth_corvette_obsidian_source.jpeg` for the original). `team_select.gd`'s ISN entry gained a `flagship_paths` array (list of `{path, scale}`, cycled by formation-slot index in `world.gd`'s `_spawn_flagship()`); a race with no `flagship_paths` (ORC, so far) falls through to the original singular `flagship_path`/`flagship_scale` for every slot, so this is additive and backward compatible. The Corvette's `scale: 0.68` was calibrated against its native resolution (516×1019, notably taller than Colossus's 535×692) so all 3 formation members read at the same on-screen size despite the size mismatch in source art.
26. **Match structure — pre-match countdown, timer, kills/deaths, scoreboard, winner banner, persistent stats, reusable GameMode/map framework** (see `overview/map1.md`) — matches now have real phases instead of ships spawning the instant a peer connects. New `MatchManager` autoload (`autoload/match_manager.gd`) runs a server-authoritative `PRE_MATCH` (30s countdown, ships held invisible/uncollidable via `ship_movement.gd`'s `server_hold_for_match_start()`/`server_release_from_hold()` — reuses the existing dead-ship machinery rather than a parallel state, so prediction/reconciliation are untouched; flagships cosmetically tween in from a random offset via `flagship.gd`'s `play_creep_in()`) → `IN_PROGRESS` (the active `GameMode`'s 7-minute clock runs, ships released) → `POST_MATCH` (winner banner, result reported to matchmaking-api) → loops back into a fresh `PRE_MATCH` forever, matching the always-on server-pool model from item 16 rather than kicking players to the menu. Phase state is replicated with the same "decide once on the server, RPC-broadcast, late joiners pull it" pattern `world.gd`'s `decide_offered_races()` already established. Win-condition logic lives entirely in a new `GameMode` abstraction (`world/game_modes/game_mode.gd` base + `team_deathmatch_mode.gd`, the only mode implemented so far — most kills when the clock runs out wins, draw on a tie) built identically on every peer off a shared `GameConfig.default_game_mode_id` constant, so a future mode (`multiplayer.md`'s Flagship Assault/Last Ship Standing/Capture and Hold ideas) is a new subclass plus one factory branch in `MatchManager`, with zero changes to the timer/scoreboard/banner code around it — none of that ever hardcodes "kills," only ever calling `GameMode.get_score_for_team()`/`get_score_label()`. `world.gd`'s hardcoded `MAP_SCENE` const became a `MAPS` array + `_pick_map()` (same static-array convention as `TeamSelect.RACES`) so a second map is an append, not a rewrite — still only `map_01` exists today. New `MatchStats` autoload (`autoload/match_stats.gd`) tracks per-match (not persistent) kills/deaths by peer_id, including bots, hooked into the single existing death-detection call site (`kill_feed_manager.gd`'s `report_kill()`) rather than duplicating it in `ship_movement.gd`. Three new signal-driven HUD `CanvasLayer`s follow every existing HUD file's autonomous-`_build_ui()` convention: `hud/match_timer.gd` (top-center countdown/clock), `hud/scoreboard.gd` (hold-Tab CS:GO-style two-team panel, new `scoreboard` input action bound to Tab), `hud/match_banner.gd` (full-screen winner banner, `layer=30` — highest in the project). Banner art (`assets/images/banners/`) was renamed from opaque UUID filenames to `isn_banner.jpeg`/`orc_banner.jpeg` (the third, Apex Dynamics, stays orphaned per item 19's precedent of leaving unused assets on disk) and wired via a new `banner_path` key on `team_select.gd`'s ISN/ORC `RACES` entries, same pattern as the existing `flagship_path` key. Persistent per-player history (total kills, deaths, wins, losses, hours played) is a new `matchmaking-api` capability: `Player` gained `kills`/`deaths`/`hours_played` columns, a new `POST /matches/report` endpoint (`app/routers/matches.py`) is called once by the hosting server's `MatchManager` (never a client, same server-only trust model as `/servers/register`) at `POST_MATCH`, upserting each real (non-bot) player's totals by callsign via a shared `app/crud.py` helper also now used by the matchmaking-queue join path; `GET /stats/{callsign}` returns the 3 new fields alongside the existing `mmr`/`wins`/`losses`. No migrations tooling exists in this API (schema changes just land in `models.py` for `create_all()` to pick up on a fresh DB) — a `docker compose down -v` is needed once to add the new columns to an already-existing local `players` table, verified end-to-end via a real `docker compose up` round-trip during implementation.
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
- [ ] Asteroids and environment hazards
- [ ] Sound effects (thrust, shoot, explosion, UI)
- [ ] Options screen — currently just a disabled stub button on the main menu
- [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`)
- [ ] Galaxy war meta / sector control for casual (see `multiplayer.md`)
- [ ] 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
+52
View File
@@ -76,6 +76,58 @@ value; `menu/server_select.gd` also queries each server directly over UDP
(`network_manager.gd`'s ping responder) for a live, pre-connect number and
RTT, overriding the DB value in the UI once that probe answers.
## Match results & persistent stats
`POST /matches/report` `{server_ip, server_port, mode, winner_team, players}`
called once by the hosting game server (never a client) when a match ends,
see `spacewar/autoload/match_manager.gd`'s `_report_match_result()` (fired
from its `MatchManager` phase state machine's `POST_MATCH` transition, driven
by `overview/map1.md`'s 7-minute-match design). `players` is a list of
`{callsign, team, kills, deaths, is_winner, seconds_played}` — one entry per
*human* player present at match end (bots, negative peer_id in the Godot
client, never report). For each player this upserts their `Player` row
(`app/crud.py`'s `get_or_create_player`, same helper `/matchmaking/queue/join`
uses) and accumulates `kills`/`deaths`/`hours_played` (`seconds_played / 3600`)
plus `wins`/`losses` (a draw — `winner_team: null` — touches neither). If the
reporting `(server_ip, server_port, mode)` matches a registered `GameServer`,
a `Match`/`MatchPlayer` row is also written — independent of any `Match` row
the matchmaking queue already created when the match was *formed*
(`queue_manager.py`), so a queued match can end up with two `Match` rows (one
per "formed"/"ended"); nothing currently reads these tables, so this hasn't
been reconciled. No auth on this endpoint, same posture as `/servers/register`.
`GET /stats/{callsign}` now also returns `kills`/`deaths`/`hours_played`
alongside the existing `mmr`/`wins`/`losses`.
**Schema changes have no migration path** (this project has no migrations
tooling — see below): `Player.kills`/`deaths`/`hours_played` are new columns
on an existing table, so `Base.metadata.create_all()` on API boot will NOT
add them to a database that already has a `players` table from before this
change. Run `docker compose down -v` once to pick them up on an existing local
dev database.
## 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()
+17
View File
@@ -0,0 +1,17 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Player
# Shared by app/routers/matchmaking.py (queue join) and app/routers/matches.py
# (match result reporting) -- both need "find this callsign's Player row, or
# create one" with no account/auth system yet (see Player's own docstring).
async def get_or_create_player(db: AsyncSession, callsign: str) -> Player:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
player = Player(callsign=callsign)
db.add(player)
await db.commit()
await db.refresh(player)
return player
+4 -1
View File
@@ -8,7 +8,7 @@ from app.config import settings
from app.database import Base, async_session, engine
from app.models import GameServer, Mode, ServerStatus
from app.queue_manager import queue_manager
from app.routers import matchmaking, ranks, servers, stats
from app.routers import matches, matchmaking, ranks, servers, stats
from app.routers.servers import sweep_stale_servers
@@ -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()
@@ -72,6 +74,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Spacewar Matchmaking API", lifespan=lifespan)
app.include_router(matchmaking.router)
app.include_router(matches.router)
app.include_router(stats.router)
app.include_router(ranks.router)
app.include_router(servers.router)
+26 -1
View File
@@ -2,7 +2,7 @@ import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, 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
@@ -30,6 +30,21 @@ class Player(Base):
mmr: Mapped[int] = mapped_column(Integer, default=1000)
wins: Mapped[int] = mapped_column(Integer, default=0)
losses: Mapped[int] = mapped_column(Integer, default=0)
# Match-history stats (see spacewar/autoload/match_manager.gd's
# _report_match_result(), POST /matches/report) -- kills/deaths accumulate
# across every match reported for this callsign; hours_played is derived
# from each match's full duration for every player present at match end
# (not precise per-player join/leave timing -- a documented simplification).
kills: Mapped[int] = mapped_column(Integer, default=0)
deaths: Mapped[int] = mapped_column(Integer, default=0)
hours_played: Mapped[float] = mapped_column(Float, default=0.0)
# 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)
@@ -49,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):
+65
View File
@@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends
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
router = APIRouter(prefix="/matches", tags=["matches"])
# Called once by the hosting server's MatchManager (spacewar/autoload/
# match_manager.gd's _report_match_result()) when a match's POST_MATCH phase
# begins -- persists this match's final kills/deaths/win-loss into each
# player's running Player totals (kills, deaths, wins, losses, hours_played),
# and records a Match/MatchPlayer row if the reporting server is a known,
# registered GameServer. No auth -- same posture as /servers/register today,
# not a new gap introduced here (see overview/map1.md's design notes).
#
# Independent of any Match row the matchmaking queue may have already created
# when this match was *formed* (see queue_manager.py's _try_form_match) --
# this always writes a fresh Match/MatchPlayer pair representing how the
# match actually ended. A queued match can therefore end up with two Match
# rows (one "how it was formed", one "how it ended"); acceptable since no
# existing code reads these tables today and there's no migrations tooling to
# reconcile the schema around it.
@router.post("/report")
async def report_match(req: MatchReportRequest, db: AsyncSession = Depends(get_db)) -> dict:
server = (
await db.execute(
select(GameServer).where(
GameServer.ip == req.server_ip,
GameServer.port == req.server_port,
GameServer.mode == req.mode,
)
)
).scalars().first()
match_row = None
if server is not None:
match_row = Match(mode=req.mode, server_id=server.id)
db.add(match_row)
await db.flush()
for result in req.players:
player = await crud.get_or_create_player(db, result.callsign)
player.kills += result.kills
player.deaths += result.deaths
player.hours_played += result.seconds_played / 3600.0
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:
player.losses += 1
if match_row is not None:
db.add(MatchPlayer(match_id=match_row.id, player_id=player.id, team=result.team))
await db.commit()
return {"status": "recorded"}
+2 -13
View File
@@ -1,30 +1,19 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app import crud
from app.database import get_db
from app.models import Player
from app.queue_manager import queue_manager
from app.schemas import QueueJoinRequest, QueueJoinResponse, QueueStatusResponse
router = APIRouter(prefix="/matchmaking", tags=["matchmaking"])
async def _get_or_create_player(db: AsyncSession, callsign: str) -> Player:
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
player = Player(callsign=callsign)
db.add(player)
await db.commit()
await db.refresh(player)
return player
@router.post("/queue/join", response_model=QueueJoinResponse)
async def join_queue(req: QueueJoinRequest, db: AsyncSession = Depends(get_db)) -> QueueJoinResponse:
player = await _get_or_create_player(db, req.callsign)
player = await crud.get_or_create_player(db, req.callsign)
ticket = await queue_manager.join(player.id, player.callsign, req.mode, player.mmr)
return QueueJoinResponse(ticket_id=ticket.ticket_id, status=ticket.status)
+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
]
+10 -1
View File
@@ -14,4 +14,13 @@ async def get_stats(callsign: str, db: AsyncSession = Depends(get_db)) -> Player
player = (await db.execute(select(Player).where(Player.callsign == callsign))).scalars().first()
if player is None:
raise HTTPException(status_code=404, detail="Player not found")
return PlayerStatsResponse(callsign=player.callsign, mmr=player.mmr, wins=player.wins, losses=player.losses)
return PlayerStatsResponse(
callsign=player.callsign,
mmr=player.mmr,
wins=player.wins,
losses=player.losses,
kills=player.kills,
deaths=player.deaths,
hours_played=player.hours_played,
ram_kb=player.ram_kb,
)
+30
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):
@@ -36,3 +40,29 @@ class PlayerStatsResponse(BaseModel):
mmr: int
wins: int
losses: int
kills: int
deaths: int
hours_played: float
ram_kb: int
# Reported by the hosting game server (never a client directly -- see
# spacewar/autoload/network_manager.gd's register_server()/heartbeat being
# server-only-triggered the same way) once a match's MatchManager POST_MATCH
# phase begins. team here is a race_id (races double as teams, see
# menu/team_select.gd's RACES), not a 0/1 index.
class MatchPlayerResult(BaseModel):
callsign: str
team: int
kills: int
deaths: int
is_winner: bool
seconds_played: float
class MatchReportRequest(BaseModel):
server_ip: str
server_port: int
mode: Mode = Mode.casual
winner_team: int | None = None # None = draw
players: list[MatchPlayerResult]
+1 -1
View File
@@ -2,7 +2,7 @@
## Rules
- Each casual match picks **2 of the 3 races** at random
- Each casual match fields both races (order presented is randomized)
- Bots fill empty slots until the match reaches **7v7** (14 total)
- Once 7v7 is reached, **no more bots are added** — additional human players replace bots as they join
- Bots are removed when real players join their team slot
+4
View File
@@ -0,0 +1,4 @@
I wanted to work on making the matches seem more official with winners and losers . Each player will have a history of total kills, total wins, total losses, hours in a match.
I wanted the current match to have a timer. Each match is 7 minutes long. The way the match works is all
players are at the startpoint where the ships are on there perspective side. They dont see there ships yet. The timer starts counting down from 30 seconds. the 3 capital ships slowly start creeping to there spots. WHen the countdown reaches 0 the players or bots ships appear in the spawn. The match will be 7 minutes long. In that time there will be stats for each player. Total kills, total deaths. At the end of the match the team with the most kills wins. There will be a banner of the winner displayhed on the map saying ORC or ISN wins . A user can press tab to see a current scoreboard similiar to csgo.
. Banners are located here /mnt/code/spacewar/spacewar/assets/images/banners/.
+28
View File
@@ -0,0 +1,28 @@
# Match Framework Implementation Progress
Tracking checklist for the plan in `overview/map1.md` (pre-match countdown, 7-minute
timer, kills/deaths, scoreboard, winner banner, persistent stats, reusable
GameMode/map framework). Full design plan: see the approved plan this session
(sections referenced below). Check items off as they're completed.
- [x] 1. GameMode abstraction — `world/game_modes/game_mode.gd` (base) + `team_deathmatch_mode.gd`
- [x] 2. `MatchStats` autoload — per-match kills/deaths, hook into `kill_feed_manager.gd`
- [x] 3. `MatchManager` autoload — PRE_MATCH/IN_PROGRESS/POST_MATCH phase state machine
- [x] 4. Flagship creep-in tween — `flagship.gd:play_creep_in()`, `world.gd:play_flagship_creep_in()`, `GameConfig.flagship_creep_in_distance`
- [x] 5. Ship-spawn gating — `ship_movement.gd` pre-match hold (`_server_respawn` phase gate, `server_hold_for_match_start`/`server_release_from_hold`/`_apply_pre_match_hold`), `world.gd` hold/release helpers
- [x] 6. Map abstraction — `world.gd` `MAPS` array + `_pick_map()` replacing hardcoded `MAP_SCENE`
- [x] 7. Scoreboard HUD — `hud/scoreboard.gd`+`.tscn`, hold-Tab, `scoreboard` input action in `project.godot`
- [x] 8. Winner banner HUD — `hud/match_banner.gd`+`.tscn`, `banner_path` in `team_select.gd` RACES, rename ISN/ORC banner assets
- [x] 9. Match timer HUD — `hud/match_timer.gd`+`.tscn`, top-center MM:SS readout
- [x] 10. Wiring — new autoloads in `project.godot`, new HUD nodes in `world.tscn`, `MatchManager.start(self)` + late-joiner pull RPCs in `world.gd:_ready()`
- [x] 11. Backend schema + endpoint — `matchmaking-api` `Player.kills/deaths/hours_played`, `app/crud.py`, `app/schemas.py`, `app/routers/matches.py` (`POST /matches/report`), `app/routers/stats.py`, `app/main.py` (verified end-to-end via docker compose)
- [x] 12. Godot-side backend call site — `network_manager.gd:get_registered_address()`, `matchmaking_client.gd:report_match_result()`, `MatchManager._report_match_result()`
- [x] 13. Docs update — `CLAUDE.md` new numbered item, `overview/multiplayer.md`, `overview/structure.md`
- [x] 14. Verification — headless server smoke test: clean compile, full PRE_MATCH→IN_PROGRESS→POST_MATCH→loop cycle with a temporarily-sped-up clock, bot-vs-bot kills flowing through MatchStats and correctly deciding the winning race, backend /matches/report round-trip via a real docker compose run. Additionally ran a REAL two-process ENet test (separate server + client, real network handshake, not single-process offline mode): confirmed real peer-connect, correct late-joiner phase/timer pull mid-countdown, and every ship (bots, server's own ghost, the real second client's ship) held/released in perfect lockstep across both processes over two full match loops. Also incidentally confirmed the pre-existing bot-rebalancing logic (item 9) correctly reacted to the real human joining. NOT verified: visual rendering (hidden ships, flagship creep-in tween, Tab scoreboard, winner banner) and the Tab keybind itself, since this sandbox has no display — needs a real client pass on the user's machine.
## All 14 items complete.
Remaining follow-up recommended: a real (non-headless) client session on the user's machine to
visually confirm the countdown/creep-in/scoreboard/banner and to exercise Tab's input-map binding
(see hud/scoreboard.gd's comment about verifying the hand-typed Tab keycode in the Godot editor's
Input Map UI).
+24 -6
View File
@@ -17,6 +17,11 @@
## Ranked Mode — 5v5
> **Status:** design ideas only — not implemented. The matchmaking API's ranked
> queue path was built and then deleted (`CLAUDE.md` item 15) once it became
> clear no client would hit it before casual was solid; it's absent from the
> main menu entirely (not just disabled) until it's built for real.
Competitive, skill-based, MMR/ELO ladder. Penalty for leaving mid-match. Smallmedium maps only.
### Ranked Mode Ideas
@@ -36,17 +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** 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 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 |
|------|-------------|
| **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.
---
+10 -2
View File
@@ -1,13 +1,21 @@
# Networking Implementation Plan
> **Status: complete.** This was the ordered checklist that took Spacewar from
> zero networking code to the real ENet authoritative-server implementation —
> all 10 phases below were executed (see `CLAUDE.md` item 6 and the many items
> after it that build on top of this foundation). Kept as a historical record
> of the approach and ordering; the checkboxes are left unchecked as
> originally written rather than retroactively edited. For current
> architecture, see `tech.md`; for the present file layout, see `structure.md`.
Step-by-step breakdown for taking Spacewar from single-player-only to working
multiplayer. `tech.md` describes the target architecture; this doc is the
ordered checklist for getting there from the current codebase.
**Current state (as of this doc):** zero networking code exists anywhere in
**Original starting state:** zero networking code existed anywhere in
the project. The server browser's JOIN button and the main menu's CASUAL
button just set local `GameConfig` fields and load `world.tscn` — no
connection is ever made. Everything downstream assumes exactly one player.
connection was ever made. Everything downstream assumed exactly one player.
**Guiding principle:** get two peers moving/shooting/dying correctly over
ENet *first*, with ugly/no prediction. Polish feel (prediction,
+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.
+22 -22
View File
@@ -2,13 +2,13 @@
## Concept
A 2D pixel-art top-down space shooter inspired by **Subspace Continuum**, built for **Steam Deck and PC**. Three alien races fight for control of a galaxy. Fast matchmaking like Rocket League — quick in, quick out.
A 2D pixel-art top-down space shooter inspired by **Subspace Continuum**, built for **Steam Deck and PC**. Two human factions — Inner Sphere Navy (disciplined military) and Outer Rim Collective (scrappy belters), not aliens — fight for control of a galaxy inside the tight corridors of factories, refineries, and shipyards. Fast matchmaking like Rocket League — quick in, quick out.
## Core Loop
1. Launch → Main Menu
2. Enter callsign → click CASUAL
3. World loads → pick your race (2 of 3 randomly offered per match) → pick your ship
2. Enter callsign (PROFILE overlay, auto-opens first time) → click QUICK PLAY
3. World loads → pick your faction (both offered every match) → pick your ship
4. Fight in a 25v25 battle
5. Return to main menu
@@ -16,45 +16,45 @@ A 2D pixel-art top-down space shooter inspired by **Subspace Continuum**, built
| Mode | Size | Status |
|------|------|--------|
| Casual | 25v25 (bots fill to 7v7 minimum) | In progress |
| Ranked | 5v5, competitive MMR | Disabled — coming later |
| Casual | 25v25 (bots fill to 7v7 minimum) | Live |
| Ranked | 5v5, competitive MMR | Removed (not just disabled) until it's real — see `CLAUDE.md` Current Tasks |
## Current Game Flow (as built)
```
Main Menu
└─ CASUAL ──► World loads immediately (game active)
Main Menu (HL2-style vertical nav: QUICK PLAY / SERVER SELECT / OPTIONS / PROFILE / QUIT)
└─ QUICK PLAY ──► matchmaking queue (casual) ──► World loads (game active)
(SERVER SELECT skips the queue, connects to a picked server directly)
└─ TeamSelect overlay appears
├─ Pick race (2 random of 3 offered)
└─ Pick ship (4 available, sprites shown)
└─ Player spawns centred, invincible briefly
├─ Pick faction (both offered every match)
└─ Pick ship (3 available, sprites shown)
└─ Player spawns centred, invincible briefly, flies in from a random direction
└─ ESC / Start ──► Pause menu overlay
├─ RESUME
├─ SETTINGS (stub)
├─ SELECT TEAM (stub)
├─ SETTINGS (live — audio, frame-rate cap, vsync, window mode, colorblind mode, key rebinding)
├─ SELECT TEAM (live — reopens TeamSelect mid-match, swaps faction/ship with a fresh respawn)
├─ QUIT TO MENU
└─ QUIT TO DESKTOP
```
## Races
## Factions
3 playable races chosen from a pool of 10 concepts — see `racesclasses.md`. Each race has 5 ship classes:
2 playable factions (a third, Apex Dynamics, was cut to simplify the art pipeline) — see `racesclasses.md`. Each faction has 3 ship classes:
| Class | Role |
|-------|------|
| Interceptor | Fast, agile generalist |
| Bomber | Area damage, slow |
| Support | Heal/rally; allies can attach |
| Stealth | Assassin, hit-and-run |
| Heavy | Tank, massive firepower |
| Fighter | Single, high-precision, high-damage shot |
| Gunner | Rapid-fire spray/burst, more shots for less damage each |
| Tank | Bigger and slower; fires both bullets and missiles |
Races are visually distinct — critical for reading a 25v25 battlefield at a glance.
Factions are visually distinct — critical for reading a 25v25 battlefield at a glance.
## Match Setup
- Each match randomly picks **2 of the 3 races** to field
- Player picks which of those 2 they fight for, then picks their ship
- Both factions are fielded every match (order they're presented in is randomized)
- Player picks which of the 2 they fight for, then picks their ship
- Bot fill ensures a minimum of **7v7** in casual; no bots added beyond that
- Each ship has an energy pool spent on firing (Fighter 100/Gunner 150/Tank 250 max, regenerating over time) alongside health — see `CLAUDE.md` item 23
## Design Philosophy
+16 -39
View File
@@ -2,30 +2,21 @@
## Setting
Three factions fight inside the massive shielding, pipes, and superstructure of
Two factions fight inside the massive shielding, pipes, and superstructure of
refineries, shipyards, and mining stations — not open void. Grounded, blue-collar
sci-fi in the vein of *The Expanse* (corporate hegemony vs. disciplined inner-planet
military vs. gritty outer-belt miners), but built as a fast, arcade 2D tactical
arena shooter rather than a hard-sci-fi sim. Tight factory corridors and choke
points are the map language — see `multiplayer.md`.
sci-fi in the vein of *The Expanse* (disciplined inner-planet military vs. gritty
outer-belt miners), but built as a fast, arcade 2D tactical arena shooter rather
than a hard-sci-fi sim. Tight factory corridors and choke points are the map
language — see `multiplayer.md`.
> **Status:** This replaces the previous 10-alien-race concept pool and the
> Terran Republic / Mechanos Sovereignty / Vorg Swarm roster. Doc-only for now —
> game code (`team_select.gd` RACES data, `GameConfig`, existing
> `assets/images/ships/<race>/` art) still reflects the old races until that work
> is scheduled.
> Terran Republic / Mechanos Sovereignty / Vorg Swarm roster. A third faction,
> Apex Dynamics (The Corporate Syndicate), was cut entirely to simplify the art
> pipeline — no code or docs should reference it going forward.
---
## The 3 Factions
### Apex Dynamics — The Corporate Syndicate
**Weapon type:** High-tech energy (coherent light / plasma)
**Visual:** Sleek, pearlescent-white hulls with razor-sharp geometric lines and
glowing neon-blue engine trails — looks more like a high-end luxury smartphone
than a military weapon.
**Feel:** High-tech and precise. Wins by not being seen until it's too late.
**Tactical ability:** **Active Camouflage** — see [Faction Abilities](#faction-abilities-universal-system)
## The 2 Factions
### Inner Sphere Navy (ISN) — The Military
**Weapon type:** Standard-issue ballistics / ordnance
@@ -55,14 +46,6 @@ Every faction fields the same 3-ship structure, re-skinned to its own theme:
| **Gunner** | Rapid-fire spray/burst, more shots for less damage each |
| **Tank** | Bigger and slower; fires both bullets and missiles |
### Apex Dynamics
| Ship | Class | Weapon |
|------|-------|--------|
| **Lancet** | Fighter | A single, high-precision bolt of blue coherent light |
| **Pulsar** | Gunner | A wide, rapid-fire fan of low-damage green plasma pulses |
| **Sovereign** | Tank | Homing micro-missiles alongside dual heavy plasma batteries |
### Inner Sphere Navy (ISN)
| Ship | Class | Weapon |
@@ -85,7 +68,7 @@ Every faction fields the same 3-ship structure, re-skinned to its own theme:
Each faction has exactly one tactical ability. Every ship in that faction gets
it — what changes per class is scale, not kind, so the ability reads instantly
off a ship's faction regardless of which of the 3 hulls it's flying. Same energy
off a ship's faction regardless of which of the 2 hulls it's flying. Same energy
cost and cooldown across factions; duration/impact scales with the hull:
| Class | Scaling rule |
@@ -94,12 +77,6 @@ cost and cooldown across factions; duration/impact scales with the hull:
| Gunner | Baseline cooldown and effect |
| Tank | Long cooldown, largest/heaviest effect |
### Apex Dynamics — Active Camouflage
Ship turns 90% transparent and disappears from radar. Firing or taking damage
breaks it instantly. Fighter gets a quick, short slip; Tank's lasts longest but
is riskiest to commit a slow hull to.
**Use:** Slip past a defender in a narrow pipe, flank, or break a missile lock.
### Inner Sphere Navy — Overcharged Aegis
A frontal hard-light barrier absorbs 100% of incoming damage from the front for
a few seconds. Sides and rear stay exposed, turn rate drops, and the ship can't
@@ -117,15 +94,15 @@ larger blast radius; Fighter's arms faster but hits smaller.
**Use:** Cover a retreat down a corridor, or seed a choke point/objective before
a push.
### The Rock-Paper-Scissors Loop
### Faction Interaction
With only 2 factions, the abilities form a single counter rather than a loop:
- **ORC mines counter Apex cloak** — a cloaked ship blundering through a mined
corridor eats the blast, which also breaks the cloak.
- **ISN shield counters ORC mines** — the frontal barrier absorbs a mine
detonation, clearing the path for the team behind it.
- **Apex cloak counters ISN shield** — a slow, frontal-shielded push leaves the
ISN ship's flank and engines open for a cloaked ship to slip around and
punish.
- **ORC mines punish a shield push once it drops** — the shield can't fire and
loses turn rate while up, so a mine dropped in its path (or behind it, once
the barrier expires) still threatens the push.
No ability wins a fight by itself — it depends on the map and the moment it's
used, same as a smoke/flash/molotov in CS:GO.
+41
View File
@@ -0,0 +1,41 @@
# Sprite & Tileset Creation Brief
For briefing other AI tools/artists on ship and tileset art needs. Not a design doc of record — see `racesclasses.md` for the authoritative faction/ship spec this is derived from.
## Project Summary
Fast-paced top-down arena shooter inspired by *Subspace Continuum*, built in Godot 4.7, targeting Steam Deck + PC. Set inside the tight corridors of factories, refineries, mining stations, and shipyards — not open void. Tone: grounded blue-collar sci-fi (think *The Expanse*), arcade-fast rather than hard-sci-fi sim.
## Factions (2 total)
A third faction, "Apex Dynamics," was cut to simplify the art pipeline — don't reference it.
- **Inner Sphere Navy (ISN)** — disciplined military. Gunmetal-gray, wedge-shaped hulls, hard militaristic angles, dense armor plating, prominent forward-facing gun turrets. Wins fights by tanking a choke point.
- **Outer Rim Collective (ORC)** — scrappy belters/miners. Blocky patchwork of mismatched metal plating, exposed hydraulic wiring, external fuel tanks, bolted-on industrial hardware. Wins by controlling terrain and denying space.
## Ships
Top-down; each faction fields the same 3-role structure, re-skinned to its own theme.
| Class | Role | On-screen height target |
|---|---|---|
| Fighter | single, high-precision/high-damage shot | ~52px |
| Gunner | rapid-fire spray/burst, low damage per shot | ~58px |
| Tank | bigger, slower, fires bullets + missiles | ~82px |
- **ISN roster:** Patriot (Fighter), Barrage (Gunner), Behemoth (Tank)
- **ORC roster:** Rail-Jack (Fighter), Scrap-Spitter (Gunner), Iron-Clad (Tank)
## What already exists
An idle sprite + a thrust/flame-sprite swap for all 6 ships. Scrap-Spitter and Iron-Clad's flame art is currently just a placeholder copy of their idle sprite — real thrust poses for those two are an open gap. Ships rotate freely at runtime from one nose-up sprite (no per-frame rotation animation used), so multi-angle rotation frames aren't required, just nice-to-have.
## New / open needs
- Real flame/thrust frames for Scrap-Spitter and Iron-Clad
- Asteroid / environmental hazard sprites (next on the roadmap, currently unbuilt)
- Factory/shipyard arena tilesets — pipes, turbines, catwalks, industrial hazards, tight choke-point corridors (CSGO-tight map philosophy, not sprawling)
- Faction docking/hub tilesets (ISN naval station vs. ORC scrap/asteroid base) — speculative, not yet greenlit
- Top-down 4-direction walking character sprites for hub areas — only needed if hubs get built
Keep pixel art style consistent across everything; ships need to read as instantly distinct by faction even mid-fight in tight corridors.
+152 -53
View File
@@ -7,40 +7,67 @@ spacewar/ ← repo root
└── spacewar/ ← Godot project root (open this in Godot editor)
├── project.godot
├── autoload/
── game_config.gd ← autoload singleton (tuning values, player state, signals)
── game_config.gd match-wide tuning constants, input-focus flags, persisted user settings (see GameConfig table below)
│ ├── network_manager.gd ← owns the ENetMultiplayerPeer; host_server()/join_server(); server self-registration+heartbeat with matchmaking-api; UDP query responder for pre-connect server-select pings
│ ├── player_registry.gd ← per-peer loadout registry (name/race/ship/role/flame/sound paths), keyed by peer_id; server-relayed to every peer
│ ├── matchmaking_client.gd ← HTTP wrapper around matchmaking-api (queue, list_servers, register_server); don't call _request() directly from outside this file
│ ├── chat_manager.gd ← server-relayed all/team chat
│ ├── kill_feed_manager.gd ← server-authoritative kill-feed broadcast (victim/killer name+race per death); also feeds MatchStats
│ ├── match_stats.gd ← per-match (resets every loop) kills/deaths by peer_id, incl. bots; hooked from kill_feed_manager.gd
│ ├── match_manager.gd ← server-authoritative match-phase state machine (PRE_MATCH/IN_PROGRESS/POST_MATCH, loops forever); owns the active GameMode
│ └── music_manager.gd ← menu background music player, routed through the Music audio bus
├── menu/
│ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav
│ ├── server_select.tscn/gd ← server list UI, backed by matchmaking-api's GET /servers
│ ├── main_menu.tscn/gd ← entry point / main scene; HL2-style vertical nav (QUICK PLAY, SERVER SELECT, OPTIONS, PROFILE, QUIT)
│ ├── server_select.tscn/gd ← server list UI, backed by matchmaking-api's GET /servers + a live UDP ping probe
│ ├── pause_menu.tscn/gd ← in-game pause overlay (ESC / Start button)
── team_select.tscn/gd ← race + ship selection overlay (shown on world load)
── settings_panel.tscn/gd ← Options screen; one instance shared by main menu (OPTIONS) and pause menu (SETTINGS); audio/frame-rate/vsync/window-mode/colorblind/key-rebind sections
│ └── team_select.tscn/gd ← faction + ship selection overlay (shown on world load and on pause-menu SELECT TEAM)
├── ships/
│ ├── ship.tscn ← player ship scene (formerly node_2d.tscn)
│ ├── ship_movement.gd ← player ship logic
│ ├── ship.tscn ← player ship scene
│ ├── ship_movement.gd ← player ship logic (movement, prediction/reconciliation, health/energy, nameplate interpolation)
│ └── bullet.tscn/gd ← projectile
├── chat/
│ └── chat_box.tscn/gd ← WoW-style chat overlay (T=all, Y=team), added to world.tscn
├── hud/
── player_list.tscn/gd ← top-left player roster (white=team, yellow=enemy, "(b)"=bot), added to world.tscn
── player_list.tscn/gd ← top-left player roster (white=team, yellow/orange=enemy per colorblind mode, "(b)"=bot)
│ ├── kill_feed.tscn/gd ← last 4 deaths, stacked above ChatBox's history panel
│ ├── mini_map.tscn/gd ← polls game state (hazards, players, flagships), pokes MiniMapView, calls queue_redraw()
│ ├── mini_map_view.gd ← pure Control _draw() surface for the minimap (split out since CanvasLayer can't override _draw())
│ ├── ping_display.tscn/gd ← top-right live RTT via ENetPacketPeer.get_statistic(PEER_ROUND_TRIP_TIME)
│ ├── energy_bar.tscn/gd ← blue center-out mirrored bar; also instanced for the health bar (top of world.tscn)
│ ├── match_timer.tscn/gd ← top-center match clock (PRE_MATCH countdown / IN_PROGRESS 7:00 countdown), driven by MatchManager
│ ├── scoreboard.tscn/gd ← hold-Tab CS:GO-style scoreboard, two team panels (name/role/K/D), driven by MatchStats + the active GameMode
│ └── match_banner.tscn/gd ← full-screen winner banner (ISN/ORC art + "<RACE> WINS"/"DRAW"), shown during MatchManager's POST_MATCH phase
├── bots/
│ ├── bot_manager.gd ← autoload; server-only bot fill (join/leave reconciliation)
│ ├── bot_ai.gd ← class_name BotAI; seek-nearest-enemy-and-shoot brain
│ ├── bot_ai.gd ← class_name BotAI; seek-nearest-enemy-and-shoot brain, tuned per-bot by BotPersonality
│ ├── bot_personality.gd ← class_name BotPersonality; 5 independent 0-1 traits (aggression/caution/accuracy/reaction/awareness) rolled once per bot-slot
│ └── bot_names.gd ← class_name BotNames; procedural callsign pool
├── world/
│ ├── world.tscn ← game world; world.gd loads the active map into MapContainer
│ ├── world.gd ← picks/instances a map scene from world/maps/
│ ├── world.tscn ← game world; world.gd loads the active map, spawns flagships, brokers bullets/hazard colliders
│ ├── world.gd ← picks/instances a map scene from world/maps/ (MAPS array), decides offered races, spawns flagship formations, hosts MatchManager's ship-hold/creep-in hooks
│ ├── world_tileset.tres ← shared TileSet resource (walls.png + asteroids.png atlas sources)
│ ├── flagship.tscn/gd ← capital-ship point-defense formation (class_name Flagship), see CLAUDE.md item 24; also plays the PRE_MATCH creep-in tween
│ ├── starfield.gdshader ← procedural starfield background
│ ├── game_modes/
│ │ ├── game_mode.gd ← class_name GameMode; base ruleset (win condition, score label, match duration) MatchManager drives every mode through
│ │ └── team_deathmatch_mode.gd ← class_name TeamDeathmatchMode; most-kills-when-clock-runs-out, the only implemented mode today (see overview/map1.md)
│ └── maps/
│ └── map_01.tscn ← hand-painted map: Walls + Asteroids TileMapLayers, team spawn Marker2Ds
│ └── map_01.tscn ← hand-painted map: Walls + Asteroids TileMapLayers (asteroids deal impact damage, not just bounce), team spawn Marker2Ds
└── assets/
├── icon.svg
├── default_bus_layout.tres ← Master/Music/SFX audio buses
└── images/
├── background/skybox/ ← 6 space background PNGs (1.png 6.png)
├── effects/ ← explosion1.png
├── ships/example_ships/ ← 4 placeholder ship sprites (1.png, 1B.png, 2a.png, 3b.png)
├── background/skybox/ ← space background PNGs
├── effects/
│ ├── explosion/ ← ship-death explosion animation frames
│ └── bullets/ ← per-race/role bullet sprites, incl. flagship turret fire
├── banners/ ← faction banner art
├── ships/isn/, ships/orc/ ← live faction ship art (idle + _flame sprites), each with a source/ concept-sheet copy
└── tiles/ ← walls.png, asteroids.png, used by world_tileset.tres
```
> **Note:** `asteroid_movement.gd` referenced in an earlier version of this doc does not exist yet — it's still an open task (see `CLAUDE.md`).
> `assets/images/ships/apex/` and the older `terran/mech/vorg` folders are left on disk but unreferenced by any code — not deleted, see CLAUDE.md items 17/19.
## Scene Graph
@@ -56,33 +83,43 @@ ServerSelect (Control) ← server_select.gd builds all UI in _ready(), fetches
### `world/world.tscn` — game world
```
World (Node2D) ← world.gd instances MAP_SCENE into MapContainer on _ready()
World (Node2D) ← world.gd instances a map into MapContainer, spawns flagships, brokers bullet/hazard colliders
├── MapContainer (Node2D) ← holds the instanced map (world/maps/map_01.tscn), background included
├── Player ← instance of ships/ship.tscn
├── HUD (CanvasLayer)
│ └── HealthLabel
├── Players (Node2D)per-peer ship.tscn instances live here
├── MultiplayerSpawner ← spawns/despawns ships into Players
├── Bullets (Node2D) ← server-spawned bullet.tscn instances
├── BulletSpawner (MultiplayerSpawner)
├── PauseMenu (CanvasLayer, layer=10) ← menu/pause_menu.tscn
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; queue_free()s after selection
├── TeamSelect (CanvasLayer, layer=20) ← menu/team_select.tscn; also reopened live from the pause menu's SELECT TEAM
├── SettingsPanel (CanvasLayer, layer=25) ← menu/settings_panel.tscn; opened by pause menu's SETTINGS
├── ChatBox (CanvasLayer, layer=5) ← chat/chat_box.tscn
── PlayerList (CanvasLayer, layer=4) ← hud/player_list.tscn
── PlayerList (CanvasLayer) ← hud/player_list.tscn
├── KillFeed (CanvasLayer, layer=5) ← hud/kill_feed.tscn
├── MiniMap ← hud/mini_map.tscn
├── PingDisplay ← hud/ping_display.tscn
├── HealthBar ← hud/energy_bar.tscn instance, reused for health
├── EnergyBar ← hud/energy_bar.tscn instance
├── MatchTimer (CanvasLayer, layer=4) ← hud/match_timer.tscn
├── Scoreboard (CanvasLayer, layer=6) ← hud/scoreboard.tscn; visible only while Tab is held
└── MatchBanner (CanvasLayer, layer=30) ← hud/match_banner.tscn; visible only during MatchManager's POST_MATCH phase
```
### `world/maps/map_01.tscn` — hand-painted map
```
Map01 (Node2D)
├── Background (TextureRect) ← per-map space background (skybox/1.png); visible while editing this scene
├── Walls (TileMapLayer) ← painted by hand in the Godot Tile Editor, uses world_tileset.tres
├── Asteroids (TileMapLayer) ← painted by hand, same shared tileset
├── Background (TextureRect) ← per-map space background; procedural starfield.gdshader, not a tiled skybox
├── Walls (TileMapLayer) ← painted by hand in the Godot Tile Editor, uses world_tileset.tres; bounces ships (environment_wall group)
├── Asteroids (TileMapLayer) ← painted by hand, same shared tileset; bounces AND deals impact damage (environment_hazard group, GameConfig.ship_wall_damage)
└── SpawnPoints (Node2D)
├── TeamASpawn1 (Marker2D) ← group "team_a_spawn"
└── TeamBSpawn1 (Marker2D) ← group "team_b_spawn"
├── TeamASpawn1 (Marker2D) ← group "team_a_spawn"; also where world.gd spawns Team A's flagship formation
└── TeamBSpawn1 (Marker2D) ← group "team_b_spawn"; also where world.gd spawns Team B's flagship formation
```
### `ships/ship.tscn` — player ship
```
Player (CharacterBody2D) ← ship_movement.gd
├── CollisionShape2D ← CircleShape2D
├── Sprite2D ← texture set at runtime from GameConfig.player_ship_path
├── Sprite2D ← texture set at runtime from PlayerRegistry's per-peer ship_path
└── VisibleOnScreenNotifier2D
```
@@ -98,17 +135,18 @@ Player (CharacterBody2D) ← ship_movement.gd
| `toggle_pause` | Esc | Start / Options button (JoyButton 6) |
| `chat_all` | T | — |
| `chat_team` | Y | — |
| `scoreboard` | Tab | — |
## Game Flow
```
main_menu.tscn
├── QUICK PLAY → matchmaking queue (casual) → world.tscn
│ ├── TeamSelect overlay: pick race (2 random of 3 offered)
│ ├── TeamSelect overlay: pick race (both offered every match)
│ ├── TeamSelect overlay: pick ship
│ └── Player spawns → game live
├── SERVER SELECT → server_select.tscn → pick a server → world.tscn (same TeamSelect flow)
├── OPTIONS → stub, coming soon
├── OPTIONS → settings_panel.tscn (audio, frame-rate cap, vsync, window mode, colorblind mode, key rebinding) — same panel the in-game pause menu's SETTINGS opens
├── PROFILE → callsign-edit overlay (also auto-opens from QUICK PLAY if no callsign is set)
└── QUIT
```
@@ -142,34 +180,61 @@ exact bug converting the play-mode buttons to be centered.
## Key Autoload — `GameConfig`
| Property | Type | Set by |
|----------|------|--------|
| `player_name` | String | Main menu callsign input |
| `player_race` | int | TeamSelect race pick |
| `player_ship_path` | String | TeamSelect ship pick |
| `team_selected` | signal | Emitted by TeamSelect when done |
| `ship_thrust` | float | Tuning constant |
| `ship_max_speed` | float | Tuning constant |
| `ship_rotation_speed` | float | Tuning constant |
| `ship_fire_rate` | float | Tuning constant |
| `bullet_speed` | float | Tuning constant |
| `ship_max_health` | int | Tuning constant |
| `ship_respawn_delay` | float | Tuning constant |
| `ship_invincibility_time` | float | Tuning constant |
| `bullet_damage` | int | Tuning constant |
| `ship_wall_damage` | int | Tuning constant |
| `chat_focused` | bool | Set by ChatBox while its input line has keyboard focus; gates ship movement/fire input |
| `bot_min_team_size` | int | Tuning constant — min humans+bots per race, see `bots/bot_manager.gd` |
| `bot_engage_range` | float | Tuning constant — bot max shoot distance |
| `bot_stop_distance` | float | Tuning constant — bot stops closing distance below this |
| `bot_aim_tolerance_deg` | float | Tuning constant — how precisely a bot must face a target to fire |
Per-peer identity (`player_race`, `player_ship_path`) moved out of `GameConfig`
early on, per the networking plan's Phase 2 — that's `PlayerRegistry`'s job
now (see below). `GameConfig` today holds match-wide tuning constants plus
persisted user settings. Grouped by area rather than listed exhaustively
(~70 properties) — see `autoload/game_config.gd` for the full list:
| Area | Examples | Notes |
|------|----------|-------|
| Persisted settings | `max_fps`, `vsync_mode`, `window_mode`, `master_volume`/`music_volume`/`sfx_volume`, `colorblind_mode`, `keybinds` | Loaded/applied in `_ready()`, written to `user://settings.cfg` by `save_settings()`; each has a `set_*()` that applies live |
| Ship movement | `ship_thrust`, `ship_max_speed`, `ship_rotation_speed`, `ship_rotation_ramp_delay` | Tuning constants |
| Combat | `ship_fire_rate`, `bullet_speed`, `ship_bullet_damage_by_role`, `ship_bullet_count_by_role`, `bullet_max_range` | Per-role dictionaries keyed `"Fighter"/"Gunner"/"Tank"` |
| Energy | `ship_max_energy_by_role`, `ship_bullet_energy_cost_by_role`, `ship_energy_regen_rate` | See CLAUDE.md item 23 |
| Health/respawn | `ship_max_health`, `ship_respawn_delay`, `ship_invincibility_time`, `ship_wall_damage` | `ship_wall_damage` applies on both wall bounces and asteroid hazard hits |
| Collision/visuals | `ship_bounce_restitution`, `ship_collision_damage_scale`, `ship_scale_factor`, `ship_hitbox_scale`, `explosion_scale`, `damage_number_*` | |
| Colors | `team_color`, `enemy_color`, `colorblind_mode` (swaps `enemy_color` between `ENEMY_COLOR_DEFAULT`/`ENEMY_COLOR_COLORBLIND`) | Read by nameplates, `hud/player_list.gd`, `hud/mini_map.gd` |
| Camera/world | `camera_zoom`, `world_bounds`, `spawn_area_inner_radius`/`outer_radius` | |
| Flagships | `flagship_count_per_spawn`, `flagship_defense_radius`, `flagship_fire_rate`, `flagship_missile_*`, `flagship_bullet_*`, `flagship_aim_jitter_px`, `flagship_creep_in_distance` | See CLAUDE.md item 24; `flagship_creep_in_distance` is the PRE_MATCH cosmetic tween's start offset |
| Match structure | `default_game_mode_id`, `match_pre_match_duration`, `match_duration`, `match_post_match_duration` | Read by `MatchManager`'s phase state machine — see item 26 |
| Input-focus gates | `chat_focused`, `team_select_focused`, `settings_focused` | Each blocks ship movement/fire input while its overlay owns keyboard focus |
| Bot tuning | `bot_min_team_size`, `bot_engage_range`, `bot_stop_distance_min`/`max`, `bot_retreat_health_frac_min`/`max`, `bot_aim_tolerance_best_deg`/`worst_deg`, `bot_aim_jitter_max_px`, `bot_reaction_update_best_sec`/`worst_sec`, `bot_awareness_range_min`/`max` | Min/max pairs are interpolated per-bot by that bot's rolled `BotPersonality` (0=worst trait, 1=best) |
## Key Autoload — `NetworkManager`
Owns the `ENetMultiplayerPeer` (`autoload/network_manager.gd`). `host_server(port)` /
`join_server(ip, port)`. A hosted server self-registers with matchmaking-api on boot
and re-heartbeats every 8s via `MatchmakingClient.register_server()` (reusing one
persistent `HTTPRequest` node — a fresh one per call caused a periodic stutter, see
CLAUDE.md item 20/21). Also runs a `UDPServer` on game-port+10000 (`_start_query_responder`)
that answers a raw `"SPACEWAR_PING"` datagram with live player count, used by
`menu/server_select.gd`'s pre-connect probe (post-connect ping uses ENet's own RTT stat
instead, via `hud/ping_display.gd`).
## Key Autoload — `PlayerRegistry`
Per-peer loadout registry (`autoload/player_registry.gd`), `Dictionary[peer_id, info]`
where `info` holds `name`/`race`/`ship_path`/`ship_scale`/`ship_speed_factor`/`role`/
`ship_flame_path`/`ship_sound_path`. `submit_local_loadout()` (real players) and
`register_bot()` (bots, negative peer_ids) both funnel through the same server-relayed
storage, so every system that reads a loadout (spawning, HUD, bots, bullets) treats
players and bots identically. `loadout_updated`/`race_changed`/`player_removed` signals
drive `BotManager`, `hud/player_list.gd`, and TeamSelect's live headcounts.
## Key Autoload — `MatchmakingClient`
HTTP wrapper around `matchmaking-api` (`autoload/matchmaking_client.gd`) — queueing,
`list_servers()` (backs `menu/server_select.gd`'s `GET /servers`), and
`register_server()`/heartbeat. Don't call the internal `_request()` directly from
outside this file; use the public wrapper methods.
## Key Autoload — `BotManager`
Server-authoritative bot fill for casual (`bots/bot_manager.gd`). Keeps each of the
match's 2 offered races at `GameConfig.bot_min_team_size` total humans+bots,
reacting to `PlayerRegistry.loadout_updated`/`player_removed` and
`World.decide_offered_races()`. See `overview/bots.md`.
match's 2 offered factions at `GameConfig.bot_min_team_size` total humans+bots,
reacting to `PlayerRegistry.loadout_updated`/`race_changed`/`player_removed` and
`World.decide_offered_races()`. Also rolls a `BotPersonality` per bot-slot. See `overview/bots.md`.
## Key Autoload — `ChatManager`
@@ -177,3 +242,37 @@ Server-relayed chat (`autoload/chat_manager.gd`). `send_chat(text, team_only)` s
`message_received(sender_name, sender_race, team_only, text)` signal delivers
incoming messages to `ChatBox`. "Team" messages are filtered server-side to
peers sharing the sender's race (race doubles as team — see `PlayerRegistry`).
## Key Autoload — `KillFeedManager`
Server-authoritative kill-feed broadcast (`autoload/kill_feed_manager.gd`). One RPC per
death, called from `ship_movement.gd`'s `_die()` — no client → server leg, unlike chat.
`kill_reported` signal feeds `hud/kill_feed.gd`'s last-4-lines display, and
`report_kill()` also calls `MatchStats.record_kill()` — the one hook point both
systems share.
## Key Autoload — `MatchStats`
Per-match (resets every `MatchManager` loop) kills/deaths by `peer_id`, including
bots (`autoload/match_stats.gd`). `get_team_kills(race_id)` sums a whole team via
`PlayerRegistry`. `stats_updated`/`stats_reset` signals drive `hud/scoreboard.gd`.
## Key Autoload — `MatchManager`
Server-authoritative match-phase state machine (`autoload/match_manager.gd`):
`PRE_MATCH` (30s countdown, ships held via `ship_movement.gd`'s
`server_hold_for_match_start()`, flagships cosmetically creep into formation) →
`IN_PROGRESS` (the active `GameMode`'s clock runs, ships released) → `POST_MATCH`
(winner banner, match result reported to matchmaking-api) → loops back into a
fresh `PRE_MATCH` indefinitely — no menu kick, matching this project's always-on
server-pool model (see item 16). Follows the same "decide once on the server,
RPC-broadcast the value, late joiners pull it" pattern `world.gd`'s
`decide_offered_races()` established. Owns the active `GameMode`
(`world/game_modes/`) instance, built identically on every peer off the shared
`GameConfig.default_game_mode_id` constant — adding a new mode is a new
`GameMode` subclass plus one factory branch, no changes to this state machine.
## Key Autoload — `MusicManager`
Menu background-music player (`autoload/music_manager.gd`), routed through the Music
audio bus so `GameConfig.music_volume` controls it live.
-39
View File
@@ -1,39 +0,0 @@
# Task Log
Task 1
I want to build an energy system for each ship. Below the health put an energy of 100/100 for the fighter/ 150/150 for middle ship and big ship 250 energy. Each bullet takes 75 to shoot. Have a blue energy bar that is mirrord top middle appear at the top . Have it take 2.5 seconds to refill 100
## Completed
| # | Task | Notes |
|---|------|-------|
| 0 | Format all design docs as Markdown | Done |
| 24 | Energy system (Fighter 100 / Gunner 150 / Tank 250, 75 per shot, blue center-out mirrored bar top-middle, +100 per 2.5s regen) | `GameConfig.ship_max_energy_by_role`/`bullet_energy_cost`/`ship_energy_regen_rate`; server-authoritative in `ships/ship_movement.gd` (broadcast via `_receive_state`, same pattern as health); role threaded through `PlayerRegistry` loadout + bots; text readout `HUD/EnergyLabel` below HP, visual bar `hud/energy_bar.gd`/`.tscn` |
| 1 | Design 10 races, pick 3 | Races 1, 4, 5 chosen — see `racesclasses.md` |
| 2 | Ranked + casual mode ideas | See `multiplayer.md` |
| 3 | Multiplayer engineering doc | See `tech.md` |
| 4 | ~~Server browser screen~~ | Superseded by #21's `menu/server_select.gd` |
| 5 | ~~Main menu (CASUAL/RANKED tiles)~~ | Superseded by #21's HL2-style nav menu |
| 6 | In-game pause menu | ESC / controller Start; game runs behind it; working: RESUME, QUIT TO MENU, QUIT TO DESKTOP |
| 7 | Team & ship selection | On world load; 2 random races offered; ship grid with real sprites; player spawns after |
| 8 | Replace placeholder races with chosen 3 (Terran Republic, Mechanos Sovereignty, Vorg Swarm) | Done |
| 9 | Real ship sprites for all 3 races (5 ships each) | Done |
| 13 | Multiplayer networking — ENet authoritative server | Done |
| 18 | In-game chat (T=all, Y=team, last 10 messages, bottom-left) | `chat/chat_box.gd` + `autoload/chat_manager.gd` — see `chat.md` |
| 11 | Bot fill for casual (min 7v7) | `bots/bot_manager.gd` + `bots/bot_ai.gd` — see `bots.md` |
| 17 | Select Team in pause menu (live team swap) | Reopens `TeamSelect` mid-match via `PlayerRegistry.submit_local_loadout`'s existing respawn path; bot fill rebalances the vacated race too (`PlayerRegistry.race_changed`) |
| 20 | Live headcount/roster + TEAM FULL lock on race select | Shared by the initial pick and #17's reopen; humans only (bots excluded), blocks joining a race >2 humans ahead of the other, exempts your own current race |
| 21 | HL2-style main menu + real server select | `main_menu.gd` rebuilt as plain white vertical nav (QUICK PLAY/SERVER SELECT/OPTIONS/PROFILE/QUIT); RANKED removed entirely; new `menu/server_select.gd` lists real servers via `MatchmakingClient.list_servers()` (`GET /servers`) and connects directly, retiring `menu/server_browser.gd` |
## Up Next
| # | Task | Priority |
|---|------|----------|
| 10 | Asteroids + environment hazards | Medium |
| 12 | Sound effects (thrust, shoot, explosion, UI clicks) | Medium |
| 14 | Galaxy war meta + sector control | Low (post-networking) |
| 15 | Ranked matchmaking + MMR | Low (post-networking) |
| 16 | Settings screen (audio, controls, display) | Low |
| 22 | Options screen (currently a disabled stub on the main menu) | Low |
| 23 | Real rank/level backend for the main menu's top-right badge | Low |
+8 -4
View File
@@ -75,10 +75,14 @@ diverged from the original plan below:
then connects directly to the returned server IP/port
Not yet built: session tokens (server IP/port are handed back unauthenticated
— fine for local dev, not for a real deployment), and a real server pool —
only one dev server is registered right now (auto-seeded on API startup);
`POST /servers/register` exists for real servers to self-register/heartbeat
but nothing calls it yet.
— fine for local dev, not for a real deployment).
Real server pool is now live: `NetworkManager.host_server()` calls
`POST /servers/register` once on boot and re-heartbeats every 8s, reporting
live player counts; a real server registering on the same (ip, port, mode)
as one of the 3 demo-seeded rows just takes it over in place. A background
sweep marks any server `offline` once its heartbeat goes stale (crash/kill
without clean deregister). See `CLAUDE.md` item 16.
### Steam Integration
+63
View File
@@ -0,0 +1,63 @@
extends Node
var _ship: Node = null
var _target: Vector2 = Vector2.ZERO
func _ready() -> void:
NetworkManager.join_server("127.0.0.1")
multiplayer.connected_to_server.connect(_on_connected)
func _on_connected() -> void:
PlayerRegistry.submit_local_loadout(
"Screenshotter", 1,
"res://assets/images/ships/isn/patriot.png", 0.504, 1.3, "Fighter",
"res://assets/images/ships/isn/patriot_flame.png", "res://assets/sound/ships/isn/isn_1.wav"
)
var tree := get_tree()
# Built and started before change_scene_to_file, not after -- a Timer
# created after the scene swap sometimes silently never fires (known
# gotcha, see memory), even though `tree` itself stays valid.
var t := Timer.new()
t.wait_time = 1.5
t.one_shot = true
tree.root.add_child(t)
t.timeout.connect(func():
var ts := tree.root.get_node_or_null("World/TeamSelect")
if ts:
ts.queue_free()
GameConfig.team_select_focused = false
var flagship := tree.root.get_node_or_null("World/MapContainer/Flagship_team_a_spawn_1")
if flagship:
_target = flagship.global_position
print("FLAGSHIP_POS=", _target)
var my_id: int = multiplayer.get_unique_id()
_ship = tree.root.get_node_or_null("World/Players/%d" % my_id)
print("SHIP=", _ship)
var shot_t := Timer.new()
shot_t.wait_time = 2.5
shot_t.one_shot = true
tree.root.add_child(shot_t)
shot_t.timeout.connect(func():
var img := tree.root.get_viewport().get_texture().get_image()
img.save_png("res://_nettest/flagship_formation.png")
print("SCREENSHOT_SAVED")
)
shot_t.start()
)
t.start()
tree.change_scene_to_file("res://world/world.tscn")
func _physics_process(_delta: float) -> void:
if _ship == null or _target == Vector2.ZERO:
return
var to_target: Vector2 = _target - _ship.global_position
_ship.rotation = Vector2.UP.angle_to(to_target)
Input.action_press("move_up")
if to_target.length() < 900.0:
Input.action_press("shoot")
else:
Input.action_release("shoot")
+1
View File
@@ -0,0 +1 @@
uid://cxexoi36ntpwk
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://_nettest/test_client.gd" id="1"]
[node name="TestClient" type="Node"]
script = ExtResource("1")
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cp0pahltfd27w"
path="res://.godot/imported/ae9283e6-0a7c-4a98-a2da-e4d0956a6b8b.jpeg-4206ebc2179384ca0655f425f5e680d1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ae9283e6-0a7c-4a98-a2da-e4d0956a6b8b.jpeg"
dest_files=["res://.godot/imported/ae9283e6-0a7c-4a98-a2da-e4d0956a6b8b.jpeg-4206ebc2179384ca0655f425f5e680d1.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: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

@@ -3,15 +3,15 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://csymmghcpcpue"
path="res://.godot/imported/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg-7e809ec090d7c262b7af7abf69bc7fa3.ctex"
path="res://.godot/imported/isn_banner.jpeg-8e168a36c4594504af8811aa2c39ac18.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/banners/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg"
dest_files=["res://.godot/imported/70fb9114-c6c8-459c-ad2f-8111f30c9b82.jpeg-7e809ec090d7c262b7af7abf69bc7fa3.ctex"]
source_file="res://assets/images/banners/isn_banner.jpeg"
dest_files=["res://.godot/imported/isn_banner.jpeg-8e168a36c4594504af8811aa2c39ac18.ctex"]
[params]

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 197 KiB

@@ -3,15 +3,15 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://do26o8h5m5hb8"
path="res://.godot/imported/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg-c98f9e50e9255c5b98318bf225959231.ctex"
path="res://.godot/imported/orc_banner.jpeg-82461efb9d01bc65c8a8282763db5129.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/banners/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg"
dest_files=["res://.godot/imported/ed212cd5-530a-4520-ba00-23795b40f32c.jpeg-c98f9e50e9255c5b98318bf225959231.ctex"]
source_file="res://assets/images/banners/orc_banner.jpeg"
dest_files=["res://.godot/imported/orc_banner.jpeg-82461efb9d01bc65c8a8282763db5129.ctex"]
[params]
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

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

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ddb4ema7v1yfg"
path="res://.godot/imported/isn_gunner.png-ce0dacb7fb7e65630f4ecf78a057359b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/bullets/isn_gunner.png"
dest_files=["res://.godot/imported/isn_gunner.png-ce0dacb7fb7e65630f4ecf78a057359b.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: 27 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dr7tydm2y2g0w"
path="res://.godot/imported/isn_missile.png-9984591866c67094cf17bf839fbadd78.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/bullets/isn_missile.png"
dest_files=["res://.godot/imported/isn_missile.png-9984591866c67094cf17bf839fbadd78.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: 5.0 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://sdcvkhou67y5"
path="res://.godot/imported/isn_tank.png-82c948beac152ebf1facdfb9d76d3d16.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/bullets/isn_tank.png"
dest_files=["res://.godot/imported/isn_tank.png-82c948beac152ebf1facdfb9d76d3d16.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.0 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://vye0cc3xywcf"
path="res://.godot/imported/orc_fighter.png-219c138cf7098067dac5d6f0d2829ca3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/effects/bullets/orc_fighter.png"
dest_files=["res://.godot/imported/orc_fighter.png-219c138cf7098067dac5d6f0d2829ca3.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: 3.8 KiB

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

After

Width:  |  Height:  |  Size: 42 KiB

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

After

Width:  |  Height:  |  Size: 15 KiB

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

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
@@ -23,7 +23,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
@@ -23,7 +23,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
@@ -23,7 +23,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
@@ -23,7 +23,7 @@ compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/generate=true
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

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

After

Width:  |  Height:  |  Size: 133 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dljxjhagv1v16"
path="res://.godot/imported/isn_flagship_colossus_source.jpeg-85c905bc5fa6211886517499c4b82425.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/isn/source/isn_flagship_colossus_source.jpeg"
dest_files=["res://.godot/imported/isn_flagship_colossus_source.jpeg-85c905bc5fa6211886517499c4b82425.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: 129 KiB

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

After

Width:  |  Height:  |  Size: 628 KiB

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

After

Width:  |  Height:  |  Size: 634 KiB

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

After

Width:  |  Height:  |  Size: 161 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://xn52vgut6p5f"
path="res://.godot/imported/orc_flagship_rust_titan_source.jpeg-326a0e1aaa749015d6dd0f59419eb303.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/images/ships/orc/source/orc_flagship_rust_titan_source.jpeg"
dest_files=["res://.godot/imported/orc_flagship_rust_titan_source.jpeg-326a0e1aaa749015d6dd0f59419eb303.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

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