Files
client/spacewar/ships/ship_movement.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

732 lines
30 KiB
GDScript

extends CharacterBody2D
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
# Bots use a negative peer_id (see bots/bot_manager.gd) so they ride every
# piece of existing networked-ship infrastructure — spawning, loadout,
# position/health/visibility sync, bullet attribution — for free, all of
# which is already keyed off peer_id and none of which cares whether it came
# from ENet or from BotManager. Set by BotManager right after spawning, only
# ever non-null on the server (bots have no owner/remote client).
var bot_ai: BotAI = null
var _fire_cooldown: float = 0.0
var _turn_hold_time: float = 0.0
var health: int = 0
var energy: float = 0.0
var max_energy: float = 100.0
var _role: String = "Fighter"
var _dead: bool = false
# Last-hit attribution for the kill feed (see autoload/kill_feed_manager.gd).
# 0 means "no attacking ship" (hazard/wall collision damage from
# _apply_collision_bounce()) -- real peer ids are never 0 (server is 1,
# clients > 1, bots negative, see bots/bot_manager.gd).
var _last_damage_source: int = 0
var _invincible: bool = false
var _invincible_timer: float = 0.0
var _ship_speed_factor: float = 1.0
# Idle (no-flame) vs engine-flame sprite swap — see _update_thrust_visual().
# _flame_texture stays null for ships with no flame_path, which just keeps
# _update_thrust_visual() from ever touching the sprite.
var _idle_texture: Texture2D
var _flame_texture: Texture2D
var _thrusting: bool = false
# Reference-counted so overlapping explosions (or a ship lingering across two
# blasts) can't have one explosion's exit re-reveal a ship another explosion
# is still covering -- see effects/explosion.gd's GhostArea.
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. 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.
const MAX_PENDING_INPUTS := 180
var _input_tick: int = 0
var _pending_inputs: Array = []
# Server-side: queued inputs received from the owning peer, plus the last
# processed one (reused when the queue runs dry, e.g. a dropped packet).
var _remote_inputs: Array = []
var _last_remote_input: Dictionary = {"up": false, "down": false, "left": false, "right": false, "shoot": false}
var _last_processed_tick: int = 0
# Remote (non-owner, non-server) clients only ever interpolate toward the
# last couple of authoritative snapshots broadcast by the server.
var _interp_from_pos: Vector2
var _interp_from_rot: float = 0.0
var _interp_to_pos: Vector2
var _interp_to_rot: float = 0.0
var _interp_elapsed: float = 0.0
func _ready() -> void:
_fixed_delta = 1.0 / Engine.physics_ticks_per_second
_is_owner = peer_id == multiplayer.get_unique_id()
add_to_group("ships") # lets BotAI find nearby targets without a World reference
# top_level (set in ship.tscn) keeps this from inheriting the ship's own
# rotation, so the name always reads upright regardless of facing.
_nameplate = get_node_or_null("NamePlate") as Label
if _nameplate:
_nameplate.visible = false
_nameplate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_nameplate.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_nameplate.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_nameplate.add_theme_font_size_override("font_size", GameConfig.ship_nameplate_font_size)
_nameplate.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_nameplate.add_theme_constant_override("outline_size", 3)
# Only ever claim the camera for the local owner. Whichever Camera2D
# enters the SceneTree first auto-claims the viewport's active camera
# regardless of its own `current` value (Godot's built-in "first camera
# wins" fallback) — with MultiplayerSpawner catch-up replication, that's
# often another peer's ship (e.g. the always-present server-ghost ship
# for peer 1), not ours. Setting `current = true` via property/deferred
# assignment does not reliably dethrone that auto-claimed camera; calling
# 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:
_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)
if info.is_empty():
visible = false
set_physics_process(false)
else:
_init_player(info)
func _on_loadout_updated(updated_peer_id: int) -> void:
if updated_peer_id == peer_id:
_init_player(PlayerRegistry.get_info(peer_id))
elif updated_peer_id == PlayerRegistry.get_local_id():
# The local viewer's own race changed (mid-match team swap) -- every
# other ship's teammate/enemy color depends on that comparison too,
# not just this ship's own info.
_refresh_nameplate()
func _init_player(info: Dictionary) -> void:
var sprite := get_node_or_null("Sprite2D") as Sprite2D
var ship_path: String = info.get("ship_path", "")
if sprite and not ship_path.is_empty():
_idle_texture = load(ship_path)
var flame_path: String = info.get("ship_flame_path", "")
_flame_texture = load(flame_path) if not flame_path.is_empty() else null
sprite.texture = _idle_texture
_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")
max_energy = GameConfig.ship_max_energy_by_role.get(_role, 100.0)
_refresh_nameplate()
set_physics_process(true)
if multiplayer.is_server():
_server_respawn()
# Bottom-right-of-ship name label — text/color only change on a loadout
# change (this ship's or the local viewer's own, see _on_loadout_updated()),
# not every frame; position/visibility instead track the ship every physics
# tick (see _physics_process()) since those change constantly.
func _refresh_nameplate() -> void:
if _nameplate == null:
return
var info := PlayerRegistry.get_info(peer_id)
var name_text: String = info.get("name", "")
if peer_id < 0:
name_text += " (b)" # bots -- see bots/bot_manager.gd's negative peer_id trick
_nameplate.text = name_text
var local_race: int = PlayerRegistry.get_info(PlayerRegistry.get_local_id()).get("race", 0)
var is_teammate: bool = info.get("race", 0) == local_race
_nameplate.add_theme_color_override("font_color", GameConfig.team_color if is_teammate else GameConfig.enemy_color)
# Sizes the hitbox to the actual sprite being flown, in the same scale-space
# as the visual (see call site above) — every ship previously shared one
# fixed-radius CircleShape2D (Godot's 10px default) regardless of its actual
# on-screen size, which is a fraction of any ship's real silhouette (ships
# render 40-110px tall depending on role). That left the nose and outer
# edges of every ship un-collidable, since only a tiny circle near the
# center actually registered hits. A CapsuleShape2D tracks a ship's own
# rotation for free (it's a child of this CharacterBody2D) and its
# height-along-local-Y shape matches this project's nose-up-at-rotation-0
# sprite convention (ship_movement.gd's Vector2.UP-based movement) far
# better than a circle for the elongated Fighter/Gunner hulls. Shrunk by
# GameConfig.ship_hitbox_scale off the full texture bounds since the source
# art has some anti-aliased/soft-edge margin outside the solid hull.
func _update_collision_shape(texture: Texture2D, total_scale: float) -> void:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision == null:
return
var capsule := collision.shape as CapsuleShape2D
if capsule == null:
return
var size := texture.get_size() * total_scale * GameConfig.ship_hitbox_scale
capsule.radius = size.x / 2.0
capsule.height = maxf(size.y, size.x)
func _physics_process(delta: float) -> void:
if _dead:
return
# INVINCIBILITY BLINK — purely visual, runs once per real tick regardless
# of role (never part of prediction replay).
if _invincible:
_invincible_timer -= delta
modulate.a = 0.3 if int(_invincible_timer * 8) % 2 == 0 else 1.0
if _invincible_timer <= 0.0:
_invincible = false
modulate.a = 1.0
if multiplayer.is_server():
_server_tick(delta)
elif _is_owner:
_owner_tick(delta)
else:
_remote_tick(delta)
if _nameplate:
_nameplate.visible = visible
_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
# thrust key is *currently held* — not current speed, since this game's
# movement has no drag and a released ship keeps drifting at speed, which
# would otherwise leave the flame on long after letting off the gas. 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 in _receive_state so every other peer's view of a ship
# (which never sees raw input, only replicated state) stays in sync too.
func _set_thrust_sprite(active: bool) -> void:
if _flame_texture == null or active == _thrusting:
return
_thrusting = active
var sprite := get_node_or_null("Sprite2D") as Sprite2D
if sprite:
sprite.texture = _flame_texture if active else _idle_texture
# Purely local/visual -- called by any Explosion whose GhostArea this ship's
# (layer-2) hull overlaps, on every peer independently. Never touches the
# networked `visible` property (that's server-authoritative via
# _receive_state/_apply_death/_apply_respawn), just the sprite underneath it.
func set_ghosted(active: bool) -> void:
_ghost_count = maxi(0, _ghost_count + (1 if active else -1))
var sprite := get_node_or_null("Sprite2D") as Sprite2D
if sprite:
sprite.visible = _ghost_count == 0
# ── Owner client: predict locally, send input to server ─────────────────────
func _owner_tick(delta: float) -> void:
var input := _sample_local_input()
_set_thrust_sprite(input.get("up", false) or input.get("down", false))
_input_tick += 1
_simulate_step(delta, input, true)
_pending_inputs.append({"tick": _input_tick, "input": input})
if _pending_inputs.size() > MAX_PENDING_INPUTS:
_pending_inputs.pop_front()
submit_input.rpc_id(1, _input_tick, input)
@rpc("authority", "call_remote", "unreliable")
func _receive_state(tick: int, pos: Vector2, rot: float, vel: Vector2, hp: int, nrg: float, shown: bool, thrusting: bool) -> void:
# Piggyback current health/energy AND visibility on every state broadcast
# (not just the one-shot _apply_health/_apply_death/_apply_respawn RPCs)
# so a peer that connects after another ship already spawned/respawned
# still converges within a tick, instead of being stuck with that ship's
# defaults (health=0, invisible) forever — those one-shot RPCs only ever
# reach peers that were already connected at the moment they fired.
var changed := health != hp or absf(energy - nrg) > 0.01
health = hp
energy = nrg
if changed:
_update_hud()
visible = shown
if _is_owner:
_reconcile(tick, pos, rot, vel)
else:
_interp_from_pos = global_position
_interp_from_rot = rotation
_interp_to_pos = pos
_interp_to_rot = rot
_interp_elapsed = 0.0
velocity = vel
_set_thrust_sprite(thrusting)
func _reconcile(server_tick: int, server_pos: Vector2, server_rot: float, server_vel: Vector2) -> void:
var replay_index := -1
for i in _pending_inputs.size():
if _pending_inputs[i].tick == server_tick:
replay_index = i
break
global_position = server_pos
rotation = server_rot
velocity = server_vel
if replay_index == -1:
_pending_inputs.clear()
return
_pending_inputs = _pending_inputs.slice(replay_index + 1)
for entry in _pending_inputs:
_simulate_step(_fixed_delta, entry.input, false)
# ── Server: simulate every ship, broadcast authoritative state ──────────────
func _server_tick(delta: float) -> void:
var input: Dictionary
if peer_id < 0:
input = bot_ai.compute_input(delta) if bot_ai else {}
elif _is_owner:
input = _sample_local_input()
else:
if not _remote_inputs.is_empty():
var entry: Dictionary = _remote_inputs.pop_front()
_last_remote_input = entry.input
_last_processed_tick = entry.tick
input = _last_remote_input
# Updates this server's own local view of the sprite too (matters for a
# listen-server that's also rendering, e.g. hosting-and-playing) — for
# every other connected peer, _receive_state below is what carries this.
var thrusting: bool = input.get("up", false) or input.get("down", false)
_set_thrust_sprite(thrusting)
_simulate_step(delta, input, true)
energy = minf(max_energy, energy + GameConfig.ship_energy_regen_rate * delta)
_server_process_shoot(delta, input)
_receive_state.rpc(_last_processed_tick, global_position, rotation, velocity, health, energy, visible, thrusting)
@rpc("any_peer", "call_remote", "unreliable")
func submit_input(tick: int, input: Dictionary) -> void:
if not multiplayer.is_server():
return
if multiplayer.get_remote_sender_id() != peer_id:
return
_remote_inputs.append({"tick": tick, "input": input})
if _remote_inputs.size() > MAX_PENDING_INPUTS:
_remote_inputs.pop_front()
# ── Remote client: no local simulation, just interpolate toward last snapshot ─
func _remote_tick(delta: float) -> void:
_interp_elapsed += delta
var t: float = clamp(_interp_elapsed / _fixed_delta, 0.0, 1.0)
global_position = _interp_from_pos.lerp(_interp_to_pos, t)
rotation = lerp_angle(_interp_from_rot, _interp_to_rot, t)
# ── Shared movement step (deterministic — replayed during reconciliation) ───
func _sample_local_input() -> Dictionary:
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"),
"down": Input.is_action_pressed("move_down"),
"left": Input.is_action_pressed("move_left"),
"right": Input.is_action_pressed("move_right"),
"shoot": Input.is_action_pressed("shoot"),
}
# apply_side_effects gates anything non-deterministic-safe (e.g. dealing
# damage) so replaying buffered inputs during reconciliation can't double it.
func _simulate_step(delta: float, input: Dictionary, apply_side_effects: bool) -> void:
var turning_right: bool = input.get("right", false)
var turning_left: bool = input.get("left", false)
# _turn_hold_time only advances on a fresh (non-replayed) step, same
# guard as damage above — during reconciliation replay it just stays
# frozen at its last real value instead of double-counting.
if apply_side_effects:
if turning_right or turning_left:
_turn_hold_time += delta
else:
_turn_hold_time = 0.0
var turn_speed := GameConfig.ship_rotation_speed
if _turn_hold_time >= GameConfig.ship_rotation_ramp_delay:
turn_speed = GameConfig.ship_rotation_speed_held
if turning_right:
rotation += turn_speed * delta
if turning_left:
rotation -= turn_speed * delta
var thrust := GameConfig.ship_thrust * _ship_speed_factor
var max_speed := GameConfig.ship_max_speed * _ship_speed_factor
if input.get("up", false):
velocity += Vector2.UP.rotated(rotation) * thrust * delta
if input.get("down", false):
velocity += Vector2.DOWN.rotated(rotation) * thrust * delta
velocity = velocity.limit_length(max_speed)
var velocity_before_move := velocity
move_and_slide()
_apply_collision_bounce(velocity_before_move, apply_side_effects)
var bounds = GameConfig.world_bounds
if global_position.x < bounds.position.x or global_position.x > bounds.end.x:
velocity.x = 0
global_position.x = clamp(global_position.x, bounds.position.x, bounds.end.x)
if global_position.y < bounds.position.y or global_position.y > bounds.end.y:
velocity.y = 0
global_position.y = clamp(global_position.y, bounds.position.y, bounds.end.y)
func _apply_collision_bounce(previous_velocity: Vector2, apply_side_effects: bool) -> void:
for i in get_slide_collision_count():
var collision := get_slide_collision(i)
var collider := collision.get_collider() as Node
if collider == null:
continue
var is_hazard: bool = collider.is_in_group("environment_hazard")
var is_wall: bool = collider.is_in_group("environment_wall")
if not (is_hazard or is_wall):
continue
var normal := collision.get_normal()
var impact_speed := -previous_velocity.dot(normal)
if impact_speed <= 0.0:
continue
velocity = previous_velocity.bounce(normal) * GameConfig.ship_bounce_restitution
if is_hazard and apply_side_effects and multiplayer.is_server():
var damage := int(impact_speed * GameConfig.ship_collision_damage_scale)
if damage > 0:
take_damage(damage)
# Server-only: decides whether this tick's queued input fires a shot, using
# its own authoritative cooldown (never trust a client's fire rate). Requests
# World spawn the bullet(s), which replicate to every peer. Damage/energy
# cost/bullet count all scale by role (Fighter fires 1 bullet, Gunner/Tank
# fire a side-by-side pair — see GameConfig.ship_bullet_count_by_role).
func _server_process_shoot(delta: float, input: Dictionary) -> void:
_fire_cooldown -= delta
var cost: float = GameConfig.ship_bullet_energy_cost_by_role.get(_role, 75.0)
if input.get("shoot", false) and _fire_cooldown <= 0.0 and energy >= cost:
_fire_cooldown = GameConfig.ship_fire_rate
energy -= cost
var world := get_node_or_null("/root/World")
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
# Constant muzzle velocity regardless of the shooter's own speed --
# used to add `velocity` on top, which made bullets faster fired
# from a ship moving forward and slower (even reversed-looking)
# fired while drifting backward.
var bullet_vel := forward * GameConfig.bullet_speed
var spacing := GameConfig.bullet_side_spacing
var start := -spacing * (count - 1) / 2.0
for i in count:
world.request_bullet_spawn(peer_id, base_pos + right * (start + i * spacing), bullet_vel, damage)
# ── Health / death — server-authoritative, broadcast to every peer ──────────
func take_damage(amount: int, source_peer_id: int = 0, hit_pos: Vector2 = Vector2.ZERO) -> void:
if not multiplayer.is_server():
return
if _invincible or _dead:
return
_last_damage_source = source_peer_id
health = max(0, health - amount)
_apply_health.rpc(health)
_notify_damage_dealt(source_peer_id, amount, hit_pos if hit_pos != Vector2.ZERO else global_position)
if health == 0:
_die()
@rpc("authority", "call_remote", "reliable")
func _apply_health(new_health: int) -> void:
health = new_health
_update_hud()
# Floating damage-number feedback for the attacker only -- unlike the kill
# feed (autoload/kill_feed_manager.gd), this is per-shooter combat info, not
# match-wide, so it's a targeted rpc_id() rather than a broadcast .rpc().
# Bots (negative peer_id) and hazard/wall damage (peer_id 0, see
# _apply_collision_bounce()) have no client to show this to. The server's own
# peer id (when hosting-and-playing) takes the direct local-call path instead
# of rpc_id()'ing itself, same pattern as world.gd's decide_offered_races().
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)
func _die() -> void:
if not multiplayer.is_server():
return
KillFeedManager.report_kill(peer_id, _last_damage_source)
_apply_death.rpc()
await get_tree().create_timer(GameConfig.ship_respawn_delay).timeout
_server_respawn()
@rpc("authority", "call_local", "reliable")
func _apply_death() -> void:
_dead = true
visible = false
velocity = Vector2.ZERO
_spawn_explosion()
# Otherwise the invisible corpse -- still fully solid for the whole
# ship_respawn_delay window -- keeps blocking bullets fired "through" the
# explosion at its position, even though live ships already ghost through
# it visually (see effects/explosion.gd's GhostArea).
_set_collision_enabled(false)
# _physics_process()'s nameplate sync never runs while _dead, so hide it
# here explicitly instead of leaving it stuck at the death position.
if _nameplate:
_nameplate.visible = false
# Deferred since _apply_death/_apply_respawn can run from inside a bullet's
# body_entered physics callback -- same "Can't change state while flushing
# queries" constraint _spawn_explosion's call_deferred works around above.
func _set_collision_enabled(enabled: bool) -> void:
var collision := get_node_or_null("CollisionShape2D") as CollisionShape2D
if collision:
collision.set_deferred("disabled", not enabled)
# Purely cosmetic, so it's spawned locally by every peer off the already
# broadcast _apply_death RPC above rather than needing its own networked
# spawner (contrast bullets/ships, which are gameplay-authoritative).
# call_deferred here since _apply_death (and this) can run from inside a
# bullet's body_entered physics callback -- adding a node with its own
# collider (the explosion's GhostArea) synchronously mid-physics-step trips
# Godot's "Can't change state while flushing queries" error.
func _spawn_explosion() -> void:
var explosion := EXPLOSION_SCENE.instantiate()
explosion.global_position = global_position
get_parent().call_deferred("add_child", explosion)
func _server_respawn() -> void:
_apply_respawn.rpc(_get_spawn_position())
@rpc("authority", "call_local", "reliable")
func _apply_respawn(pos: Vector2) -> void:
global_position = pos
velocity = Vector2.ZERO
rotation = 0.0
health = GameConfig.ship_max_health
energy = max_energy
_dead = false
_last_damage_source = 0
visible = true
_set_collision_enabled(true)
_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
_interp_to_rot = rotation
_interp_elapsed = 0.0
_pending_inputs.clear()
_remote_inputs.clear()
_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 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()
# Which side of the map this ship's race spawns on. Without this, spawn
# position was picked at random from *both* groups regardless of race, so
# opposite-race ships regularly landed right next to each other at the same
# marker -- immediate point-blank combat at spawn that, since dying just
# respawns back to that same shared marker, never actually moves away from
# spawn at all (looks identical to the ships-piled-up-and-frozen bug, but
# caused by a standing enemy contact instead of an idle one).
func _team_spawn_group() -> String:
var race_id: int = PlayerRegistry.get_info(peer_id).get("race", 0)
var world := get_node_or_null("/root/World")
if world:
var offered: Array = world.decide_offered_races()
if offered.size() == 2 and race_id == offered[1]:
return "team_b_spawn"
return "team_a_spawn"
func _update_hud() -> void:
if not _is_owner:
return
var health_bar = get_node_or_null("/root/World/HealthBar")
if health_bar and health_bar.has_method("set_fill"):
health_bar.set_fill(float(health) / GameConfig.ship_max_health if GameConfig.ship_max_health > 0 else 0.0)
var energy_bar = get_node_or_null("/root/World/EnergyBar")
if energy_bar and energy_bar.has_method("set_fill"):
energy_bar.set_fill(energy / max_energy if max_energy > 0.0 else 0.0)