a94bccc57b
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>
732 lines
30 KiB
GDScript
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)
|