GameGuide

Phase 5: Evolutions

Goal: once a weapon is at max level and you own its matching passive, an Access Card pick can offer to evolve it into a stronger version with new behaviour. Holding Tab shows which passive evolves which weapon.

Access Cards themselves are dropped by elites in Phase 6. In this phase a debug key (F1) pretends you picked one up, so you can build and test evolutions first.

Checklist


How evolutions work (the rules)

These come straight from Swarm:

  1. The weapon is at max level (5).
  2. You own at least one level of the weapon’s evolution passive.
  3. When both are true, an Access Card pick has a chance to offer the evolution instead of a normal choice.
  4. Evolving is permanent and changes how the weapon behaves, not just its numbers.

The evolution passive is often not an obvious damage stat. Swarm pairs weapons with things like Move Speed or Health Regen, which pushes you to take passives you’d otherwise skip. That’s a big part of what makes builds interesting.


5.1 Evolution data on WeaponData

What

Each weapon resource gets an Evolution section: which passive unlocks it, the evolved name, description and icon, and a separate set of stats for the evolved version.

Why separate fields instead of “level 6”?

An evolution isn’t more of the same; it’s a transformation. Giving it its own name, icon and stats makes it feel like a reward, and it means the normal level-up offers never accidentally include it. Only the Access Card rule (5.3) does.

How

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

Find this:

@export var scales_with: Array[PassiveData.Stat] = []

Replace with:

@export var scales_with: Array[PassiveData.Stat] = []

@export_group("Evolution")
# Own at least one level of this passive (and max the weapon) to unlock the evolution.
@export var evolution_passive: PassiveData
@export var evolved_name: String = ""
@export_multiline var evolved_description: String = ""
@export var evolved_icon: Texture2D
@export var evolved_stats: WeaponLevel

@export_group("Evolution") folds everything below it into a collapsible Evolution section in the Inspector.

Why a PassiveData reference instead of an id string? You can drag the passive resource straight in, so there’s no way to make a typo. When we compare, we still compare ids (the same way get_passive() does), so two copies of the same resource still match.


5.2 The evolution check on each weapon

What

Why on the weapon?

The weapon already knows its level and its data, and it has a reference to its player (to check the passive). Putting the check here means anything (the offer rules, the overlay, a future HUD glow) can just ask weapon.can_evolve().

How

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

Find this:

var player: Player

Replace with:

var player: Player
var evolved: bool = false

Find this:

func get_display_name() -> String:
	return data.display_name

func get_icon() -> Texture2D:
	return data.icon

Replace with:

func get_display_name() -> String:
	if evolved and data.evolved_name != "":
		return data.evolved_name
	return data.display_name

func get_icon() -> Texture2D:
	if evolved and data.evolved_icon:
		return data.evolved_icon
	return data.icon

func can_evolve() -> bool:
	if evolved or data.evolution_passive == null or data.evolved_stats == null:
		return false
	return is_max_level() and player.get_passive(data.evolution_passive) != null

func evolve() -> void:
	evolved = true
	stats = data.evolved_stats

This is why get_display_name() and get_icon() were added back in Phase 1: the HUD and offers already use them, so they show the evolved name and icon automatically.


5.3 Offer evolutions through the Access Card pick

What

Why reuse the level-up screen?

An Access Card pick looks and behaves almost exactly like a level-up: pause, show choices, apply one, unpause. The only differences are the title and how the choices are built. That’s exactly why Phase 3 put a reason in the queue instead of a plain counter.

Step A: the signal

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

Find this:

signal gold_changed(total: int)

Replace with:

signal gold_changed(total: int)
signal access_card_collected

Step B: the offer kind

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

Find this:

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

Replace with:

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

Find this in apply():

		Kind.NEW_WEAPON:
			player.add_weapon(weapon_data)

Replace with:

		Kind.NEW_WEAPON:
			player.add_weapon(weapon_data)
		Kind.EVOLUTION:
			player.evolve_weapon(weapon)

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

Add this function below level_up_weapon():

func evolve_weapon(weapon: Weapon) -> void:
	weapon.evolve()
	loadout_changed.emit()

The player wraps weapon.evolve() so it can announce loadout_changed. The HUD only listens to the player, not to each weapon.

Step C: the Access Card rules

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

Find this:

class_name UpgradeOffers
extends RefCounted

Replace with:

class_name UpgradeOffers
extends RefCounted

