Files
client/spacewar/autoload/game_config.gd
T
anekdotin a94bccc57b Add capital ships, and settings screen with smooth-motion fixes
Capital ships & support art:
- New Flagship capital ships (world/flagship.gd) spawn a small
  point-defense formation at each team's spawn marker, independently
  targeting the nearest enemy in range with a slow unmissable-looking
  missile and a faster bullet stream -- keeps a team from parking on
  the enemy spawn and farming respawns.
- Per-race/role bullet sprites for turret fire (assets/images/effects/
  bullets/), flagship art for both factions, minimap now draws
  flagship blips, bot_ai awareness tuned to notice them.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 20:02:31 -04:00

402 lines
20 KiB
GDScript

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
var ship_rotation_speed: float = 1.0
# Continuing to hold left/right past this many seconds ramps to
# ship_rotation_speed_held (double the tap rate) — a quick tap stays precise,
# a held turn goes faster. See ship_movement.gd's _simulate_step().
var ship_rotation_ramp_delay: float = 0.15
var ship_rotation_speed_held: float = 2.0
# Shooting — damage/energy cost/bullet count all scale per-role (Fighter/
# Gunner/Tank, see menu/team_select.gd's RACES "role" field); Gunner and Tank
# fire their bullets in a side-by-side pair instead of Fighter's single shot
# (see ship_movement.gd's _server_process_shoot()).
var ship_fire_rate: float = 0.15
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 = 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
# AudioStreamPlayer2D expects, so only ships within this radius of the shot
# actually hear it (see world.gd's _play_shoot_sound()).
var ship_shoot_sound_max_distance: float = 500.0
# Health & respawn
var ship_max_health: int = 100
var ship_respawn_delay: float = 3.0
var ship_invincibility_time: float = 2.0
# Energy (see ships/ship_movement.gd) — max energy scales by ship role
# (Fighter/Gunner/Tank, see menu/team_select.gd's RACES ship "role" field);
# regen is a flat rate regardless of role, so bigger ships take longer to
# top off from empty than smaller ones.
var ship_max_energy_by_role: Dictionary = {"Fighter": 100.0, "Gunner": 150.0, "Tank": 250.0}
var ship_bullet_energy_cost_by_role: Dictionary = {"Fighter": 75.0, "Gunner": 25.0, "Tank": 50.0}
var ship_energy_regen_rate: float = 100.0 / 2.5 # 100 energy per 2.5s
# Environment collision (asteroids only) — bounce-back and impact damage scale with impact speed
var ship_bounce_restitution: float = 0.45
var ship_collision_damage_scale: float = 0.064
# One shared knob to resize every ship's in-world sprite at once, applied on
# top of each ship's own per-role "scale" in team_select.gd's RACES (see
# ship_movement.gd's _init_player()) — doesn't touch those individually
# balanced per-role values, just scales the end result uniformly.
var ship_scale_factor: float = 0.75
# How much of the sprite's own texture bounds its collision capsule covers
# (see ship_movement.gd's _update_collision_shape()) — under 1.0 to stay
# inside the hull's soft/anti-aliased edge instead of the full texture rect.
var ship_hitbox_scale: float = 0.85
# Explosion VFX (effects/explosion.gd) — one shared knob to resize every
# explosion in the game at once; explosion_fps controls playback speed of the
# 12-frame animation (see assets/images/effects/explosion/). Each frame is
# held for 5 ticks (explosion.tscn's SpriteFrames "duration": 5.0 per frame,
# so mostly-transparent/dark frames like the opening spark and the frame 6
# ember cloud have time to register instead of reading as a flicker-to-black
# gap between the brighter frames) — total time is (12 * 5) / explosion_fps.
var explosion_scale: float = 0.75
var explosion_fps: float = 24.0 # 60 ticks / 24.0 fps = 2.5s total
# Damage-number VFX (effects/damage_number.gd) — floating "(amount)" combat
# text shown only to the attacker when their shot lands, not broadcast to
# every peer like the kill feed (see ship_movement.gd's
# _notify_damage_dealt()/_show_damage_number()).
var damage_number_rise_distance: float = 36.0
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 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 = 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
# (keeps it clear of the engine flame and doesn't read as a health-bar-like
# element stacked straight below).
var ship_nameplate_offset: Vector2 = Vector2(26, 24)
var ship_nameplate_font_size: int = 18
# One shared knob for the local player's view distance -- Camera2D.zoom below
# 1.0 shows more world per screen pixel (zoomed out), above 1.0 shows less
# (zoomed in). Applied once in ship_movement.gd's _ready() to the owner's own
# Camera2D only; doesn't affect any other peer's view.
var camera_zoom: float = 0.85
# Locally-remembered callsign, pre-fills the name field on the menu.
# Race/ship/speed are per-peer now — see PlayerRegistry.
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)
# 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.
var chat_focused: bool = false
# True while TeamSelect is reopened mid-match (pause menu "SELECT TEAM") —
# same purpose as chat_focused, so clicking ship rows doesn't also fly the
# ship underneath. Not needed for the initial pre-spawn pick since the ship
# has no physics processing yet at that point.
var team_select_focused: bool = false
# 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
# Personality trait ranges (bots/bot_personality.gd) — each bot rolls all 5
# traits 0..1 independently once at spawn; these ranges convert that roll
# into the concrete numbers BotAI actually flies with.
var bot_stop_distance_min: float = 70.0 # aggression 1: dogfights up close
var bot_stop_distance_max: float = 260.0 # aggression 0: hangs back at range
var bot_retreat_health_frac_min: float = 0.10 # caution 0: fights to the death
var bot_retreat_health_frac_max: float = 0.65 # caution 1: breaks off early
var bot_aim_tolerance_best_deg: float = 3.0 # accuracy 1: pinpoint gate
var bot_aim_tolerance_worst_deg: float = 14.0 # accuracy 0: sloppy gate
var bot_aim_jitter_max_px: float = 220.0 # accuracy 0: aims this far off the real spot
var bot_reaction_update_best_sec: float = 0.05 # reaction 1: re-aims almost every tick
var bot_reaction_update_worst_sec: float = 0.6 # reaction 0: laggy, stale tracking
var bot_awareness_range_min: float = 900.0 # awareness 0: near-sighted
var bot_awareness_range_max: float = 16000.0 # awareness 1: sees across the whole map
# map_01 spawns the two teams ~10608 units apart (see world/maps/map_01.tscn),
# on a ~14016x6000 map (diagonal ~15247) — the max must clear that or every
# bot spawns permanently unable to detect the enemy team and never advances.