GameGuide

Phase 1: The signature weapon fires

Goal: the Gunner automatically fires a fan of bullets at the mouse cursor. Pressing C switches to auto-aim at the nearest enemy (that part becomes testable once enemies exist in Phase 2).

Checklist

Why this order? Each step only uses things made in earlier steps, so Godot won’t show red “type not found” errors while you work. The projectile comes first because the weapon creates projectiles; the weapon comes before the player because the player creates weapons.


1.1 The projectile scene

What

A small glowing ball (an Area3D) that flies in a straight line and deletes itself after a set time. Phase 2 makes it damage enemies.

Why an Area3D?

Godot has two main kinds of physics object:

A bullet shouldn’t bounce off an enemy; it should notice the hit, deal damage, and either vanish or keep going (pierce). That’s exactly what an area does.

Why is it a separate scene?

Each bullet is its own copy (instance) of the scene. You build it once in the editor, and the weapon makes as many copies as it needs with instantiate().

How

Create the script first. In the FileSystem dock, right-click scripts/characters/player/weapons/, choose New > Script…, name it projectile.gd, and set Inherits to Area3D.

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

class_name Projectile
extends Area3D

var direction: Vector3 = Vector3.RIGHT
var speed: float = 30.0
var lifetime: float = 1.0
var damage: float = 10.0
var crit_chance: float = 0.0
# Extra enemies this can pass through after the first hit.
var pierce: int = 0

func _physics_process(delta: float) -> void:
	global_position += direction * speed * delta
	lifetime -= delta
	if lifetime <= 0.0:
		queue_free()

What the code does:

Now build the scene. In the Godot editor:

  1. Scene > New Scene. Under “Create Root Node” choose Other Node and pick Area3D. Rename it Bullet.
  2. With Bullet selected, add a child CollisionShape3D. In the Inspector, click Shape > New SphereShape3D, click the sphere to open it, and set Radius to 0.2.
  3. Add another child of Bullet: MeshInstance3D. Set Mesh > New SphereMesh, open it, and set Radius 0.15 and Height 0.3.
  4. Still on the SphereMesh, set Material > New StandardMaterial3D. Open it, set Albedo > Color to yellow, then tick Emission > Enabled and set the emission colour to yellow. Emission makes it glow so it’s easy to see.
  5. Select Bullet and look at Collision in the Inspector:
    • Layer: turn off 1 and turn on 5 (projectile).
    • Mask: turn off 1 and turn on 3 (enemy).
  6. Still on Bullet, untick Monitorable. Nothing needs to detect the bullet; the bullet detects enemies.
  7. Drag projectile.gd onto the Bullet node to attach it.
  8. Save the scene as res://scenes/characters/player/weapons/bullet.tscn. Create the weapons folder if it doesn’t exist.
Bullet (Area3D)            script: projectile.gd, layer 5, mask 3
├─ CollisionShape3D        SphereShape3D, radius 0.2
└─ MeshInstance3D          SphereMesh, radius 0.15, glowing yellow

Layer vs mask, one more time: the bullet’s layer (5) is what it is. Its mask (3) is what it looks for. It looks for enemies; it doesn’t care about the floor (1) or the player (2).


1.2 The base Weapon class

What

Rewrite weapon.gd into a base class: the shared code every weapon gets for free. Each individual weapon (Machine Gun, Orbiting Blades, and so on) will extend Weapon and only write its own fire() function.

Why a base class?

Every weapon needs the same plumbing:

Writing that once in the base class means a new weapon can be about 15 lines long. If you find a bug in the cooldown logic, you fix it in one place and every weapon gets the fix.

Why is a weapon a node at all?

A weapon needs a position (bullets come out of it), it needs _physics_process to tick its cooldown, and some weapons have children (the orbiting blades in Phase 4). Nodes give us all of that.

How

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

Replace the whole file with:

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 _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:
	return data.display_name

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

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

Walking through the important parts

setup() instead of @export var data. The old file had @export var data, meant to be filled in the Inspector. But a weapon is created by code when the player spawns, not placed by hand, so the player passes in the data and itself through setup(). It’s called before the weapon joins the scene tree. That way stats is never null when _physics_process first runs.

The cooldown loop.

	_cooldown_left -= delta
	if _cooldown_left > 0.0:
		return
	if fire():
		_cooldown_left = get_cooldown()

Every physics frame the timer counts down. At zero the weapon tries to fire(). If it actually fired, the timer resets to the full cooldown. If it didn’t (for example “no enemy in range”), the timer stays at zero, so it tries again next frame and fires the moment a target appears.

fire() returns false here. This is the “override me” function. The base version does nothing. Each weapon script writes its own fire() that replaces it.

The get_...() functions combine the weapon’s level stats with the player’s bonuses. This is step 1.6, covered below.

