GameGuide

Phase 4: Weapon slots and more weapons

Goal: level-ups can now offer new weapons, up to 4 alongside your signature weapon. Three new weapons show off different targeting styles: one locks onto the nearest enemies, one orbits you, and one pulses around you. Tooltips show which stats each weapon scales with.

Checklist


4.1 The signature weapon plus 4 more slots

What

A constant that caps the player at 5 weapons in total, and a clear rule that the signature weapon is always slot 0.

Why this is mostly done already

Back in Phase 1 the player got a weapons array, and the signature weapon was the first thing added to it. So:

The one missing piece is the limit itself.

How

File: res://scripts/characters/player/player.gd

Find this:

const MAX_PASSIVE_LEVEL: int = 5

Replace with:

const MAX_PASSIVE_LEVEL: int = 5
# The signature weapon plus 4 picked up during the run.
const MAX_WEAPON_SLOTS: int = 5

4.2 add_weapon() and new-weapon offers

What

Why mirror add_passive()?

Weapons and passives follow the same rule: “if you already own it, level it up; otherwise add it if there’s room”. Writing them the same way means once you understand one, you understand both, and bugs are easier to spot when two similar things look similar.

Step A: the player

File: res://scripts/characters/player/player.gd

Find this:

func level_up_weapon(weapon: Weapon) -> bool:

Replace with:

func get_weapon(data: WeaponData) -> Weapon:
	for weapon: Weapon in weapons:
		if weapon.data.id == data.id:
			return weapon
	return null

func add_weapon(data: WeaponData) -> bool:
	var owned: Weapon = get_weapon(data)
	if owned:
		return level_up_weapon(owned)
	if weapons.size() >= MAX_WEAPON_SLOTS:
		return false
	_spawn_weapon(data)
	loadout_changed.emit()
	return true

func level_up_weapon(weapon: Weapon) -> bool:

add_weapon() reuses level_up_weapon() for the “already owned” case instead of repeating it. Comparing by id (not by the resource itself) means two copies of the same weapon resource still count as the same weapon.

Step B: the offer

File: res://scripts/systems/upgrade_offer.gd

Find this:

enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD }

Replace with:

enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON }

Find this:

var weapon: Weapon

Replace with:

var weapon: Weapon
var weapon_data: WeaponData

Find this inside apply():

		Kind.GOLD:
			game_state.add_gold(FALLBACK_GOLD)

Replace with:

		Kind.GOLD:
			game_state.add_gold(FALLBACK_GOLD)
		Kind.NEW_WEAPON:
			player.add_weapon(weapon_data)

Why two weapon fields? A weapon you already own is a Weapon node (it has a level). A weapon you don’t own yet only exists as WeaponData, a resource on disk. A “level up” offer points at the node; a “new weapon” offer points at the data.

Why add NEW_WEAPON at the end of the enum? Each enum name is really a number (NEW_PASSIVE is 0, PASSIVE_LEVEL is 1, and so on). Adding at the end leaves the existing numbers alone. It doesn’t matter much here, but it’s a good habit, because it does matter once enum values are saved to disk.

Step C: the rules

File: res://scripts/systems/upgrade_offers.gd

Find the whole _add_weapon_offers() function and replace it with:

static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for weapon: Weapon in player.weapons:
		if weapon.is_max_level():
			continue
		var offer: UpgradeOffer = UpgradeOffer.new()
		offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
		offer.weapon = weapon
		offer.title = "%s  Lv %d" % [weapon.get_display_name(), weapon.level + 1]
		offer.description = weapon.data.level_stats(weapon.level + 1).description
		offer.icon = weapon.get_icon()
		candidates.append(offer)
	if player.weapons.size() >= Player.MAX_WEAPON_SLOTS:
		return
	for data: WeaponData in Upgrades.weapons:
		if player.get_weapon(data) != null:
			continue
		var offer: UpgradeOffer = UpgradeOffer.new()
		offer.kind = UpgradeOffer.Kind.NEW_WEAPON
		offer.weapon_data = data
		offer.title = "NEW  %s" % data.display_name
		offer.description = data.level_stats(1).description
		offer.icon = data.icon
		candidates.append(offer)

The first half is unchanged. The second half is rule 4 from Phase 3: “a new weapon is only offered if you have a free weapon slot”. It’s the same shape as the passive rule.


4.3 Three weapons that each target differently

What

Weapon Targeting style Scales with
Seeker Shot Fires at the nearest enemies; each projectile picks a different one Damage, CDR, Projectiles, Crit
Orbiting Blades Blades circle you for a while, hitting and knocking back enemies Damage, CDR, Projectiles, Area, Duration, Crit
Pulse Aura Damages every enemy within a radius around you, on a timer Damage, CDR, Area, Crit

Between these and the Machine Gun (aimed), you get four of Swarm’s main patterns. Others you could build later the same way: random direction, ground zones, trails left behind as you move, pets, and bouncing shots.

Why build three at once?

Each one teaches something new:

Step A: more targeting helpers

File: res://scripts/utils/targeting.gd

Add at the bottom:

# Every enemy whose flat (ground) distance from center is within radius.
static func enemies_in_radius(tree: SceneTree, center: Vector3, radius: float) -> Array[Enemy]:
	var found: Array[Enemy] = []
	var radius_sq: float = radius * radius
	for node: Node in tree.get_nodes_in_group(&"enemies"):
		var enemy: Enemy = node as Enemy
		var offset: Vector3 = enemy.global_position - center
		offset.y = 0.0
		if offset.length_squared() <= radius_sq:
			found.append(enemy)
	return found

# Up to `count` enemies, closest first.
static func nearest_enemies(tree: SceneTree, from: Vector3, count: int, max_range: float = INF) -> Array[Enemy]:
	var found: Array[Enemy] = enemies_in_radius(tree, from, max_range)
	found.sort_custom(func(a: Enemy, b: Enemy) -> bool:
		return from.distance_squared_to(a.global_position) < from.distance_squared_to(b.global_position)
	)
	if found.size() > count:
		found.resize(count)
	return found

sort_custom with a lambda: func(a, b) -> bool: ... is a small unnamed function written inline. sort_custom calls it to compare pairs of enemies; returning true means “a goes before b”. Here that means “closer first”. The lambda can use from from the surrounding function.