# Chance an Access Card offers an evolution when one is available. 1.0 = always.
const EVOLUTION_CHANCE: float = 0.5

static func build_access_card(player: Player) -> Array[UpgradeOffer]:
	var evolutions: Array[UpgradeOffer] = []
	for weapon: Weapon in player.weapons:
		if weapon.can_evolve():
			evolutions.append(_evolution_offer(weapon))
	if not evolutions.is_empty() and randf() < EVOLUTION_CHANCE:
		evolutions.shuffle()
		evolutions.resize(1)
		return evolutions
	return build_level_up(player, 1)

static func _evolution_offer(weapon: Weapon) -> UpgradeOffer:
	var offer: UpgradeOffer = UpgradeOffer.new()
	offer.kind = UpgradeOffer.Kind.EVOLUTION
	offer.weapon = weapon
	offer.title = "EVOLVE  %s  >  %s" % [weapon.data.display_name, weapon.data.evolved_name]
	offer.description = weapon.data.evolved_description
	offer.icon = weapon.data.evolved_icon if weapon.data.evolved_icon else weapon.data.icon
	return offer

How it works

  1. Collect an evolution offer for every weapon that can_evolve().
  2. If there’s at least one, roll the dice. randf() is a random number from 0 to 1, so < 0.5 succeeds half the time. On success, offer one evolution, picked at random if several are ready.
  3. Otherwise fall back to a normal pick, but with only one choice (build_level_up(player, 1)). If nothing at all is left, that function already returns the heal and gold fallbacks, which matches Swarm’s Access Card behaviour.

Why a chance and not a guarantee? It’s Swarm’s design: Access Cards are exciting because they might be the one. If you find it frustrating, set EVOLUTION_CHANCE to 1.0.

Step D: the level-up screen handles Access Cards

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

Find this:

enum Reason { LEVEL_UP }

Replace with:

enum Reason { LEVEL_UP, ACCESS_CARD }

Find this:

func _ready() -> void:
	visible = false
	game_state.leveled_up.connect(_on_leveled_up)

func _on_leveled_up(_new_level: int) -> void:
	_enqueue(Reason.LEVEL_UP)

Replace with:

func _ready() -> void:
	visible = false
	game_state.leveled_up.connect(_on_leveled_up)
	game_state.access_card_collected.connect(_on_access_card_collected)

func _on_leveled_up(_new_level: int) -> void:
	_enqueue(Reason.LEVEL_UP)

func _on_access_card_collected() -> void:
	_enqueue(Reason.ACCESS_CARD)

Find this in _show_next():

	match reason:
		Reason.LEVEL_UP:
			title_label.text = "Level Up!"
			offers = UpgradeOffers.build_level_up(player)

Replace with:

	match reason:
		Reason.LEVEL_UP:
			title_label.text = "Level Up!"
			offers = UpgradeOffers.build_level_up(player)
		Reason.ACCESS_CARD:
			title_label.text = "Access Card!"
			offers = UpgradeOffers.build_access_card(player)

Nothing else in the screen changes: it already handles any number of choices.

Step E: a debug key

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

Find this:

	match key.keycode:
		KEY_F2:
			game_state.add_xp(game_state.xp_to_next - game_state.xp)

Replace with:

	match key.keycode:
		KEY_F1:
			game_state.access_card_collected.emit()
		KEY_F2:
			game_state.add_xp(game_state.xp_to_next - game_state.xp)

5.4 Evolved weapons get new behaviour

What

Why behaviour and not just numbers?

Bigger numbers are fine, but they don’t feel like a new weapon. Swarm’s evolutions change something you can see: pierce everything, orbit forever, fire twice. Even a small rule change makes the evolution moment memorable.

The pattern is the same for every weapon: inside fire() (or wherever it matters), check if evolved: and do something different.

Step A: Bullet Storm (Machine Gun + Cooldown)

In the Godot editor: open weapon_gunner_default.tres and expand Evolution:

Field Value
Evolution Passive res://resources/passives/cdr.tres
Evolved Name Bullet Storm
Evolved Description Fires twice as fast. Bullets pierce every enemy.
Evolved Icon (leave empty for now, or a different placeholder)
Evolved Stats New WeaponLevel: Damage 10, Cooldown 0.5, Projectiles 7, Pierce 0

Pierce is left at 0 in the stats because the code below overrides it for the evolved version.

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

Replace the whole file with:

extends Weapon