spawn_projectile() is a helper so every weapon that shoots doesn’t have to repeat the same eight lines. Two details:

static func roll_crit(). A static function belongs to the class, not to one particular weapon, so any script can call it as Weapon.roll_crit(...). The projectile uses it in Phase 2. crit_chance is a percentage (5.0 means 5%), so we compare against randf() * 100.0 (a random number from 0 to 100).


1.3 The Machine Gun

What

The Gunner’s signature weapon: fires a fan of bullets in the aim direction. More projectiles means a wider fan.

How

Create res://scripts/characters/player/weapons/machine_gun.gd. Set Inherits to Node3D; you’ll overwrite the extends line anyway.

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

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)
		spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
	return true

How the fan maths works

With 3 bullets and a 6° gap:

With 4 bullets: -9°, -3°, +3°, +9°. The fan is always centred on where you’re aiming.

rotated(Vector3.UP, angle) turns the aim direction around the vertical axis, like turning your head left or right. Godot rotations use radians, so deg_to_rad() converts our degrees.

Why no class_name?

Nothing else needs to refer to “MachineGun” as a type; everything treats it as a Weapon. class_name is only needed when other code uses the name.

In the Godot editor:

  1. Scene > New Scene > Other Node > Node3D. Rename it MachineGun.
  2. Attach machine_gun.gd.
  3. Save as res://scenes/characters/player/weapons/machine_gun.tscn.
MachineGun (Node3D)        script: machine_gun.gd

That’s the whole scene. It has no visuals, because the weapon is just the thing that spawns bullets.


1.4 Aiming: cursor plus auto-aim

What

Why store aim_direction on the player?

Several things need to know where you’re aiming: the signature weapon, the model rotation, and (in Phase 7) your abilities. Working it out once per frame on the player and letting everything read it keeps those in sync and avoids repeating the mouse raycast.

Step A: a targeting helper

Finding “the nearest thing in a group” will be needed by auto-aim, by enemies (nearest player), and by several weapons. It goes in scripts/utils/, which your README describes as “stateless helpers”.

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)

Why squared distance? Real distance needs a square root, which is slow-ish. If A is closer than B, A’s squared distance is also smaller than B’s, so for “which is nearest?” we can skip the square root entirely. With hundreds of enemies, that adds up.

Why extends RefCounted and static? This class never gets placed in a scene; it’s a toolbox of functions. static means you call them straight on the class: Targeting.nearest_enemy(...).

Why pass tree in? Static functions don’t belong to a node, so they can’t call get_tree() themselves. The caller passes its tree in.

INF means “infinity”, so leaving out max_range means “any distance”.

Step B: add the input action

In the Godot editor:

  1. Project > Project Settings > Input Map.
  2. In “Add New Action” type auto_aim and press Add.
  3. Click the + on the new row, press C, and confirm.

Step C: update the player’s aiming code

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

Find this:

signal stats_changed
const MAX_PASSIVE_LEVEL: int = 5

Replace with:

signal stats_changed
const MAX_PASSIVE_LEVEL: int = 5
const AUTO_AIM_RANGE: float = 25.0

Find this (the weapon slot section from Phase 0):

# --- Add weapon slots ---
var signature_weapon: WeaponData

Replace with:

# --- Add weapon slots ---
var signature_weapon: WeaponData

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

(signature_weapon gets replaced in step 1.5.)

Find this at the end of _physics_process:

	move_and_slide()
	_face_mouse()

Replace with:

	move_and_slide()
	_update_aim()

Find the whole _face_mouse() function and replace it with these three functions:

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)

What changed and why


1.5 The player spawns its signature weapon

What

When the character is applied, the player creates its signature weapon’s scene, sets it up, and adds it under a new Weapons node.

Why a Weapons node?

Keeping all weapons under one parent keeps the scene tree tidy, and it gives us a single node to switch off when the player dies (Phase 2).

How

In the Godot editor:

  1. Open res://scenes/characters/player/player.tscn.
  2. Select the root Player, add a child Node3D, and rename it Weapons.
  3. Save.
Player (CharacterBody3D)
├─ CollisionShape3D
├─ CharacterModel (Node3D)
├─ Camera3D
└─ Weapons (Node3D)          <- new

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

Find this:

@onready var camera: Camera3D = $Camera3D

Replace with:

@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons

Find this:

# --- Add weapon slots ---
var signature_weapon: WeaponData

Replace with:

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

The signature_weapon variable goes away because it only held the data. Now the actual weapon node lives in weapons.

Find this:

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

Replace with:

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

Why the order inside _spawn_weapon() matters

  1. instantiate() creates the weapon node in memory. It isn’t in the game yet.
  2. setup() gives it its data and level, so stats is filled in.
  3. add_child() puts it in the game. From the next frame, _physics_process starts running, and it can safely read stats.