Why ignore height (offset.y = 0.0)? Area effects in a top-down game should be circles on the ground. An enemy standing on a slightly different floor height should still be “in range” if it’s inside the circle you can see.

Step B: knockback on enemies

Orbiting Blades push enemies away. Enemies need to be able to receive a push that fades out.

File: res://scripts/characters/enemy/enemy.gd

Find this:

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")

Replace with:

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0

Find this:

var _flash_tween: Tween

Replace with:

var _flash_tween: Tween
var _knockback: Vector3 = Vector3.ZERO

Find this in _physics_process():

	velocity.x = move.x
	velocity.z = move.z
	move_and_slide()

Replace with:

	velocity.x = move.x + _knockback.x
	velocity.z = move.z + _knockback.z
	_knockback = _knockback.move_toward(Vector3.ZERO, KNOCKBACK_DECAY * delta)
	move_and_slide()

Add this function below take_damage():

func apply_knockback(force: Vector3) -> void:
	_knockback += force

How it works: knockback is an extra velocity added on top of normal walking. Each frame move_toward shrinks it towards zero, so a push of 6 m/s fades out in about 0.3 seconds. Several pushes add together, so getting hit by two blades shoves harder.

Step C: Seeker Shot

File: res://scripts/characters/player/weapons/seeker_shot.gd

extends Weapon

const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const RANGE: float = 20.0
const BULLET_SPEED: float = 25.0
const BULLET_LIFETIME: float = 1.2

func fire() -> bool:
	var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, get_projectile_count(), RANGE)
	if targets.is_empty():
		return false
	for target: Enemy in targets:
		var direction: Vector3 = target.global_position - global_position
		direction.y = 0.0
		spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
	return true

This is where return false from Phase 1 pays off. With no enemy in range, the weapon doesn’t waste its cooldown; it checks again next frame and fires the moment something comes close.

In the Godot editor: New Scene > Node3D, renamed SeekerShot, attach seeker_shot.gd, and save as res://scenes/characters/player/weapons/seeker_shot.tscn.

Want Seeker bullets to look different? Right-click bullet.tscn > Duplicate…, call it seeker_bullet.tscn, change the mesh colour, and point BULLET_SCENE at it.

Step D: Orbiting Blades

This weapon has two parts: the blade (an Area3D that hurts what it touches) and the weapon (spawns blades in a circle and spins them).

File: res://scripts/characters/player/weapons/orbit_blade.gd

class_name OrbitBlade
extends Area3D

var damage: float = 10.0
var crit_chance: float = 0.0
var knockback: float = 0.0

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node3D) -> void:
	if not (body is Enemy):
		return
	var enemy: Enemy = body as Enemy
	enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	var center: Vector3 = (get_parent() as Node3D).global_position
	var push: Vector3 = enemy.global_position - center
	push.y = 0.0
	enemy.apply_knockback(push.normalized() * knockback)

The push direction is from the centre of the orbit (the player) out to the enemy, so enemies are shoved away from you, not sideways.

Why does each blade only hit once per pass? body_entered fires when an enemy starts overlapping. As the blade sweeps round, it enters an enemy, leaves, and enters again on the next lap. That naturally limits it to one hit per lap per enemy, without any extra timers.

In the Godot editor:

  1. New Scene > Area3D, renamed OrbitBlade.
  2. Child CollisionShape3D with a BoxShape3D of size (1.0, 0.4, 0.3).
  3. Child MeshInstance3D with a BoxMesh of size (1.0, 0.1, 0.3), light blue, emission on.
  4. Collision: Layer only 5; Mask only 3. Untick Monitorable.
  5. Attach orbit_blade.gd and save as res://scenes/characters/player/weapons/orbit_blade.tscn.

File: res://scripts/characters/player/weapons/orbiting_blades.gd

extends Weapon

const BLADE_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/orbit_blade.tscn")
const BASE_RADIUS: float = 3.0
# Radians per second; TAU would be one full turn per second.
const SPIN_SPEED: float = 3.0
const KNOCKBACK: float = 6.0

var _pivot: Node3D
var _time_left: float = 0.0

func _ready() -> void:
	_pivot = Node3D.new()
	add_child(_pivot)

func _physics_process(delta: float) -> void:
	super(delta)
	_pivot.rotate_y(SPIN_SPEED * delta)
	if _time_left > 0.0:
		_time_left -= delta
		if _time_left <= 0.0:
			_clear_blades()

func fire() -> bool:
	_clear_blades()
	var count: int = get_projectile_count()
	var radius: float = BASE_RADIUS * get_area_scale()
	for i: int in count:
		var blade: OrbitBlade = BLADE_SCENE.instantiate()
		blade.damage = get_damage()
		blade.crit_chance = player.crit_chance
		blade.knockback = KNOCKBACK
		var angle: float = TAU * i / count
		blade.position = Vector3(cos(angle), 0.0, sin(angle)) * radius
		blade.rotation.y = -angle
		blade.scale = Vector3.ONE * get_area_scale()
		_pivot.add_child(blade)
	_time_left = get_duration()
	return true

# Blades are out for `duration`, then the normal cooldown starts.
func get_cooldown() -> float:
	return super() + get_duration()

func _clear_blades() -> void:
	for blade: Node in _pivot.get_children():
		blade.queue_free()

Walking through Orbiting Blades

The pivot. Blades are children of an invisible _pivot node, which is a child of the weapon, which is a child of the player. Rotating the pivot swings all the blades round together, and because the whole chain is under the player, they follow you as you walk. Unlike bullets, we want these to move with you.

super(delta). In Godot 4, if a child class writes its own _physics_process(), the parent’s version does not run automatically. The base Weapon._physics_process() is the cooldown loop, so without super(delta) the blades would never fire. super(...) means “also run the parent’s version of this function”.

Spacing. TAU * i / count spreads count blades evenly round a full circle (TAU is 360° in radians). blade.rotation.y = -angle turns each blade so it points along the circle.

Area and duration.

Uniform scale only. Physics shapes handle uniform scaling (the same on all three axes) fine. Stretching a shape unevenly isn’t reliably supported, which is why we use Vector3.ONE * scale.