const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const BULLET_SPEED: float = 30.0
const BULLET_LIFETIME: float = 1.0
# Gap between neighbouring bullets in the fan.
const SPREAD_DEGREES: float = 6.0
# Effectively "pierce everything".
const EVOLVED_PIERCE: int = 999
const EVOLVED_BULLET_SCALE: float = 1.5

func fire() -> bool:
	var count: int = get_projectile_count()
	var start_angle: float = -SPREAD_DEGREES * (count - 1) / 2.0
	for i: int in count:
		var angle: float = deg_to_rad(start_angle + SPREAD_DEGREES * i)
		var direction: Vector3 = player.aim_direction.rotated(Vector3.UP, angle)
		var bullet: Projectile = spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
		if evolved:
			bullet.pierce = EVOLVED_PIERCE
			bullet.scale = Vector3.ONE * EVOLVED_BULLET_SCALE
	return true

This is why spawn_projectile() returns the projectile: the evolved version can tweak a bullet after the standard setup.

Step B: suggested evolutions for the Phase 4 weapons

Fill in each weapon resource’s Evolution section like this:

Weapon Evolution Passive Evolved Name Evolved Description
Seeker Shot projectile.tres Hunter Swarm Fires two waves of seekers at twice as many targets.
Orbiting Blades duration.tres Endless Cyclone Blades orbit forever.
Pulse Aura max_hp.tres Heartbeat Each pulse also deals 2% of your max health as damage.

For Evolved Stats, copy each weapon’s level 5 numbers into a New WeaponLevel.

Hunter Swarm (Seeker Shot). File: res://scripts/characters/player/weapons/seeker_shot.gd

Find this:

	var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, get_projectile_count(), RANGE)

Replace with:

	var target_count: int = get_projectile_count() * (2 if evolved else 1)
	var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, target_count, RANGE)

Endless Cyclone (Orbiting Blades). File: res://scripts/characters/player/weapons/orbiting_blades.gd

Find this:

	if _time_left > 0.0:
		_time_left -= delta
		if _time_left <= 0.0:
			_clear_blades()

Replace with:

	if _time_left > 0.0 and not evolved:
		_time_left -= delta
		if _time_left <= 0.0:
			_clear_blades()

Find this:

func get_cooldown() -> float:
	return super() + get_duration()

Replace with:

func get_cooldown() -> float:
	if evolved:
		return super()
	return super() + get_duration()

When evolved, blades are never removed by the timer. fire() still runs every cooldown, and since it starts with _clear_blades() and makes a fresh set, the blades always use your latest damage, count and area. The swap happens within a single frame, so you never see a gap.

Heartbeat (Pulse Aura). File: res://scripts/characters/player/weapons/pulse_aura.gd

Find this:

const FLASH_TIME: float = 0.15

Replace with:

const FLASH_TIME: float = 0.15
const EVOLVED_MAX_HP_DAMAGE: float = 0.02

Find this:

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

Replace with:

	var radius: float = BASE_RADIUS * get_area_scale()
	var damage: float = get_damage()
	if evolved:
		damage += player.max_hp * EVOLVED_MAX_HP_DAMAGE
	for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
		enemy.take_damage(Weapon.roll_crit(damage, player.crit_chance))

Heartbeat shows how a “non-combat” evolution passive (Max Health) can feed back into damage, the kind of build synergy Swarm is built around.

Step C: HUD shows EVO

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

Find this:

		weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level, tooltip))

Replace with:

		var caption: String = "EVO" if weapon.evolved else ("Lv %d" % weapon.level)
		weapon_icons.add_child(_make_slot(weapon.get_icon(), caption, tooltip))

5.5 Evolution reference overlay

What

Hold Tab to see every weapon’s evolution recipe and how close you are:

EVOLUTIONS

Machine Gun + Cooldown  >  Bullet Storm
    Lv 5/5, passive owned - READY (collect an Access Card)
Seeker Shot + Projectiles  >  Hunter Swarm
    not owned

Why

Without this you’d have to remember every pairing. Swarm’s Tab screen does the same thing, and it’s what lets players plan builds instead of guessing.

Step A: input action

Project > Project Settings > Input Map: add an action named show_build and bind it to Tab.

Godot also uses Tab to move keyboard focus between buttons. While the level-up screen is open, Tab will do both. That’s harmless, and handy, because you can check your recipes while choosing.

Step B: the script

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

