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
Weapon class with a cooldown loop and an overridable fire()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.
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.
Area3D?Godot has two main kinds of physics object:
CharacterBody3D, RigidBody3D, StaticBody3D) bump into things and stop.Area3D) don’t block anything. They only notice when something overlaps them.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.
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().
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:
direction * speed * delta is “how far to move this frame”. delta is the time since the last physics frame (about 1/60 of a second), which keeps speed the same on fast and slow computers.lifetime counts down, and the bullet removes itself at zero. Without this, bullets that miss would fly forever and slowly fill up memory.queue_free() deletes the node safely at the end of the current frame.damage, crit_chance and pierce aren’t used yet. The weapon fills them in now, and Phase 2 uses them when the bullet hits something.Now build the scene. In the Godot editor:
Bullet.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.Bullet: MeshInstance3D. Set Mesh > New SphereMesh, open it, and set Radius 0.15 and Height 0.3.Bullet and look at Collision in the Inspector:
Bullet, untick Monitorable. Nothing needs to detect the bullet; the bullet detects enemies.projectile.gd onto the Bullet node to attach it.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).
Weapon classRewrite 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.
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.
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.
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
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:
get_tree().current_scene (the Main node), not as children of the weapon. Children move with their parent; if bullets were children of the weapon, they would slide sideways every time you walked.global_position is set after add_child, because a node outside the scene tree has no global position.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).
The Gunner’s signature weapon: fires a fan of bullets in the aim direction. More projectiles means a wider fan.
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
With 3 bullets and a 6° gap:
start_angle = -6 × (3 - 1) / 2 = -6°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.
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:
MachineGun.machine_gun.gd.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.
aim_direction that weapons read._face_mouse() logic).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.
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”.
In the Godot editor:
auto_aim and press Add.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)
_get_aim_point(). There are now two ways to get an aim point (mouse or nearest enemy), and the rest of the logic doesn’t care which one was used._get_aim_point() returns Variant because it might return a Vector3 or null (no enemy, or the mouse ray missed). A function typed -> Vector3 can’t return null. The caller checks for null, then stores the result in a properly typed Vector3, the same pattern your original code used with hit.to_point.y = 0.0 flattens the direction so you never aim up or down. Enemies and the player can be at slightly different heights, and we don’t want bullets angling into the floor._unhandled_input only gets input that the UI didn’t already use. Clicking a menu button won’t also toggle auto-aim.aim_direction simply keeps its last value. You keep firing where you last aimed.When the character is applied, the player creates its signature weapon’s scene, sets it up, and adds it under a new Weapons node.
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).
In the Godot editor:
res://scenes/characters/player/player.tscn.Player, add a child Node3D, and rename it Weapons.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
_spawn_weapon() mattersinstantiate() creates the weapon node in memory. It isn’t in the game yet.setup() gives it its data and level, so stats is filled in.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.
Make sure every player stat that should affect weapons does, and understand where that happens.
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 |
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.
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.
Point the weapon resource at the Machine Gun scene and give it 5 levels.
In the Godot editor:
res://resources/weapons/weapon_gunner_default.tres.res://scenes/characters/player/weapons/machine_gun.tscn.res://icon.svg for now as a placeholder. The HUD in Phase 3 shows it.5. Element 0 already exists (“Base level”). For elements 1 to 4, click <empty> and choose New WeaponLevel.| 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.
These are just starting values. Tune them once you can play.
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. |
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)