In the Godot editor: New Scene > Node3D, renamed OrbitingBlades, attach orbiting_blades.gd, and save as res://scenes/characters/player/weapons/orbiting_blades.tscn. The pivot and blades are created by code.

Step E: Pulse Aura

File: res://scripts/characters/player/weapons/pulse_aura.gd

extends Weapon

const BASE_RADIUS: float = 4.0
const FLASH_TIME: float = 0.15

@onready var ring: MeshInstance3D = $Ring

func _ready() -> void:
	ring.visible = false

func fire() -> bool:
	var radius: float = BASE_RADIUS * get_area_scale()
	for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
		enemy.take_damage(Weapon.roll_crit(get_damage(), player.crit_chance))
	_flash_ring(radius)
	return true

func _flash_ring(radius: float) -> void:
	ring.scale = Vector3(radius, 1.0, radius)
	ring.visible = true
	var tween: Tween = create_tween()
	tween.tween_interval(FLASH_TIME)
	tween.tween_callback(ring.hide)

Why no Area3D? We could put a big sphere on the player and track who’s inside it, but the aura only needs to know “who’s in range right now” once per second. A plain distance check at that moment is simpler and does the same job.

Is it safe to kill enemies while looping? Yes. enemies_in_radius() returns its own array, so enemies dying and being removed from the "enemies" group during the loop doesn’t affect it. And queue_free() only deletes them at the end of the frame.

The ring. A flat cylinder 1 m in radius, scaled to the real radius, shown for 0.15 s. It’s a simple way to show where the damage happened.

In the Godot editor:

  1. New Scene > Node3D, renamed PulseAura.
  2. Child MeshInstance3D named Ring. Mesh > New CylinderMesh: Top Radius 1, Bottom Radius 1, Height 0.05.
  3. Give the mesh a StandardMaterial3D: Transparency Alpha, Albedo cyan with alpha about 90, Shading Mode Unshaded.
  4. Set the ring’s Position Y to -0.95, so it sits at the player’s feet. The player’s origin is the middle of the 2 m capsule.
  5. Attach pulse_aura.gd to PulseAura and save as res://scenes/characters/player/weapons/pulse_aura.tscn.
PulseAura (Node3D)        script: pulse_aura.gd
└─ Ring (MeshInstance3D)  flat transparent cyan cylinder, y = -0.95

Step F: two passives these weapons care about

Make these in res://resources/passives/:

File Id Display Name Stat Values
area.tres passive_area Area Size AREA_SIZE_MULT 0.1, 0.2, 0.3, 0.4, 0.5
duration.tres passive_duration Duration DURATION_MULT 0.12, 0.24, 0.36, 0.48, 0.6

Step G: weapon resources

Make these in res://resources/weapons/ (New Resource > WeaponData). Set Icon to res://icon.svg for now and Weapon Scene to the matching .tscn. Leave unlisted fields at their defaults.

seeker_shot.tres: Id weapon_seeker_shot, Display Name Seeker Shot

Lv Description Damage Cooldown Projectiles
1 Fires at the nearest enemy. 12 1.2 1
2 +1 target. 12 1.2 2
3 +33% damage. 16 1.2 2
4 +1 target. 16 1.2 3
5 +25% damage, fires faster. 20 1.0 3

orbiting_blades.tres: Id weapon_orbiting_blades, Display Name Orbiting Blades

Lv Description Damage Cooldown Projectiles Area Scale Duration
1 Blades circle you, pushing enemies back. 8 3.0 2 1.0 3.0
2 +1 blade. 8 3.0 3 1.0 3.0
3 +50% damage. 12 3.0 3 1.0 3.0
4 +1 blade, lasts longer. 12 3.0 4 1.0 4.0
5 +33% damage, wider orbit. 16 3.0 4 1.3 4.0

pulse_aura.tres: Id weapon_pulse_aura, Display Name Pulse Aura

Lv Description Damage Cooldown Area Scale
1 Damages all nearby enemies every second. 6 1.0 1.0
2 +33% damage. 8 1.0 1.0
3 +20% radius. 8 1.0 1.2
4 +37% damage. 11 1.0 1.2
5 Bigger and faster. 11 0.8 1.5

Step H: add them to the pool

File: res://scripts/autoload/upgrades.gd

Replace the whole file with:

extends Node

# Everything that can show up as a level-up choice.
# Signature weapons aren't listed; each character brings its own.
var weapons: Array[WeaponData] = [
	preload("res://resources/weapons/seeker_shot.tres"),
	preload("res://resources/weapons/orbiting_blades.tres"),
	preload("res://resources/weapons/pulse_aura.tres"),
]
var passives: Array[PassiveData] = [
	preload("res://resources/passives/damage.tres"),
	preload("res://resources/passives/cdr.tres"),
	preload("res://resources/passives/projectile.tres"),
	preload("res://resources/passives/max_hp.tres"),
	preload("res://resources/passives/move_speed.tres"),
	preload("res://resources/passives/pickup_range.tres"),
	preload("res://resources/passives/area.tres"),
	preload("res://resources/passives/duration.tres"),
]

4.4 A “Scales with” list for tooltips

What

Each WeaponData gets a scales_with list of stats, shown in level-up offers and as a tooltip when you hover a weapon icon on the HUD.

Why

Swarm shows this in every weapon tooltip, because it’s how you plan a build: “my Orbiting Blades scale with Area and Duration, so those passives are worth more to me than Projectiles”. The stats a weapon actually uses are decided by its code (which get_...() functions it calls). This list is just a description for the player, so keep it in sync when you write a weapon.

Step A: readable stat names

File: res://scripts/resources/passive_data.gd

Find this:

@export var stat: Stat = Stat.MAX_HP

Replace with:

const STAT_NAMES: Dictionary = {
	Stat.MAX_HP: "Max Health",
	Stat.DEFENCE: "Defence",
	Stat.DAMAGE_MULT: "Damage",
	Stat.CDR: "Cooldown",
	Stat.HP_REGEN: "Health Regen",
	Stat.MOVE_SPEED: "Move Speed",
	Stat.PROJECTILE: "Projectiles",
	Stat.CRIT_CHANCE: "Crit Chance",
	Stat.PICKUP_RANGE: "Pickup Range",
	Stat.XP_MULT: "XP",
	Stat.GOLD_MULT: "Gold",
	Stat.AREA_SIZE_MULT: "Area Size",
	Stat.DURATION_MULT: "Duration",
}