class_name BuildOverlay
extends CanvasLayer

@onready var label: Label = $Panel/Margin/Label

var player: Player

func _ready() -> void:
	visible = false

func _process(_delta: float) -> void:
	var held: bool = Input.is_action_pressed("show_build")
	if held and not visible:
		label.text = _build_text()
	visible = held

func _build_text() -> String:
	var lines: PackedStringArray = ["EVOLUTIONS", ""]
	for data: WeaponData in _all_weapons():
		if data.evolution_passive == null:
			continue
		lines.append("%s + %s  >  %s" % [data.display_name, data.evolution_passive.display_name, data.evolved_name])
		lines.append("    " + _status(data))
	return "\n".join(lines)

# Owned weapons first, then everything else in the pool.
func _all_weapons() -> Array[WeaponData]:
	var result: Array[WeaponData] = []
	for weapon: Weapon in player.weapons:
		result.append(weapon.data)
	for data: WeaponData in Upgrades.weapons:
		if not result.has(data):
			result.append(data)
	return result

func _status(data: WeaponData) -> String:
	var weapon: Weapon = player.get_weapon(data)
	if weapon == null:
		return "not owned"
	if weapon.evolved:
		return "EVOLVED"
	var level_part: String = "Lv %d/%d" % [weapon.level, data.max_level()]
	var has_passive: bool = player.get_passive(data.evolution_passive) != null
	var passive_part: String = "passive owned" if has_passive else "passive missing"
	if weapon.can_evolve():
		return "%s, %s - READY (collect an Access Card)" % [level_part, passive_part]
	return "%s, %s" % [level_part, passive_part]

Notes:

Step C: the scene

In the Godot editor:

BuildOverlay (CanvasLayer)          script: build_overlay.gd, Layer 15, Process Mode Always
└─ Panel (PanelContainer)           Anchors Preset: Center Left; Position x 16
   └─ Margin (MarginContainer)      all margins 16
      └─ Label (Label)

Save as res://scenes/ui/build_overlay.tscn.

Step D: add it in main.gd

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

Find this:

const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")

Replace with:

const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
const BUILD_OVERLAY_SCENE: PackedScene = preload("res://scenes/ui/build_overlay.tscn")

Find this:

	run_end_screen = RUN_END_SCENE.instantiate()
	add_child(run_end_screen)

Replace with:

	var build_overlay: BuildOverlay = BUILD_OVERLAY_SCENE.instantiate()
	build_overlay.player = player
	add_child(build_overlay)

	run_end_screen = RUN_END_SCENE.instantiate()
	add_child(run_end_screen)

Test it

  1. F5, Gunner, Start.
  2. Hold Tab: you see the recipes, with Machine Gun at Lv 1/5, passive missing.
  3. Press F2 until you’ve taken Machine Gun to Lv 5 and picked up Cooldown. Tab now says READY.
  4. Press F1 a few times. About half the time the Access Card pick is EVOLVE Machine Gun > Bullet Storm. Otherwise it’s a single normal choice.
  5. Take the evolution: bullets are bigger, fire twice as often, and go through every enemy. The HUD shows EVO and the name changes in the tooltip.
  6. Press F1 again. Bullet Storm is never offered again.

If something breaks:

Problem Likely cause
Never offered, even when READY EVOLUTION_CHANCE is very low, or game_state.access_card_collected isn’t connected in the level-up screen.
Tab shows nothing show_build isn’t in the Input Map, or BuildOverlay isn’t Process Mode: Always (it won’t work while paused).
Invalid access to property 'cooldown' after evolving Evolved Stats is empty in the weapon resource. can_evolve() should prevent this; check it includes the evolved_stats == null test.
Endless Cyclone blades still disappear The and not evolved check is missing in _physics_process().

Full file listings

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] = []

@export_group("Evolution")
# Own at least one level of this passive (and max the weapon) to unlock the evolution.
@export var evolution_passive: PassiveData
@export var evolved_name: String = ""
@export_multiline var evolved_description: String = ""
@export var evolved_icon: Texture2D
@export var evolved_stats: WeaponLevel

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/characters/player/weapons/weapon.gd

class_name Weapon
extends Node3D

const CRIT_MULTIPLIER: float = 2.0
# How far in front of the player projectiles appear.
const MUZZLE_OFFSET: float = 0.8

var data: WeaponData
var level: int = 1
var stats: WeaponLevel
var player: Player
var evolved: bool = false

