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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 11:40:15 -04:00

365 lines
17 KiB
GDScript

extends Node2D
# Only one map exists today; structured as pick-from-list (same static-array
# convention as TeamSelect.RACES) so a second .tscn -- and later, per-mode or
# rotation selection driven by MatchManager -- is just appending here and
# changing _pick_map()'s selection logic. World's own load path never needs
# to change again.
const MAPS := [
{"id": "map_01", "name": "Sector Alpha", "scene_path": "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
@onready var _spawner: MultiplayerSpawner = $MultiplayerSpawner
@onready var _bullets: Node2D = $Bullets
@onready var _bullet_spawner: MultiplayerSpawner = $BulletSpawner
@onready var _team_select: TeamSelect = $TeamSelect
# Server-authoritative: the 2 races offered for this match, decided once and
# shared by every player — see TeamSelect._ready(), which either reads this
# directly (server's own instance) or requests it over RPC (remote clients).
var _offered_race_ids: Array = []
# Same "decide once on the server, deliver the concrete value to every
# client" pattern as _offered_race_ids, for _spawn_flagship()'s
# RandomNumberGenerator -- without this, every match landed on the exact
# same flagship layout forever, since a fresh RandomNumberGenerator defaults
# to a fixed seed (0) unless explicitly randomized, and the old code seeded
# it with only spawn_group.hash() (a compile-time-constant string, so always
# the same value). randi() here rides Godot 4's auto-seeded-at-boot global
# RNG (this project never calls randomize() itself) so it differs each
# server process/match, while every peer still converges on the same
# concrete _match_seed value via the RPC below, keeping formations
# consistent across peers within one match.
var _match_seed: int = 0
# Guards _spawn_flagships() against ever running twice on the same peer
# (doubling every formation to 6 hulls per side instead of 3) -- decide_
# offered_races() already guards its own call to this against re-firing
# from a second local caller, but _deliver_offered_races() (the RPC that
# hands this same call to a remote client) has no equivalent guard of its
# own, so a client somehow receiving that RPC twice (a duplicate/late
# delivery, or manually reconnecting mid-session) would otherwise spawn a
# second full set of Flagship nodes right on top of the first.
var _flagships_spawned: bool = false
func _ready() -> void:
var map: Node = load(_pick_map().scene_path).instantiate()
_map_container.add_child(map)
var background := map.get_node_or_null("Background") as Control
if background:
GameConfig.world_bounds = Rect2(background.position, background.size)
_build_tile_collisions(map)
_spawner.spawn_function = _spawn_ship
_bullet_spawner.spawn_function = _spawn_bullet
NetworkManager.peer_joined.connect(_on_peer_joined)
NetworkManager.peer_left.connect(_on_peer_left)
MatchManager.phase_changed.connect(_on_match_phase_changed)
if multiplayer.is_server():
spawn_peer(multiplayer.get_unique_id())
for peer_id in multiplayer.get_peers():
spawn_peer(peer_id)
MatchManager.start(self)
else:
MatchManager.request_match_state.rpc_id(1)
MatchStats.request_match_stats.rpc_id(1)
# Only one map exists today (see MAPS above); this is the seam a future
# map/mode-rotation selection hooks into without World's load path changing.
func _pick_map() -> Dictionary:
return MAPS[0]
# Picks the match's offered races once and caches them, so every caller
# (the server's own TeamSelect and every remote client's request) converges
# on the same pair regardless of call order. Also kicks off bot fill for
# those races — see bots/bot_manager.gd. Both factions are always offered
# (only order is randomized, for spawn-side variety).
func decide_offered_races() -> Array:
if _offered_race_ids.is_empty():
var ids: Array = TeamSelect.RACES.map(func(race): return race.id)
ids.shuffle()
_offered_race_ids = ids
_match_seed = randi()
# Deferred: this can run from TeamSelect._ready(), which (as World's
# child) fires before World's own _ready() — before _players/_spawner
# 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, _match_seed)
return _offered_race_ids
@rpc("any_peer", "call_remote", "reliable")
func request_offered_races() -> void:
if not multiplayer.is_server():
return
var sender_id := multiplayer.get_remote_sender_id()
var race_ids := decide_offered_races()
_deliver_offered_races.rpc_id(sender_id, race_ids, _match_seed)
@rpc("authority", "call_remote", "reliable")
func _deliver_offered_races(race_ids: Array, match_seed: int) -> void:
_team_select.offer_races(race_ids)
call_deferred("_spawn_flagships", race_ids, match_seed)
# 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 and match_seed
# (both 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, match_seed: int) -> void:
if race_ids.size() != 2 or _flagships_spawned:
return
_flagships_spawned = true
_spawn_flagship(race_ids[0], "team_a_spawn", match_seed)
_spawn_flagship(race_ids[1], "team_b_spawn", match_seed)
func _spawn_flagship(race_id: int, spawn_group: String, _match_seed: int) -> 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)
# Optional per-formation-slot texture/scale override, cycled by slot index --
# lets a faction's 3-ship formation mix hull types instead of 3 identical
# copies of flagship_path. Races that don't define this (ORC, so far) fall
# through to flagship_path/flagship_scale for every slot, unchanged.
var flagship_variants: Array = matches[0].get("flagship_paths", []) if not matches.is_empty() else []
# Line-abreast, evenly spaced perpendicular to the two teams' spawn axis
# (both team_a_spawn/team_b_spawn markers sit at the same Y, see
# map_01.tscn) -- a clean vertical line at fixed flagship_spawn_spacing
# intervals, every hull facing straight up (rotation 0, this project's
# nose-up convention -- Vector2.UP.rotated(rotation) in ship_movement.gd),
# since flagship.gd's play_creep_in() now brings the whole formation in
# together as one rigid group (see its own comment); a scattered/jittered
# formation (position OR facing) doesn't read as "a disciplined fleet"
# once the ships are actually moving in sync toward it.
for i in GameConfig.flagship_count_per_spawn:
var offset_index: float = i - (GameConfig.flagship_count_per_spawn - 1) / 2.0
var pos: Vector2 = base_pos + Vector2(0, offset_index * GameConfig.flagship_spawn_spacing)
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)
var variant: Dictionary = flagship_variants[i % flagship_variants.size()] if not flagship_variants.is_empty() else {}
flagship.setup(variant.get("path", flagship_path), variant.get("scale", flagship_scale), race_id)
flagship.global_position = pos
flagship.rotation = 0.0
# _on_match_phase_changed() only reaches flagships that already exist
# at the moment PRE_MATCH's phase_changed fires -- a flagship built
# just after that (e.g. this peer's own _spawn_flagships() deferred
# call landing a beat later than the phase RPC, more likely for a
# remote client racing its own network round trip) would otherwise
# just sit at its final `pos` with no tween ever applied to it. Catch
# that case here, once, right as the node is placed. Guarded on
# get_remaining_seconds() > 0, not just phase == PRE_MATCH -- phase
# defaults to PRE_MATCH from its very declaration, true even before
# MatchManager's own match loop has genuinely started that phase for
# the first time (phase_started_at_msec/phase_duration_sec both still
# 0), which made this fire with duration 0.0 on every fresh server
# boot's first-ever flagship spawn. A zero-duration tween doesn't
# resolve synchronously -- it needs one process frame -- so the real
# 30s tween moments later (from the actual phase broadcast) read
# global_position while it was still sitting at that bogus tween's
# intermediate (still off in deep space) value, corrupting the
# target it computed to tween back to.
var remaining := MatchManager.get_remaining_seconds()
if MatchManager.phase == MatchManager.Phase.PRE_MATCH and remaining > 0.0:
flagship.play_creep_in(remaining)
# Called by MatchManager (server-only) at the top of every PRE_MATCH phase --
# hides/freezes every currently-flying ship (see ship_movement.gd's
# server_hold_for_match_start()) rather than just gating fresh spawns, so a
# ship already in flight when a new match loop rolls over gets hidden too.
func hold_all_ships_for_pre_match() -> void:
for child in _players.get_children():
if child is Ship:
child.server_hold_for_match_start()
# Called by MatchManager (server-only) once IN_PROGRESS begins -- releases
# every ship still held from the PRE_MATCH phase (a no-op for any ship that
# joined/respawned after IN_PROGRESS already started, see
# ship_movement.gd's server_release_from_hold()).
func release_held_ships() -> void:
for child in _players.get_children():
if child is Ship:
child.server_release_from_hold()
# MatchManager.phase_changed fires identically on every peer (the server via
# its own _begin_phase RPC's call_local, remote clients via that same RPC
# delivered over the wire, and a late joiner via _deliver_match_state) -- so
# this is the one hook every peer needs for its own local flagship tween,
# rather than MatchManager._run_pre_match() (server-only code, never runs on
# a remote client at all) calling into World directly like it used to.
func _on_match_phase_changed(phase: int, duration: float) -> void:
if phase == MatchManager.Phase.PRE_MATCH:
play_flagship_creep_in(duration)
# Purely cosmetic, see Flagship.play_creep_in().
func play_flagship_creep_in(duration: float) -> void:
for child in _map_container.get_children():
if child is Flagship:
child.play_creep_in(duration)
# Spawns (or returns the already-spawned) ship for a given peer_id — real
# (positive) or bot (negative, see bots/bot_manager.gd). Replicates to every
# client automatically via MultiplayerSpawner.
func spawn_peer(peer_id: int) -> Node:
if _players.has_node(str(peer_id)):
return _players.get_node(str(peer_id))
return _spawner.spawn(peer_id)
func despawn_peer(peer_id: int) -> void:
var ship := _players.get_node_or_null(str(peer_id))
if ship:
# Stops _server_tick() from emitting any further _receive_state
# broadcasts for this ship the instant despawn is requested — without
# this, a state packet already in flight on the unreliable channel can
# still reach a client after that client has already processed this
# node's despawn replication, causing a "Node not found" RPC error.
ship.set_physics_process(false)
ship.queue_free()
func _spawn_ship(peer_id: int) -> Node:
var ship := SHIP_SCENE.instantiate()
ship.name = str(peer_id)
ship.peer_id = peer_id
return ship
# Called by a Ship node running on the server when its authoritative fire
# 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,
"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:
var bullet := BULLET_SCENE.instantiate()
bullet.global_position = data.pos
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
# Runs once per peer (this spawn_function itself is what MultiplayerSpawner
# replicates, so every client fires this locally, not just the server) —
# each peer's own ship's Camera2D is that peer's audio listener, so
# AudioStreamPlayer2D's max_distance naturally limits who hears the shot to
# ships within GameConfig.ship_shoot_sound_max_distance, with no need to
# track which peers are "in range" server-side.
func _play_shoot_sound(source_peer_id: int, pos: Vector2) -> void:
var sound_path: String = PlayerRegistry.get_info(source_peer_id).get("ship_sound_path", "")
if sound_path.is_empty():
return
var player := AudioStreamPlayer2D.new()
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()
func _on_peer_joined(peer_id: int) -> void:
if multiplayer.is_server():
spawn_peer(peer_id)
func _on_peer_left(peer_id: int) -> void:
if not multiplayer.is_server():
return
despawn_peer(peer_id)
# Tiles have no collision shapes of their own, so spawn one StaticBody2D per
# occupied cell. Asteroids get round hitboxes to roughly match their sprites.
# Walls only bounce the ship; asteroids also deal impact damage.
func _build_tile_collisions(map: Node) -> void:
for layer in map.find_children("*", "TileMapLayer", true, false):
var is_asteroid := layer.name == "Asteroids"
var group := "environment_hazard" if is_asteroid else "environment_wall"
_add_layer_colliders(layer, is_asteroid, group)
func _add_layer_colliders(layer: TileMapLayer, use_circle: bool, group: String) -> void:
var tile_size := Vector2(layer.tile_set.tile_size)
for cell in layer.get_used_cells():
var body := StaticBody2D.new()
body.position = layer.map_to_local(cell)
body.add_to_group(group)
var shape := CollisionShape2D.new()
if use_circle:
var circle := CircleShape2D.new()
circle.radius = min(tile_size.x, tile_size.y) * 0.5
shape.shape = circle
else:
var rect := RectangleShape2D.new()
rect.size = tile_size
shape.shape = rect
body.add_child(shape)
layer.add_child(body)