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>
This commit is contained in:
2026-07-17 20:02:31 -04:00
parent 1ebcca3807
commit a94bccc57b
66 changed files with 1998 additions and 65 deletions
+20 -1
View File
@@ -41,12 +41,31 @@ 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.
## Current Tasks
- [ ] Asteroids and environment hazards
- [ ] Sound effects (thrust, shoot, explosion, UI)
- [ ] Options screen — currently just a disabled stub button on the main menu
- [ ] 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 account-linked identity — GodotSteam auth + VAC still not started; `callsign` is the only player identity today
+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.
+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")
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

@@ -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
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
@@ -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: 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
+272 -11
View File
@@ -1,5 +1,192 @@
extends Node
# Render frame-rate cap (Engine.max_fps) -- 0 mirrors Engine.max_fps's own
# "no cap" meaning. Persisted to user://settings.cfg so each player's choice
# survives restarts; changed live via menu/settings_panel.gd (reachable from
# both the main menu's OPTIONS and the in-game pause menu's SETTINGS, see
# main_menu.gd/pause_menu.gd), not hardcoded here, since the right value
# depends on each player's own monitor refresh rate -- physics only ever
# ticks at a fixed 60Hz (see ships/ship_movement.gd), so a render rate that
# isn't a clean multiple of that (e.g. an uncapped rate on a high-refresh
# monitor) is what reads as stuttery/blurry motion, especially against
# world/starfield.gdshader's pixel-crisp stars.
const SETTINGS_PATH := "user://settings.cfg"
var max_fps: int = 0
# DisplayServer.VSyncMode int value (VSYNC_ENABLED=1 is both Godot's and this
# project's prior default -- project.godot never overrode it, so this keeps
# existing behavior unchanged until a player picks something else). Separate
# knob from max_fps -- capping frame rate doesn't help if the cap itself
# doesn't match what vsync will actually let through (see menu/settings_panel.gd's
# vsync dropdown, added after a 165 FPS cap on a display vsync wouldn't
# cleanly deliver at that rate caused a repeating smooth/stutter cycle that
# capping to exactly 60 fixed -- Adaptive/Disabled here are the other two
# standard ways to resolve that same class of mismatch without guessing at
# the "right" cap).
var vsync_mode: int = DisplayServer.VSYNC_ENABLED
# DisplayServer.WindowMode int value -- project.godot's window/size/mode=3
# already boots into WINDOW_MODE_FULLSCREEN (Godot's non-exclusive
# borderless-style fullscreen, see item 10 in CLAUDE.md); this just makes
# that switchable at runtime instead of fixed. Exclusive Fullscreen can
# perform better on some GPU/driver combos at the cost of slower alt-tabbing;
# Windowed is for players who want to multitask or stream.
var window_mode: int = DisplayServer.WINDOW_MODE_FULLSCREEN
# Master/Music/SFX, linear 0..1 (matches HSlider's natural range in
# menu/settings_panel.gd) — converted to dB only when actually applied to a
# bus. "Music"/"SFX" are real AudioServer buses (default_bus_layout.tres,
# both routed to Master) that MusicManager and world.gd's _play_shoot_sound()
# route their players onto; Master is the built-in bus every sound already
# passes through regardless. 0.0 explicitly mutes the bus (AudioServer.
# set_bus_mute()) rather than relying on linear_to_db(0)'s -inf dB edge case.
var master_volume: float = 1.0
var music_volume: float = 1.0
var sfx_volume: float = 1.0
# Key rebinding (menu/settings_panel.gd's CONTROLS section) — keyboard-only
# (this project's only non-keyboard binding is toggle_pause's joypad Start
# button, which _apply_single_keybind() below always leaves untouched, so
# there's nothing to rebind there yet). InputMap resets to project.godot's
# compiled-in defaults on every engine boot, so DEFAULT_KEYCODES mirrors that
# file's [input] section (needed both to reapply a persisted override in
# _apply_keybinds() and to know what "reset to default" means once an
# action's original binding has already been erased from InputMap).
# keybinds only holds entries that override a default; an action absent from
# it just means "still on its default", not "unbound".
const KEYBIND_ACTIONS := ["move_up", "move_down", "move_left", "move_right", "shoot", "toggle_pause", "chat_all", "chat_team"]
const DEFAULT_KEYCODES := {
"move_up": KEY_W, "move_down": KEY_S, "move_left": KEY_A, "move_right": KEY_D,
"shoot": KEY_SPACE, "toggle_pause": KEY_ESCAPE, "chat_all": KEY_T, "chat_team": KEY_Y,
}
var keybinds: Dictionary = {}
func _ready() -> void:
_load_settings()
Engine.max_fps = max_fps
DisplayServer.window_set_vsync_mode(vsync_mode as DisplayServer.VSyncMode)
DisplayServer.window_set_mode(window_mode as DisplayServer.WindowMode)
_apply_bus_volume("Master", master_volume)
_apply_bus_volume("Music", music_volume)
_apply_bus_volume("SFX", sfx_volume)
enemy_color = ENEMY_COLOR_COLORBLIND if colorblind_mode else ENEMY_COLOR_DEFAULT
for action in KEYBIND_ACTIONS:
_apply_single_keybind(action, get_keycode(action))
func set_max_fps(value: int) -> void:
max_fps = value
Engine.max_fps = value
save_settings()
func set_vsync_mode(value: int) -> void:
vsync_mode = value
DisplayServer.window_set_vsync_mode(value as DisplayServer.VSyncMode)
save_settings()
func set_window_mode(value: int) -> void:
window_mode = value
DisplayServer.window_set_mode(value as DisplayServer.WindowMode)
save_settings()
func set_colorblind_mode(value: bool) -> void:
colorblind_mode = value
enemy_color = ENEMY_COLOR_COLORBLIND if value else ENEMY_COLOR_DEFAULT
save_settings()
func get_keycode(action: String) -> int:
return keybinds.get(action, DEFAULT_KEYCODES.get(action, 0))
func rebind_action(action: String, keycode: int) -> void:
keybinds[action] = keycode
_apply_single_keybind(action, keycode)
save_settings()
func reset_keybind(action: String) -> void:
keybinds.erase(action)
_apply_single_keybind(action, DEFAULT_KEYCODES.get(action, 0))
save_settings()
# Only touches this action's InputEventKey entries -- toggle_pause also has a
# joypad Start-button event in project.godot's default map (Steam Deck), and
# wiping every event on the action (instead of just the keyboard ones) would
# silently break that controller binding the first time a player rebinds its
# keyboard key.
func _apply_single_keybind(action: String, keycode: int) -> void:
if not InputMap.has_action(action):
return
for event in InputMap.action_get_events(action):
if event is InputEventKey:
InputMap.action_erase_event(action, event)
var new_event := InputEventKey.new()
new_event.physical_keycode = keycode
InputMap.action_add_event(action, new_event)
# Volume setters deliberately don't call save_settings() themselves -- an
# HSlider fires value_changed continuously through a drag, and writing
# user://settings.cfg to disk on every one of those (unlike a dropdown's one
# discrete selection) would reintroduce exactly the kind of per-event hitch
# this session already hunted down once (see the matchmaking heartbeat fix in
# CLAUDE.md item 20). menu/settings_panel.gd instead applies live on every
# value_changed and calls save_settings() once on the slider's drag_ended.
func set_master_volume(value: float) -> void:
master_volume = value
_apply_bus_volume("Master", value)
func set_music_volume(value: float) -> void:
music_volume = value
_apply_bus_volume("Music", value)
func set_sfx_volume(value: float) -> void:
sfx_volume = value
_apply_bus_volume("SFX", value)
func _apply_bus_volume(bus_name: String, value: float) -> void:
var idx := AudioServer.get_bus_index(bus_name)
if idx == -1:
return
AudioServer.set_bus_mute(idx, value <= 0.0)
AudioServer.set_bus_volume_db(idx, linear_to_db(maxf(value, 0.001)))
func save_settings() -> void:
var cfg := ConfigFile.new()
cfg.set_value("display", "max_fps", max_fps)
cfg.set_value("display", "vsync_mode", vsync_mode)
cfg.set_value("display", "window_mode", window_mode)
cfg.set_value("audio", "master_volume", master_volume)
cfg.set_value("audio", "music_volume", music_volume)
cfg.set_value("audio", "sfx_volume", sfx_volume)
cfg.set_value("accessibility", "colorblind_mode", colorblind_mode)
cfg.set_value("input", "keybinds", keybinds)
cfg.save(SETTINGS_PATH)
func _load_settings() -> void:
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) == OK:
max_fps = cfg.get_value("display", "max_fps", max_fps)
vsync_mode = cfg.get_value("display", "vsync_mode", vsync_mode)
window_mode = cfg.get_value("display", "window_mode", window_mode)
master_volume = cfg.get_value("audio", "master_volume", master_volume)
music_volume = cfg.get_value("audio", "music_volume", music_volume)
sfx_volume = cfg.get_value("audio", "sfx_volume", sfx_volume)
colorblind_mode = cfg.get_value("accessibility", "colorblind_mode", colorblind_mode)
keybinds = cfg.get_value("input", "keybinds", keybinds)
# Ship movement
var ship_thrust: float = 250.0
var ship_max_speed: float = 300.0
@@ -19,7 +206,7 @@ var bullet_speed: float = 750.0
var ship_bullet_damage_by_role: Dictionary = {"Fighter": 100, "Gunner": 25, "Tank": 50}
var ship_bullet_count_by_role: Dictionary = {"Fighter": 1, "Gunner": 2, "Tank": 2}
var bullet_side_spacing: float = 14.0
var bullet_max_range: float = 1500.0
var bullet_max_range: float = 1875.0 # 1500 * 1.25
# Per-shot sound effect (menu/team_select.gd's RACES "sound_path" field, one
# shared clip per faction) — max_distance is in world/pixel units, same as
@@ -74,12 +261,23 @@ var damage_number_duration: float = 1.4
var damage_number_font_size: int = 34
var damage_number_color: Color = Color(0.95, 0.15, 0.15)
# Shared white/teammate vs. red/enemy color scheme -- used by both the
# top-left roster (hud/player_list.gd) and in-world ship nameplates
# (ships/ship_movement.gd) so the two always agree. Was a yellow enemy_color
# that read too close to white/teammate at a glance; red is unambiguous.
# Shared white/teammate vs. red/enemy color scheme -- used by the top-left
# roster (hud/player_list.gd), in-world ship nameplates (ships/ship_movement.gd),
# and the minimap (hud/mini_map.gd) so all three always agree. Was a yellow
# enemy_color that read too close to white/teammate at a glance; red is
# unambiguous -- for most people. team_color is white in both palettes below
# (already colorblind-safe, nothing to swap); only enemy_color changes.
const ENEMY_COLOR_DEFAULT := Color(0.95, 0.25, 0.25)
# Red can still read as muddy/brown for red-green color vision deficiency
# (deuteranopia/protanopia, the most common forms). Orange stays distinct
# from white/team and from the minimap's own blue self-marker
# (hud/mini_map.gd's SELF_COLOR) across effectively all forms of CVD,
# including tritanopia -- see set_colorblind_mode().
const ENEMY_COLOR_COLORBLIND := Color(1.0, 0.55, 0.0)
var team_color: Color = Color(0.92, 0.95, 1.0)
var enemy_color: Color = Color(0.95, 0.25, 0.25)
var enemy_color: Color = ENEMY_COLOR_DEFAULT
var colorblind_mode: bool = false
# In-world ship nameplate (ships/ship_movement.gd) -- offset from ship center
# so it sits at the bottom-right of the sprite rather than directly under it
@@ -101,11 +299,65 @@ var player_name: String = ""
# Current map's play area, in world coordinates. Set by world.gd on load.
var world_bounds: Rect2 = Rect2(0, 0, 1152, 648)
# Random offset applied around a chosen team_a_spawn/team_b_spawn marker
# (see ship_movement.gd's _get_spawn_position()) so multiple ships spawning
# off the same single marker -- e.g. a 7-bot casual-fill team -- don't stack
# exactly on top of each other.
var spawn_scatter_radius: float = 150.0
# Ships spawn at a random point in an annulus (ring) around a chosen
# team_a_spawn/team_b_spawn marker (see ship_movement.gd's
# _get_spawn_position()) rather than right on top of it -- a plain small-radius
# square jitter left casual's up-to-25-per-team fill stacking ships nearly on
# top of each other. The inner radius also keeps ships mostly clear of the
# flagship formation now sitting on/near the marker (see world.gd's
# _spawn_flagships()) instead of spawning inside a hull.
var spawn_area_inner_radius: float = 420.0
var spawn_area_outer_radius: float = 1200.0
# Capital-ship backdrop on each team's spawn marker (world.gd's
# _spawn_flagships()) -- a small line-abreast formation instead of one hull,
# spaced along the axis perpendicular to the two teams' spawn line so the
# ships read as a fleet rather than overlapping each other.
# flagship_spawn_jitter is a per-ship random offset (both axes, see
# _spawn_flagship()) on top of that even spacing -- large relative to the
# spacing itself (deliberately: a small wobble still reads as "a line", this
# needs to read as scattered) so ships can end up well out of their nominal
# slot order, and flagship_spawn_rotation_jitter_deg randomizes each ship's
# facing too, so the formation reads as a loose defensive cluster instead of
# 3 hulls snapped to a ruler and all facing the same way.
var flagship_count_per_spawn: int = 3
var flagship_spawn_spacing: float = 950.0
var flagship_spawn_jitter: float = 750.0
var flagship_spawn_rotation_jitter_deg: float = 35.0
# Point-defense turret behavior (flagship.gd) -- each formation ship
# independently scans for the nearest enemy within flagship_defense_radius
# and fires two independent weapons at it, each on its own cooldown: a big,
# slow, unmissable-looking Missile and a faster-cadence stream of ordinary
# per-race bullets (the "Fighter" role sprite -- see BULLET_SPRITES in
# bullet.gd), both slower than a player's own shots so a target has a real
# chance to dodge despite a guaranteed-lethal hit. This is what keeps a team
# from parking on the enemy's spawn marker and farming respawns: get within
# range and multiple capital ships start shooting back with both.
# flagship_missile_damage/flagship_bullet_damage are set far above
# ship_max_health so a hit from either is always a kill regardless of the
# target's current health -- the only counterplay is staying out of range or
# dodging the (non-homing, fired straight at a lead-predicted point) shot
# before it arrives. flagship_defense_radius is kept comfortably under
# bullet_max_range (1875, shared with every other bullet) so a shot fired
# right at the edge of detection range still has enough travel budget left
# to reach a target that keeps moving away after it's launched.
var flagship_defense_radius: float = 1200.0
var flagship_fire_rate: float = 1.4
var flagship_missile_speed: float = 950.0
var flagship_missile_damage: int = 999
var flagship_missile_visual_scale: float = 0.8
var flagship_bullet_fire_rate: float = 0.9
var flagship_bullet_speed: float = 500.0
var flagship_bullet_damage: int = 999
# Random offset applied to both weapons' lead-predicted aim point (see
# flagship.gd's _lead_aim_dir()) -- keeps the turret feeling sharp without
# being an unavoidable guarantee at range; the farther out a target engages
# from, the more this same pixel offset translates into a wider angular
# miss, so distance is already its own accuracy penalty on top of this.
var flagship_aim_jitter_px: float = 90.0
# True while the chat input box has keyboard focus — gates ship movement/fire
# input so typing (e.g. the letter "w") doesn't also move the ship.
@@ -117,6 +369,15 @@ var chat_focused: bool = false
# has no physics processing yet at that point.
var team_select_focused: bool = false
# True while SettingsPanel is open (see menu/settings_panel.gd's open()/
# close()) — same purpose as chat_focused/team_select_focused, but more
# load-bearing here: the CONTROLS section's key-rebind capture listens for
# literally any physical key, including WASD/Space, so without this a rebind
# click would also thrust/turn/fire the ship (if unpaused behind the panel)
# and toggle_pause's own Escape-to-cancel-a-rebind would also close the pause
# menu underneath it (pause_menu.gd's _input() checks this too).
var settings_focused: bool = false
# Bots (casual fill) — see bots/bot_manager.gd and overview/bots.md
var bot_min_team_size: int = 7 # mirrors matchmaking-api's CASUAL_TEAM_SIZE default
var bot_engage_range: float = 500.0
+25 -3
View File
@@ -17,6 +17,17 @@ var _ticket_id: String = ""
var _poll_timer: Timer
var _searching: bool = false
# Reused for the whole match instead of a fresh short-lived node per call
# (see _request()'s "reuse" param) -- register_server() is the one endpoint
# called on a tight, indefinitely-repeating interval (NetworkManager's 8s
# heartbeat, for as long as a hosted server stays up), unlike every other
# call here which fires once or for a short pre-match queueing window.
# Spinning up (and, on queue_free(), tearing down the background thread of) a
# brand-new HTTPRequest every single heartbeat was a real periodic hitch --
# invisible while the local player's ship sat still, very visible as a
# stutter/blur while panning, landing every ~8s.
var _heartbeat_http: HTTPRequest
func _ready() -> void:
for arg in OS.get_cmdline_user_args():
@@ -29,6 +40,9 @@ func _ready() -> void:
_poll_timer.timeout.connect(_poll_status)
add_child(_poll_timer)
_heartbeat_http = HTTPRequest.new()
add_child(_heartbeat_http)
func start_matchmaking(callsign: String, mode: String) -> void:
if _searching:
@@ -76,7 +90,7 @@ func register_server(ip: String, port: int, mode: String, player_count: int, max
"mode": mode,
"player_count": player_count,
"max_players": max_players,
})
}, _heartbeat_http)
return result.get("code", 0) == 200
@@ -127,8 +141,14 @@ func _fail(reason: String) -> void:
# Returns {"code": int, "body": Dictionary} — "code" is 0 if the request
# itself couldn't even be sent (e.g. malformed URL), not an HTTP status.
func _request(method: String, path: String, body) -> Dictionary:
var http := HTTPRequest.new()
# reuse_http lets a caller that fires on a tight recurring interval (see
# _heartbeat_http above) pass in its own persistent node instead of paying
# for a fresh HTTPRequest (and the background thread that comes with it)
# every single call — every other caller here fires rarely enough that a
# short-lived per-call node (so concurrent calls never collide) is fine.
func _request(method: String, path: String, body, reuse_http: HTTPRequest = null) -> Dictionary:
var http := reuse_http if reuse_http != null else HTTPRequest.new()
if reuse_http == null:
add_child(http)
var headers := PackedStringArray()
@@ -140,10 +160,12 @@ func _request(method: String, path: String, body) -> Dictionary:
var http_method := HTTPClient.METHOD_GET if method == "GET" else HTTPClient.METHOD_POST
var err := http.request(base_url + path, headers, http_method, body_str)
if err != OK:
if reuse_http == null:
http.queue_free()
return {"code": 0, "body": null}
var response = await http.request_completed
if reuse_http == null:
http.queue_free()
var response_code: int = response[1]
+1
View File
@@ -19,6 +19,7 @@ func _ready() -> void:
_player = AudioStreamPlayer.new()
_player.stream = MENU_MUSIC
_player.volume_db = PLAY_VOLUME_DB
_player.bus = "Music"
add_child(_player)
_player.finished.connect(_player.play) # loop the track
+6 -1
View File
@@ -110,7 +110,12 @@ func _find_nearest_enemy() -> Ship:
if candidate == null or candidate == _ship or not candidate.visible:
continue
var info: Dictionary = PlayerRegistry.get_info(candidate.peer_id)
if info.get("race", -1) == _race_id:
# info.is_empty() covers the server's own never-loadout-picked "ghost"
# ship on a dedicated headless server (peer_id 1, nobody ever runs
# TeamSelect for it) -- without this it's a permanent, invisible-to-
# players phantom "enemy" every bot on every team can detect and
# attack forever, since a missing race never equals any real _race_id.
if info.is_empty() or info.get("race", -1) == _race_id:
continue
var dist: float = _ship.global_position.distance_squared_to(candidate.global_position)
if dist > _awareness_range_sq:
+15
View File
@@ -0,0 +1,15 @@
[gd_resource type="AudioBusLayout" format=3]
[resource]
bus/1/name = "Music"
bus/1/solo = false
bus/1/mute = false
bus/1/bypass_fx = false
bus/1/volume_db = 0.0
bus/1/send = "Master"
bus/2/name = "SFX"
bus/2/solo = false
bus/2/mute = false
bus/2/bypass_fx = false
bus/2/volume_db = 0.0
bus/2/send = "Master"
+24 -6
View File
@@ -1,17 +1,18 @@
extends CanvasLayer
# Bottom-right minimap: green translucent background, gray dots for every
# wall/asteroid tile, white dots for teammates, yellow dots for enemies.
# Drawing itself lives in mini_map_view.gd (a Control, since CanvasLayer
# can't _draw()) — this script only polls game state and feeds it in.
# wall/asteroid tile, team-colored dots for teammates/enemies (GameConfig.
# team_color/enemy_color -- same scheme the top-left roster and in-world
# nameplates use, and same colorblind-mode-aware enemy_color, see
# GameConfig.set_colorblind_mode()). Drawing itself lives in
# mini_map_view.gd (a Control, since CanvasLayer can't _draw()) — this
# script only polls game state and feeds it in.
const MARGIN := 16.0
const WIDTH := 260.0
const HEIGHT := 180.0
const REFRESH_INTERVAL := 0.1 # ~10Hz is plenty for a minimap dot
const TEAM_COLOR := Color(1.0, 1.0, 1.0)
const ENEMY_COLOR := Color(0.95, 0.85, 0.25)
const SELF_COLOR := Color(0.3, 0.55, 1.0)
var _view: MiniMapView
@@ -54,6 +55,7 @@ func _process(delta: float) -> void:
return
_refresh_timer = REFRESH_INTERVAL
_update_players()
_update_flagships()
_view.queue_redraw()
@@ -90,11 +92,27 @@ func _update_players() -> void:
else:
var info: Dictionary = PlayerRegistry.players[peer_id]
var is_teammate: bool = info.get("race", 0) == local_race
color = TEAM_COLOR if is_teammate else ENEMY_COLOR
color = GameConfig.team_color if is_teammate else GameConfig.enemy_color
points.append({"pos": _normalize(ship.global_position, bounds), "color": color})
_view.player_points = points
# Flagships are static (never move once world.gd spawns them) but spawn on a
# deferred call at an unpredictable time relative to MiniMap's own _ready(),
# so this just re-scans the (tiny, 6-node) "flagships" group every refresh
# tick rather than trying to cache once like _cache_hazard_points() does --
# simpler than special-casing "not spawned yet".
func _update_flagships() -> void:
var bounds := GameConfig.world_bounds
var local_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0)
var points: Array = []
for flagship in get_tree().get_nodes_in_group("flagships"):
var color := GameConfig.team_color if flagship.race_id == local_race else GameConfig.enemy_color
points.append({"pos": _normalize(flagship.global_position, bounds), "color": color})
_view.flagship_points = points
func _normalize(world_pos: Vector2, bounds: Rect2) -> Vector2:
var frac := (world_pos - bounds.position) / bounds.size
return frac.clamp(Vector2.ZERO, Vector2.ONE)
+16
View File
@@ -14,11 +14,19 @@ var hazard_points: PackedVector2Array = PackedVector2Array()
# [{pos: Vector2 (normalized 0..1), color: Color}], rebuilt every refresh.
var player_points: Array = []
# Same shape as player_points -- one big marker per Flagship (see
# hud/mini_map.gd's _update_flagships()), team-colored the same way as
# player dots but drawn much larger so the capital-ship formations read as
# a landmark on the minimap, not just more dots.
var flagship_points: Array = []
const BG_COLOR := Color(0.04, 0.28, 0.08, 0.6)
const BORDER_COLOR := Color(0.5, 0.85, 0.5, 0.8)
const HAZARD_COLOR := Color(0.65, 0.65, 0.65, 0.9)
const HAZARD_RADIUS := 1.5
const PLAYER_RADIUS := 3.0
const FLAGSHIP_RADIUS := 6.0
const FLAGSHIP_OUTLINE_COLOR := Color(0.0, 0.0, 0.0, 0.6)
func _draw() -> void:
@@ -28,6 +36,14 @@ func _draw() -> void:
for p in hazard_points:
draw_circle(p * size, HAZARD_RADIUS, HAZARD_COLOR)
# Drawn before player dots so a ship sitting right on top of a flagship
# marker (e.g. hugging its own spawn) still shows as a distinct dot on
# top rather than getting buried under the bigger shape.
for entry in flagship_points:
var pos: Vector2 = entry.pos * size
draw_circle(pos, FLAGSHIP_RADIUS, entry.color)
draw_circle(pos, FLAGSHIP_RADIUS, FLAGSHIP_OUTLINE_COLOR, false, 1.0)
for entry in player_points:
draw_circle(entry.pos * size, PLAYER_RADIUS, entry.color)
+11 -4
View File
@@ -12,6 +12,9 @@ var _profile_overlay: Control
var _profile_input: LineEdit
var _profile_hint_lbl: Label
const SETTINGS_PANEL_SCENE := preload("res://menu/settings_panel.tscn")
var _settings_panel: SettingsPanel
var _connecting: bool = false
var _pending_quick_play: bool = false # profile overlay was forced open by Quick Play
@@ -51,6 +54,7 @@ func _build_ui() -> void:
_add_status_label()
_add_version_label()
_add_profile_overlay()
_add_settings_panel()
_refresh_profile_badge()
@@ -95,8 +99,7 @@ func _add_logo() -> void:
func _add_nav_menu() -> void:
for i in NAV_ITEMS.size():
var label_text: String = NAV_ITEMS[i]
var stub := label_text == "OPTIONS"
var btn := _make_nav_btn(label_text, stub)
var btn := _make_nav_btn(label_text, false)
btn.anchor_left = 0.0
btn.anchor_top = 0.0
btn.anchor_right = 0.0
@@ -369,8 +372,12 @@ func _on_server_select() -> void:
func _on_options() -> void:
_status_lbl.add_theme_color_override("font_color", Color(0.5, 0.54, 0.62))
_status_lbl.text = "Options — coming soon."
_settings_panel.open()
func _add_settings_panel() -> void:
_settings_panel = SETTINGS_PANEL_SCENE.instantiate()
add_child(_settings_panel)
func _on_profile() -> void:
+8 -2
View File
@@ -11,7 +11,7 @@ func _ready() -> void:
func _input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_pause") and not GameConfig.chat_focused \
and not GameConfig.team_select_focused:
and not GameConfig.team_select_focused and not GameConfig.settings_focused:
_toggle()
get_viewport().set_input_as_handled()
@@ -75,7 +75,7 @@ func _build_ui() -> void:
vb.add_child(gap)
_add_btn(vb, "RESUME", true).pressed.connect(_toggle)
_add_btn(vb, "SETTINGS", false)
_add_btn(vb, "SETTINGS", true).pressed.connect(_on_settings)
_add_btn(vb, "SELECT TEAM", true).pressed.connect(_on_select_team)
vb.add_child(_hsep())
@@ -121,6 +121,12 @@ func _flat(col: Color, radius: int = 0) -> StyleBoxFlat:
return s
func _on_settings() -> void:
var settings := get_node_or_null("/root/World/SettingsPanel") as SettingsPanel
if settings:
settings.open()
func _on_select_team() -> void:
_root.visible = false
var team_select := get_node_or_null("/root/World/TeamSelect") as TeamSelect
+403
View File
@@ -0,0 +1,403 @@
extends CanvasLayer
class_name SettingsPanel
# Reusable settings overlay -- opened from both the main menu
# (menu/main_menu.gd's OPTIONS) and the in-game pause menu
# (menu/pause_menu.gd's SETTINGS), each holding their own instance (see
# main_menu.gd's _add_settings_panel() and world.tscn's SettingsPanel node).
# Every value here lives on GameConfig (persisted to disk), so a choice made
# in one instance is already reflected in the other the next time it's
# opened. CanvasLayer (not a plain Control) with a layer above PauseMenu's 10
# and TeamSelect's 20 (same pattern those two use) so it still renders and
# receives input on top when opened from inside the already-paused pause
# menu, not just from the main menu where layering doesn't otherwise matter.
# Body is a fixed-size panel + ScrollContainer rather than growing the panel
# per section, since this keeps growing (frame rate/vsync/audio so far, more
# planned) and a fixed height fits Steam Deck's 800px-tall screen regardless
# of how many sections end up in here.
# A wide spread of common monitor refresh rates is offered rather than just a
# couple of presets -- capping Engine.max_fps to a clean multiple of the
# fixed 60Hz physics tick (see ships/ship_movement.gd) is what actually
# removes the judder that reads as "blurry/laggy movement" (most visible
# against world/starfield.gdshader's pixel-crisp stars), and the right
# multiple depends on each player's own monitor, which isn't something this
# project can detect automatically.
const FPS_OPTIONS := [30, 60, 75, 90, 100, 120, 144, 165, 180, 200, 240, 280, 300, 360]
# VSync -- the other half of the "smooth motion" knob alongside the frame-rate
# cap above: a cap that doesn't match what vsync actually lets through can
# still stutter (confirmed live -- a 165 FPS cap on a display vsync wouldn't
# cleanly deliver at that rate caused a repeating smooth/stutter cycle;
# capping to exactly 60 fixed it). Adaptive (only syncs when the frame rate
# would otherwise exceed the display's refresh, so it doesn't reintroduce
# stutter when running under it) and Disabled (no sync at all, lowest input
# lag, tearing possible) are the two standard alternatives to try if the
# default Enabled still doesn't land cleanly on a given display.
const VSYNC_OPTIONS := [
{"label": "ENABLED (Recommended)", "value": DisplayServer.VSYNC_ENABLED},
{"label": "ADAPTIVE", "value": DisplayServer.VSYNC_ADAPTIVE},
{"label": "DISABLED", "value": DisplayServer.VSYNC_DISABLED},
]
# Fullscreen matches project.godot's boot default (item 10 in CLAUDE.md).
# Exclusive Fullscreen trades slower alt-tabbing for potentially better
# performance on some GPU/driver combos; Windowed is for players who want to
# multitask or stream.
const WINDOW_MODE_OPTIONS := [
{"label": "FULLSCREEN (Recommended)", "value": DisplayServer.WINDOW_MODE_FULLSCREEN},
{"label": "EXCLUSIVE FULLSCREEN", "value": DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN},
{"label": "WINDOWED", "value": DisplayServer.WINDOW_MODE_WINDOWED},
]
# Human-readable label per GameConfig.KEYBIND_ACTIONS entry, in display order.
const ACTION_LABELS := {
"move_up": "THRUST FORWARD",
"move_down": "THRUST REVERSE",
"move_left": "TURN LEFT",
"move_right": "TURN RIGHT",
"shoot": "FIRE",
"toggle_pause": "PAUSE MENU",
"chat_all": "CHAT (ALL)",
"chat_team": "CHAT (TEAM)",
}
var _root: Control
var _fps_option_btn: OptionButton
var _vsync_option_btn: OptionButton
var _window_mode_option_btn: OptionButton
var _colorblind_check: CheckButton
var _master_slider: HSlider
var _music_slider: HSlider
var _sfx_slider: HSlider
var _master_value_lbl: Label
var _music_value_lbl: Label
var _sfx_value_lbl: Label
# action -> Button showing that action's current key, so a rebind (or reset)
# can update just the one row's label without rebuilding the whole list.
var _keybind_buttons: Dictionary = {}
# Empty when not capturing a key; set to the action name between clicking its
# REBIND button and the next physical key press (see _input() below).
var _rebinding_action: String = ""
func _ready() -> void:
layer = 25
_build_ui()
_root.visible = false
func open() -> void:
_refresh_selected()
_root.visible = true
GameConfig.settings_focused = true
func close() -> void:
_rebinding_action = ""
_root.visible = false
GameConfig.settings_focused = false
func _build_ui() -> void:
_root = Control.new()
_root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_root)
var dim := ColorRect.new()
dim.color = Color(0.0, 0.0, 0.0, 0.65)
dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
dim.mouse_filter = Control.MOUSE_FILTER_STOP
_root.add_child(dim)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_CENTER)
panel.offset_left = -220
panel.offset_top = -320
panel.offset_right = 220
panel.offset_bottom = 320
var ps := StyleBoxFlat.new()
ps.bg_color = Color(0.05, 0.05, 0.07, 0.97)
ps.set_corner_radius_all(6)
ps.set_border_width_all(1)
ps.border_color = Color(1.0, 1.0, 1.0, 0.25)
panel.add_theme_stylebox_override("panel", ps)
_root.add_child(panel)
var outer_vb := VBoxContainer.new()
outer_vb.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
outer_vb.offset_left = 26
outer_vb.offset_top = 22
outer_vb.offset_right = -26
outer_vb.offset_bottom = -22
outer_vb.add_theme_constant_override("separation", 12)
panel.add_child(outer_vb)
var title := Label.new()
title.text = "SETTINGS"
title.add_theme_font_size_override("font_size", 20)
title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0))
outer_vb.add_child(title)
outer_vb.add_child(HSeparator.new())
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
outer_vb.add_child(scroll)
var vb := VBoxContainer.new()
vb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
vb.add_theme_constant_override("separation", 12)
scroll.add_child(vb)
_add_section(vb, "MAX FRAME RATE")
_fps_option_btn = _add_dropdown(vb)
for fps in FPS_OPTIONS:
_fps_option_btn.add_item("%d FPS" % fps)
_fps_option_btn.add_item("UNLIMITED")
_fps_option_btn.item_selected.connect(_on_fps_selected)
_add_hint(vb, "Movement looking stuttery or blurry? 60 is the one cap guaranteed to match your display.")
_add_section(vb, "VSYNC")
_vsync_option_btn = _add_dropdown(vb)
for option in VSYNC_OPTIONS:
_vsync_option_btn.add_item(option.label)
_vsync_option_btn.item_selected.connect(_on_vsync_selected)
_add_hint(vb, "Still stuttery at the right frame rate? Try Adaptive or Disabled.")
_add_section(vb, "WINDOW MODE")
_window_mode_option_btn = _add_dropdown(vb)
for option in WINDOW_MODE_OPTIONS:
_window_mode_option_btn.add_item(option.label)
_window_mode_option_btn.item_selected.connect(_on_window_mode_selected)
_add_section(vb, "MASTER VOLUME")
var master_row := _add_slider(vb, GameConfig.master_volume)
_master_slider = master_row.slider
_master_value_lbl = master_row.value_lbl
_master_slider.value_changed.connect(_on_master_changed)
_master_slider.drag_ended.connect(_on_slider_drag_ended)
_add_section(vb, "MUSIC VOLUME")
var music_row := _add_slider(vb, GameConfig.music_volume)
_music_slider = music_row.slider
_music_value_lbl = music_row.value_lbl
_music_slider.value_changed.connect(_on_music_changed)
_music_slider.drag_ended.connect(_on_slider_drag_ended)
_add_section(vb, "SFX VOLUME")
var sfx_row := _add_slider(vb, GameConfig.sfx_volume)
_sfx_slider = sfx_row.slider
_sfx_value_lbl = sfx_row.value_lbl
_sfx_slider.value_changed.connect(_on_sfx_changed)
_sfx_slider.drag_ended.connect(_on_slider_drag_ended)
_add_section(vb, "ACCESSIBILITY")
_colorblind_check = CheckButton.new()
_colorblind_check.text = "COLORBLIND-FRIENDLY ENEMY COLOR"
_colorblind_check.toggled.connect(_on_colorblind_toggled)
vb.add_child(_colorblind_check)
_add_hint(vb, "Swaps the red enemy color (nameplates, roster, minimap) for a high-contrast orange.")
_add_section(vb, "CONTROLS")
for action in GameConfig.KEYBIND_ACTIONS:
vb.add_child(_build_keybind_row(action))
_add_hint(vb, "Click a key, then press its replacement. Esc cancels.")
var reset_controls_btn := Button.new()
reset_controls_btn.text = "RESET ALL CONTROLS TO DEFAULT"
reset_controls_btn.custom_minimum_size = Vector2(0, 36)
reset_controls_btn.pressed.connect(_on_reset_all_controls)
vb.add_child(reset_controls_btn)
var close_btn := Button.new()
close_btn.text = "CLOSE"
close_btn.custom_minimum_size = Vector2(0, 42)
close_btn.pressed.connect(close)
outer_vb.add_child(close_btn)
func _add_section(vb: VBoxContainer, label_text: String) -> void:
vb.add_child(HSeparator.new())
var lbl := Label.new()
lbl.text = label_text
lbl.add_theme_font_size_override("font_size", 12)
lbl.add_theme_color_override("font_color", Color(0.65, 0.68, 0.75))
vb.add_child(lbl)
func _add_hint(vb: VBoxContainer, text: String) -> void:
var hint := Label.new()
hint.text = text
hint.autowrap_mode = TextServer.AUTOWRAP_WORD
hint.add_theme_font_size_override("font_size", 11)
hint.add_theme_color_override("font_color", Color(0.5, 0.54, 0.62))
vb.add_child(hint)
func _add_dropdown(vb: VBoxContainer) -> OptionButton:
var btn := OptionButton.new()
btn.custom_minimum_size = Vector2(0, 40)
vb.add_child(btn)
return btn
# Returns {"slider": HSlider, "value_lbl": Label} -- the label shows a live
# percentage next to the slider (rather than only in the section header
# above it) so the exact value is visible without needing to stop dragging.
func _add_slider(vb: VBoxContainer, initial_value: float) -> Dictionary:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
vb.add_child(row)
var slider := HSlider.new()
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.01
slider.value = initial_value
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.custom_minimum_size = Vector2(0, 24)
row.add_child(slider)
var value_lbl := Label.new()
value_lbl.text = "%d%%" % roundi(initial_value * 100.0)
value_lbl.custom_minimum_size = Vector2(42, 0)
value_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
value_lbl.add_theme_font_size_override("font_size", 13)
value_lbl.add_theme_color_override("font_color", Color(0.82, 0.84, 0.9))
row.add_child(value_lbl)
return {"slider": slider, "value_lbl": value_lbl}
# action's own current-key Button lives in _keybind_buttons so a rebind only
# has to update that one row instead of rebuilding the whole CONTROLS list.
func _build_keybind_row(action: String) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var lbl := Label.new()
lbl.text = ACTION_LABELS.get(action, action)
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
lbl.add_theme_font_size_override("font_size", 13)
lbl.add_theme_color_override("font_color", Color(0.82, 0.84, 0.9))
row.add_child(lbl)
var key_btn := Button.new()
key_btn.custom_minimum_size = Vector2(120, 34)
key_btn.pressed.connect(_start_rebind.bind(action))
row.add_child(key_btn)
_keybind_buttons[action] = key_btn
return row
func _key_label(keycode: int) -> String:
if keycode == 0:
return ""
var ev := InputEventKey.new()
ev.physical_keycode = keycode
return ev.as_text_physical_keycode()
func _refresh_keybind_row(action: String) -> void:
var btn: Button = _keybind_buttons.get(action)
if btn:
btn.text = _key_label(GameConfig.get_keycode(action))
func _start_rebind(action: String) -> void:
if _rebinding_action != "":
return
_rebinding_action = action
_keybind_buttons[action].text = "PRESS A KEY (ESC CANCELS)"
func _input(event: InputEvent) -> void:
if _rebinding_action == "":
return
if event is InputEventKey and event.pressed and not event.echo:
var action := _rebinding_action
if event.physical_keycode != KEY_ESCAPE:
GameConfig.rebind_action(action, event.physical_keycode)
_rebinding_action = ""
_refresh_keybind_row(action)
get_viewport().set_input_as_handled()
func _on_reset_all_controls() -> void:
for action in GameConfig.KEYBIND_ACTIONS:
GameConfig.reset_keybind(action)
_refresh_keybind_row(action)
func _refresh_selected() -> void:
var fps_idx := FPS_OPTIONS.find(GameConfig.max_fps)
_fps_option_btn.select(fps_idx if fps_idx != -1 else FPS_OPTIONS.size())
var vsync_idx := 0
for i in VSYNC_OPTIONS.size():
if VSYNC_OPTIONS[i].value == GameConfig.vsync_mode:
vsync_idx = i
break
_vsync_option_btn.select(vsync_idx)
var window_mode_idx := 0
for i in WINDOW_MODE_OPTIONS.size():
if WINDOW_MODE_OPTIONS[i].value == GameConfig.window_mode:
window_mode_idx = i
break
_window_mode_option_btn.select(window_mode_idx)
_master_slider.value = GameConfig.master_volume
_music_slider.value = GameConfig.music_volume
_sfx_slider.value = GameConfig.sfx_volume
_colorblind_check.button_pressed = GameConfig.colorblind_mode
for action in GameConfig.KEYBIND_ACTIONS:
_refresh_keybind_row(action)
func _on_fps_selected(index: int) -> void:
var value: int = FPS_OPTIONS[index] if index < FPS_OPTIONS.size() else 0
GameConfig.set_max_fps(value)
func _on_vsync_selected(index: int) -> void:
GameConfig.set_vsync_mode(VSYNC_OPTIONS[index].value)
func _on_window_mode_selected(index: int) -> void:
GameConfig.set_window_mode(WINDOW_MODE_OPTIONS[index].value)
func _on_colorblind_toggled(pressed: bool) -> void:
GameConfig.set_colorblind_mode(pressed)
func _on_master_changed(value: float) -> void:
GameConfig.set_master_volume(value)
_master_value_lbl.text = "%d%%" % roundi(value * 100.0)
func _on_music_changed(value: float) -> void:
GameConfig.set_music_volume(value)
_music_value_lbl.text = "%d%%" % roundi(value * 100.0)
func _on_sfx_changed(value: float) -> void:
GameConfig.set_sfx_volume(value)
_sfx_value_lbl.text = "%d%%" % roundi(value * 100.0)
# Shared by all three volume sliders -- see the "deliberately don't call
# save_settings()" note on GameConfig's volume setters for why the disk
# write is deferred to here instead of every value_changed.
func _on_slider_drag_ended(_value_changed: bool) -> void:
GameConfig.save_settings()
+1
View File
@@ -0,0 +1 @@
uid://e3otf8r1hvyh
+6
View File
@@ -0,0 +1,6 @@
[gd_scene format=3 uid="uid://csettingspanel1"]
[ext_resource type="Script" path="res://menu/settings_panel.gd" id="1_script"]
[node name="SettingsPanel" type="CanvasLayer"]
script = ExtResource("1_script")
+2
View File
@@ -13,6 +13,7 @@ const RACES := [
{
"id": 1, "name": "INNER SPHERE NAVY", "sub": "Gunmetal-gray navy of Earth, Mars, and Mercury — disciplined, frontal, built to hold a line",
"color": Color(0.80, 0.32, 0.32),
"flagship_path": "res://assets/images/ships/isn/flagship_colossus.png", "flagship_scale": 1.0,
"ships": [
{
"name": "PATRIOT", "role": "Fighter",
@@ -40,6 +41,7 @@ const RACES := [
{
"id": 2, "name": "OUTER RIM COLLECTIVE", "sub": "Gritty, jury-rigged mining rebels — kinetic weapons scavenged and welded onto anything that flies",
"color": Color(0.82, 0.52, 0.24),
"flagship_path": "res://assets/images/ships/orc/flagship_rust_titan.png", "flagship_scale": 1.0,
"ships": [
{
"name": "RAIL-JACK", "role": "Fighter",
+1
View File
@@ -78,6 +78,7 @@ chat_team={
[physics]
common/physics_interpolation=true
3d/physics_engine="Jolt Physics"
[rendering]
+74 -1
View File
@@ -1,21 +1,85 @@
extends Area2D
# Per-(race, role) bullet art, cropped from a concept ammunition sheet
# (assets/images/effects/bullets/) — nose points along +X in every source
# image (same "rotate to face travel" convention as ship sprites, but those
# are nose-up so need a -90° offset; these are nose-right so velocity.angle()
# lines them up with zero offset). `scale` is a per-sprite constant tuned so
# every role reads at a consistent on-screen size (Missile biggest, Gunner
# smallest) despite the source crops being wildly different pixel sizes.
# "Missile" is Flagship-turret-only (see flagship.gd's _fire_at()) -- a
# distinct, larger torpedo-with-flame-trail sprite instead of reusing the
# Tank role's plain heavy-shell icon, so turret fire reads as a genuinely
# bigger threat than a ship's own Tank-role shots.
const BULLET_SPRITES := {
1: {
"Fighter": {"texture": preload("res://assets/images/effects/bullets/isn_fighter.png"), "scale": 0.310},
"Gunner": {"texture": preload("res://assets/images/effects/bullets/isn_gunner.png"), "scale": 0.238},
"Tank": {"texture": preload("res://assets/images/effects/bullets/isn_tank.png"), "scale": 0.382},
"Missile": {"texture": preload("res://assets/images/effects/bullets/isn_missile.png"), "scale": 0.4},
},
2: {
"Fighter": {"texture": preload("res://assets/images/effects/bullets/orc_fighter.png"), "scale": 0.292},
"Gunner": {"texture": preload("res://assets/images/effects/bullets/orc_gunner.png"), "scale": 0.429},
"Tank": {"texture": preload("res://assets/images/effects/bullets/orc_tank.png"), "scale": 0.315},
"Missile": {"texture": preload("res://assets/images/effects/bullets/orc_missile.png"), "scale": 0.4},
},
}
const DEFAULT_SPRITE := {"texture": preload("res://assets/images/effects/bullets/isn_fighter.png"), "scale": 0.310}
# Set by World._spawn_bullet() before this node enters the tree.
var velocity: Vector2 = Vector2.ZERO
var source_peer_id: int = 0
var damage: int = 100
# Explicit sprite override for shooters with no PlayerRegistry entry (e.g. a
# Flagship turret, see world.gd's request_bullet_spawn()). sprite_role empty
# means "no override" -- _apply_sprite() falls back to deriving race/role
# from source_peer_id, same as every player-fired bullet.
var sprite_race: int = -1
var sprite_role: String = ""
var visual_scale_mult: float = 1.0
# Name of a Flagship node (see flagship.gd's _fire_at()) this bullet must
# never collide with -- a capital ship fires from its own center, which is
# inside its own HullBody's collision shape, so without this every flagship
# shot would immediately register a body_entered against itself and
# queue_free() before ever traveling anywhere. Empty for player-fired
# bullets, which sidestep the same problem with a muzzle offset instead
# (their hull is small enough that 40px in front of the nose clears it).
var ignore_hull_name: String = ""
var _spawn_pos: Vector2
@onready var _sprite: Sprite2D = $Sprite2D
# Runs on every peer (not just the server) since bullets replicate visually
# to everyone — each client resolves its own texture/rotation locally from
# PlayerRegistry, which is already replicated, rather than syncing sprite
# choice over the network.
# Only the server detects hits and despawns bullets — that's replicated to
# every client automatically since bullets are spawned via BulletSpawner.
# Movement itself stays local on every peer (deterministic constant-velocity
# simulation from the replicated spawn state, no further sync needed).
func _ready() -> void:
_spawn_pos = global_position
_apply_sprite()
rotation = velocity.angle()
if multiplayer.is_server():
body_entered.connect(_on_body_entered)
func _apply_sprite() -> void:
var race: int = sprite_race
var role: String = sprite_role
if role.is_empty():
var info: Dictionary = PlayerRegistry.get_info(source_peer_id)
race = info.get("race", -1)
role = info.get("role", "")
var entry: Dictionary = BULLET_SPRITES.get(race, {}).get(role, DEFAULT_SPRITE)
_sprite.texture = entry.texture
_sprite.scale = Vector2.ONE * entry.scale * visual_scale_mult
func _process(delta: float) -> void:
global_position += velocity * delta
if multiplayer.is_server():
@@ -26,6 +90,9 @@ func _process(delta: float) -> void:
queue_free()
func _on_body_entered(body: Node) -> void:
if not ignore_hull_name.is_empty() and body.name == "HullBody" \
and body.get_parent() and body.get_parent().name == ignore_hull_name:
return
var target_peer_id = body.get("peer_id")
if target_peer_id == source_peer_id:
return
@@ -34,9 +101,15 @@ func _on_body_entered(body: Node) -> void:
# real target (has take_damage, i.e. a Ship) is team-checked; hazards/
# walls have neither peer_id nor take_damage and fall straight through
# to queue_free(), same as before.
if target_peer_id != null and body.has_method("take_damage"):
if body.has_method("take_damage"):
if target_peer_id != null:
var source_race: int = PlayerRegistry.get_info(source_peer_id).get("race", -1)
var target_race: int = PlayerRegistry.get_info(target_peer_id).get("race", -2)
if source_race != target_race:
body.take_damage(damage, source_peer_id, global_position)
else:
# Environmental damageable target (e.g. a Flagship, see
# world/flagship.gd) has no peer_id/race to check -- anyone's
# fire damages it, no team check applies.
body.take_damage(damage, source_peer_id, global_position)
queue_free()
+2 -4
View File
@@ -7,12 +7,10 @@ radius = 2.8
[node name="Bullet" type="Area2D"]
collision_layer = 0
collision_mask = 3
collision_mask = 7
script = ExtResource("1")
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape2D_1")
[node name="Polygon2D" type="Polygon2D" parent="."]
color = Color(1, 0.9, 0.2, 1)
polygon = PackedVector2Array(3.5, 0, 3.031, 1.75, 1.75, 3.031, 0, 3.5, -1.75, 3.031, -3.031, 1.75, -3.5, 0, -3.031, -1.75, -1.75, -3.031, 0, -3.5, 1.75, -3.031, 3.031, -1.75)
[node name="Sprite2D" type="Sprite2D" parent="."]
+124 -9
View File
@@ -4,6 +4,38 @@ class_name Ship
const EXPLOSION_SCENE: PackedScene = preload("res://effects/explosion.tscn")
const DAMAGE_NUMBER_SCENE: PackedScene = preload("res://effects/damage_number.tscn")
# "Fly in" spawn intro (see _play_spawn_intro()) -- fast-then-slow, like a
# ship warping in from off-screen rather than just popping into place.
const SPAWN_INTRO_DISTANCE := 700.0
const SPAWN_INTRO_DURATION := 0.7
# Per-ship visual-only correction, in native (unscaled) source-texture
# pixels -- several ship textures' actual bilateral symmetry axis (found by
# searching for the x that best mirrors the alpha silhouette left/right, not
# just eyeballing the nose tip, since a few ships have an off-center antenna
# or sensor that isn't representative of the hull's true axis) doesn't sit
# exactly on the image's geometric center, which is what Sprite2D (centered
# = true) rotates around. That's purely a leftover imprecision from when
# this art was cropped/rotated (see overview docs on the ISN/ORC art pass),
# not a bug in movement or firing -- both already fire in the ship's exact
# mathematical forward direction (Vector2.UP.rotated(rotation), verified via
# direct server-side instrumentation with zero drift across dozens of live
# shots). But a nose drawn off-axis still visually reads as "shots aren't
# coming straight out of my nose" once bullets got a directional sprite,
# since the sprite swings around an axis that isn't quite where its own
# nose is. _init_player() below shifts the Sprite2D's local position to
# recenter each ship's true axis on the rotation pivot -- collision (a
# CapsuleShape2D on this same CharacterBody2D, see _update_collision_shape())
# and the fire direction are untouched, so this is cosmetic-only.
const NOSE_OFFSET_BY_SHIP_PATH := {
"res://assets/images/ships/isn/patriot.png": 5.0,
"res://assets/images/ships/isn/barrage.png": 7.5,
"res://assets/images/ships/isn/behemoth.png": 3.0,
"res://assets/images/ships/orc/rail_jack.png": -0.5,
"res://assets/images/ships/orc/scrap_spitter.png": 9.0,
"res://assets/images/ships/orc/iron_clad.png": 2.5,
}
# Set by World._spawn_ship() before this node enters the tree.
var peer_id: int = 0
@@ -47,9 +79,40 @@ var _ghost_count: int = 0
var _is_owner: bool = false
var _fixed_delta: float = 1.0 / 60.0
# Owner-only (see _ready()) -- re-grabbed in _apply_respawn() so a teleport
# (spawn/respawn) can reset_physics_interpolation() the camera too, not just
# this ship's own CharacterBody2D. Physics interpolation (project setting
# "physics/common/physics_interpolation", added to smooth the fixed 60Hz
# physics tick up to whatever render/refresh rate a player's monitor and
# menu/settings_panel.gd frame-rate cap land on -- see GameConfig.max_fps)
# blends each CanvasItem's *own* last two physics-tick global transforms; a
# child (the camera) doesn't inherit a reset called on its parent (the ship),
# so without this a respawn's instant position jump would still render as the
# camera sliding across the map over one interpolation window even though the
# ship sprite itself snapped correctly.
var _camera: Camera2D
# True once this ship's very first _apply_respawn() has played its "fly in"
# intro (see _play_spawn_intro()) -- never reset, so a later death/respawn or
# mid-match team swap (both of which also call _apply_respawn()) doesn't
# replay it; only a ship's first-ever appearance should look like it's
# warping into the match.
var _played_spawn_intro: bool = false
# Bottom-right nameplate — see _refresh_nameplate() and ship.tscn's
# top_level Label child.
# top_level Label child. Position is interpolated across render frames the
# same way _remote_tick() interpolates a remote ship's position (see
# _nameplate_from_pos/_nameplate_to_pos/_nameplate_interp_elapsed and
# _process() below) rather than relying on the engine's own physics
# interpolation (project setting "physics/common/physics_interpolation") the
# rest of a Ship's Node2D/Camera2D transforms get automatically -- that
# system only covers Node2D/Node3D, not Control (Label's base class), so
# without this the nameplate would stay snapped to the raw 60Hz physics tick
# and visibly judder/blur in motion while everything else around it is smooth.
var _nameplate: Label
var _nameplate_from_pos: Vector2
var _nameplate_to_pos: Vector2
var _nameplate_interp_elapsed: float = 0.0
# Owner-side prediction: inputs sent to the server but not yet acknowledged,
# replayed on top of the server's authoritative state on correction.
@@ -99,10 +162,10 @@ func _ready() -> void:
# make_current() does, since it's the actual API that updates the
# viewport's tracked active camera rather than just the local property.
if _is_owner:
var camera := get_node_or_null("Camera2D") as Camera2D
if camera:
camera.zoom = Vector2.ONE * GameConfig.camera_zoom
camera.call_deferred("make_current")
_camera = get_node_or_null("Camera2D") as Camera2D
if _camera:
_camera.zoom = Vector2.ONE * GameConfig.camera_zoom
_camera.call_deferred("make_current")
PlayerRegistry.loadout_updated.connect(_on_loadout_updated)
var info := PlayerRegistry.get_info(peer_id)
@@ -134,6 +197,7 @@ func _init_player(info: Dictionary) -> void:
_thrusting = false
var total_scale: float = info.get("ship_scale", 1.0) * GameConfig.ship_scale_factor
sprite.scale = Vector2.ONE * total_scale
sprite.position.x = -NOSE_OFFSET_BY_SHIP_PATH.get(ship_path, 0.0) * total_scale
_update_collision_shape(_idle_texture, total_scale)
_ship_speed_factor = info.get("ship_speed_factor", 1.0)
_role = info.get("role", "Fighter")
@@ -208,7 +272,17 @@ func _physics_process(delta: float) -> void:
if _nameplate:
_nameplate.visible = visible
_nameplate.global_position = global_position + GameConfig.ship_nameplate_offset
_nameplate_from_pos = _nameplate.global_position
_nameplate_to_pos = global_position + GameConfig.ship_nameplate_offset
_nameplate_interp_elapsed = 0.0
func _process(delta: float) -> void:
if _nameplate == null or not _nameplate.visible:
return
_nameplate_interp_elapsed += delta
var t: float = clamp(_nameplate_interp_elapsed / _fixed_delta, 0.0, 1.0)
_nameplate.global_position = _nameplate_from_pos.lerp(_nameplate_to_pos, t)
# Swaps the sprite between idle/flame textures based on whether the up/down
@@ -351,7 +425,7 @@ func _remote_tick(delta: float) -> void:
# ── Shared movement step (deterministic — replayed during reconciliation) ───
func _sample_local_input() -> Dictionary:
if GameConfig.chat_focused or GameConfig.team_select_focused:
if GameConfig.chat_focused or GameConfig.team_select_focused or GameConfig.settings_focused:
return {"up": false, "down": false, "left": false, "right": false, "shoot": false}
return {
"up": Input.is_action_pressed("move_up"),
@@ -440,6 +514,12 @@ func _server_process_shoot(delta: float, input: Dictionary) -> void:
if world:
var damage: int = GameConfig.ship_bullet_damage_by_role.get(_role, 100)
var count: int = GameConfig.ship_bullet_count_by_role.get(_role, 1)
# Same Vector2.UP.rotated(rotation) formula as the ship's own thrust
# above -- guarantees the bullet fires exactly along the ship's true
# heading, with no possible drift between how the ship moves/faces
# and where its shots go (a fixed-degree "calibration" offset here
# was tried and reverted -- it only ever fights this guarantee, it
# can't actually fix a real mismatch since there isn't one to fix).
var forward := Vector2.UP.rotated(rotation)
var right := Vector2.RIGHT.rotated(rotation)
var base_pos := global_position + forward * 40.0
@@ -569,6 +649,13 @@ func _apply_respawn(pos: Vector2) -> void:
_invincible = true
_invincible_timer = GameConfig.ship_invincibility_time
# Teleport, not movement -- tell physics interpolation not to smear a
# blend between the pre-respawn and post-respawn positions (see _camera's
# declaration above for why the camera needs its own explicit reset too).
reset_physics_interpolation()
if _camera:
_camera.reset_physics_interpolation()
_interp_from_pos = global_position
_interp_to_pos = global_position
_interp_from_rot = rotation
@@ -579,12 +666,40 @@ func _apply_respawn(pos: Vector2) -> void:
_update_hud()
if not _played_spawn_intro:
_played_spawn_intro = true
_play_spawn_intro()
# Purely cosmetic sprite-local offset -- global_position/collision/camera are
# already at the real spawn point by the time this runs (see above), so
# hit detection and every other peer's view of this ship's actual position
# are untouched; only the Sprite2D itself visibly eases in from a random
# direction "off-screen". Runs identically on every peer (this whole function
# is called from _apply_respawn(), which every peer receives via its
# call_local RPC), so every viewer sees the same ship warp in, not just its
# owner. TRANS_EXPO/EASE_OUT gives the fast-then-slow feel asked for --
# most of the travel happens in the first fraction of SPAWN_INTRO_DURATION,
# tailing off gently into the landing spot rather than decelerating evenly.
func _play_spawn_intro() -> void:
var sprite := get_node_or_null("Sprite2D") as Sprite2D
if sprite == null:
return
var target := sprite.position
var start := target + Vector2.from_angle(randf() * TAU) * SPAWN_INTRO_DISTANCE
sprite.position = start
var tween := create_tween()
tween.set_trans(Tween.TRANS_EXPO)
tween.set_ease(Tween.EASE_OUT)
tween.tween_property(sprite, "position", target, SPAWN_INTRO_DURATION)
func _get_spawn_position() -> Vector2:
var markers := get_tree().get_nodes_in_group(_team_spawn_group())
if markers.size() > 0:
var base: Vector2 = markers[randi() % markers.size()].global_position
var offset := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * GameConfig.spawn_scatter_radius
return base + offset
var angle := randf() * TAU
var radius := randf_range(GameConfig.spawn_area_inner_radius, GameConfig.spawn_area_outer_radius)
return base + Vector2.from_angle(angle) * radius
return GameConfig.world_bounds.get_center()
+221
View File
@@ -0,0 +1,221 @@
extends Node2D
class_name Flagship
# Capital ship parked on a team's spawn marker (see world.gd's
# _spawn_flagships()) -- not a networked node, every peer builds an
# identical one locally from the same server-decided race_ids, so there's
# nothing here that needs replication. Its point-defense fire (_find_target/
# _fire_missile_at/_fire_bullet_at below) is server-only decision-making
# layered on top of that same deterministic placement, same as take_damage()
# already was: the only externally-visible effect is the bullets it
# requests, which ride the already-networked BulletSpawner, so this node
# still needs no sync of its own despite now actively fighting back.
const DAMAGE_NUMBER_SCENE: PackedScene = preload("res://effects/damage_number.tscn")
# No destruction/respawn logic yet -- shootable is just player feedback for
# now (damage numbers), not a real objective. That's the future Flagship
# Assault mode.
var health: int = 5000
# Set by setup() -- which team this ship defends, so _find_target() can tell
# an intruder from a teammate passing through for cover.
var race_id: int = -1
var _missile_cooldown: float = 0.0
var _bullet_cooldown: float = 0.0
@onready var _sprite: Sprite2D = $Sprite2D
@onready var _hull_shape: CollisionShape2D = $HullBody/CollisionShape2D
@onready var _detector_shape: CollisionShape2D = $ShipDetector/CollisionShape2D
@onready var _world: Node = get_node("/root/World")
func _ready() -> void:
add_to_group("flagships") # lets hud/mini_map.gd draw these without a World reference, same as ships' own "ships" group
$ShipDetector.body_entered.connect(_on_ship_entered)
$ShipDetector.body_exited.connect(_on_ship_exited)
# Staggered so a formation's 3 ships don't all open fire in lockstep the
# instant an intruder crosses into range, and so the missile/bullet
# weapons don't always fire on the exact same frame as each other.
_missile_cooldown = randf() * GameConfig.flagship_fire_rate
_bullet_cooldown = randf() * GameConfig.flagship_bullet_fire_rate
# Server-only point-defense turret: scan for the nearest enemy ship within
# flagship_defense_radius and put a lethal, lead-aimed shot on it -- two
# independent weapons, each on its own cooldown, sharing one target scan per
# tick (only actually scans when at least one weapon is ready to fire, not
# every single tick). This is what keeps a team from parking on the enemy's
# spawn and farming respawns -- close enough to the flagship formation and
# it starts shooting back. Neither shot homes: each is aimed once at fire
# time, so a target that changes course afterward can still dodge it.
func _physics_process(delta: float) -> void:
if not multiplayer.is_server() or race_id < 0:
return
_missile_cooldown -= delta
_bullet_cooldown -= delta
if _missile_cooldown > 0.0 and _bullet_cooldown > 0.0:
return
var target := _find_target()
if target == null:
return
if _missile_cooldown <= 0.0:
_missile_cooldown = GameConfig.flagship_fire_rate
_fire_missile_at(target)
if _bullet_cooldown <= 0.0:
_bullet_cooldown = GameConfig.flagship_bullet_fire_rate
_fire_bullet_at(target)
# Mirrors BotAI._find_nearest_enemy()'s "ships" group scan (bots/bot_ai.gd)
# -- same info.is_empty() guard for the server's own never-loadout-picked
# ghost ship, same visible check for dead/respawning ships.
func _find_target() -> Ship:
var best: Ship = null
var best_dist := INF
var range_sq: float = GameConfig.flagship_defense_radius * GameConfig.flagship_defense_radius
for node in get_tree().get_nodes_in_group("ships"):
var candidate := node as Ship
if candidate == null or not candidate.visible:
continue
var info: Dictionary = PlayerRegistry.get_info(candidate.peer_id)
if info.is_empty() or info.get("race", -1) == race_id:
continue
var dist: float = global_position.distance_squared_to(candidate.global_position)
if dist > range_sq or dist >= best_dist:
continue
best_dist = dist
best = candidate
return best
# Single-step lead prediction (aim where the target will be when the shot
# arrives at the given travel speed, assuming it holds its current velocity)
# plus a small random miss offset (GameConfig.flagship_aim_jitter_px) so the
# turret reads as very good but not a laser-precise guarantee -- still flies
# straight once fired (no homing), so a target that breaks course afterward
# can dodge it same as before, but now a shot can also just miss outright.
func _lead_aim_dir(target: Ship, shot_speed: float) -> Vector2:
var to_target: Vector2 = target.global_position - global_position
var time_to_hit: float = to_target.length() / shot_speed
var aim_point: Vector2 = target.global_position + target.velocity * time_to_hit
var jitter := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * GameConfig.flagship_aim_jitter_px
return (aim_point + jitter - global_position).normalized()
# Both weapons fire from this ship's actual center, not a pushed-out muzzle
# point -- a capital ship's hull spans several hundred px, so spawning right
# at global_position puts the bullet's Area2D inside its own HullBody's
# collision polygon. Rather than offsetting the visible spawn point away from
# the ship (looks wrong -- shots should visibly come from the ship), bullet.gd
# is told this exact hull's name and skips colliding with it specifically,
# same as it already skips colliding with its own shooter ship via
# source_peer_id -- every other flagship (including a teammate's) stays
# hittable. damage is set far above ship_max_health in GameConfig, so any hit
# from either weapon is a kill regardless of the target's current health.
func _fire_missile_at(target: Ship) -> void:
var dir := _lead_aim_dir(target, GameConfig.flagship_missile_speed)
_world.request_bullet_spawn(0, global_position, dir * GameConfig.flagship_missile_speed,
GameConfig.flagship_missile_damage, race_id, "Missile", GameConfig.flagship_missile_visual_scale, name)
# The "regular bullets" half of the turret -- a faster-cadence stream of
# ordinary per-race rounds (the ship-fired "Fighter" sprite/scale, see
# BULLET_SPRITES in bullet.gd) alongside the slower, heavier Missile, so the
# formation reads as multiple weapons converging on an intruder rather than
# one gun. Deliberately slower than a player's own bullet_speed (see
# GameConfig.flagship_bullet_speed) so it's dodgeable at range, same as the
# missile.
func _fire_bullet_at(target: Ship) -> void:
var dir := _lead_aim_dir(target, GameConfig.flagship_bullet_speed)
_world.request_bullet_spawn(0, global_position, dir * GameConfig.flagship_bullet_speed,
GameConfig.flagship_bullet_damage, race_id, "Fighter", 1.0, name)
# Sizes the sprite and both collision shapes to the actual texture -- called
# once by world.gd right after instancing, since the texture/scale differ
# per faction. Both shapes are built from the sprite's actual opaque pixels
# rather than its full rectangular bounding box -- these hulls taper sharply
# toward the nose/tail (down to ~50% of the canvas width at the extremes), so
# a plain bounding-box rect left big transparent triangles at the corners
# still counted as "under the hull" for both the ghosting detector and bullet
# hits, well before a ship was visually anywhere near the flagship.
func setup(texture_path: String, ship_scale: float, ship_race_id: int) -> void:
race_id = ship_race_id
_sprite.texture = load(texture_path)
_sprite.scale = Vector2.ONE * ship_scale
var hull_points := _build_hull_points(_sprite.texture, ship_scale)
var hull_poly := ConvexPolygonShape2D.new()
hull_poly.points = hull_points
_hull_shape.shape = hull_poly
var detector_poly := ConvexPolygonShape2D.new()
detector_poly.points = hull_points
_detector_shape.shape = detector_poly
# Traces the texture's alpha channel into a convex hull, in the same
# centered/scaled space the Sprite2D itself renders in (centered=true, so the
# texture spans -size/2..+size/2 before scale).
func _build_hull_points(texture: Texture2D, ship_scale: float) -> PackedVector2Array:
var image := texture.get_image()
var bitmap := BitMap.new()
bitmap.create_from_image_alpha(image)
var polygons := bitmap.opaque_to_polygons(Rect2(Vector2.ZERO, image.get_size()), 2.0)
var points: PackedVector2Array = []
for polygon in polygons:
points.append_array(polygon)
var hull := Geometry2D.convex_hull(points)
var half_size := Vector2(image.get_size()) * 0.5
for i in hull.size():
hull[i] = (hull[i] - half_size) * ship_scale
return hull
# Reuses Ship's existing reference-counted ghost mechanism (see
# ship_movement.gd's set_ghosted(), originally built for explosion cover) so
# a ship flying under the flagship's hull just visually disappears, like it
# passed underneath -- ShipDetector is a plain Area2D (not on any physics
# layer ships collide against, see flagship.tscn), so this is purely visual
# and never affects movement.
func _on_ship_entered(body: Node) -> void:
if body.has_method("set_ghosted"):
body.set_ghosted(true)
func _on_ship_exited(body: Node) -> void:
if body.has_method("set_ghosted"):
body.set_ghosted(false)
# Called by bullet.gd on hit (HullBody sits on its own collision layer so
# ships pass through it -- see flagship.tscn -- but bullets still detect it).
# Only ever reached server-side, same as bullet.gd's other take_damage calls.
func take_damage(amount: int, source_peer_id: int = 0, hit_pos: Vector2 = Vector2.ZERO) -> void:
if not multiplayer.is_server():
return
health = max(0, health - amount)
_notify_damage_dealt(source_peer_id, amount, hit_pos if hit_pos != Vector2.ZERO else global_position)
# Same targeted-feedback pattern as ship_movement.gd's _notify_damage_dealt()
# -- only the shooter sees the damage number, not a match-wide broadcast.
func _notify_damage_dealt(source_peer_id: int, amount: int, pos: Vector2) -> void:
if source_peer_id <= 0:
return
if source_peer_id == multiplayer.get_unique_id():
_spawn_damage_number(pos, amount)
else:
_show_damage_number.rpc_id(source_peer_id, amount, pos)
@rpc("authority", "call_remote", "reliable")
func _show_damage_number(amount: int, pos: Vector2) -> void:
_spawn_damage_number(pos, amount)
func _spawn_damage_number(pos: Vector2, amount: int) -> void:
var number := DAMAGE_NUMBER_SCENE.instantiate()
number.global_position = pos
number.amount = amount
get_parent().add_child(number)
+1
View File
@@ -0,0 +1 @@
uid://vj6pwam4kfqo
+21
View File
@@ -0,0 +1,21 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://world/flagship.gd" id="1"]
[node name="Flagship" type="Node2D"]
script = ExtResource("1")
[node name="Sprite2D" type="Sprite2D" parent="."]
[node name="HullBody" type="StaticBody2D" parent="."]
collision_layer = 4
collision_mask = 0
[node name="CollisionShape2D" type="CollisionShape2D" parent="HullBody"]
[node name="ShipDetector" type="Area2D" parent="."]
collision_layer = 0
collision_mask = 2
monitorable = false
[node name="CollisionShape2D" type="CollisionShape2D" parent="ShipDetector"]
+1
View File
@@ -0,0 +1 @@
uid://bd7rdcie4yvqo
+91 -5
View File
@@ -3,6 +3,7 @@ extends Node2D
const MAP_SCENE: PackedScene = preload("res://world/maps/map_01.tscn")
const SHIP_SCENE: PackedScene = preload("res://ships/ship.tscn")
const BULLET_SCENE: PackedScene = preload("res://ships/bullet.tscn")
const FLAGSHIP_SCENE: PackedScene = preload("res://world/flagship.tscn")
@onready var _map_container: Node2D = $MapContainer
@onready var _players: Node2D = $Players
@@ -51,6 +52,7 @@ func decide_offered_races() -> Array:
# are assigned. Deferring runs it after the whole scene's _ready
# cascade finishes, once World is actually spawn-ready.
BotManager.call_deferred("on_match_start", self, _offered_race_ids)
call_deferred("_spawn_flagships", _offered_race_ids)
return _offered_race_ids
@@ -65,6 +67,68 @@ func request_offered_races() -> void:
@rpc("authority", "call_remote", "reliable")
func _deliver_offered_races(race_ids: Array) -> void:
_team_select.offer_races(race_ids)
call_deferred("_spawn_flagships", race_ids)
# Unnetworked capital-ship backdrop sitting on each team's spawn marker --
# every peer (server and every remote client alike) derives the same
# placement independently from the same offered race_ids (decided once by
# the server, RPC'd identically to every client above), so there's no need
# to replicate the sprites themselves. Their point-defense fire (see
# flagship.gd) is server-only decision-making on top of that same
# deterministic placement, same as take_damage() already was -- the only
# externally-visible effect (the bullets) rides the already-networked
# BulletSpawner, so the Flagship node itself still needs no sync of its own.
# Deferred at both call sites above since decide_offered_races() can run
# before the map (and its team_a_spawn/team_b_spawn markers) has been added
# to the tree -- same ordering hazard BotManager.on_match_start already
# works around.
func _spawn_flagships(race_ids: Array) -> void:
if race_ids.size() != 2:
return
_spawn_flagship(race_ids[0], "team_a_spawn")
_spawn_flagship(race_ids[1], "team_b_spawn")
func _spawn_flagship(race_id: int, spawn_group: String) -> void:
var matches: Array = TeamSelect.RACES.filter(func(r): return r.id == race_id)
var flagship_path: String = matches[0].get("flagship_path", "") if not matches.is_empty() else ""
if flagship_path.is_empty():
return
var markers := get_tree().get_nodes_in_group(spawn_group)
if markers.is_empty():
return
var base_pos: Vector2 = markers[0].global_position
var flagship_scale: float = matches[0].get("flagship_scale", 1.0)
# Loosely line-abreast, spread perpendicular to the two teams' spawn axis
# (both team_a_spawn/team_b_spawn markers sit at the same Y, see
# map_01.tscn) plus a large random per-ship position jitter on both axes
# and a random facing so the formation reads as ships that fell into a
# rough defensive line rather than 3 hulls snapped to a ruler and all
# facing the same way. Seeded off spawn_group (not the shared global RNG)
# so this stays deterministic -- every peer independently building this
# same formation (see this function's caller) must land on the exact
# same jittered positions, not just the same average spacing; team_a and
# team_b get different-looking jitter since their seeds differ, rather
# than mirroring each other.
var rng := RandomNumberGenerator.new()
rng.seed = spawn_group.hash()
for i in GameConfig.flagship_count_per_spawn:
var offset_index: float = i - (GameConfig.flagship_count_per_spawn - 1) / 2.0
var jitter := Vector2(
rng.randf_range(-GameConfig.flagship_spawn_jitter, GameConfig.flagship_spawn_jitter),
rng.randf_range(-GameConfig.flagship_spawn_jitter, GameConfig.flagship_spawn_jitter)
)
var flagship := FLAGSHIP_SCENE.instantiate()
# Named off the spawn group + formation slot, not spawn order, so this
# path is identical on every peer regardless of timing -- required for
# Flagship._show_damage_number's rpc_id() to address the same node on
# both ends (see flagship.gd).
flagship.name = "Flagship_%s_%d" % [spawn_group, i]
_map_container.add_child(flagship)
flagship.setup(flagship_path, flagship_scale, race_id)
flagship.global_position = base_pos + Vector2(0, offset_index * GameConfig.flagship_spawn_spacing) + jitter
flagship.rotation = deg_to_rad(rng.randf_range(-GameConfig.flagship_spawn_rotation_jitter_deg, GameConfig.flagship_spawn_rotation_jitter_deg))
# Spawns (or returns the already-spawned) ship for a given peer_id — real
@@ -96,13 +160,30 @@ func _spawn_ship(peer_id: int) -> Node:
# Called by a Ship node running on the server when its authoritative fire
# cooldown allows a shot. Replicated to every peer via the bullet spawner.
# damage is decided by the firing ship's role (see
# ship_movement.gd's _server_process_shoot()), not a flat global.
func request_bullet_spawn(source_peer_id: int, pos: Vector2, vel: Vector2, damage: int) -> void:
# cooldown allows a shot, or by a Flagship turret (flagship.gd) targeting an
# intruder. Replicated to every peer via the bullet spawner. damage is
# decided by the firing ship's role (see ship_movement.gd's
# _server_process_shoot()), not a flat global.
# sprite_race/sprite_role/visual_scale_mult pick a per-race/role textured
# sprite for a Flagship turret shot (see bullet.gd's BULLET_SPRITES) --
# sprite_role empty (the default) leaves a bullet as the plain placeholder
# Polygon2D every ship-fired shot has always used; only turret fire sets it.
# ignore_hull_name is a Flagship node name (see flagship.gd's _fire_at()) the
# bullet should never collide with -- a capital ship fires from its own
# center, which sits inside its own HullBody's collision shape, so without
# this every flagship shot would immediately hit itself. Real ship fire
# leaves this empty; ships already avoid the same problem with a muzzle
# offset in front of their (much smaller) hull instead.
func request_bullet_spawn(source_peer_id: int, pos: Vector2, vel: Vector2, damage: int,
sprite_race: int = -1, sprite_role: String = "", visual_scale_mult: float = 1.0,
ignore_hull_name: String = "") -> void:
if not multiplayer.is_server():
return
_bullet_spawner.spawn({"peer_id": source_peer_id, "pos": pos, "vel": vel, "damage": damage})
_bullet_spawner.spawn({
"peer_id": source_peer_id, "pos": pos, "vel": vel, "damage": damage,
"sprite_race": sprite_race, "sprite_role": sprite_role, "visual_scale_mult": visual_scale_mult,
"ignore_hull_name": ignore_hull_name,
})
func _spawn_bullet(data: Dictionary) -> Node:
@@ -111,6 +192,10 @@ func _spawn_bullet(data: Dictionary) -> Node:
bullet.velocity = data.vel
bullet.source_peer_id = data.peer_id
bullet.damage = data.damage
bullet.sprite_race = data.sprite_race
bullet.sprite_role = data.sprite_role
bullet.visual_scale_mult = data.visual_scale_mult
bullet.ignore_hull_name = data.ignore_hull_name
_play_shoot_sound(data.peer_id, data.pos)
return bullet
@@ -129,6 +214,7 @@ func _play_shoot_sound(source_peer_id: int, pos: Vector2) -> void:
player.stream = load(sound_path)
player.global_position = pos
player.max_distance = GameConfig.ship_shoot_sound_max_distance
player.bus = "SFX"
player.finished.connect(player.queue_free)
add_child(player)
player.play()
+3
View File
@@ -9,6 +9,7 @@
[ext_resource type="PackedScene" uid="uid://cminimap0001" path="res://hud/mini_map.tscn" id="8_mmap"]
[ext_resource type="PackedScene" path="res://hud/ping_display.tscn" id="9_ping"]
[ext_resource type="PackedScene" path="res://hud/energy_bar.tscn" id="10_ebar"]
[ext_resource type="PackedScene" uid="uid://csettingspanel1" path="res://menu/settings_panel.tscn" id="11_settings"]
[node name="World" type="Node2D" unique_id=1962020789]
script = ExtResource("4_world")
@@ -29,6 +30,8 @@ spawn_path = NodePath("../Bullets")
[node name="TeamSelect" parent="." instance=ExtResource("3_ts")]
[node name="SettingsPanel" parent="." instance=ExtResource("11_settings")]
[node name="ChatBox" parent="." instance=ExtResource("5_chat")]
[node name="PlayerList" parent="." instance=ExtResource("6_plist")]