var _cooldown_left: float = 0.0

# Called by the player before the weapon is added to the scene tree.
func setup(owning_player: Player, weapon_data: WeaponData) -> void:
	player = owning_player
	data = weapon_data
	set_level(1)

func set_level(new_level: int) -> void:
	level = new_level
	stats = data.level_stats(level)

func is_max_level() -> bool:
	return level >= data.max_level()

func get_display_name() -> String:
	if evolved and data.evolved_name != "":
		return data.evolved_name
	return data.display_name

func get_icon() -> Texture2D:
	if evolved and data.evolved_icon:
		return data.evolved_icon
	return data.icon

func can_evolve() -> bool:
	if evolved or data.evolution_passive == null or data.evolved_stats == null:
		return false
	return is_max_level() and player.get_passive(data.evolution_passive) != null

func evolve() -> void:
	evolved = true
	stats = data.evolved_stats

func _physics_process(delta: float) -> void:
	_cooldown_left -= delta
	if _cooldown_left > 0.0:
		return
	if fire():
		_cooldown_left = get_cooldown()

# Each weapon overrides this. Return false if there was nothing to fire at,
# so the weapon tries again next frame instead of waiting a full cooldown.
func fire() -> bool:
	return false

# --- Stats after the player's bonuses ---

func get_cooldown() -> float:
	return stats.cooldown / (1.0 + player.cdr / 100.0)

func get_damage() -> float:
	return stats.damage * player.damage_mult

func get_projectile_count() -> int:
	return maxi(1, stats.projectiles + player.projectile)

func get_area_scale() -> float:
	return stats.area_scale * player.area_size_mult

func get_duration() -> float:
	return stats.duration * player.duration_mult

# --- Helpers ---

func spawn_projectile(scene: PackedScene, direction: Vector3, speed: float, lifetime: float) -> Projectile:
	var projectile: Projectile = scene.instantiate()
	projectile.direction = direction.normalized()
	projectile.speed = speed
	projectile.lifetime = lifetime
	projectile.damage = get_damage()
	projectile.crit_chance = player.crit_chance
	projectile.pierce = stats.pierce
	get_tree().current_scene.add_child(projectile)
	projectile.global_position = global_position + projectile.direction * MUZZLE_OFFSET
	return projectile

static func roll_crit(damage: float, crit_chance: float) -> float:
	if randf() * 100.0 < crit_chance:
		return damage * CRIT_MULTIPLIER
	return damage

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

class_name UpgradeOffer
extends RefCounted

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

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)
		Kind.EVOLUTION:
			player.evolve_weapon(weapon)

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

class_name UpgradeOffers
extends RefCounted

# Chance an Access Card offers an evolution when one is available. 1.0 = always.
const EVOLUTION_CHANCE: float = 0.5

static func build_access_card(player: Player) -> Array[UpgradeOffer]:
	var evolutions: Array[UpgradeOffer] = []
	for weapon: Weapon in player.weapons:
		if weapon.can_evolve():
			evolutions.append(_evolution_offer(weapon))
	if not evolutions.is_empty() and randf() < EVOLUTION_CHANCE:
		evolutions.shuffle()
		evolutions.resize(1)
		return evolutions
	return build_level_up(player, 1)

static func _evolution_offer(weapon: Weapon) -> UpgradeOffer:
	var offer: UpgradeOffer = UpgradeOffer.new()
	offer.kind = UpgradeOffer.Kind.EVOLUTION
	offer.weapon = weapon
	offer.title = "EVOLVE  %s  >  %s" % [weapon.data.display_name, weapon.data.evolved_name]
	offer.description = weapon.data.evolved_description
	offer.icon = weapon.data.evolved_icon if weapon.data.evolved_icon else weapon.data.icon
	return offer

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/autoload/game_state.gd

extends Node

# --- Game state tracker ---
# This file will be used to keep track of the progress of any given round.
# It should not include any locked in variables on its own, but should be
# used to keep track of per-round data, like current XP, phase of the round,
# tally enemy kills.

signal xp_changed(current: float, required: float)
signal leveled_up(new_level: int)
signal gold_changed(total: int)
signal access_card_collected

const MAX_LEVEL: int = 99

var selected_character: CharacterData
var selected_level: PackedScene

var passive_slots: int = 3

# --- Per-run tracking ---
var run_time: float = 0.0
var level: int = 1
var xp: float = 0.0
var xp_to_next: float = 5.0
var gold: int = 0