@export var stat: Stat = Stat.MAX_HP

A Dictionary maps keys to values. Here each enum value maps to the name a player should see. Stat.keys() would give "DAMAGE_MULT", which is fine for code but not for players.

Step B: the list on WeaponData

File: res://scripts/resources/weapon_data.gd

Find this:

@export var levels: Array[WeaponLevel] = []

Replace with:

@export var levels: Array[WeaponLevel] = []
# For tooltips only; keep in sync with which get_...() functions the weapon script calls.
@export var scales_with: Array[PassiveData.Stat] = []

Add at the bottom:

func scaling_text() -> String:
	if scales_with.is_empty():
		return ""
	var names: PackedStringArray = []
	for stat: PassiveData.Stat in scales_with:
		names.append(PassiveData.STAT_NAMES[stat])
	return "Scales with: " + ", ".join(names)

", ".join(names) glues the names together with commas: Damage, Cooldown, Area Size.

If Godot ever refuses Array[PassiveData.Stat] as an export type, use Array[int] instead. It shows numbers instead of names in the Inspector but works the same.

In the Godot editor: open each weapon resource and fill in Scales With (click Add Element and pick from the dropdown):

Resource Scales With
weapon_gunner_default.tres DAMAGE_MULT, CDR, PROJECTILE, CRIT_CHANCE
seeker_shot.tres DAMAGE_MULT, CDR, PROJECTILE, CRIT_CHANCE
orbiting_blades.tres DAMAGE_MULT, CDR, PROJECTILE, AREA_SIZE_MULT, DURATION_MULT, CRIT_CHANCE
pulse_aura.tres DAMAGE_MULT, CDR, AREA_SIZE_MULT, CRIT_CHANCE

Step C: show it on new-weapon offers

File: res://scripts/systems/upgrade_offers.gd

Find this (in the new-weapon half of _add_weapon_offers()):

		offer.description = data.level_stats(1).description

Replace with:

		offer.description = "%s\n%s" % [data.level_stats(1).description, data.scaling_text()]

Step D: HUD tooltips

File: res://scripts/ui/hud.gd

Find the _refresh_loadout() and _make_slot() functions and replace both with:

func _refresh_loadout() -> void:
	for child: Node in weapon_icons.get_children():
		child.queue_free()
	for child: Node in passive_icons.get_children():
		child.queue_free()
	for weapon: Weapon in player.weapons:
		var tooltip: String = "%s\n%s\n%s" % [weapon.get_display_name(), weapon.stats.description, weapon.data.scaling_text()]
		weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level, tooltip))
	for passive: OwnedPassive in player.passives:
		var tooltip: String = passive.data.describe_level(passive.level)
		passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level, tooltip))

func _make_slot(icon: Texture2D, caption: String, tooltip: String) -> Control:
	var slot: VBoxContainer = VBoxContainer.new()
	slot.tooltip_text = tooltip
	var picture: TextureRect = TextureRect.new()
	picture.texture = icon
	picture.custom_minimum_size = SLOT_SIZE
	picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	picture.mouse_filter = Control.MOUSE_FILTER_PASS
	var label: Label = Label.new()
	label.text = caption
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	slot.add_child(picture)
	slot.add_child(label)
	return slot

MOUSE_FILTER_PASS on the picture lets the hover pass through to the slot that owns the tooltip. Tooltips work while the level-up screen is open too, which is handy for checking what you already have.


Test it

  1. F5, Gunner, Start. Level up (or press F2).
  2. Choices now include NEW Seeker Shot, NEW Orbiting Blades and NEW Pulse Aura, with “Scales with: …” underneath.
  3. Take each one and watch:
    • Seeker Shot fires at the closest enemies, one bullet each, and doesn’t fire when nothing is within 20 m.
    • Orbiting Blades circle you for 3 s, push enemies away, disappear, then come back after the cooldown.
    • Pulse Aura flashes a cyan ring and damages everything inside it every second.
  4. Take Area Size: the orbit gets wider and the aura ring gets bigger. The Machine Gun doesn’t change.
  5. Hover a weapon icon on the HUD to see its tooltip.
  6. Once you own all three new weapons, NEW weapon offers stop. The pool only has 3, so one slot stays free until you add more weapons to Upgrades.weapons.

If something breaks:

Problem Likely cause
Orbiting Blades never appear super(delta) is missing from its _physics_process().
Blades appear but don’t damage Blade layer/mask isn’t 5/3, or orbit_blade.gd isn’t attached.
Invalid call. Nonexistent function 'apply_knockback' Step B (knockback on enemies) was skipped.
Pulse ring floats mid-air Ring Position Y isn’t -0.95.
Invalid access to key ... STAT_NAMES A stat is missing from the STAT_NAMES dictionary.
No NEW weapon offers Upgrades.weapons is empty, or the resource paths are wrong.

Full file listings

File: res://scripts/characters/player/player.gd

extends CharacterBody3D
class_name Player

signal stats_changed
signal health_changed(current: float, maximum: float)
signal died
signal loadout_changed
const MAX_PASSIVE_LEVEL: int = 5
# The signature weapon plus 4 picked up during the run.
const MAX_WEAPON_SLOTS: int = 5
const AUTO_AIM_RANGE: float = 25.0

@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons
@onready var pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D


# --- Set up stats ---
var max_hp: float
var defence: float
var damage_mult: float
# Haste-style: cooldown = base / (1 + cdr / 100), so it never reaches zero.
var cdr: float
var hp_regen: float
var move_speed: float
var projectile: int
var crit_chance: float
var pickup_range: float
var xp_mult: float
var gold_mult: float
var area_size_mult: float
var duration_mult: float
var current_hp: float = 0.0
var is_dead: bool = false

# --- Create passive slots ---
var passive_slots: int = Profile.MAX_PASSIVE_SLOTS
var passives: Array[OwnedPassive] = []

# --- Weapons ---
var weapons: Array[Weapon] = []