If you swapped steps 2 and 3, there would be a moment where the weapon is in the game with stats == null. If a physics frame landed in that moment, it would crash.

The signature weapon is always created first, so it’s always weapons[0]. Phase 4 relies on that.


1.6 Player stats feed into the weapon

What

Make sure every player stat that should affect weapons does, and understand where that happens.

How it already works

This is done by the get_...() functions you wrote in step 1.2. Here’s what each player stat does to a weapon:

Player stat Weapon function Effect
damage_mult get_damage() Multiplies the level’s damage
cdr get_cooldown() cooldown / (1 + cdr / 100) (Haste maths from Phase 0)
projectile get_projectile_count() Adds to the level’s projectile count
area_size_mult get_area_scale() Multiplies the level’s area
duration_mult get_duration() Multiplies the level’s duration
crit_chance spawn_projectile(), then roll_crit() Chance for each hit to deal double damage

Why read the stats every time instead of storing them?

The weapon calls get_damage() at the moment it fires, so it always uses the player’s current stats. When you pick up a Damage passive, the very next shot is stronger, with no “update the weapon” code needed. Reading a few numbers per shot costs next to nothing.

“A weapon only uses the stats that fit how it works”

The Machine Gun calls get_damage(), get_projectile_count() and (through the base loop) get_cooldown(). It never calls get_area_scale() or get_duration(), so Area Size and Duration don’t affect it. That’s intended and matches Swarm. An orbiting-blade weapon in Phase 4 will call get_area_scale() and get_duration(), so those stats will affect it.

Phase 4 adds a “Scales with” list to each weapon so the player can see this in tooltips.


1.7 Fill in the Gunner’s weapon resource

What

Point the weapon resource at the Machine Gun scene and give it 5 levels.

How

In the Godot editor:

  1. Double-click res://resources/weapons/weapon_gunner_default.tres.
  2. Weapon Scene: drag in res://scenes/characters/player/weapons/machine_gun.tscn.
  3. Icon: drag in res://icon.svg for now as a placeholder. The HUD in Phase 3 shows it.
  4. Levels: set the array Size to 5. Element 0 already exists (“Base level”). For elements 1 to 4, click <empty> and choose New WeaponLevel.
  5. Click each element to expand it and fill in:
Level Description Damage Cooldown Projectiles Pierce
1 Fires a fan of 3 bullets. 10 1.0 3 0
2 +1 bullet. 10 1.0 4 0
3 +1 bullet. 10 1.0 5 0
4 +1 bullet. 10 1.0 6 0
5 +1 bullet. Bullets pierce 1 enemy. 10 1.0 7 1

Leave Area Scale and Duration at their defaults; the Machine Gun doesn’t use them.

  1. Press Ctrl+S.

These are just starting values. Tune them once you can play.


Test it

  1. F5, then Gunner, then Start.
  2. A fan of 3 yellow bullets should fire toward the mouse every second, from just in front of the player, and disappear after about 30 m.
  3. Walk around while firing: bullets already in the air should not move with you.
  4. Press C. With no enemies yet, the aim freezes in its last direction. Press C again and it follows the mouse.

If something breaks:

Problem Likely cause
Cannot call method 'instantiate' on a null value Weapon Scene isn’t set in weapon_gunner_default.tres, or Phase 0 step 0.3 (re-link the weapon) was skipped.
Invalid access to property or key 'cooldown' on a base object of type 'Nil' The levels array is empty, or setup() wasn’t called before add_child().
Nothing fires, no errors machine_gun.tscn doesn’t have machine_gun.gd attached.
Identifier "Projectile" not declared projectile.gd is missing class_name Projectile, or the file wasn’t saved.
Bullets move with the player The projectile is being added as a child of the weapon instead of current_scene.

Full file listings

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

class_name Projectile
extends Area3D

var direction: Vector3 = Vector3.RIGHT
var speed: float = 30.0
var lifetime: float = 1.0
var damage: float = 10.0
var crit_chance: float = 0.0
# Extra enemies this can pass through after the first hit.
var pierce: int = 0

func _physics_process(delta: float) -> void:
	global_position += direction * speed * delta
	lifetime -= delta
	if lifetime <= 0.0:
		queue_free()

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 _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:
	return data.display_name

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

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

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)
		spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
	return true

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)

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

extends CharacterBody3D
class_name Player

signal stats_changed
const MAX_PASSIVE_LEVEL: 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


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

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

# 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 add_passive(data: PassiveData) -> bool:
	for passive: OwnedPassive in passives:
		if passive.data.id == data.id:
			if passive.level >= MAX_PASSIVE_LEVEL:
				return false
			passive.level += 1
			recalculate_stats()
			return true
	if passives.size() >= passive_slots:
		return false
	passives.append(OwnedPassive.new(data))
	recalculate_stats()
	return true

func _physics_process(delta: float) -> void:
	# 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 _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 2: Something to shoot