func start_run(character: CharacterData, level_scene: PackedScene) -> void:
	selected_character = character
	selected_level = level_scene
	passive_slots = Profile.unlocked_passive_slots
	reset_run()

func reset_run() -> void:
	run_time = 0.0
	level = 1
	xp = 0.0
	xp_to_next = xp_required(1)
	gold = 0

# XP needed to go from this level to the next. Tune freely.
func xp_required(from_level: int) -> float:
	return 5.0 + 10.0 * (from_level - 1)

func add_xp(amount: float) -> void:
	if level >= MAX_LEVEL:
		return
	xp += amount
	while xp >= xp_to_next and level < MAX_LEVEL:
		xp -= xp_to_next
		level += 1
		xp_to_next = xp_required(level)
		leveled_up.emit(level)
	xp_changed.emit(xp, xp_to_next)

func add_gold(amount: int) -> void:
	gold += amount
	gold_changed.emit(gold)

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

class_name LevelUpScreen
extends CanvasLayer

enum Reason { LEVEL_UP, ACCESS_CARD }

const CHOICE_SIZE: Vector2 = Vector2(560, 96)
const ICON_SIZE: int = 64

@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var choices_box: VBoxContainer = $Center/Panel/Margin/VBox/Choices

var player: Player
# Holds Reason values, one per pending pick.
var _queue: Array[int] = []

func _ready() -> void:
	visible = false
	game_state.leveled_up.connect(_on_leveled_up)
	game_state.access_card_collected.connect(_on_access_card_collected)

func _on_leveled_up(_new_level: int) -> void:
	_enqueue(Reason.LEVEL_UP)

func _on_access_card_collected() -> void:
	_enqueue(Reason.ACCESS_CARD)

func _enqueue(reason: Reason) -> void:
	_queue.append(reason)
	if not visible:
		_show_next()

func _show_next() -> void:
	if _queue.is_empty():
		visible = false
		get_tree().paused = false
		return
	var reason: int = _queue.pop_front()
	var offers: Array[UpgradeOffer] = []
	match reason:
		Reason.LEVEL_UP:
			title_label.text = "Level Up!"
			offers = UpgradeOffers.build_level_up(player)
		Reason.ACCESS_CARD:
			title_label.text = "Access Card!"
			offers = UpgradeOffers.build_access_card(player)
	_show_offers(offers)
	visible = true
	get_tree().paused = true

func _show_offers(offers: Array[UpgradeOffer]) -> void:
	for child: Node in choices_box.get_children():
		child.queue_free()
	var first_button: Button = null
	for offer: UpgradeOffer in offers:
		var button: Button = Button.new()
		button.text = "%s\n%s" % [offer.title, offer.description]
		button.icon = offer.icon
		button.alignment = HORIZONTAL_ALIGNMENT_LEFT
		button.custom_minimum_size = CHOICE_SIZE
		button.add_theme_constant_override("icon_max_width", ICON_SIZE)
		button.pressed.connect(_on_offer_chosen.bind(offer))
		choices_box.add_child(button)
		if first_button == null:
			first_button = button
	if first_button:
		first_button.grab_focus()

func _on_offer_chosen(offer: UpgradeOffer) -> void:
	offer.apply(player)
	_show_next()

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

extends Weapon

const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const BULLET_SPEED: float = 30.0
const BULLET_LIFETIME: float = 1.0
# Gap between neighbouring bullets in the fan.
const SPREAD_DEGREES: float = 6.0
# Effectively "pierce everything".
const EVOLVED_PIERCE: int = 999
const EVOLVED_BULLET_SCALE: float = 1.5

func fire() -> bool:
	var count: int = get_projectile_count()
	var start_angle: float = -SPREAD_DEGREES * (count - 1) / 2.0
	for i: int in count:
		var angle: float = deg_to_rad(start_angle + SPREAD_DEGREES * i)
		var direction: Vector3 = player.aim_direction.rotated(Vector3.UP, angle)
		var bullet: Projectile = spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
		if evolved:
			bullet.pierce = EVOLVED_PIERCE
			bullet.scale = Vector3.ONE * EVOLVED_BULLET_SCALE
	return true

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 target_count: int = get_projectile_count() * (2 if evolved else 1)
	var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, target_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/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 and not evolved:
		_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:
	if evolved:
		return super()
	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