# --- Aiming ---
# The model's front is +X, so that's the starting aim.
var aim_direction: Vector3 = Vector3.RIGHT
var auto_aim: bool = false

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	add_to_group("players")
	pickup_area.area_entered.connect(_on_pickup_area_entered)
	passive_slots = game_state.passive_slots
	if character_data == null:
		character_data = game_state.selected_character
	if character_data:
		_apply_character(character_data)
	recalculate_stats()
	current_hp = max_hp

func _apply_character(character: CharacterData) -> void:
	if character.model_scene:
		character_model.add_child(character.model_scene.instantiate())
	if character.signature_weapon:
		_spawn_weapon(character.signature_weapon)

func _spawn_weapon(data: WeaponData) -> Weapon:
	var weapon: Weapon = data.weapon_scene.instantiate()
	weapon.setup(self, data)
	weapon_holder.add_child(weapon)
	weapons.append(weapon)
	return weapon

func recalculate_stats() -> void:
	var previous_max_hp: float = max_hp
	max_hp = 1000.0
	defence = 0.0
	damage_mult = 1.0
	cdr = 0.0
	hp_regen = 10.0
	move_speed = 7.0
	projectile = 0
	crit_chance = 5.0
	pickup_range = 3.0
	xp_mult = 1.0
	gold_mult = 1.0
	area_size_mult = 1.0
	duration_mult = 1.0
	if character_data:
		max_hp += character_data.bonus_max_hp
		defence += character_data.bonus_defence
	for passive: OwnedPassive in passives:
		_apply_bonus(passive.data.stat, passive.bonus())
	(pickup_shape.shape as SphereShape3D).radius = pickup_range
	# Raising max HP grants the difference rather than healing to full.
	current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
	stats_changed.emit()
	health_changed.emit(current_hp, max_hp)

# Each call is one "source". Percentage stats multiply between sources; flat stats add.
func _apply_bonus(stat: PassiveData.Stat, amount: float) -> void:
	match stat:
		PassiveData.Stat.MAX_HP:
			max_hp += amount
		PassiveData.Stat.DEFENCE:
			defence += amount
		PassiveData.Stat.DAMAGE_MULT:
			damage_mult *= 1.0 + amount
		PassiveData.Stat.CDR:
			cdr += amount
		PassiveData.Stat.HP_REGEN:
			hp_regen += amount
		PassiveData.Stat.MOVE_SPEED:
			move_speed *= 1.0 + amount
		PassiveData.Stat.PROJECTILE:
			projectile += roundi(amount)
		PassiveData.Stat.CRIT_CHANCE:
			crit_chance += amount
		PassiveData.Stat.PICKUP_RANGE:
			pickup_range *= 1.0 + amount
		PassiveData.Stat.XP_MULT:
			xp_mult *= 1.0 + amount
		PassiveData.Stat.GOLD_MULT:
			gold_mult *= 1.0 + amount
		PassiveData.Stat.AREA_SIZE_MULT:
			area_size_mult *= 1.0 + amount
		PassiveData.Stat.DURATION_MULT:
			duration_mult *= 1.0 + amount

func get_passive(data: PassiveData) -> OwnedPassive:
	for passive: OwnedPassive in passives:
		if passive.data.id == data.id:
			return passive
	return null

func add_passive(data: PassiveData) -> bool:
	var owned: OwnedPassive = get_passive(data)
	if owned:
		if owned.level >= MAX_PASSIVE_LEVEL:
			return false
		owned.level += 1
	else:
		if passives.size() >= passive_slots:
			return false
		passives.append(OwnedPassive.new(data))
	recalculate_stats()
	loadout_changed.emit()
	return true

func get_weapon(data: WeaponData) -> Weapon:
	for weapon: Weapon in weapons:
		if weapon.data.id == data.id:
			return weapon
	return null

func add_weapon(data: WeaponData) -> bool:
	var owned: Weapon = get_weapon(data)
	if owned:
		return level_up_weapon(owned)
	if weapons.size() >= MAX_WEAPON_SLOTS:
		return false
	_spawn_weapon(data)
	loadout_changed.emit()
	return true

func level_up_weapon(weapon: Weapon) -> bool:
	if weapon.is_max_level():
		return false
	weapon.set_level(weapon.level + 1)
	loadout_changed.emit()
	return true

func heal(amount: float) -> void:
	if is_dead:
		return
	current_hp = minf(current_hp + amount, max_hp)
	health_changed.emit(current_hp, max_hp)

func _on_pickup_area_entered(area: Area3D) -> void:
	if area is Pickup:
		(area as Pickup).attract(self)

func _physics_process(delta: float) -> void:
	_regenerate(delta)
	# Gravity in case we fuck something up
	if not is_on_floor():
		velocity += get_gravity() * delta
	
	var input_dir: Vector2 = Input.get_vector("leftward", "rightward", "upward", "downward")
	var direction: Vector3 = Vector3(input_dir.x, 0.0, input_dir.y).normalized()
	if direction:
		velocity.x = direction.x * move_speed
		velocity.z = direction.z * move_speed
	else:
		velocity.x = move_toward(velocity.x, 0, move_speed)
		velocity.z = move_toward(velocity.z, 0, move_speed)
	
	move_and_slide()
	_update_aim()

func _regenerate(delta: float) -> void:
	if current_hp >= max_hp:
		return
	current_hp = minf(current_hp + hp_regen * delta, max_hp)
	health_changed.emit(current_hp, max_hp)

func take_damage(amount: float) -> void:
	if is_dead:
		return
	# Like League armour: 100 defence means taking half damage.
	current_hp -= amount * 100.0 / (100.0 + maxf(defence, 0.0))
	health_changed.emit(current_hp, max_hp)
	if current_hp <= 0.0:
		_die()

func _die() -> void:
	current_hp = 0.0
	is_dead = true
	remove_from_group("players")
	set_physics_process(false)
	weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
	character_model.visible = false
	died.emit()

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("auto_aim"):
		auto_aim = not auto_aim

func _update_aim() -> void:
	var aim_point: Variant = _get_aim_point()
	if aim_point == null:
		return
	var point: Vector3 = aim_point
	var to_point: Vector3 = point - global_position
	to_point.y = 0.0
	if to_point.length_squared() < 0.0001:
		return
	aim_direction = to_point.normalized()
	# The model's front is +X, so the angle is measured from +X.
	character_model.rotation.y = atan2(-aim_direction.z, aim_direction.x)

