Goal: enemies drop XP orbs that fly to you when you get close. Filling the XP bar pauses the game and offers 3 random choices (new passives, passive levels, weapon levels). A HUD shows health, XP, level, gold, the run timer, and your weapons and passives.
Checklist
game_statemain.gd (plus a debug key)The XP counter comes before the orbs this time, because orbs call
game_state.add_xp()when collected.
game_stategame_state tracks the run’s level, XP, XP needed for the next level, and gold. It announces changes through signals.
game_state and not on the player?In Swarm, XP is shared: any player picking up an orb levels up the whole team together. Putting XP in the autoload already works that way, so co-op wouldn’t need it moved. It’s also exactly what your comment at the top of game_state.gd says the file is for.
while loop in add_xp()?A big XP pickup (the boss, or a vacuum in Phase 9) could be worth enough for two levels at once. An if would only level you once and leave you with an overfull bar. The while keeps levelling until the leftover XP fits, and emits leveled_up once per level, so the level-up screen queues up one choice per level.
File: res://scripts/autoload/game_state.gd
Find this:
var selected_character: CharacterData
Replace with:
signal xp_changed(current: float, required: float)
signal leveled_up(new_level: int)
signal gold_changed(total: int)
const MAX_LEVEL: int = 99
var selected_character: CharacterData
Find this:
# --- Per-run tracking ---
var run_time: float = 0.0
Replace with:
# --- 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
Find the reset_run() function and replace it with:
func reset_run() -> void:
run_time = 0.0
level = 1
xp = 0.0
xp_to_next = xp_required(1)
gold = 0
# XP needed to go from this level to the next. Tune freely.
func xp_required(from_level: int) -> float:
return 5.0 + 10.0 * (from_level - 1)
func add_xp(amount: float) -> void:
if level >= MAX_LEVEL:
return
xp += amount
while xp >= xp_to_next and level < MAX_LEVEL:
xp -= xp_to_next
level += 1
xp_to_next = xp_required(level)
leveled_up.emit(level)
xp_changed.emit(xp, xp_to_next)
func add_gold(amount: int) -> void:
gold += amount
gold_changed.emit(gold)
game_state now has a variable called level, and start_run() has a parameter also called level. Inside that function the parameter would hide the variable, which Godot warns about (“shadowing”). Rename the parameter:
Find this:
func start_run(character: CharacterData, level: PackedScene) -> void:
selected_character = character
selected_level = level
Replace with:
func start_run(character: CharacterData, level_scene: PackedScene) -> void:
selected_character = character
selected_level = level_scene
5 + 10 × (level - 1) means 5 XP for level 2, then 15, 25, 35, and so on. Each level asks for a bit more, so early levels come quickly (lots of choices to start) and later ones slow down. This is the single best number to tweak if levelling feels too fast or slow. Swarm caps at level 99, so we do too.
Pickup base class: something on the ground that, once attracted, flies to a player and is collected.XpOrb: a pickup that gives XP.pickup_range.Pickup base class?XP orbs are the first pickup, but not the last. Access Cards (Phase 6), gold coins (Phase 8) and heal packs (Phase 9) all need “fly to the player, then do something”. Writing the flying part once means each new pickup only writes collect().
Hundreds of orbs might be lying around. If each one checked the distance to the player every frame, that’s hundreds of checks per frame for orbs that are nowhere near you. Instead, the orbs sit completely idle (physics processing switched off). The player’s pickup area does the detecting: Godot’s physics engine tells us when an orb enters it, and only then does that orb wake up.
Pickup base classFile: res://scripts/props/pickups/pickup.gd
class_name Pickup
extends Area3D
const ATTRACT_ACCELERATION: float = 40.0
const COLLECT_DISTANCE: float = 0.6
var _target: Player = null
var _speed: float = 0.0
func _ready() -> void:
add_to_group("pickups")
# Idle pickups cost nothing until a player attracts them.
set_physics_process(false)
func attract(player: Player) -> void:
if _target != null:
return
_target = player
set_physics_process(true)
func _physics_process(delta: float) -> void:
if not is_instance_valid(_target) or _target.is_dead:
_target = null
_speed = 0.0
set_physics_process(false)
return
_speed += ATTRACT_ACCELERATION * delta
var to_target: Vector3 = _target.global_position - global_position
var distance: float = to_target.length()
if distance <= COLLECT_DISTANCE:
collect(_target)
queue_free()
return
global_position += to_target / distance * minf(_speed * delta, distance)
# Each pickup type overrides this.
func collect(_player: Player) -> void:
pass
How it works:
set_physics_process(false) in _ready() puts the pickup to sleep. attract() wakes it up._speed grows every frame, so the orb starts slowly and then zips in. That “whoosh” feel is part of what makes collecting satisfying, and it means the orb can’t fall behind you even if you’re running.minf(_speed * delta, distance) stops the orb overshooting you when it’s very fast and very close.is_instance_valid(_target) guards against the player being deleted, for example when the scene changes. If the target died, the orb gives up and goes back to sleep.add_to_group("pickups") isn’t used yet; Phase 9’s vacuum pickup will use it.File: res://scripts/props/pickups/xp_orb.gd
class_name XpOrb
extends Pickup
var amount: float = 1.0
func collect(player: Player) -> void:
game_state.add_xp(amount * player.xp_mult)
The player who picks it up applies their XP bonus, and the XP goes into the shared pool.
In the Godot editor:
XpOrb.0.3.0.2, height 0.4, and a StandardMaterial3D that’s green with Emission on (green).XpOrb, under Collision: Layer only 4 (interactable); Mask none. Untick Monitoring; keep Monitorable ticked.xp_orb.gd and save as res://scenes/props/pickups/xp_orb.tscn.XpOrb (Area3D) script: xp_orb.gd, layer 4, no mask, Monitoring off
├─ CollisionShape3D SphereShape3D r 0.3
└─ MeshInstance3D green glowing sphere
Monitoring vs Monitorable: Monitoring means “I look for things overlapping me”. Monitorable means “other areas can see me”. The orb doesn’t need to look for anything (the player’s area does that), so Monitoring is off. That’s one less thing for physics to check, times every orb on the floor.
In the Godot editor:
player.tscn. Under Player, add an Area3D named PickupArea.3 (code will overwrite this).PickupArea, under Collision: Layer none; Mask only 4. Keep Monitoring on; untick Monitorable.Player (CharacterBody3D)
├─ ...
└─ PickupArea (Area3D) no layer, mask 4
└─ CollisionShape3D SphereShape3D, Local to Scene
Why “Local to Scene”? Resources like shapes are shared by default: every copy of
player.tscnuses the exact same SphereShape3D. Changing its radius for one player would change it for all of them. With one player you’d never notice, but in co-op one player’s Pickup Range passive would grow everyone’s radius. Local to Scene gives each copy its own shape.
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 pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D
Find this:
func _ready() -> void:
add_to_group("players")
Replace with:
func _ready() -> void:
add_to_group("players")
pickup_area.area_entered.connect(_on_pickup_area_entered)
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())
(pickup_shape.shape as SphereShape3D).radius = pickup_range
Add this function anywhere in the file:
func _on_pickup_area_entered(area: Area3D) -> void:
if area is Pickup:
(area as Pickup).attract(self)
Because the radius is set in recalculate_stats(), picking up a Pickup Range passive grows the sphere straight away. Godot notices any orbs that are now inside the bigger sphere and calls area_entered for them.
File: res://scripts/resources/enemy_data.gd
Add at the bottom:
@export var xp_value: float = 1.0
File: res://scripts/characters/enemy/enemy.gd
Find this:
signal died(enemy: Enemy)
Replace with:
signal died(enemy: Enemy)
const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
Find this:
func die() -> void:
died.emit(self)
queue_free()
Replace with:
func die() -> void:
_drop_loot()
died.emit(self)
queue_free()
func _drop_loot() -> void:
var orb: XpOrb = XP_ORB_SCENE.instantiate()
orb.amount = data.xp_value
_spawn_here(orb)
# Deferred: die() often runs inside a physics callback, where adding physics objects isn't allowed.
func _spawn_here(node: Node3D) -> void:
node.position = global_position
get_tree().current_scene.add_child.call_deferred(node)
call_deferred?Enemies usually die inside a bullet’s body_entered signal, which runs while Godot’s physics engine is in the middle of working out collisions. Adding a new physics object (the orb is an Area3D) at that exact moment isn’t allowed. Godot would print “Can’t change this state while flushing queries. Use call_deferred()”. call_deferred means “do this at the end of the frame, once physics is finished”.
Why set position rather than global_position? The orb isn’t in the scene yet (it’s added at the end of the frame), so it has no global position to set. Because Main sits at the world origin (see the README), position before adding equals global_position after.
UpgradeOffer: one choice on the level-up screen (its title, description, icon, and what happens when you pick it).UpgradeOffers: builds the list of possible choices using Swarm’s rules, then picks 3 at random.Upgrades autoload listing every passive (and later, every weapon) that can be offered.The level-up screen (3.4) only knows how to display a list of offers and call apply() on the one you click. Everything about which offers exist lives in UpgradeOffers. That means:
The offer needs text like “Damage +20%”. PassiveData can produce that itself.
File: res://scripts/resources/passive_data.gd
Add at the bottom:
func value_at(level: int) -> float:
return values[clampi(level - 1, 0, values.size() - 1)]
func is_percent() -> bool:
match stat:
Stat.DAMAGE_MULT, Stat.MOVE_SPEED, Stat.PICKUP_RANGE, Stat.XP_MULT, \
Stat.GOLD_MULT, Stat.AREA_SIZE_MULT, Stat.DURATION_MULT:
return true
return false
func describe_level(level: int) -> String:
var value: float = value_at(level)
if is_percent():
return "%s +%d%%" % [display_name, roundi(value * 100.0)]
return "%s +%s" % [display_name, String.num(value)]
is_percent() lists the same stats that multiply in _apply_bonus() (Phase 0). These are the ones stored as fractions.%% in a format string prints a literal %.String.num(10.0) prints 10 rather than 10.0, which reads better.\ at the end of a line continues the pattern list onto the next line.Make these in res://resources/passives/ (right-click > New Resource… > PassiveData). Use res://icon.svg as the icon for now.
| File | Id | Display Name | Stat | Values |
|---|---|---|---|---|
damage.tres (exists) |
passive_damage |
Damage | DAMAGE_MULT | 0.1, 0.2, 0.3, 0.4, 0.5 |
cdr.tres |
passive_cdr |
Cooldown | CDR | 10, 20, 30, 40, 50 |
projectile.tres |
passive_projectile |
Projectiles | PROJECTILE | 1, 1, 2, 2, 3 |
max_hp.tres |
passive_max_hp |
Max Health | MAX_HP | 150, 300, 450, 600, 750 |
move_speed.tres |
passive_move_speed |
Move Speed | MOVE_SPEED | 0.08, 0.16, 0.24, 0.32, 0.4 |
pickup_range.tres |
passive_pickup_range |
Pickup Range | PICKUP_RANGE | 0.3, 0.6, 0.9, 1.2, 1.5 |
Remember the Phase 0 rule: percentage stats as fractions, flat stats as plain numbers.
Upgrades autoloadFile: res://scripts/autoload/upgrades.gd
extends Node
# Everything that can show up as a level-up choice.
# Signature weapons aren't listed; each character brings its own.
var weapons: Array[WeaponData] = []
var passives: Array[PassiveData] = [
preload("res://resources/passives/damage.tres"),
preload("res://resources/passives/cdr.tres"),
preload("res://resources/passives/projectile.tres"),
preload("res://resources/passives/max_hp.tres"),
preload("res://resources/passives/move_speed.tres"),
preload("res://resources/passives/pickup_range.tres"),
]
This follows the same pattern as your Characters autoload. weapons is empty until Phase 4.
In the Godot editor: Project > Project Settings > Globals > Autoload. Pick upgrades.gd as the path, set the name to Upgrades, and click Add.
The offers need to ask the player “do you own this passive?”, level up a weapon, heal, and announce “my loadout changed” so the HUD can redraw.
File: res://scripts/characters/player/player.gd
Find this:
signal died
Replace with:
signal died
signal loadout_changed
Find the whole add_passive() function and replace it with:
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 level_up_weapon(weapon: Weapon) -> bool:
if weapon.is_max_level():
return false
weapon.set_level(weapon.level + 1)
loadout_changed.emit()
return true
func heal(amount: float) -> void:
if is_dead:
return
current_hp = minf(current_hp + amount, max_hp)
health_changed.emit(current_hp, max_hp)
add_passive() does the same job as before, but the “find it” loop is now its own function (get_passive()), because the offer rules need it too.
UpgradeOfferFile: res://scripts/systems/upgrade_offer.gd
class_name UpgradeOffer
extends RefCounted
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD }
const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25
var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon
func apply(player: Player) -> void:
match kind:
Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
player.add_passive(passive_data)
Kind.WEAPON_LEVEL:
player.level_up_weapon(weapon)
Kind.HEAL:
player.heal(player.max_hp * HEAL_FRACTION)
Kind.GOLD:
game_state.add_gold(FALLBACK_GOLD)
Why RefCounted? An offer is a short-lived bundle of data. RefCounted objects delete themselves automatically once nothing refers to them, so you never need to free them.
Why an enum Kind? It’s a fixed list of named options. match kind: reads much more clearly than comparing strings, and a typo like Kind.HAEL is caught by the editor instead of silently doing nothing.
UpgradeOffersFile: res://scripts/systems/upgrade_offers.gd
class_name UpgradeOffers
extends RefCounted
static func build_level_up(player: Player, count: int = 3) -> Array[UpgradeOffer]:
var candidates: Array[UpgradeOffer] = []
_add_weapon_offers(player, candidates)
_add_passive_offers(player, candidates)
if candidates.is_empty():
return _fallback_offers()
candidates.shuffle()
if candidates.size() > count:
candidates.resize(count)
return candidates
static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for weapon: Weapon in player.weapons:
if weapon.is_max_level():
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
offer.weapon = weapon
offer.title = "%s Lv %d" % [weapon.get_display_name(), weapon.level + 1]
offer.description = weapon.data.level_stats(weapon.level + 1).description
offer.icon = weapon.get_icon()
candidates.append(offer)
static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for passive: OwnedPassive in player.passives:
if passive.level < Player.MAX_PASSIVE_LEVEL:
candidates.append(_passive_offer(passive.data, passive.level + 1))
if player.passives.size() >= player.passive_slots:
return
for data: PassiveData in Upgrades.passives:
if player.get_passive(data) == null:
candidates.append(_passive_offer(data, 1))
static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
var offer: UpgradeOffer = UpgradeOffer.new()
offer.passive_data = data
offer.description = data.describe_level(level)
offer.icon = data.icon
if level == 1:
offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
offer.title = "NEW %s" % data.display_name
else:
offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
offer.title = "%s Lv %d" % [data.display_name, level]
return offer
static func _fallback_offers() -> Array[UpgradeOffer]:
var heal: UpgradeOffer = UpgradeOffer.new()
heal.kind = UpgradeOffer.Kind.HEAL
heal.title = "Patch Up"
heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
var gold: UpgradeOffer = UpgradeOffer.new()
gold.kind = UpgradeOffer.Kind.GOLD
gold.title = "Pocket Change"
gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
var offers: Array[UpgradeOffer] = [heal, gold]
return offers
candidates, following the rules listed at the top of this step.count (3). Every valid choice has the same chance.Arrays are passed by reference. When build_level_up() passes candidates into _add_weapon_offers(), both functions are looking at the same array. Appending inside the helper adds to the original. That’s why the helpers don’t need to return anything.
Why are these static? UpgradeOffers has no state of its own; it reads the player and the Upgrades list and returns a result. Static functions make that clear: you call UpgradeOffers.build_level_up(player) without creating anything first.
Later: Swarm doesn’t weight choices, but many games make owned items more likely than new ones. If you want that, this is the only file you’d change.
A CanvasLayer that listens for game_state.leveled_up, pauses the game, shows the offers as buttons, applies the one you click, and unpauses. If several level-ups happened at once, it shows them one after another.
If one orb gives you two levels, leveled_up fires twice in the same frame. Without a queue, the second call would overwrite the first set of choices. The queue holds one entry per pending choice; each time you pick, the next one is shown, and the game only unpauses once the queue is empty.
The queue stores a reason rather than just a counter, because Phase 5 adds a second reason (Access Cards) that builds its choices differently.
File: res://scripts/ui/level_up_screen.gd
class_name LevelUpScreen
extends CanvasLayer
enum Reason { LEVEL_UP }
const CHOICE_SIZE: Vector2 = Vector2(560, 96)
const ICON_SIZE: int = 64
@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var choices_box: VBoxContainer = $Center/Panel/Margin/VBox/Choices
var player: Player
# Holds Reason values, one per pending pick.
var _queue: Array[int] = []
func _ready() -> void:
visible = false
game_state.leveled_up.connect(_on_leveled_up)
func _on_leveled_up(_new_level: int) -> void:
_enqueue(Reason.LEVEL_UP)
func _enqueue(reason: Reason) -> void:
_queue.append(reason)
if not visible:
_show_next()
func _show_next() -> void:
if _queue.is_empty():
visible = false
get_tree().paused = false
return
var reason: int = _queue.pop_front()
var offers: Array[UpgradeOffer] = []
match reason:
Reason.LEVEL_UP:
title_label.text = "Level Up!"
offers = UpgradeOffers.build_level_up(player)
_show_offers(offers)
visible = true
get_tree().paused = true
func _show_offers(offers: Array[UpgradeOffer]) -> void:
for child: Node in choices_box.get_children():
child.queue_free()
var first_button: Button = null
for offer: UpgradeOffer in offers:
var button: Button = Button.new()
button.text = "%s\n%s" % [offer.title, offer.description]
button.icon = offer.icon
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = CHOICE_SIZE
button.add_theme_constant_override("icon_max_width", ICON_SIZE)
button.pressed.connect(_on_offer_chosen.bind(offer))
choices_box.add_child(button)
if first_button == null:
first_button = button
if first_button:
first_button.grab_focus()
func _on_offer_chosen(offer: UpgradeOffer) -> void:
offer.apply(player)
_show_next()
_queue: Array[int] stores Reason values. Enum values are just integers underneath, and an int array is the most reliable way to store them..bind(offer) attaches the offer to the button’s signal. When that button is pressed, _on_offer_chosen receives that button’s offer. Without bind, all three buttons would call the same function with no way to tell which was clicked.icon_max_width keeps big icons (like the 128 px icon.svg) from making the buttons enormous.grab_focus() on the first button means the arrow keys and Enter (or a gamepad) work without a mouse.queue_free(). They vanish at the end of the frame, which is why we keep track of first_button instead of using get_child(0) (which could still be an old button).In the Godot editor:
LevelUpScreen.10; Process > Mode Always.LevelUpScreen (CanvasLayer) script: level_up_screen.gd, Layer 10, Process Mode Always
├─ Dimmer (ColorRect) Anchors Preset: Full Rect, Color black with alpha about 150
└─ Center (CenterContainer) Anchors Preset: Full Rect
└─ Panel (PanelContainer)
└─ Margin (MarginContainer) all margins 24
└─ VBox (VBoxContainer) Separation 16
├─ Title (Label) Horizontal Alignment Center, font size 40
└─ Choices (VBoxContainer) Separation 8
res://scenes/ui/level_up_screen.tscn.The Dimmer darkens the game behind the menu so it’s obvious the game is paused. It also blocks mouse clicks from reaching anything underneath.
A CanvasLayer showing:
The HUD could read player.current_hp 60 times a second, but most frames nothing has changed. Listening to health_changed, xp_changed, loadout_changed and so on means the HUD only does work when something actually changes. The timer is the exception: it changes every frame anyway, so it updates in _process().
File: res://scripts/ui/hud.gd
class_name Hud
extends CanvasLayer
const SLOT_SIZE: Vector2 = Vector2(48, 48)
@onready var hp_bar: ProgressBar = $TopBar/HBox/HpBar
@onready var level_label: Label = $TopBar/HBox/LevelLabel
@onready var gold_label: Label = $TopBar/HBox/GoldLabel
@onready var timer_label: Label = $TopBar/HBox/TimerLabel
@onready var weapon_icons: HBoxContainer = $Loadout/WeaponIcons
@onready var passive_icons: HBoxContainer = $Loadout/PassiveIcons
@onready var xp_bar: ProgressBar = $XpBar
var player: Player
func setup(target_player: Player) -> void:
player = target_player
player.health_changed.connect(_on_health_changed)
player.loadout_changed.connect(_refresh_loadout)
game_state.xp_changed.connect(_on_xp_changed)
game_state.leveled_up.connect(_on_leveled_up)
game_state.gold_changed.connect(_on_gold_changed)
_on_health_changed(player.current_hp, player.max_hp)
_on_xp_changed(game_state.xp, game_state.xp_to_next)
_on_leveled_up(game_state.level)
_on_gold_changed(game_state.gold)
_refresh_loadout()
func _process(_delta: float) -> void:
var total_seconds: int = floori(game_state.run_time)
timer_label.text = "%02d:%02d" % [floori(total_seconds / 60.0), total_seconds % 60]
func _on_health_changed(current: float, maximum: float) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
func _on_xp_changed(current: float, required: float) -> void:
xp_bar.max_value = required
xp_bar.value = current
func _on_leveled_up(new_level: int) -> void:
level_label.text = "Lv %d" % new_level
func _on_gold_changed(total: int) -> void:
gold_label.text = "%d gold" % total
func _refresh_loadout() -> void:
for child: Node in weapon_icons.get_children():
child.queue_free()
for child: Node in passive_icons.get_children():
child.queue_free()
for weapon: Weapon in player.weapons:
weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level))
for passive: OwnedPassive in player.passives:
passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level))
func _make_slot(icon: Texture2D, caption: String) -> Control:
var slot: VBoxContainer = VBoxContainer.new()
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
var label: Label = Label.new()
label.text = caption
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
slot.add_child(picture)
slot.add_child(label)
return slot
setup() calls every handler once right after connecting. The player emitted its first health_changed before the HUD existed, so without this the bars would start empty until something changed."%02d:%02d" prints numbers with at least two digits, padding with zeros: 01:05, not 1:5.floori(total_seconds / 60.0) divides by 60.0 (a float) on purpose. Dividing two integers works too, but Godot shows an “integer division” warning, because it’s a common source of bugs._refresh_loadout() rebuilds the icon rows from scratch whenever the loadout changes. It’s at most 11 small nodes, and only after a level-up, so there’s no need for anything cleverer.EXPAND_IGNORE_SIZE lets the icon shrink to SLOT_SIZE instead of forcing the slot to the texture’s full size.In the Godot editor:
Hud. Leave Layer at 1.Hud (CanvasLayer) script: hud.gd
├─ TopBar (MarginContainer) Anchors Preset: Top Wide; all margins 16
│ └─ HBox (HBoxContainer) Separation 16
│ ├─ HpBar (ProgressBar) Custom Minimum Size (300, 24); Show Percentage off
│ ├─ LevelLabel (Label)
│ ├─ GoldLabel (Label)
│ ├─ Spacer (Control) Container Sizing > Horizontal: tick Expand
│ └─ TimerLabel (Label) font size 32
├─ Loadout (VBoxContainer) Layout > Position (16, 64)
│ ├─ WeaponIcons (HBoxContainer)
│ └─ PassiveIcons (HBoxContainer)
└─ XpBar (ProgressBar) Custom Minimum Size (0, 16) FIRST, then
Anchors Preset: Bottom Wide; Show Percentage off
res://scenes/ui/hud.tscn.The Spacer trick: an empty Control set to Expand soaks up all the spare width in the HBoxContainer, pushing TimerLabel to the far right.
XpBar order matters: setting the minimum height before choosing Bottom Wide means the preset keeps it 16 px tall. If you do it the other way round and the bar looks wrong, just choose Bottom Wide again.
Tip: give the HP bar a red fill and the XP bar a blue one: Theme Overrides > Styles > Fill > New StyleBoxFlat, then set its Bg Color.
main.gdCreate the HUD and the level-up screen when a run starts, give them the player, and add a debug key (F2 = instant level-up) for testing.
Testing “what happens when I have 3 passives at max level?” would take ten minutes of play every time. A key that levels you up instantly makes that ten seconds. OS.is_debug_build() is true when you run from the editor and false in an exported release build, so players can never use it.
File: res://scripts/systems/main.gd
Replace the whole file with:
extends Node3D
const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const HUD_SCENE: PackedScene = preload("res://scenes/ui/hud.tscn")
const LEVEL_UP_SCENE: PackedScene = preload("res://scenes/ui/level_up_screen.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
var player: Player
var run_end_screen: RunEndScreen
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
var level: Node3D = game_state.selected_level.instantiate()
add_child(level)
player = PLAYER_SCENE.instantiate()
# Floor surface is at y=0 and the capsule is 2 tall, centred on its origin.
player.position = Vector3(0, 1, 0)
add_child(player)
player.died.connect(_on_player_died)
var hud: Hud = HUD_SCENE.instantiate()
add_child(hud)
hud.setup(player)
var level_up_screen: LevelUpScreen = LEVEL_UP_SCENE.instantiate()
level_up_screen.player = player
add_child(level_up_screen)
run_end_screen = RUN_END_SCENE.instantiate()
add_child(run_end_screen)
func _process(delta: float) -> void:
game_state.run_time += delta
func _unhandled_input(event: InputEvent) -> void:
# Debug shortcuts; ignored in exported release builds.
if not OS.is_debug_build():
return
var key: InputEventKey = event as InputEventKey
if key == null or not key.pressed or key.echo:
return
match key.keycode:
KEY_F2:
game_state.add_xp(game_state.xp_to_next - game_state.xp)
func _on_player_died() -> void:
# Only end the run when nobody is left (matters for co-op).
if get_tree().get_node_count_in_group("players") == 0:
run_end_screen.show_result(false)
Order matters here too: hud.setup() is called after add_child(hud), because setup() uses the HUD’s @onready variables, which only exist once the HUD is in the tree. The level-up screen’s player is set before adding it, since it only needs to be ready by the time the first level-up happens.
event as InputEventKey converts the event if it’s a key press, or gives null if it’s something else (mouse, gamepad). key.echo is true for the repeated events you get while holding a key; ignoring those means holding F2 gives one level, not dozens.
Co-op note: in Swarm, every player picks their own upgrade and the game waits for all of them. That would mean one
LevelUpScreenper player, each building offers for its own player, plus a “waiting for others” state.
If something breaks:
| Problem | Likely cause |
|---|---|
| Orbs never move | Orb isn’t on layer 4, or PickupArea doesn’t mask 4, or PickupArea has Monitoring off. |
| “Can’t change this state while flushing queries” | The orb is being added without call_deferred. |
| Level-up screen shows but buttons don’t respond | LevelUpScreen isn’t Process Mode: Always. |
| Game stays paused after picking | _show_next() isn’t reaching the empty-queue branch. Check _on_offer_chosen calls _show_next(). |
Identifier "Upgrades" not declared |
The autoload isn’t registered, or its name isn’t exactly Upgrades. |
| HUD bars start empty | hud.setup(player) isn’t being called, or it’s called before add_child(hud). |
| Icons are huge | EXPAND_IGNORE_SIZE is missing on the TextureRect. |
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)
const MAX_LEVEL: int = 99
var selected_character: CharacterData
var selected_level: PackedScene
var passive_slots: int = 3
# --- Per-run tracking ---
var run_time: float = 0.0
var level: int = 1
var xp: float = 0.0
var xp_to_next: float = 5.0
var gold: int = 0
func start_run(character: CharacterData, level_scene: PackedScene) -> void:
selected_character = character
selected_level = level_scene
passive_slots = Profile.unlocked_passive_slots
reset_run()
func reset_run() -> void:
run_time = 0.0
level = 1
xp = 0.0
xp_to_next = xp_required(1)
gold = 0
# XP needed to go from this level to the next. Tune freely.
func xp_required(from_level: int) -> float:
return 5.0 + 10.0 * (from_level - 1)
func add_xp(amount: float) -> void:
if level >= MAX_LEVEL:
return
xp += amount
while xp >= xp_to_next and level < MAX_LEVEL:
xp -= xp_to_next
level += 1
xp_to_next = xp_required(level)
leveled_up.emit(level)
xp_changed.emit(xp, xp_to_next)
func add_gold(amount: int) -> void:
gold += amount
gold_changed.emit(gold)
File: res://scripts/props/pickups/pickup.gd
class_name Pickup
extends Area3D
const ATTRACT_ACCELERATION: float = 40.0
const COLLECT_DISTANCE: float = 0.6
var _target: Player = null
var _speed: float = 0.0
func _ready() -> void:
add_to_group("pickups")
# Idle pickups cost nothing until a player attracts them.
set_physics_process(false)
func attract(player: Player) -> void:
if _target != null:
return
_target = player
set_physics_process(true)
func _physics_process(delta: float) -> void:
if not is_instance_valid(_target) or _target.is_dead:
_target = null
_speed = 0.0
set_physics_process(false)
return
_speed += ATTRACT_ACCELERATION * delta
var to_target: Vector3 = _target.global_position - global_position
var distance: float = to_target.length()
if distance <= COLLECT_DISTANCE:
collect(_target)
queue_free()
return
global_position += to_target / distance * minf(_speed * delta, distance)
# Each pickup type overrides this.
func collect(_player: Player) -> void:
pass
File: res://scripts/props/pickups/xp_orb.gd
class_name XpOrb
extends Pickup
var amount: float = 1.0
func collect(player: Player) -> void:
game_state.add_xp(amount * player.xp_mult)
File: res://scripts/resources/enemy_data.gd
class_name EnemyData
extends GameData
@export var model_scene: PackedScene
@export var max_hp: float = 30.0
@export var move_speed: float = 4.0
@export var damage: float = 10.0
# Distance from the enemy's centre at which it can hit a player.
@export var attack_range: float = 1.5
@export var attack_cooldown: float = 1.0
@export var xp_value: float = 1.0
File: res://scripts/characters/enemy/enemy.gd
class_name Enemy
extends CharacterBody3D
signal died(enemy: Enemy)
const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
@export var data: EnemyData
@onready var model_holder: Node3D = $Model
var current_hp: float = 0.0
var _attack_cooldown_left: float = 0.0
var _flash_tween: Tween
func _ready() -> void:
add_to_group("enemies")
current_hp = data.max_hp
if data.model_scene:
model_holder.add_child(data.model_scene.instantiate())
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity += get_gravity() * delta
var target: Player = Targeting.nearest_player(get_tree(), global_position)
var move: Vector3 = Vector3.ZERO
var distance: float = INF
if target:
var to_target: Vector3 = target.global_position - global_position
to_target.y = 0.0
distance = to_target.length()
if distance > 0.01:
var direction: Vector3 = to_target / distance
move = direction * data.move_speed
# Same convention as the player: the model's front is +X.
model_holder.rotation.y = atan2(-direction.z, direction.x)
velocity.x = move.x
velocity.z = move.z
move_and_slide()
_attack_cooldown_left -= delta
if target and distance <= data.attack_range and _attack_cooldown_left <= 0.0:
target.take_damage(data.damage)
_attack_cooldown_left = data.attack_cooldown
func take_damage(amount: float) -> void:
if current_hp <= 0.0:
return
current_hp -= amount
_play_hit_flash()
if current_hp <= 0.0:
die()
func die() -> void:
_drop_loot()
died.emit(self)
queue_free()
func _drop_loot() -> void:
var orb: XpOrb = XP_ORB_SCENE.instantiate()
orb.amount = data.xp_value
_spawn_here(orb)
# Deferred: die() often runs inside a physics callback, where adding physics objects isn't allowed.
func _spawn_here(node: Node3D) -> void:
node.position = global_position
get_tree().current_scene.add_child.call_deferred(node)
func _play_hit_flash() -> void:
if _flash_tween:
_flash_tween.kill()
model_holder.scale = Vector3.ONE * 1.25
_flash_tween = create_tween()
_flash_tween.tween_property(model_holder, "scale", Vector3.ONE, 0.1)
File: res://scripts/resources/passive_data.gd
class_name PassiveData
extends GameData
enum Stat { MAX_HP, DEFENCE, DAMAGE_MULT, CDR, HP_REGEN, MOVE_SPEED, PROJECTILE,
CRIT_CHANCE, PICKUP_RANGE, XP_MULT, GOLD_MULT, AREA_SIZE_MULT, DURATION_MULT }
@export var stat: Stat = Stat.MAX_HP
# Total bonus at each level, not a per-level delta.
# Percentage stats use fractions (0.1 = +10%); flat stats use plain numbers.
@export var values: Array[float] = [0.0, 0.0, 0.0, 0.0, 0.0]
func value_at(level: int) -> float:
return values[clampi(level - 1, 0, values.size() - 1)]
func is_percent() -> bool:
match stat:
Stat.DAMAGE_MULT, Stat.MOVE_SPEED, Stat.PICKUP_RANGE, Stat.XP_MULT, \
Stat.GOLD_MULT, Stat.AREA_SIZE_MULT, Stat.DURATION_MULT:
return true
return false
func describe_level(level: int) -> String:
var value: float = value_at(level)
if is_percent():
return "%s +%d%%" % [display_name, roundi(value * 100.0)]
return "%s +%s" % [display_name, String.num(value)]
File: res://scripts/autoload/upgrades.gd
extends Node
# Everything that can show up as a level-up choice.
# Signature weapons aren't listed; each character brings its own.
var weapons: Array[WeaponData] = []
var passives: Array[PassiveData] = [
preload("res://resources/passives/damage.tres"),
preload("res://resources/passives/cdr.tres"),
preload("res://resources/passives/projectile.tres"),
preload("res://resources/passives/max_hp.tres"),
preload("res://resources/passives/move_speed.tres"),
preload("res://resources/passives/pickup_range.tres"),
]
File: res://scripts/systems/upgrade_offer.gd
class_name UpgradeOffer
extends RefCounted
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD }
const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25
var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon
func apply(player: Player) -> void:
match kind:
Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
player.add_passive(passive_data)
Kind.WEAPON_LEVEL:
player.level_up_weapon(weapon)
Kind.HEAL:
player.heal(player.max_hp * HEAL_FRACTION)
Kind.GOLD:
game_state.add_gold(FALLBACK_GOLD)
File: res://scripts/systems/upgrade_offers.gd
class_name UpgradeOffers
extends RefCounted
static func build_level_up(player: Player, count: int = 3) -> Array[UpgradeOffer]:
var candidates: Array[UpgradeOffer] = []
_add_weapon_offers(player, candidates)
_add_passive_offers(player, candidates)
if candidates.is_empty():
return _fallback_offers()
candidates.shuffle()
if candidates.size() > count:
candidates.resize(count)
return candidates
static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for weapon: Weapon in player.weapons:
if weapon.is_max_level():
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
offer.weapon = weapon
offer.title = "%s Lv %d" % [weapon.get_display_name(), weapon.level + 1]
offer.description = weapon.data.level_stats(weapon.level + 1).description
offer.icon = weapon.get_icon()
candidates.append(offer)
static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for passive: OwnedPassive in player.passives:
if passive.level < Player.MAX_PASSIVE_LEVEL:
candidates.append(_passive_offer(passive.data, passive.level + 1))
if player.passives.size() >= player.passive_slots:
return
for data: PassiveData in Upgrades.passives:
if player.get_passive(data) == null:
candidates.append(_passive_offer(data, 1))
static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
var offer: UpgradeOffer = UpgradeOffer.new()
offer.passive_data = data
offer.description = data.describe_level(level)
offer.icon = data.icon
if level == 1:
offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
offer.title = "NEW %s" % data.display_name
else:
offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
offer.title = "%s Lv %d" % [data.display_name, level]
return offer
static func _fallback_offers() -> Array[UpgradeOffer]:
var heal: UpgradeOffer = UpgradeOffer.new()
heal.kind = UpgradeOffer.Kind.HEAL
heal.title = "Patch Up"
heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
var gold: UpgradeOffer = UpgradeOffer.new()
gold.kind = UpgradeOffer.Kind.GOLD
gold.title = "Pocket Change"
gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
var offers: Array[UpgradeOffer] = [heal, gold]
return offers
File: res://scripts/ui/level_up_screen.gd
class_name LevelUpScreen
extends CanvasLayer
enum Reason { LEVEL_UP }
const CHOICE_SIZE: Vector2 = Vector2(560, 96)
const ICON_SIZE: int = 64
@onready var title_label: Label = $Center/Panel/Margin/VBox/Title
@onready var choices_box: VBoxContainer = $Center/Panel/Margin/VBox/Choices
var player: Player
# Holds Reason values, one per pending pick.
var _queue: Array[int] = []
func _ready() -> void:
visible = false
game_state.leveled_up.connect(_on_leveled_up)
func _on_leveled_up(_new_level: int) -> void:
_enqueue(Reason.LEVEL_UP)
func _enqueue(reason: Reason) -> void:
_queue.append(reason)
if not visible:
_show_next()
func _show_next() -> void:
if _queue.is_empty():
visible = false
get_tree().paused = false
return
var reason: int = _queue.pop_front()
var offers: Array[UpgradeOffer] = []
match reason:
Reason.LEVEL_UP:
title_label.text = "Level Up!"
offers = UpgradeOffers.build_level_up(player)
_show_offers(offers)
visible = true
get_tree().paused = true
func _show_offers(offers: Array[UpgradeOffer]) -> void:
for child: Node in choices_box.get_children():
child.queue_free()
var first_button: Button = null
for offer: UpgradeOffer in offers:
var button: Button = Button.new()
button.text = "%s\n%s" % [offer.title, offer.description]
button.icon = offer.icon
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.custom_minimum_size = CHOICE_SIZE
button.add_theme_constant_override("icon_max_width", ICON_SIZE)
button.pressed.connect(_on_offer_chosen.bind(offer))
choices_box.add_child(button)
if first_button == null:
first_button = button
if first_button:
first_button.grab_focus()
func _on_offer_chosen(offer: UpgradeOffer) -> void:
offer.apply(player)
_show_next()
File: res://scripts/ui/hud.gd
class_name Hud
extends CanvasLayer
const SLOT_SIZE: Vector2 = Vector2(48, 48)
@onready var hp_bar: ProgressBar = $TopBar/HBox/HpBar
@onready var level_label: Label = $TopBar/HBox/LevelLabel
@onready var gold_label: Label = $TopBar/HBox/GoldLabel
@onready var timer_label: Label = $TopBar/HBox/TimerLabel
@onready var weapon_icons: HBoxContainer = $Loadout/WeaponIcons
@onready var passive_icons: HBoxContainer = $Loadout/PassiveIcons
@onready var xp_bar: ProgressBar = $XpBar
var player: Player
func setup(target_player: Player) -> void:
player = target_player
player.health_changed.connect(_on_health_changed)
player.loadout_changed.connect(_refresh_loadout)
game_state.xp_changed.connect(_on_xp_changed)
game_state.leveled_up.connect(_on_leveled_up)
game_state.gold_changed.connect(_on_gold_changed)
_on_health_changed(player.current_hp, player.max_hp)
_on_xp_changed(game_state.xp, game_state.xp_to_next)
_on_leveled_up(game_state.level)
_on_gold_changed(game_state.gold)
_refresh_loadout()
func _process(_delta: float) -> void:
var total_seconds: int = floori(game_state.run_time)
timer_label.text = "%02d:%02d" % [floori(total_seconds / 60.0), total_seconds % 60]
func _on_health_changed(current: float, maximum: float) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
func _on_xp_changed(current: float, required: float) -> void:
xp_bar.max_value = required
xp_bar.value = current
func _on_leveled_up(new_level: int) -> void:
level_label.text = "Lv %d" % new_level
func _on_gold_changed(total: int) -> void:
gold_label.text = "%d gold" % total
func _refresh_loadout() -> void:
for child: Node in weapon_icons.get_children():
child.queue_free()
for child: Node in passive_icons.get_children():
child.queue_free()
for weapon: Weapon in player.weapons:
weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level))
for passive: OwnedPassive in player.passives:
passive_icons.add_child(_make_slot(passive.data.icon, "Lv %d" % passive.level))
func _make_slot(icon: Texture2D, caption: String) -> Control:
var slot: VBoxContainer = VBoxContainer.new()
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
var label: Label = Label.new()
label.text = caption
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
slot.add_child(picture)
slot.add_child(label)
return slot
File: res://scripts/systems/main.gd
extends Node3D
const PLAYER_SCENE: PackedScene = preload("res://scenes/characters/player/player.tscn")
const HUD_SCENE: PackedScene = preload("res://scenes/ui/hud.tscn")
const LEVEL_UP_SCENE: PackedScene = preload("res://scenes/ui/level_up_screen.tscn")
const RUN_END_SCENE: PackedScene = preload("res://scenes/ui/run_end_screen.tscn")
var player: Player
var run_end_screen: RunEndScreen
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
var level: Node3D = game_state.selected_level.instantiate()
add_child(level)
player = PLAYER_SCENE.instantiate()
# Floor surface is at y=0 and the capsule is 2 tall, centred on its origin.
player.position = Vector3(0, 1, 0)
add_child(player)
player.died.connect(_on_player_died)
var hud: Hud = HUD_SCENE.instantiate()
add_child(hud)
hud.setup(player)
var level_up_screen: LevelUpScreen = LEVEL_UP_SCENE.instantiate()
level_up_screen.player = player
add_child(level_up_screen)
run_end_screen = RUN_END_SCENE.instantiate()
add_child(run_end_screen)
func _process(delta: float) -> void:
game_state.run_time += delta
func _unhandled_input(event: InputEvent) -> void:
# Debug shortcuts; ignored in exported release builds.
if not OS.is_debug_build():
return
var key: InputEventKey = event as InputEventKey
if key == null or not key.pressed or key.echo:
return
match key.keycode:
KEY_F2:
game_state.add_xp(game_state.xp_to_next - game_state.xp)
func _on_player_died() -> void:
# Only end the run when nobody is left (matters for co-op).
if get_tree().get_node_count_in_group("players") == 0:
run_end_screen.show_result(false)
File: res://scripts/characters/player/player.gd
extends CharacterBody3D
class_name Player
signal stats_changed
signal health_changed(current: float, maximum: float)
signal died
signal loadout_changed
const MAX_PASSIVE_LEVEL: int = 5
const AUTO_AIM_RANGE: float = 25.0
@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons
@onready var pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D
# --- Set up stats ---
var max_hp: float
var defence: float
var damage_mult: float
# Haste-style: cooldown = base / (1 + cdr / 100), so it never reaches zero.
var cdr: float
var hp_regen: float
var move_speed: float
var projectile: int
var crit_chance: float
var pickup_range: float
var xp_mult: float
var gold_mult: float
var area_size_mult: float
var duration_mult: float
var current_hp: float = 0.0
var is_dead: bool = false
# --- Create passive slots ---
var passive_slots: int = Profile.MAX_PASSIVE_SLOTS
var passives: Array[OwnedPassive] = []
# --- Weapons ---
var weapons: Array[Weapon] = []
# --- Aiming ---
# The model's front is +X, so that's the starting aim.
var aim_direction: Vector3 = Vector3.RIGHT
var auto_aim: bool = false
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
add_to_group("players")
pickup_area.area_entered.connect(_on_pickup_area_entered)
passive_slots = game_state.passive_slots
if character_data == null:
character_data = game_state.selected_character
if character_data:
_apply_character(character_data)
recalculate_stats()
current_hp = max_hp
func _apply_character(character: CharacterData) -> void:
if character.model_scene:
character_model.add_child(character.model_scene.instantiate())
if character.signature_weapon:
_spawn_weapon(character.signature_weapon)
func _spawn_weapon(data: WeaponData) -> Weapon:
var weapon: Weapon = data.weapon_scene.instantiate()
weapon.setup(self, data)
weapon_holder.add_child(weapon)
weapons.append(weapon)
return weapon
func recalculate_stats() -> void:
var previous_max_hp: float = max_hp
max_hp = 1000.0
defence = 0.0
damage_mult = 1.0
cdr = 0.0
hp_regen = 10.0
move_speed = 7.0
projectile = 0
crit_chance = 5.0
pickup_range = 3.0
xp_mult = 1.0
gold_mult = 1.0
area_size_mult = 1.0
duration_mult = 1.0
if character_data:
max_hp += character_data.bonus_max_hp
defence += character_data.bonus_defence
for passive: OwnedPassive in passives:
_apply_bonus(passive.data.stat, passive.bonus())
(pickup_shape.shape as SphereShape3D).radius = pickup_range
# Raising max HP grants the difference rather than healing to full.
current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
stats_changed.emit()
health_changed.emit(current_hp, max_hp)
# Each call is one "source". Percentage stats multiply between sources; flat stats add.
func _apply_bonus(stat: PassiveData.Stat, amount: float) -> void:
match stat:
PassiveData.Stat.MAX_HP:
max_hp += amount
PassiveData.Stat.DEFENCE:
defence += amount
PassiveData.Stat.DAMAGE_MULT:
damage_mult *= 1.0 + amount
PassiveData.Stat.CDR:
cdr += amount
PassiveData.Stat.HP_REGEN:
hp_regen += amount
PassiveData.Stat.MOVE_SPEED:
move_speed *= 1.0 + amount
PassiveData.Stat.PROJECTILE:
projectile += roundi(amount)
PassiveData.Stat.CRIT_CHANCE:
crit_chance += amount
PassiveData.Stat.PICKUP_RANGE:
pickup_range *= 1.0 + amount
PassiveData.Stat.XP_MULT:
xp_mult *= 1.0 + amount
PassiveData.Stat.GOLD_MULT:
gold_mult *= 1.0 + amount
PassiveData.Stat.AREA_SIZE_MULT:
area_size_mult *= 1.0 + amount
PassiveData.Stat.DURATION_MULT:
duration_mult *= 1.0 + amount
func get_passive(data: PassiveData) -> OwnedPassive:
for passive: OwnedPassive in passives:
if passive.data.id == data.id:
return passive
return null
func add_passive(data: PassiveData) -> bool:
var owned: OwnedPassive = get_passive(data)
if owned:
if owned.level >= MAX_PASSIVE_LEVEL:
return false
owned.level += 1
else:
if passives.size() >= passive_slots:
return false
passives.append(OwnedPassive.new(data))
recalculate_stats()
loadout_changed.emit()
return true
func level_up_weapon(weapon: Weapon) -> bool:
if weapon.is_max_level():
return false
weapon.set_level(weapon.level + 1)
loadout_changed.emit()
return true
func heal(amount: float) -> void:
if is_dead:
return
current_hp = minf(current_hp + amount, max_hp)
health_changed.emit(current_hp, max_hp)
func _on_pickup_area_entered(area: Area3D) -> void:
if area is Pickup:
(area as Pickup).attract(self)
func _physics_process(delta: float) -> void:
_regenerate(delta)
# Gravity in case we fuck something up
if not is_on_floor():
velocity += get_gravity() * delta
var input_dir: Vector2 = Input.get_vector("leftward", "rightward", "upward", "downward")
var direction: Vector3 = Vector3(input_dir.x, 0.0, input_dir.y).normalized()
if direction:
velocity.x = direction.x * move_speed
velocity.z = direction.z * move_speed
else:
velocity.x = move_toward(velocity.x, 0, move_speed)
velocity.z = move_toward(velocity.z, 0, move_speed)
move_and_slide()
_update_aim()
func _regenerate(delta: float) -> void:
if current_hp >= max_hp:
return
current_hp = minf(current_hp + hp_regen * delta, max_hp)
health_changed.emit(current_hp, max_hp)
func take_damage(amount: float) -> void:
if is_dead:
return
# Like League armour: 100 defence means taking half damage.
current_hp -= amount * 100.0 / (100.0 + maxf(defence, 0.0))
health_changed.emit(current_hp, max_hp)
if current_hp <= 0.0:
_die()
func _die() -> void:
current_hp = 0.0
is_dead = true
remove_from_group("players")
set_physics_process(false)
weapon_holder.process_mode = Node.PROCESS_MODE_DISABLED
character_model.visible = false
died.emit()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("auto_aim"):
auto_aim = not auto_aim
func _update_aim() -> void:
var aim_point: Variant = _get_aim_point()
if aim_point == null:
return
var point: Vector3 = aim_point
var to_point: Vector3 = point - global_position
to_point.y = 0.0
if to_point.length_squared() < 0.0001:
return
aim_direction = to_point.normalized()
# The model's front is +X, so the angle is measured from +X.
character_model.rotation.y = atan2(-aim_direction.z, aim_direction.x)
# Returns a Vector3, or null when there's nothing to aim at.
func _get_aim_point() -> Variant:
if auto_aim:
var target: Node3D = Targeting.nearest_enemy(get_tree(), global_position, AUTO_AIM_RANGE)
if target == null:
return null
return target.global_position
var mouse_pos: Vector2 = get_viewport().get_mouse_position()
var ray_origin: Vector3 = camera.project_ray_origin(mouse_pos)
var ray_dir: Vector3 = camera.project_ray_normal(mouse_pos)
return Plane(Vector3.UP, global_position.y).intersects_ray(ray_origin, ray_dir)