Goal: the Gunner gets a full Swarm-style kit:
The HUD shows each one’s status (“locked”, cooldown, “READY”, or kill progress).
Checklist
Ability base classCharacterData, the player spawning abilities, and the HUDHow this maps to the to-do list: 7.1, 7.2 and 7.4 are the “built-in passive”, 7.3 and 7.5 to 7.7 are the “E ability and R ultimate”, and 7.8 is “extend
CharacterData”.
| Part | Unlocks | Control | Notes |
|---|---|---|---|
| Signature weapon | Start | Automatic, you aim it | Already done (Phase 1). |
| Innate | Start | Automatic | Usually reacts to something (kills, damage taken). |
| Active ability | Level 3 | You press it | Short-to-medium cooldown. |
| Ultimate | Level 6 | You press it | Long cooldown, big effect. |
Abilities don’t get picked on level-ups. Instead they get stronger automatically as the run level goes up, and they scale with the same stats as weapons: damage, CDR, area, projectiles.
A way to give the player a stat bonus for a few seconds, such as “+100 CDR for 6 seconds”. It’s used by the innate here and by boons in Phase 9.
recalculate_stats()?All stats are rebuilt from scratch in one place (Phase 0). If a buff changed cdr directly, then the next time anything called recalculate_stats() (say, picking up a passive mid-buff), the buff would be wiped out. Keeping a list of active buffs and applying each one inside recalculate_stats() means buffs survive any recalculation, and they disappear cleanly when they run out.
Each buff is its own source, so it stacks the same way as everything else: percentage buffs multiply, flat buffs add.
File: res://scripts/characters/player/player.gd
Find this:
const AUTO_AIM_RANGE: float = 25.0
Replace with:
const AUTO_AIM_RANGE: float = 25.0
# A temporary stat bonus; each one counts as its own source.
class TimedBonus:
var stat: PassiveData.Stat
var amount: float
var time_left: float
Find this:
# --- Weapons ---
var weapons: Array[Weapon] = []
Replace with:
# --- Weapons ---
var weapons: Array[Weapon] = []
# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []
Find this in recalculate_stats():
for passive: OwnedPassive in passives:
_apply_bonus(passive.data.stat, passive.bonus())
Replace with:
for passive: OwnedPassive in passives:
_apply_bonus(passive.data.stat, passive.bonus())
for bonus: TimedBonus in _timed_bonuses:
_apply_bonus(bonus.stat, bonus.amount)
Find this at the start of _physics_process():
func _physics_process(delta: float) -> void:
_regenerate(delta)
Replace with:
func _physics_process(delta: float) -> void:
_tick_timed_bonuses(delta)
_regenerate(delta)
Add these functions below heal():
func add_timed_bonus(stat: PassiveData.Stat, amount: float, duration: float) -> void:
var bonus: TimedBonus = TimedBonus.new()
bonus.stat = stat
bonus.amount = amount
bonus.time_left = duration
_timed_bonuses.append(bonus)
recalculate_stats()
func _tick_timed_bonuses(delta: float) -> void:
var expired: bool = false
# Walk backwards so removing an entry doesn't skip the next one.
for i: int in range(_timed_bonuses.size() - 1, -1, -1):
_timed_bonuses[i].time_left -= delta
if _timed_bonuses[i].time_left <= 0.0:
_timed_bonuses.remove_at(i)
expired = true
if expired:
recalculate_stats()
class TimedBonus: is an inner class, a small class that lives inside player.gd because nothing else needs it. It’s just three variables bundled together.range(size - 1, -1, -1) means “from the last index, down to 0, stepping by -1”.recalculate_stats() only runs when something expires, not every frame.game_state counts kills for the run and emits enemy_killed whenever any enemy dies.
died signal?The innate needs to hear about every enemy. Connecting to died on hundreds of enemies as they spawn would be messy. One global signal on the autoload is simpler, and the run’s kill count is useful for the end screen and objectives (Phase 8).
File: res://scripts/autoload/game_state.gd
Find this:
signal boss_defeated
Replace with:
signal boss_defeated
signal enemy_killed(enemy: Enemy)
Find this:
var gold: int = 0
Replace with:
var gold: int = 0
var kills: int = 0
Find this in reset_run():
gold = 0
Replace with:
gold = 0
kills = 0
Add at the bottom:
func register_kill(enemy: Enemy) -> void:
kills += 1
enemy_killed.emit(enemy)
File: res://scripts/characters/enemy/enemy.gd
Find this:
func die() -> void:
_drop_loot()
died.emit(self)
queue_free()
Replace with:
func die() -> void:
_drop_loot()
game_state.register_kill(self)
died.emit(self)
queue_free()
Co-op note: Swarm’s innates count kills made by that player. That would need
take_damage()to know who dealt the damage (an extrasourceparameter passed down from weapons). For single-player, every kill is yours.
Ability base classA base class for all three kinds of ability, handling:
Weapons use WeaponData because they have 5 levels and appear as offers, so there’s a lot of data to manage. Abilities have no levels or offers; each one belongs to one character. Putting the few settings straight on the scene’s root node (as @exports) is the simplest thing that works. You set them in the Inspector when you build each ability scene.
File: res://scripts/characters/player/abilities/ability.gd
class_name Ability
extends Node3D
@export var display_name: String = ""
@export var icon: Texture2D
# Shown on the HUD, for example "E".
@export var key_hint: String = ""
# Leave empty for innate abilities that are never pressed.
@export var input_action: StringName = &""
@export var unlock_level: int = 1
@export var base_cooldown: float = 8.0
@export_group("Damage")
@export var base_damage: float = 0.0
@export var damage_per_level: float = 0.0
var player: Player
var cooldown_left: float = 0.0
func is_unlocked() -> bool:
return game_state.level >= unlock_level
func _physics_process(delta: float) -> void:
cooldown_left = maxf(0.0, cooldown_left - delta)
func _unhandled_input(event: InputEvent) -> void:
if input_action != &"" and event.is_action_pressed(input_action):
try_cast()
func try_cast() -> void:
if not is_unlocked() or cooldown_left > 0.0 or player.is_dead:
return
cast()
cooldown_left = get_cooldown()
func get_cooldown() -> float:
return base_cooldown / (1.0 + player.cdr / 100.0)
# Abilities grow with the run level, like Swarm.
func get_damage() -> float:
return (base_damage + damage_per_level * game_state.level) * player.damage_mult
func get_status_text() -> String:
if not is_unlocked():
return "unlocks at Lv %d" % unlock_level
if cooldown_left > 0.0:
return "%.1fs" % cooldown_left
return "READY"
# Each ability overrides this.
func cast() -> void:
pass
input_action != &"" stops Godot printing “action not found” errors for innates, which have no action.%.1f formats a number with one decimal place: 3.2s.E and R actions already exist in your project’s Input Map as special and ult. You set those up before this guide, so there’s nothing to add.Count kills. At 50 (or instantly on an elite kill), gain +100 CDR and +20% Move Speed for 6 seconds, then start counting again.
It mirrors Jinx’s “Get Excited!” from the wiki: it rewards aggressive play, and the elite shortcut makes elites feel even more worth chasing.
File: res://scripts/characters/player/abilities/adrenaline.gd
extends Ability
const KILLS_NEEDED: int = 50
const BONUS_CDR: float = 100.0
const BONUS_MOVE_SPEED: float = 0.2
const BONUS_DURATION: float = 6.0
var _kills: int = 0
func _ready() -> void:
game_state.enemy_killed.connect(_on_enemy_killed)
func _on_enemy_killed(enemy: Enemy) -> void:
if player.is_dead:
return
# An elite counts as a full set of kills.
_kills += KILLS_NEEDED if enemy.is_elite else 1
if _kills < KILLS_NEEDED:
return
_kills = 0
player.add_timed_bonus(PassiveData.Stat.CDR, BONUS_CDR, BONUS_DURATION)
player.add_timed_bonus(PassiveData.Stat.MOVE_SPEED, BONUS_MOVE_SPEED, BONUS_DURATION)
func get_status_text() -> String:
return "%d / %d kills" % [_kills, KILLS_NEEDED]
+100 CDR means cooldowns are halved (1 / (1 + 100/100) = 0.5), so every weapon fires twice as fast for 6 seconds. That’s very noticeable, which is exactly what an innate should be.
In the Godot editor:
Adrenaline, with adrenaline.gd attached.Adrenaline, Key Hint empty, Input Action empty, Unlock Level 1.res://scenes/characters/player/abilities/adrenaline.tscn.A scene that, the moment it’s added, damages every enemy in a radius and flashes an orange ring. The grenade, the rockets, and later the Phase 9 sea mine all use it.
Three things need “boom, damage in a circle, show it”. One scene means one place to improve the visuals (add particles or a sound later) and every explosion in the game gets the upgrade.
File: res://scripts/combat/explosion.gd
class_name Explosion
extends Node3D
const FLASH_TIME: float = 0.2
var radius: float = 3.0
var damage: float = 30.0
var crit_chance: float = 0.0
@onready var ring: MeshInstance3D = $Ring
# Set position, radius and damage before adding this to the scene.
func _ready() -> void:
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
ring.scale = Vector3(radius, 1.0, radius)
var tween: Tween = create_tween()
tween.tween_interval(FLASH_TIME)
tween.tween_callback(queue_free)
Why does the damage happen in _ready()? An explosion is instant. Doing it in _ready() means “the moment you’re added, explode”. That’s also why the comment says to set everything (including position) before adding it: _ready() runs during add_child().
In the Godot editor:
Explosion (Node3D) script: explosion.gd
└─ Ring (MeshInstance3D) CylinderMesh r 1, h 0.05; orange, alpha about 150, Unshaded; Position Y -0.95
Save as res://scenes/combat/explosion.tscn. Create the scenes/combat/ and scripts/combat/ folders if needed.
Press E (from level 3) to lob a grenade 8 m in your aim direction. It explodes on landing.
File: res://scripts/characters/player/abilities/frag_grenade.gd
extends Ability
const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
const THROW_DISTANCE: float = 8.0
const FLIGHT_TIME: float = 0.4
const BASE_RADIUS: float = 4.0
func cast() -> void:
var start: Vector3 = player.global_position
var target: Vector3 = start + player.aim_direction * THROW_DISTANCE
var grenade: MeshInstance3D = MeshInstance3D.new()
var mesh: SphereMesh = SphereMesh.new()
mesh.radius = 0.25
mesh.height = 0.5
grenade.mesh = mesh
grenade.position = start
get_tree().current_scene.add_child(grenade)
var tween: Tween = grenade.create_tween()
tween.tween_property(grenade, "position", target, FLIGHT_TIME)
tween.tween_callback(_explode.bind(target))
tween.tween_callback(grenade.queue_free)
func _explode(at: Vector3) -> void:
var explosion: Explosion = EXPLOSION_SCENE.instantiate()
explosion.radius = BASE_RADIUS * player.area_size_mult
explosion.damage = get_damage()
explosion.crit_chance = player.crit_chance
explosion.position = at
get_tree().current_scene.add_child(explosion)
_explode(target), then delete the grenade.grenade.create_tween() (not self.create_tween()) ties the tween to the grenade, so the tween is cleaned up along with it and pauses if the game pauses mid-throw.get_damage()), and its cooldown with CDR.In the Godot editor:
FragGrenade, with frag_grenade.gd attached.Frag Grenade, Key Hint E, Input Action special, Unlock Level 3, Base Cooldown 8. Under Damage: Base Damage 60, Damage Per Level 6.res://scenes/characters/player/abilities/frag_grenade.tscn.Press R (from level 6) to fire a cone of rockets (9 plus your projectile bonus). Each one explodes on the first enemy it hits.
The rockets are Projectiles with one extra ability: exploding instead of hitting one enemy. Adding an optional explosion_radius to Projectile means any future weapon can fire explosive shots too.
File: res://scripts/characters/player/weapons/projectile.gd
Find this:
class_name Projectile
extends Area3D
Replace with:
class_name Projectile
extends Area3D
const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
Find this:
var pierce: int = 0
Replace with:
var pierce: int = 0
# When above 0, hitting an enemy causes an explosion instead of a single hit.
var explosion_radius: float = 0.0
Find this in _on_body_entered():
var enemy: Enemy = body as Enemy
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
Replace with:
var enemy: Enemy = body as Enemy
if explosion_radius > 0.0:
_explode()
else:
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
Add at the bottom:
func _explode() -> void:
var explosion: Explosion = EXPLOSION_SCENE.instantiate()
explosion.radius = explosion_radius
explosion.damage = damage
explosion.crit_chance = crit_chance
explosion.position = global_position
get_tree().current_scene.add_child.call_deferred(explosion)
Why skip the direct hit? The explosion already covers the enemy that was hit. Doing both would double-damage it.
Why call_deferred here but not in the grenade? This runs inside body_entered, a physics callback. Kills from the explosion can spawn XP orbs (physics objects), so it’s safer to explode at the end of the frame. The grenade’s explosion comes from a tween, which isn’t a physics callback.
In the Godot editor: right-click bullet.tscn > Duplicate…, name it rocket.tscn, open it, and change the mesh to a red SphereMesh with radius 0.25 and height 0.5. It keeps projectile.gd and the same layer and mask.
File: res://scripts/characters/player/abilities/rocket_barrage.gd
extends Ability
const ROCKET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/rocket.tscn")
const BASE_ROCKETS: int = 9
const CONE_DEGREES: float = 50.0
const ROCKET_SPEED: float = 20.0
const ROCKET_LIFETIME: float = 1.5
const EXPLOSION_RADIUS: float = 2.0
func cast() -> void:
var count: int = BASE_ROCKETS + player.projectile
for i: int in count:
var angle: float = deg_to_rad(randf_range(-CONE_DEGREES, CONE_DEGREES) / 2.0)
var rocket: Projectile = ROCKET_SCENE.instantiate()
rocket.direction = player.aim_direction.rotated(Vector3.UP, angle)
rocket.speed = ROCKET_SPEED * randf_range(0.8, 1.2)
rocket.lifetime = ROCKET_LIFETIME
rocket.damage = get_damage()
rocket.crit_chance = player.crit_chance
rocket.explosion_radius = EXPLOSION_RADIUS * player.area_size_mult
get_tree().current_scene.add_child(rocket)
rocket.global_position = player.global_position + rocket.direction * 0.8
Why random angles and speeds? A neat, evenly spaced fan looks mechanical. Random angles within the cone and slightly different speeds make it look like a chaotic barrage, much closer to Swarm’s “rockets fly in random directions within a cone”.
It scales with Projectiles (more rockets), Area (bigger explosions), Damage, Crit and CDR, which is why abilities feel stronger as your build comes together.
In the Godot editor:
RocketBarrage, with rocket_barrage.gd attached.Rocket Barrage, Key Hint R, Input Action ult, Unlock Level 6, Base Cooldown 50. Base Damage 120, Damage Per Level 15.res://scenes/characters/player/abilities/rocket_barrage.tscn.CharacterData, spawning abilities, and the HUDCharacterDataFile: res://scripts/resources/character_data.gd
Find this:
@export_group("Loadout")
@export var signature_weapon: WeaponData
Replace with:
@export_group("Loadout")
@export var signature_weapon: WeaponData
@export_group("Abilities")
@export var innate_scene: PackedScene
@export var special_scene: PackedScene
@export var ultimate_scene: PackedScene
In the Godot editor: open gunner.tres and under Abilities set Innate Scene to adrenaline.tscn, Special Scene to frag_grenade.tscn, and Ultimate Scene to rocket_barrage.tscn.
A character is now completely described by one resource: model, base stat bonuses, signature weapon, and three abilities. A new character is “duplicate gunner.tres and swap things out”.
In the Godot editor: open player.tscn and add a Node3D child named Abilities.
File: res://scripts/characters/player/player.gd
Find this:
@onready var weapon_holder: Node3D = $Weapons
Replace with:
@onready var weapon_holder: Node3D = $Weapons
@onready var ability_holder: Node3D = $Abilities
Find this:
# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []
Replace with:
# --- Abilities ---
var abilities: Array[Ability] = []
# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []
Find this:
if character.signature_weapon:
_spawn_weapon(character.signature_weapon)
Replace with:
if character.signature_weapon:
_spawn_weapon(character.signature_weapon)
for scene: PackedScene in [character.innate_scene, character.special_scene, character.ultimate_scene]:
if scene:
_spawn_ability(scene)
func _spawn_ability(scene: PackedScene) -> void:
var ability: Ability = scene.instantiate()
ability.player = self
ability_holder.add_child(ability)
abilities.append(ability)
Find this in _die():
weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
Replace with:
weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
ability_holder.process_mode = Node.PROCESS_MODE_DISABLED
ability.player = self is set before add_child(), so the ability’s _ready() (Adrenaline connects a signal there) can rely on player being set.
In the Godot editor: open hud.tscn and add:
Hud (CanvasLayer)
├─ ...
└─ AbilityBox (VBoxContainer) Anchors Preset: Bottom Left; Position about (16, -110)
File: res://scripts/ui/hud.gd
Find this:
@onready var boss_bar: ProgressBar = $BossBox/BossBar
var player: Player
Replace with:
@onready var boss_bar: ProgressBar = $BossBox/BossBar
@onready var ability_box: VBoxContainer = $AbilityBox
var player: Player
var _ability_labels: Dictionary[Ability, Label] = {}
Find this at the end of setup():
_refresh_loadout()
Replace with:
_refresh_loadout()
_build_ability_labels()
Find this:
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]
Replace with:
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]
for ability: Ability in _ability_labels:
_ability_labels[ability].text = _ability_text(ability)
Add at the bottom:
func _build_ability_labels() -> void:
for ability: Ability in player.abilities:
var label: Label = Label.new()
ability_box.add_child(label)
_ability_labels[ability] = label
func _ability_text(ability: Ability) -> String:
var prefix: String = ("[%s] " % ability.key_hint) if ability.key_hint != "" else ""
return "%s%s: %s" % [prefix, ability.display_name, ability.get_status_text()]
Dictionary[Ability, Label] is a typed dictionary (Godot 4.4 and newer): every key must be an Ability and every value a Label. It works like a normal dictionary but catches mistakes earlier. Looping over a dictionary with for ability in _ability_labels goes through its keys.
Why update in _process()? Cooldowns tick down every frame, so the text changes every frame anyway. This is the same reasoning as the timer label.
Adrenaline: 0 / 50 kills
[E] Frag Grenade: unlocks at Lv 3
[R] Rocket Barrage: unlocks at Lv 6
If something breaks:
| Problem | Likely cause |
|---|---|
Invalid access to property 'is_dead' on a base object of type 'Nil' |
ability.player wasn’t set before add_child(). |
| E or R does nothing | Input Action doesn’t exactly match the Input Map name (special, ult), or the level isn’t high enough. |
| Buff never ends | _tick_timed_bonuses(delta) isn’t called in _physics_process(). |
| Buff disappears when picking a passive | The _timed_bonuses loop is missing from recalculate_stats(). |
Can't change this state while flushing queries from rockets |
_explode() in projectile.gd isn’t using call_deferred. |
Dictionary[Ability, Label] is a parse error |
Your Godot is older than 4.4. Use var _ability_labels: Dictionary = {} instead. |
File: res://scripts/characters/player/abilities/ability.gd
class_name Ability
extends Node3D
@export var display_name: String = ""
@export var icon: Texture2D
# Shown on the HUD, for example "E".
@export var key_hint: String = ""
# Leave empty for innate abilities that are never pressed.
@export var input_action: StringName = &""
@export var unlock_level: int = 1
@export var base_cooldown: float = 8.0
@export_group("Damage")
@export var base_damage: float = 0.0
@export var damage_per_level: float = 0.0
var player: Player
var cooldown_left: float = 0.0
func is_unlocked() -> bool:
return game_state.level >= unlock_level
func _physics_process(delta: float) -> void:
cooldown_left = maxf(0.0, cooldown_left - delta)
func _unhandled_input(event: InputEvent) -> void:
if input_action != &"" and event.is_action_pressed(input_action):
try_cast()
func try_cast() -> void:
if not is_unlocked() or cooldown_left > 0.0 or player.is_dead:
return
cast()
cooldown_left = get_cooldown()
func get_cooldown() -> float:
return base_cooldown / (1.0 + player.cdr / 100.0)
# Abilities grow with the run level, like Swarm.
func get_damage() -> float:
return (base_damage + damage_per_level * game_state.level) * player.damage_mult
func get_status_text() -> String:
if not is_unlocked():
return "unlocks at Lv %d" % unlock_level
if cooldown_left > 0.0:
return "%.1fs" % cooldown_left
return "READY"
# Each ability overrides this.
func cast() -> void:
pass
File: res://scripts/characters/player/abilities/adrenaline.gd
extends Ability
const KILLS_NEEDED: int = 50
const BONUS_CDR: float = 100.0
const BONUS_MOVE_SPEED: float = 0.2
const BONUS_DURATION: float = 6.0
var _kills: int = 0
func _ready() -> void:
game_state.enemy_killed.connect(_on_enemy_killed)
func _on_enemy_killed(enemy: Enemy) -> void:
if player.is_dead:
return
# An elite counts as a full set of kills.
_kills += KILLS_NEEDED if enemy.is_elite else 1
if _kills < KILLS_NEEDED:
return
_kills = 0
player.add_timed_bonus(PassiveData.Stat.CDR, BONUS_CDR, BONUS_DURATION)
player.add_timed_bonus(PassiveData.Stat.MOVE_SPEED, BONUS_MOVE_SPEED, BONUS_DURATION)
func get_status_text() -> String:
return "%d / %d kills" % [_kills, KILLS_NEEDED]
File: res://scripts/combat/explosion.gd
class_name Explosion
extends Node3D
const FLASH_TIME: float = 0.2
var radius: float = 3.0
var damage: float = 30.0
var crit_chance: float = 0.0
@onready var ring: MeshInstance3D = $Ring
# Set position, radius and damage before adding this to the scene.
func _ready() -> void:
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
ring.scale = Vector3(radius, 1.0, radius)
var tween: Tween = create_tween()
tween.tween_interval(FLASH_TIME)
tween.tween_callback(queue_free)
File: res://scripts/characters/player/abilities/frag_grenade.gd
extends Ability
const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
const THROW_DISTANCE: float = 8.0
const FLIGHT_TIME: float = 0.4
const BASE_RADIUS: float = 4.0
func cast() -> void:
var start: Vector3 = player.global_position
var target: Vector3 = start + player.aim_direction * THROW_DISTANCE
var grenade: MeshInstance3D = MeshInstance3D.new()
var mesh: SphereMesh = SphereMesh.new()
mesh.radius = 0.25
mesh.height = 0.5
grenade.mesh = mesh
grenade.position = start
get_tree().current_scene.add_child(grenade)
var tween: Tween = grenade.create_tween()
tween.tween_property(grenade, "position", target, FLIGHT_TIME)
tween.tween_callback(_explode.bind(target))
tween.tween_callback(grenade.queue_free)
func _explode(at: Vector3) -> void:
var explosion: Explosion = EXPLOSION_SCENE.instantiate()
explosion.radius = BASE_RADIUS * player.area_size_mult
explosion.damage = get_damage()
explosion.crit_chance = player.crit_chance
explosion.position = at
get_tree().current_scene.add_child(explosion)
File: res://scripts/characters/player/abilities/rocket_barrage.gd
extends Ability
const ROCKET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/rocket.tscn")
const BASE_ROCKETS: int = 9
const CONE_DEGREES: float = 50.0
const ROCKET_SPEED: float = 20.0
const ROCKET_LIFETIME: float = 1.5
const EXPLOSION_RADIUS: float = 2.0
func cast() -> void:
var count: int = BASE_ROCKETS + player.projectile
for i: int in count:
var angle: float = deg_to_rad(randf_range(-CONE_DEGREES, CONE_DEGREES) / 2.0)
var rocket: Projectile = ROCKET_SCENE.instantiate()
rocket.direction = player.aim_direction.rotated(Vector3.UP, angle)
rocket.speed = ROCKET_SPEED * randf_range(0.8, 1.2)
rocket.lifetime = ROCKET_LIFETIME
rocket.damage = get_damage()
rocket.crit_chance = player.crit_chance
rocket.explosion_radius = EXPLOSION_RADIUS * player.area_size_mult
get_tree().current_scene.add_child(rocket)
rocket.global_position = player.global_position + rocket.direction * 0.8
File: res://scripts/characters/player/weapons/projectile.gd
class_name Projectile
extends Area3D
const EXPLOSION_SCENE: PackedScene = preload("res://scenes/combat/explosion.tscn")
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
# When above 0, hitting an enemy causes an explosion instead of a single hit.
var explosion_radius: float = 0.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _physics_process(delta: float) -> void:
global_position += direction * speed * delta
lifetime -= delta
if lifetime <= 0.0:
queue_free()
func _on_body_entered(body: Node3D) -> void:
# Several enemies can be touched in the same frame; stop once used up.
if pierce < 0:
return
if not (body is Enemy):
return
var enemy: Enemy = body as Enemy
if explosion_radius > 0.0:
_explode()
else:
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
pierce -= 1
if pierce < 0:
queue_free()
func _explode() -> void:
var explosion: Explosion = EXPLOSION_SCENE.instantiate()
explosion.radius = explosion_radius
explosion.damage = damage
explosion.crit_chance = crit_chance
explosion.position = global_position
get_tree().current_scene.add_child.call_deferred(explosion)
File: res://scripts/resources/character_data.gd
class_name CharacterData
extends GameData
@export var model_scene: PackedScene
# Bonuses are additive on top of player.gd's base stats, so they can only raise them.
@export_group("Stat Bonuses")
@export_range(0, 500) var bonus_max_hp: int = 0
@export_range(0, 25) var bonus_defence: int = 0
@export_group("Loadout")
@export var signature_weapon: WeaponData
@export_group("Abilities")
@export var innate_scene: PackedScene
@export var special_scene: PackedScene
@export var ultimate_scene: PackedScene
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
signal boss_spawned(boss: Enemy)
signal boss_defeated
signal enemy_killed(enemy: Enemy)
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
var kills: 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
kills = 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)
func register_kill(enemy: Enemy) -> void:
kills += 1
enemy_killed.emit(enemy)
File: res://scripts/characters/enemy/enemy.gd
class_name Enemy
extends CharacterBody3D
signal died(enemy: Enemy)
signal health_changed(current: float, maximum: float)
const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
const ACCESS_CARD_SCENE: PackedScene = preload("res://scenes/props/pickups/access_card.tscn")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0
const ELITE_HP_MULT: float = 8.0
const ELITE_XP_MULT: float = 10.0
const ELITE_SIZE: float = 1.4
@export var data: EnemyData
# Set by the spawner before the enemy is added to the scene.
@export var is_elite: bool = false
@onready var model_holder: Node3D = $Model
@onready var collision_shape: CollisionShape3D = $CollisionShape3D
@onready var elite_glow: MeshInstance3D = $Model/EliteGlow
var max_hp: float = 0.0
var current_hp: float = 0.0
var damage_mult: float = 1.0
var model_scale: float = 1.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween
var _knockback: Vector3 = Vector3.ZERO
func _ready() -> void:
add_to_group("enemies")
max_hp = data.max_hp
if data.model_scene:
model_holder.add_child(data.model_scene.instantiate())
elite_glow.visible = false
if is_elite:
_make_elite()
current_hp = max_hp
func _make_elite() -> void:
max_hp *= ELITE_HP_MULT
model_scale = ELITE_SIZE
model_holder.scale = Vector3.ONE * model_scale
elite_glow.visible = true
# Shapes are shared by every enemy using this scene, so resize a copy.
var capsule: CapsuleShape3D = collision_shape.shape.duplicate()
capsule.radius *= ELITE_SIZE
capsule.height *= ELITE_SIZE
collision_shape.shape = capsule
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta
var target: Player = Targeting.nearest_player(get_tree(), global_position)
var move: Vector3 = Vector3.ZERO
var distance: float = INF
if target:
var to_target: Vector3 = target.global_position - global_position
to_target.y = 0.0
distance = to_target.length()
if distance > 0.01:
var direction: Vector3 = to_target / distance
move = direction * data.move_speed
# Same convention as the player: the model's front is +X.
model_holder.rotation.y = atan2(-direction.z, direction.x)
velocity.x = move.x + _knockback.x
velocity.z = move.z + _knockback.z
_knockback = _knockback.move_toward(Vector3.ZERO, KNOCKBACK_DECAY * delta)
move_and_slide()
_attack_cooldown_left -= delta
if target and distance <= data.attack_range and _attack_cooldown_left <= 0.0:
target.take_damage(data.damage * damage_mult)
_attack_cooldown_left = data.attack_cooldown
func take_damage(amount: float) -> void:
if current_hp <= 0.0:
return
current_hp -= amount
health_changed.emit(current_hp, max_hp)
_play_hit_flash()
if current_hp <= 0.0:
die()
func apply_knockback(force: Vector3) -> void:
_knockback += force
func die() -> void:
_drop_loot()
game_state.register_kill(self)
died.emit(self)
queue_free()
func _drop_loot() -> void:
var orb: XpOrb = XP_ORB_SCENE.instantiate()
orb.amount = data.xp_value * (ELITE_XP_MULT if is_elite else 1.0)
_spawn_here(orb)
if is_elite or data.drops_access_card:
_spawn_here(ACCESS_CARD_SCENE.instantiate())
# Deferred: die() often runs inside a physics callback, where adding physics objects isn't allowed.
func _spawn_here(node: Node3D) -> void:
node.position = global_position
get_tree().current_scene.add_child.call_deferred(node)
func _play_hit_flash() -> void:
if _flash_tween:
_flash_tween.kill()
model_holder.scale = Vector3.ONE * model_scale * 1.25
_flash_tween = create_tween()
_flash_tween.tween_property(model_holder, "scale", Vector3.ONE * model_scale, 0.1)
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
@onready var boss_box: VBoxContainer = $BossBox
@onready var boss_name_label: Label = $BossBox/BossName
@onready var boss_bar: ProgressBar = $BossBox/BossBar
@onready var ability_box: VBoxContainer = $AbilityBox
var player: Player
var _ability_labels: Dictionary[Ability, Label] = {}
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)
game_state.boss_spawned.connect(_on_boss_spawned)
boss_box.visible = false
_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()
_build_ability_labels()
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]
for ability: Ability in _ability_labels:
_ability_labels[ability].text = _ability_text(ability)
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
func _on_boss_spawned(boss: Enemy) -> void:
boss_box.visible = true
boss_name_label.text = boss.data.display_name
boss.health_changed.connect(_on_boss_health_changed)
_on_boss_health_changed(boss.current_hp, boss.max_hp)
func _on_boss_health_changed(current: float, maximum: float) -> void:
boss_bar.max_value = maximum
boss_bar.value = current
func _build_ability_labels() -> void:
for ability: Ability in player.abilities:
var label: Label = Label.new()
ability_box.add_child(label)
_ability_labels[ability] = label
func _ability_text(ability: Ability) -> String:
var prefix: String = ("[%s] " % ability.key_hint) if ability.key_hint != "" else ""
return "%s%s: %s" % [prefix, ability.display_name, ability.get_status_text()]
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
# A temporary stat bonus; each one counts as its own source.
class TimedBonus:
var stat: PassiveData.Stat
var amount: float
var time_left: float
@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons
@onready var ability_holder: Node3D = $Abilities
@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] = []
# --- Abilities ---
var abilities: Array[Ability] = []
# --- Temporary buffs ---
var _timed_bonuses: Array[TimedBonus] = []
# --- 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)
for scene: PackedScene in [character.innate_scene, character.special_scene, character.ultimate_scene]:
if scene:
_spawn_ability(scene)
func _spawn_ability(scene: PackedScene) -> void:
var ability: Ability = scene.instantiate()
ability.player = self
ability_holder.add_child(ability)
abilities.append(ability)
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())
for bonus: TimedBonus in _timed_bonuses:
_apply_bonus(bonus.stat, bonus.amount)
(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 add_timed_bonus(stat: PassiveData.Stat, amount: float, duration: float) -> void:
var bonus: TimedBonus = TimedBonus.new()
bonus.stat = stat
bonus.amount = amount
bonus.time_left = duration
_timed_bonuses.append(bonus)
recalculate_stats()
func _tick_timed_bonuses(delta: float) -> void:
var expired: bool = false
# Walk backwards so removing an entry doesn't skip the next one.
for i: int in range(_timed_bonuses.size() - 1, -1, -1):
_timed_bonuses[i].time_left -= delta
if _timed_bonuses[i].time_left <= 0.0:
_timed_bonuses.remove_at(i)
expired = true
if expired:
recalculate_stats()
func _on_pickup_area_entered(area: Area3D) -> void:
if area is Pickup:
(area as Pickup).attract(self)
func _physics_process(delta: float) -> void:
_tick_timed_bonuses(delta)
_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
ability_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)