# Returns a Vector3, or null when there's nothing to aim at.
func _get_aim_point() -> Variant:
	if auto_aim:
		var target: Node3D = Targeting.nearest_enemy(get_tree(), global_position, AUTO_AIM_RANGE)
		if target == null:
			return null
		return target.global_position
	var mouse_pos: Vector2 = get_viewport().get_mouse_position()
	var ray_origin: Vector3 = camera.project_ray_origin(mouse_pos)
	var ray_dir: Vector3 = camera.project_ray_normal(mouse_pos)
	return Plane(Vector3.UP, global_position.y).intersects_ray(ray_origin, ray_dir)

File: res://scripts/systems/upgrade_offer.gd

class_name UpgradeOffer
extends RefCounted

enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON }

const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25

var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon
var weapon_data: WeaponData

func apply(player: Player) -> void:
	match kind:
		Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
			player.add_passive(passive_data)
		Kind.WEAPON_LEVEL:
			player.level_up_weapon(weapon)
		Kind.HEAL:
			player.heal(player.max_hp * HEAL_FRACTION)
		Kind.GOLD:
			game_state.add_gold(FALLBACK_GOLD)
		Kind.NEW_WEAPON:
			player.add_weapon(weapon_data)

File: res://scripts/systems/upgrade_offers.gd

class_name UpgradeOffers
extends RefCounted

static func build_level_up(player: Player, count: int = 3) -> Array[UpgradeOffer]:
	var candidates: Array[UpgradeOffer] = []
	_add_weapon_offers(player, candidates)
	_add_passive_offers(player, candidates)
	if candidates.is_empty():
		return _fallback_offers()
	candidates.shuffle()
	if candidates.size() > count:
		candidates.resize(count)
	return candidates

static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for weapon: Weapon in player.weapons:
		if weapon.is_max_level():
			continue
		var offer: UpgradeOffer = UpgradeOffer.new()
		offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
		offer.weapon = weapon
		offer.title = "%s  Lv %d" % [weapon.get_display_name(), weapon.level + 1]
		offer.description = weapon.data.level_stats(weapon.level + 1).description
		offer.icon = weapon.get_icon()
		candidates.append(offer)
	if player.weapons.size() >= Player.MAX_WEAPON_SLOTS:
		return
	for data: WeaponData in Upgrades.weapons:
		if player.get_weapon(data) != null:
			continue
		var offer: UpgradeOffer = UpgradeOffer.new()
		offer.kind = UpgradeOffer.Kind.NEW_WEAPON
		offer.weapon_data = data
		offer.title = "NEW  %s" % data.display_name
		offer.description = "%s\n%s" % [data.level_stats(1).description, data.scaling_text()]
		offer.icon = data.icon
		candidates.append(offer)

static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
	for passive: OwnedPassive in player.passives:
		if passive.level < Player.MAX_PASSIVE_LEVEL:
			candidates.append(_passive_offer(passive.data, passive.level + 1))
	if player.passives.size() >= player.passive_slots:
		return
	for data: PassiveData in Upgrades.passives:
		if player.get_passive(data) == null:
			candidates.append(_passive_offer(data, 1))

static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
	var offer: UpgradeOffer = UpgradeOffer.new()
	offer.passive_data = data
	offer.description = data.describe_level(level)
	offer.icon = data.icon
	if level == 1:
		offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
		offer.title = "NEW  %s" % data.display_name
	else:
		offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
		offer.title = "%s  Lv %d" % [data.display_name, level]
	return offer

static func _fallback_offers() -> Array[UpgradeOffer]:
	var heal: UpgradeOffer = UpgradeOffer.new()
	heal.kind = UpgradeOffer.Kind.HEAL
	heal.title = "Patch Up"
	heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
	var gold: UpgradeOffer = UpgradeOffer.new()
	gold.kind = UpgradeOffer.Kind.GOLD
	gold.title = "Pocket Change"
	gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
	var offers: Array[UpgradeOffer] = [heal, gold]
	return offers

File: res://scripts/utils/targeting.gd

class_name Targeting
extends RefCounted

# Returns the closest node in the group within max_range, or null.
static func nearest_in_group(tree: SceneTree, group: StringName, from: Vector3, max_range: float = INF) -> Node3D:
	var nearest: Node3D = null
	var nearest_dist_sq: float = max_range * max_range
	for node: Node in tree.get_nodes_in_group(group):
		var candidate: Node3D = node as Node3D
		var dist_sq: float = from.distance_squared_to(candidate.global_position)
		if dist_sq < nearest_dist_sq:
			nearest_dist_sq = dist_sq
			nearest = candidate
	return nearest

static func nearest_enemy(tree: SceneTree, from: Vector3, max_range: float = INF) -> Node3D:
	return nearest_in_group(tree, &"enemies", from, max_range)

static func nearest_player(tree: SceneTree, from: Vector3) -> Player:
	return nearest_in_group(tree, &"players", from) as Player

# Every enemy whose flat (ground) distance from center is within radius.
static func enemies_in_radius(tree: SceneTree, center: Vector3, radius: float) -> Array[Enemy]:
	var found: Array[Enemy] = []
	var radius_sq: float = radius * radius
	for node: Node in tree.get_nodes_in_group(&"enemies"):
		var enemy: Enemy = node as Enemy
		var offset: Vector3 = enemy.global_position - center
		offset.y = 0.0
		if offset.length_squared() <= radius_sq:
			found.append(enemy)
	return found

# Up to `count` enemies, closest first.
static func nearest_enemies(tree: SceneTree, from: Vector3, count: int, max_range: float = INF) -> Array[Enemy]:
	var found: Array[Enemy] = enemies_in_radius(tree, from, max_range)
	found.sort_custom(func(a: Enemy, b: Enemy) -> bool:
		return from.distance_squared_to(a.global_position) < from.distance_squared_to(b.global_position)
	)
	if found.size() > count:
		found.resize(count)
	return found

File: res://scripts/characters/enemy/enemy.gd

class_name Enemy
extends CharacterBody3D

signal died(enemy: Enemy)

const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0

@export var data: EnemyData