const EVOLVED_MAX_HP_DAMAGE: float = 0.02

@onready var ring: MeshInstance3D = $Ring

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

func fire() -> bool:
	var radius: float = BASE_RADIUS * get_area_scale()
	var damage: float = get_damage()
	if evolved:
		damage += player.max_hp * EVOLVED_MAX_HP_DAMAGE
	for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
		enemy.take_damage(Weapon.roll_crit(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/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()]
		var caption: String = "EVO" if weapon.evolved else ("Lv %d" % weapon.level)
		weapon_icons.add_child(_make_slot(weapon.get_icon(), caption, 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

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

class_name BuildOverlay
extends CanvasLayer

@onready var label: Label = $Panel/Margin/Label

var player: Player

func _ready() -> void:
	visible = false

func _process(_delta: float) -> void:
	var held: bool = Input.is_action_pressed("show_build")
	if held and not visible:
		label.text = _build_text()
	visible = held

func _build_text() -> String:
	var lines: PackedStringArray = ["EVOLUTIONS", ""]
	for data: WeaponData in _all_weapons():
		if data.evolution_passive == null:
			continue
		lines.append("%s + %s  >  %s" % [data.display_name, data.evolution_passive.display_name, data.evolved_name])
		lines.append("    " + _status(data))
	return "\n".join(lines)

# Owned weapons first, then everything else in the pool.
func _all_weapons() -> Array[WeaponData]:
	var result: Array[WeaponData] = []
	for weapon: Weapon in player.weapons:
		result.append(weapon.data)
	for data: WeaponData in Upgrades.weapons:
		if not result.has(data):
			result.append(data)
	return result

func _status(data: WeaponData) -> String:
	var weapon: Weapon = player.get_weapon(data)
	if weapon == null:
		return "not owned"
	if weapon.evolved:
		return "EVOLVED"
	var level_part: String = "Lv %d/%d" % [weapon.level, data.max_level()]
	var has_passive: bool = player.get_passive(data.evolution_passive) != null
	var passive_part: String = "passive owned" if has_passive else "passive missing"
	if weapon.can_evolve():
		return "%s, %s - READY (collect an Access Card)" % [level_part, passive_part]
	return "%s, %s" % [level_part, passive_part]

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

extends Node3D

const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const HUD_SCENE: PackedScene = preload("res://scenes/ui/hud.tscn")
const LEVEL_UP_SCENE: PackedScene = preload("res://scenes/ui/level_up_screen.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
const BUILD_OVERLAY_SCENE: PackedScene = preload("res://scenes/ui/build_overlay.tscn")

var player: Player
var run_end_screen: RunEndScreen

# Called when the node enters the scene tree for the first time.
func _ready() -> void:
	var level: Node3D = game_state.selected_level.instantiate()
	add_child(level)
	player = PLAYER_SCENE.instantiate()
	# Floor surface is at y=0 and the capsule is 2 tall, centred on its origin.
	player.position = Vector3(0, 1, 0)
	add_child(player)
	player.died.connect(_on_player_died)

	var hud: Hud = HUD_SCENE.instantiate()
	add_child(hud)
	hud.setup(player)

	var level_up_screen: LevelUpScreen = LEVEL_UP_SCENE.instantiate()
	level_up_screen.player = player
	add_child(level_up_screen)

	var build_overlay: BuildOverlay = BUILD_OVERLAY_SCENE.instantiate()
	build_overlay.player = player
	add_child(build_overlay)

	run_end_screen = RUN_END_SCENE.instantiate()
	add_child(run_end_screen)

func _process(delta: float) -> void:
	game_state.run_time += delta

func _unhandled_input(event: InputEvent) -> void:
	# Debug shortcuts; ignored in exported release builds.
	if not OS.is_debug_build():
		return
	var key: InputEventKey = event as InputEventKey
	if key == null or not key.pressed or key.echo:
		return
	match key.keycode:
		KEY_F1:
			game_state.access_card_collected.emit()
		KEY_F2:
			game_state.add_xp(game_state.xp_to_next - game_state.xp)

func _on_player_died() -> void:
	# Only end the run when nobody is left (matters for co-op).
	if get_tree().get_node_count_in_group("players") == 0:
		run_end_screen.show_result(false)

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 evolve_weapon(weapon: Weapon) -> void:
	weapon.evolve()
	loadout_changed.emit()

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)

Next: Phase 6: Elites, Access Cards and the boss