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")

# 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

# Bottom-right nameplate — see _refresh_nameplate() and ship.tscn's
# top_level Label child.
var _nameplate: Label

# 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:
		var 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
		_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.global_position = global_position + GameConfig.ship_nameplate_offset


# 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:
		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)
			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

	_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()

func _get_spawn_position() -> Vector2:
	var markers := get_tree().get_nodes_in_group(_team_spawn_group())
	if markers.size() > 0:
		var base: Vector2 = markers[randi() % markers.size()].global_position
		var offset := Vector2(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)) * GameConfig.spawn_scatter_radius
		return base + offset
	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)