@onready var model_holder: Node3D = $Model

var current_hp: float = 0.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween
var _knockback: Vector3 = Vector3.ZERO

func _ready() -> void:
	add_to_group("enemies")
	current_hp = data.max_hp
	if data.model_scene:
		model_holder.add_child(data.model_scene.instantiate())

func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity += get_gravity() * delta
	var target: Player = Targeting.nearest_player(get_tree(), global_position)
	var move: Vector3 = Vector3.ZERO
	var distance: float = INF
	if target:
		var to_target: Vector3 = target.global_position - global_position
		to_target.y = 0.0
		distance = to_target.length()
		if distance > 0.01:
			var direction: Vector3 = to_target / distance
			move = direction * data.move_speed
			# Same convention as the player: the model's front is +X.
			model_holder.rotation.y = atan2(-direction.z, direction.x)
	velocity.x = move.x + _knockback.x
	velocity.z = move.z + _knockback.z
	_knockback = _knockback.move_toward(Vector3.ZERO, KNOCKBACK_DECAY * delta)
	move_and_slide()
	_attack_cooldown_left -= delta
	if target and distance <= data.attack_range and _attack_cooldown_left <= 0.0:
		target.take_damage(data.damage)
		_attack_cooldown_left = data.attack_cooldown

func take_damage(amount: float) -> void:
	if current_hp <= 0.0:
		return
	current_hp -= amount
	_play_hit_flash()
	if current_hp <= 0.0:
		die()

func apply_knockback(force: Vector3) -> void:
	_knockback += force

func die() -> void:
	_drop_loot()
	died.emit(self)
	queue_free()

func _drop_loot() -> void:
	var orb: XpOrb = XP_ORB_SCENE.instantiate()
	orb.amount = data.xp_value
	_spawn_here(orb)

# Deferred: die() often runs inside a physics callback, where adding physics objects isn't allowed.
func _spawn_here(node: Node3D) -> void:
	node.position = global_position
	get_tree().current_scene.add_child.call_deferred(node)

func _play_hit_flash() -> void:
	if _flash_tween:
		_flash_tween.kill()
	model_holder.scale = Vector3.ONE * 1.25
	_flash_tween = create_tween()
	_flash_tween.tween_property(model_holder, "scale", Vector3.ONE, 0.1)

File: res://scripts/characters/player/weapons/seeker_shot.gd

extends Weapon

const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const RANGE: float = 20.0
const BULLET_SPEED: float = 25.0
const BULLET_LIFETIME: float = 1.2

func fire() -> bool:
	var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, get_projectile_count(), RANGE)
	if targets.is_empty():
		return false
	for target: Enemy in targets:
		var direction: Vector3 = target.global_position - global_position
		direction.y = 0.0
		spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
	return true

File: res://scripts/characters/player/weapons/orbit_blade.gd

class_name OrbitBlade
extends Area3D

var damage: float = 10.0
var crit_chance: float = 0.0
var knockback: float = 0.0

func _ready() -> void:
	body_entered.connect(_on_body_entered)

func _on_body_entered(body: Node3D) -> void:
	if not (body is Enemy):
		return
	var enemy: Enemy = body as Enemy
	enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
	var center: Vector3 = (get_parent() as Node3D).global_position
	var push: Vector3 = enemy.global_position - center
	push.y = 0.0
	enemy.apply_knockback(push.normalized() * knockback)

File: res://scripts/characters/player/weapons/orbiting_blades.gd

extends Weapon

const BLADE_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/orbit_blade.tscn")
const BASE_RADIUS: float = 3.0
# Radians per second; TAU would be one full turn per second.
const SPIN_SPEED: float = 3.0
const KNOCKBACK: float = 6.0

var _pivot: Node3D
var _time_left: float = 0.0

func _ready() -> void:
	_pivot = Node3D.new()
	add_child(_pivot)

func _physics_process(delta: float) -> void:
	super(delta)
	_pivot.rotate_y(SPIN_SPEED * delta)
	if _time_left > 0.0:
		_time_left -= delta
		if _time_left <= 0.0:
			_clear_blades()

func fire() -> bool:
	_clear_blades()
	var count: int = get_projectile_count()
	var radius: float = BASE_RADIUS * get_area_scale()
	for i: int in count:
		var blade: OrbitBlade = BLADE_SCENE.instantiate()
		blade.damage = get_damage()
		blade.crit_chance = player.crit_chance
		blade.knockback = KNOCKBACK
		var angle: float = TAU * i / count
		blade.position = Vector3(cos(angle), 0.0, sin(angle)) * radius
		blade.rotation.y = -angle
		blade.scale = Vector3.ONE * get_area_scale()
		_pivot.add_child(blade)
	_time_left = get_duration()
	return true

# Blades are out for `duration`, then the normal cooldown starts.
func get_cooldown() -> float:
	return super() + get_duration()

func _clear_blades() -> void:
	for blade: Node in _pivot.get_children():
		blade.queue_free()

File: res://scripts/characters/player/weapons/pulse_aura.gd

extends Weapon

const BASE_RADIUS: float = 4.0
const FLASH_TIME: float = 0.15

@onready var ring: MeshInstance3D = $Ring

func _ready() -> void:
	ring.visible = false

func fire() -> bool:
	var radius: float = BASE_RADIUS * get_area_scale()
	for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
		enemy.take_damage(Weapon.roll_crit(get_damage(), player.crit_chance))
	_flash_ring(radius)
	return true

func _flash_ring(radius: float) -> void:
	ring.scale = Vector3(radius, 1.0, radius)
	ring.visible = true
	var tween: Tween = create_tween()
	tween.tween_interval(FLASH_TIME)
	tween.tween_callback(ring.hide)

File: res://scripts/resources/weapon_data.gd

class_name WeaponData
extends GameData

@export var weapon_scene: PackedScene

# One entry per level, so entry 0 is level 1.
@export var levels: Array[WeaponLevel] = []
# For tooltips only; keep in sync with which get_...() functions the weapon script calls.
@export var scales_with: Array[PassiveData.Stat] = []

func max_level() -> int:
	return levels.size()

func level_stats(level: int) -> WeaponLevel:
	return levels[clampi(level - 1, 0, levels.size() - 1)]

func scaling_text() -> String:
	if scales_with.is_empty():
		return ""
	var names: PackedStringArray = []
	for stat: PassiveData.Stat in scales_with:
		names.append(PassiveData.STAT_NAMES[stat])
	return "Scales with: " + ", ".join(names)

File: res://scripts/resources/passive_data.gd

class_name PassiveData
extends GameData

enum Stat { MAX_HP, DEFENCE, DAMAGE_MULT, CDR, HP_REGEN, MOVE_SPEED, PROJECTILE,
	CRIT_CHANCE, PICKUP_RANGE, XP_MULT, GOLD_MULT, AREA_SIZE_MULT, DURATION_MULT }

const STAT_NAMES: Dictionary = {
	Stat.MAX_HP: "Max Health",
	Stat.DEFENCE: "Defence",
	Stat.DAMAGE_MULT: "Damage",
	Stat.CDR: "Cooldown",
	Stat.HP_REGEN: "Health Regen",
	Stat.MOVE_SPEED: "Move Speed",
	Stat.PROJECTILE: "Projectiles",
	Stat.CRIT_CHANCE: "Crit Chance",
	Stat.PICKUP_RANGE: "Pickup Range",
	Stat.XP_MULT: "XP",
	Stat.GOLD_MULT: "Gold",
	Stat.AREA_SIZE_MULT: "Area Size",
	Stat.DURATION_MULT: "Duration",
}

@export var stat: Stat = Stat.MAX_HP

# Total bonus at each level, not a per-level delta.
# Percentage stats use fractions (0.1 = +10%); flat stats use plain numbers.
@export var values: Array[float] = [0.0, 0.0, 0.0, 0.0, 0.0]

func value_at(level: int) -> float:
	return values[clampi(level - 1, 0, values.size() - 1)]

func is_percent() -> bool:
	match stat:
		Stat.DAMAGE_MULT, Stat.MOVE_SPEED, Stat.PICKUP_RANGE, Stat.XP_MULT, \
				Stat.GOLD_MULT, Stat.AREA_SIZE_MULT, Stat.DURATION_MULT:
			return true
	return false

func describe_level(level: int) -> String:
	var value: float = value_at(level)
	if is_percent():
		return "%s +%d%%" % [display_name, roundi(value * 100.0)]
	return "%s +%s" % [display_name, String.num(value)]

File: res://scripts/autoload/upgrades.gd

extends Node

# Everything that can show up as a level-up choice.
# Signature weapons aren't listed; each character brings its own.
var weapons: Array[WeaponData] = [
	preload("res://resources/weapons/seeker_shot.tres"),
	preload("res://resources/weapons/orbiting_blades.tres"),
	preload("res://resources/weapons/pulse_aura.tres"),
]
var passives: Array[PassiveData] = [
	preload("res://resources/passives/damage.tres"),
	preload("res://resources/passives/cdr.tres"),
	preload("res://resources/passives/projectile.tres"),
	preload("res://resources/passives/max_hp.tres"),
	preload("res://resources/passives/move_speed.tres"),
	preload("res://resources/passives/pickup_range.tres"),
	preload("res://resources/passives/area.tres"),
	preload("res://resources/passives/duration.tres"),
]

File: res://scripts/ui/hud.gd

class_name Hud
extends CanvasLayer

const SLOT_SIZE: Vector2 = Vector2(48, 48)

@onready var hp_bar: ProgressBar = $TopBar/HBox/HpBar
@onready var level_label: Label = $TopBar/HBox/LevelLabel
@onready var gold_label: Label = $TopBar/HBox/GoldLabel
@onready var timer_label: Label = $TopBar/HBox/TimerLabel
@onready var weapon_icons: HBoxContainer = $Loadout/WeaponIcons
@onready var passive_icons: HBoxContainer = $Loadout/PassiveIcons
@onready var xp_bar: ProgressBar = $XpBar

var player: Player

func setup(target_player: Player) -> void:
	player = target_player
	player.health_changed.connect(_on_health_changed)
	player.loadout_changed.connect(_refresh_loadout)
	game_state.xp_changed.connect(_on_xp_changed)
	game_state.leveled_up.connect(_on_leveled_up)
	game_state.gold_changed.connect(_on_gold_changed)
	_on_health_changed(player.current_hp, player.max_hp)
	_on_xp_changed(game_state.xp, game_state.xp_to_next)
	_on_leveled_up(game_state.level)
	_on_gold_changed(game_state.gold)
	_refresh_loadout()

func _process(_delta: float) -> void:
	var total_seconds: int = floori(game_state.run_time)
	timer_label.text = "%02d:%02d" % [floori(total_seconds / 60.0), total_seconds % 60]

func _on_health_changed(current: float, maximum: float) -> void:
	hp_bar.max_value = maximum
	hp_bar.value = current

func _on_xp_changed(current: float, required: float) -> void:
	xp_bar.max_value = required
	xp_bar.value = current

func _on_leveled_up(new_level: int) -> void:
	level_label.text = "Lv %d" % new_level

func _on_gold_changed(total: int) -> void:
	gold_label.text = "%d gold" % total

func _refresh_loadout() -> void:
	for child: Node in weapon_icons.get_children():
		child.queue_free()
	for child: Node in passive_icons.get_children():
		child.queue_free()
	for weapon: Weapon in player.weapons:
		var tooltip: String = "%s\n%s\n%s" % [weapon.get_display_name(), weapon.stats.description, weapon.data.scaling_text()]
		weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level, tooltip))
	for passive: OwnedPassive in player.passives:
		var tooltip: String = passive.data.describe_level(passive.level)
		passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level, tooltip))

func _make_slot(icon: Texture2D, caption: String, tooltip: String) -> Control:
	var slot: VBoxContainer = VBoxContainer.new()
	slot.tooltip_text = tooltip
	var picture: TextureRect = TextureRect.new()
	picture.texture = icon
	picture.custom_minimum_size = SLOT_SIZE
	picture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
	picture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
	picture.mouse_filter = Control.MOUSE_FILTER_PASS
	var label: Label = Label.new()
	label.text = caption
	label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	slot.add_child(picture)
	slot.add_child(label)
	return slot

Next: Phase 5: Evolutions