Goal: level-ups can now offer new weapons, up to 4 alongside your signature weapon. Three new weapons show off different targeting styles: one locks onto the nearest enemies, one orbits you, and one pulses around you. Tooltips show which stats each weapon scales with.
Checklist
add_weapon() and new-weapon offersA constant that caps the player at 5 weapons in total, and a clear rule that the signature weapon is always slot 0.
Back in Phase 1 the player got a weapons array, and the signature weapon was the first thing added to it. So:
weapons[0]. Nothing ever removes or reorders weapons, so that stays true for the whole run.The one missing piece is the limit itself.
File: res://scripts/characters/player/player.gd
Find this:
const MAX_PASSIVE_LEVEL: int = 5
Replace with:
const MAX_PASSIVE_LEVEL: int = 5
# The signature weapon plus 4 picked up during the run.
const MAX_WEAPON_SLOTS: int = 5
add_weapon() and new-weapon offersget_weapon() and add_weapon() on the player, mirroring get_passive() and add_passive().NEW_WEAPON.Upgrades.weapons while there’s a free slot.add_passive()?Weapons and passives follow the same rule: “if you already own it, level it up; otherwise add it if there’s room”. Writing them the same way means once you understand one, you understand both, and bugs are easier to spot when two similar things look similar.
File: res://scripts/characters/player/player.gd
Find this:
func level_up_weapon(weapon: Weapon) -> bool:
Replace with:
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:
add_weapon() reuses level_up_weapon() for the “already owned” case instead of repeating it. Comparing by id (not by the resource itself) means two copies of the same weapon resource still count as the same weapon.
File: res://scripts/systems/upgrade_offer.gd
Find this:
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD }
Replace with:
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON }
Find this:
var weapon: Weapon
Replace with:
var weapon: Weapon
var weapon_data: WeaponData
Find this inside apply():
Kind.GOLD:
game_state.add_gold(FALLBACK_GOLD)
Replace with:
Kind.GOLD:
game_state.add_gold(FALLBACK_GOLD)
Kind.NEW_WEAPON:
player.add_weapon(weapon_data)
Why two weapon fields? A weapon you already own is a Weapon node (it has a level). A weapon you don’t own yet only exists as WeaponData, a resource on disk. A “level up” offer points at the node; a “new weapon” offer points at the data.
Why add NEW_WEAPON at the end of the enum? Each enum name is really a number (NEW_PASSIVE is 0, PASSIVE_LEVEL is 1, and so on). Adding at the end leaves the existing numbers alone. It doesn’t matter much here, but it’s a good habit, because it does matter once enum values are saved to disk.
File: res://scripts/systems/upgrade_offers.gd
Find the whole _add_weapon_offers() function and replace it with:
static func _add_weapon_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for weapon: Weapon in player.weapons:
if weapon.is_max_level():
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.WEAPON_LEVEL
offer.weapon = weapon
offer.title = "%s Lv %d" % [weapon.get_display_name(), weapon.level + 1]
offer.description = weapon.data.level_stats(weapon.level + 1).description
offer.icon = weapon.get_icon()
candidates.append(offer)
if player.weapons.size() >= Player.MAX_WEAPON_SLOTS:
return
for data: WeaponData in Upgrades.weapons:
if player.get_weapon(data) != null:
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.NEW_WEAPON
offer.weapon_data = data
offer.title = "NEW %s" % data.display_name
offer.description = data.level_stats(1).description
offer.icon = data.icon
candidates.append(offer)
The first half is unchanged. The second half is rule 4 from Phase 3: “a new weapon is only offered if you have a free weapon slot”. It’s the same shape as the passive rule.
| Weapon | Targeting style | Scales with |
|---|---|---|
| Seeker Shot | Fires at the nearest enemies; each projectile picks a different one | Damage, CDR, Projectiles, Crit |
| Orbiting Blades | Blades circle you for a while, hitting and knocking back enemies | Damage, CDR, Projectiles, Area, Duration, Crit |
| Pulse Aura | Damages every enemy within a radius around you, on a timer | Damage, CDR, Area, Crit |
Between these and the Machine Gun (aimed), you get four of Swarm’s main patterns. Others you could build later the same way: random direction, ground zones, trails left behind as you move, pets, and bouncing shots.
Each one teaches something new:
spawn_projectile()._physics_process with super(), and knockback.File: res://scripts/utils/targeting.gd
Add at the bottom:
# Every enemy whose flat (ground) distance from center is within radius.
static func enemies_in_radius(tree: SceneTree, center: Vector3, radius: float) -> Array[Enemy]:
var found: Array[Enemy] = []
var radius_sq: float = radius * radius
for node: Node in tree.get_nodes_in_group(&"enemies"):
var enemy: Enemy = node as Enemy
var offset: Vector3 = enemy.global_position - center
offset.y = 0.0
if offset.length_squared() <= radius_sq:
found.append(enemy)
return found
# Up to `count` enemies, closest first.
static func nearest_enemies(tree: SceneTree, from: Vector3, count: int, max_range: float = INF) -> Array[Enemy]:
var found: Array[Enemy] = enemies_in_radius(tree, from, max_range)
found.sort_custom(func(a: Enemy, b: Enemy) -> bool:
return from.distance_squared_to(a.global_position) < from.distance_squared_to(b.global_position)
)
if found.size() > count:
found.resize(count)
return found
sort_custom with a lambda: func(a, b) -> bool: ... is a small unnamed function written inline. sort_custom calls it to compare pairs of enemies; returning true means “a goes before b”. Here that means “closer first”. The lambda can use from from the surrounding function.
Why ignore height (offset.y = 0.0)? Area effects in a top-down game should be circles on the ground. An enemy standing on a slightly different floor height should still be “in range” if it’s inside the circle you can see.
Orbiting Blades push enemies away. Enemies need to be able to receive a push that fades out.
File: res://scripts/characters/enemy/enemy.gd
Find this:
const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
Replace with:
const XP_ORB_SCENE: PackedScene = preload("res://scenes/props/pickups/xp_orb.tscn")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0
Find this:
var _flash_tween: Tween
Replace with:
var _flash_tween: Tween
var _knockback: Vector3 = Vector3.ZERO
Find this in _physics_process():
velocity.x = move.x
velocity.z = move.z
move_and_slide()
Replace with:
velocity.x = move.x + _knockback.x
velocity.z = move.z + _knockback.z
_knockback = _knockback.move_toward(Vector3.ZERO, KNOCKBACK_DECAY * delta)
move_and_slide()
Add this function below take_damage():
func apply_knockback(force: Vector3) -> void:
_knockback += force
How it works: knockback is an extra velocity added on top of normal walking. Each frame move_toward shrinks it towards zero, so a push of 6 m/s fades out in about 0.3 seconds. Several pushes add together, so getting hit by two blades shoves harder.
File: res://scripts/characters/player/weapons/seeker_shot.gd
extends Weapon
const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const RANGE: float = 20.0
const BULLET_SPEED: float = 25.0
const BULLET_LIFETIME: float = 1.2
func fire() -> bool:
var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, get_projectile_count(), RANGE)
if targets.is_empty():
return false
for target: Enemy in targets:
var direction: Vector3 = target.global_position - global_position
direction.y = 0.0
spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
return true
This is where return false from Phase 1 pays off. With no enemy in range, the weapon doesn’t waste its cooldown; it checks again next frame and fires the moment something comes close.
In the Godot editor: New Scene > Node3D, renamed SeekerShot, attach seeker_shot.gd, and save as res://scenes/characters/player/weapons/seeker_shot.tscn.
Want Seeker bullets to look different? Right-click
bullet.tscn> Duplicate…, call itseeker_bullet.tscn, change the mesh colour, and pointBULLET_SCENEat it.
This weapon has two parts: the blade (an Area3D that hurts what it touches) and the weapon (spawns blades in a circle and spins them).
File: res://scripts/characters/player/weapons/orbit_blade.gd
class_name OrbitBlade
extends Area3D
var damage: float = 10.0
var crit_chance: float = 0.0
var knockback: float = 0.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D) -> void:
if not (body is Enemy):
return
var enemy: Enemy = body as Enemy
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
var center: Vector3 = (get_parent() as Node3D).global_position
var push: Vector3 = enemy.global_position - center
push.y = 0.0
enemy.apply_knockback(push.normalized() * knockback)
The push direction is from the centre of the orbit (the player) out to the enemy, so enemies are shoved away from you, not sideways.
Why does each blade only hit once per pass? body_entered fires when an enemy starts overlapping. As the blade sweeps round, it enters an enemy, leaves, and enters again on the next lap. That naturally limits it to one hit per lap per enemy, without any extra timers.
In the Godot editor:
OrbitBlade.(1.0, 0.4, 0.3).(1.0, 0.1, 0.3), light blue, emission on.orbit_blade.gd and save as res://scenes/characters/player/weapons/orbit_blade.tscn.File: res://scripts/characters/player/weapons/orbiting_blades.gd
extends Weapon
const BLADE_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/orbit_blade.tscn")
const BASE_RADIUS: float = 3.0
# Radians per second; TAU would be one full turn per second.
const SPIN_SPEED: float = 3.0
const KNOCKBACK: float = 6.0
var _pivot: Node3D
var _time_left: float = 0.0
func _ready() -> void:
_pivot = Node3D.new()
add_child(_pivot)
func _physics_process(delta: float) -> void:
super(delta)
_pivot.rotate_y(SPIN_SPEED * delta)
if _time_left > 0.0:
_time_left -= delta
if _time_left <= 0.0:
_clear_blades()
func fire() -> bool:
_clear_blades()
var count: int = get_projectile_count()
var radius: float = BASE_RADIUS * get_area_scale()
for i: int in count:
var blade: OrbitBlade = BLADE_SCENE.instantiate()
blade.damage = get_damage()
blade.crit_chance = player.crit_chance
blade.knockback = KNOCKBACK
var angle: float = TAU * i / count
blade.position = Vector3(cos(angle), 0.0, sin(angle)) * radius
blade.rotation.y = -angle
blade.scale = Vector3.ONE * get_area_scale()
_pivot.add_child(blade)
_time_left = get_duration()
return true
# Blades are out for `duration`, then the normal cooldown starts.
func get_cooldown() -> float:
return super() + get_duration()
func _clear_blades() -> void:
for blade: Node in _pivot.get_children():
blade.queue_free()
The pivot. Blades are children of an invisible _pivot node, which is a child of the weapon, which is a child of the player. Rotating the pivot swings all the blades round together, and because the whole chain is under the player, they follow you as you walk. Unlike bullets, we want these to move with you.
super(delta). In Godot 4, if a child class writes its own _physics_process(), the parent’s version does not run automatically. The base Weapon._physics_process() is the cooldown loop, so without super(delta) the blades would never fire. super(...) means “also run the parent’s version of this function”.
Spacing. TAU * i / count spreads count blades evenly round a full circle (TAU is 360° in radians). blade.rotation.y = -angle turns each blade so it points along the circle.
Area and duration.
get_area_scale() makes the circle wider and the blades bigger.get_duration() decides how long the blades stay out.get_cooldown() adds the duration on top, so the cooldown only really starts once the blades have gone. Without this, a short cooldown would spawn a new set before the old one finished. super() here calls the parent’s get_cooldown(), so CDR still applies to the base part.Uniform scale only. Physics shapes handle uniform scaling (the same on all three axes) fine. Stretching a shape unevenly isn’t reliably supported, which is why we use Vector3.ONE * scale.
In the Godot editor: New Scene > Node3D, renamed OrbitingBlades, attach orbiting_blades.gd, and save as res://scenes/characters/player/weapons/orbiting_blades.tscn. The pivot and blades are created by code.
File: res://scripts/characters/player/weapons/pulse_aura.gd
extends Weapon
const BASE_RADIUS: float = 4.0
const FLASH_TIME: float = 0.15
@onready var ring: MeshInstance3D = $Ring
func _ready() -> void:
ring.visible = false
func fire() -> bool:
var radius: float = BASE_RADIUS * get_area_scale()
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(get_damage(), player.crit_chance))
_flash_ring(radius)
return true
func _flash_ring(radius: float) -> void:
ring.scale = Vector3(radius, 1.0, radius)
ring.visible = true
var tween: Tween = create_tween()
tween.tween_interval(FLASH_TIME)
tween.tween_callback(ring.hide)
Why no Area3D? We could put a big sphere on the player and track who’s inside it, but the aura only needs to know “who’s in range right now” once per second. A plain distance check at that moment is simpler and does the same job.
Is it safe to kill enemies while looping? Yes. enemies_in_radius() returns its own array, so enemies dying and being removed from the "enemies" group during the loop doesn’t affect it. And queue_free() only deletes them at the end of the frame.
The ring. A flat cylinder 1 m in radius, scaled to the real radius, shown for 0.15 s. It’s a simple way to show where the damage happened.
In the Godot editor:
PulseAura.Ring. Mesh > New CylinderMesh: Top Radius 1, Bottom Radius 1, Height 0.05.Alpha, Albedo cyan with alpha about 90, Shading Mode Unshaded.-0.95, so it sits at the player’s feet. The player’s origin is the middle of the 2 m capsule.pulse_aura.gd to PulseAura and save as res://scenes/characters/player/weapons/pulse_aura.tscn.PulseAura (Node3D) script: pulse_aura.gd
└─ Ring (MeshInstance3D) flat transparent cyan cylinder, y = -0.95
Make these in res://resources/passives/:
| File | Id | Display Name | Stat | Values |
|---|---|---|---|---|
area.tres |
passive_area |
Area Size | AREA_SIZE_MULT | 0.1, 0.2, 0.3, 0.4, 0.5 |
duration.tres |
passive_duration |
Duration | DURATION_MULT | 0.12, 0.24, 0.36, 0.48, 0.6 |
Make these in res://resources/weapons/ (New Resource > WeaponData). Set Icon to res://icon.svg for now and Weapon Scene to the matching .tscn. Leave unlisted fields at their defaults.
seeker_shot.tres: Id weapon_seeker_shot, Display Name Seeker Shot
| Lv | Description | Damage | Cooldown | Projectiles |
|---|---|---|---|---|
| 1 | Fires at the nearest enemy. | 12 | 1.2 | 1 |
| 2 | +1 target. | 12 | 1.2 | 2 |
| 3 | +33% damage. | 16 | 1.2 | 2 |
| 4 | +1 target. | 16 | 1.2 | 3 |
| 5 | +25% damage, fires faster. | 20 | 1.0 | 3 |
orbiting_blades.tres: Id weapon_orbiting_blades, Display Name Orbiting Blades
| Lv | Description | Damage | Cooldown | Projectiles | Area Scale | Duration |
|---|---|---|---|---|---|---|
| 1 | Blades circle you, pushing enemies back. | 8 | 3.0 | 2 | 1.0 | 3.0 |
| 2 | +1 blade. | 8 | 3.0 | 3 | 1.0 | 3.0 |
| 3 | +50% damage. | 12 | 3.0 | 3 | 1.0 | 3.0 |
| 4 | +1 blade, lasts longer. | 12 | 3.0 | 4 | 1.0 | 4.0 |
| 5 | +33% damage, wider orbit. | 16 | 3.0 | 4 | 1.3 | 4.0 |
pulse_aura.tres: Id weapon_pulse_aura, Display Name Pulse Aura
| Lv | Description | Damage | Cooldown | Area Scale |
|---|---|---|---|---|
| 1 | Damages all nearby enemies every second. | 6 | 1.0 | 1.0 |
| 2 | +33% damage. | 8 | 1.0 | 1.0 |
| 3 | +20% radius. | 8 | 1.0 | 1.2 |
| 4 | +37% damage. | 11 | 1.0 | 1.2 |
| 5 | Bigger and faster. | 11 | 0.8 | 1.5 |
File: res://scripts/autoload/upgrades.gd
Replace the whole file with:
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] = [
preload("res://resources/weapons/seeker_shot.tres"),
preload("res://resources/weapons/orbiting_blades.tres"),
preload("res://resources/weapons/pulse_aura.tres"),
]
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"),
preload("res://resources/passives/area.tres"),
preload("res://resources/passives/duration.tres"),
]
Each WeaponData gets a scales_with list of stats, shown in level-up offers and as a tooltip when you hover a weapon icon on the HUD.
Swarm shows this in every weapon tooltip, because it’s how you plan a build: “my Orbiting Blades scale with Area and Duration, so those passives are worth more to me than Projectiles”. The stats a weapon actually uses are decided by its code (which get_...() functions it calls). This list is just a description for the player, so keep it in sync when you write a weapon.
File: res://scripts/resources/passive_data.gd
Find this:
@export var stat: Stat = Stat.MAX_HP
Replace with:
const STAT_NAMES: Dictionary = {
Stat.MAX_HP: "Max Health",
Stat.DEFENCE: "Defence",
Stat.DAMAGE_MULT: "Damage",
Stat.CDR: "Cooldown",
Stat.HP_REGEN: "Health Regen",
Stat.MOVE_SPEED: "Move Speed",
Stat.PROJECTILE: "Projectiles",
Stat.CRIT_CHANCE: "Crit Chance",
Stat.PICKUP_RANGE: "Pickup Range",
Stat.XP_MULT: "XP",
Stat.GOLD_MULT: "Gold",
Stat.AREA_SIZE_MULT: "Area Size",
Stat.DURATION_MULT: "Duration",
}
@export var stat: Stat = Stat.MAX_HP
A Dictionary maps keys to values. Here each enum value maps to the name a player should see. Stat.keys() would give "DAMAGE_MULT", which is fine for code but not for players.
WeaponDataFile: res://scripts/resources/weapon_data.gd
Find this:
@export var levels: Array[WeaponLevel] = []
Replace with:
@export var levels: Array[WeaponLevel] = []
# For tooltips only; keep in sync with which get_...() functions the weapon script calls.
@export var scales_with: Array[PassiveData.Stat] = []
Add at the bottom:
func scaling_text() -> String:
if scales_with.is_empty():
return ""
var names: PackedStringArray = []
for stat: PassiveData.Stat in scales_with:
names.append(PassiveData.STAT_NAMES[stat])
return "Scales with: " + ", ".join(names)
", ".join(names) glues the names together with commas: Damage, Cooldown, Area Size.
If Godot ever refuses
Array[PassiveData.Stat]as an export type, useArray[int]instead. It shows numbers instead of names in the Inspector but works the same.
In the Godot editor: open each weapon resource and fill in Scales With (click Add Element and pick from the dropdown):
| Resource | Scales With |
|---|---|
weapon_gunner_default.tres |
DAMAGE_MULT, CDR, PROJECTILE, CRIT_CHANCE |
seeker_shot.tres |
DAMAGE_MULT, CDR, PROJECTILE, CRIT_CHANCE |
orbiting_blades.tres |
DAMAGE_MULT, CDR, PROJECTILE, AREA_SIZE_MULT, DURATION_MULT, CRIT_CHANCE |
pulse_aura.tres |
DAMAGE_MULT, CDR, AREA_SIZE_MULT, CRIT_CHANCE |
File: res://scripts/systems/upgrade_offers.gd
Find this (in the new-weapon half of _add_weapon_offers()):
offer.description = data.level_stats(1).description
Replace with:
offer.description = "%s\n%s" % [data.level_stats(1).description, data.scaling_text()]
File: res://scripts/ui/hud.gd
Find the _refresh_loadout() and _make_slot() functions and replace both with:
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()]
weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level, 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
MOUSE_FILTER_PASS on the picture lets the hover pass through to the slot that owns the tooltip. Tooltips work while the level-up screen is open too, which is handy for checking what you already have.
Upgrades.weapons.If something breaks:
| Problem | Likely cause |
|---|---|
| Orbiting Blades never appear | super(delta) is missing from its _physics_process(). |
| Blades appear but don’t damage | Blade layer/mask isn’t 5/3, or orbit_blade.gd isn’t attached. |
Invalid call. Nonexistent function 'apply_knockback' |
Step B (knockback on enemies) was skipped. |
| Pulse ring floats mid-air | Ring Position Y isn’t -0.95. |
Invalid access to key ... STAT_NAMES |
A stat is missing from the STAT_NAMES dictionary. |
| No NEW weapon offers | Upgrades.weapons is empty, or the resource paths are wrong. |
File: res://scripts/characters/player/player.gd
extends CharacterBody3D
class_name Player
signal stats_changed
signal health_changed(current: float, maximum: float)
signal died
signal loadout_changed
const MAX_PASSIVE_LEVEL: int = 5
# The signature weapon plus 4 picked up during the run.
const MAX_WEAPON_SLOTS: int = 5
const AUTO_AIM_RANGE: float = 25.0
@export var character_data: CharacterData
@onready var character_model: Node3D = $CharacterModel
@onready var camera: Camera3D = $Camera3D
@onready var weapon_holder: Node3D = $Weapons
@onready var pickup_area: Area3D = $PickupArea
@onready var pickup_shape: CollisionShape3D = $PickupArea/CollisionShape3D
# --- Set up stats ---
var max_hp: float
var defence: float
var damage_mult: float
# Haste-style: cooldown = base / (1 + cdr / 100), so it never reaches zero.
var cdr: float
var hp_regen: float
var move_speed: float
var projectile: int
var crit_chance: float
var pickup_range: float
var xp_mult: float
var gold_mult: float
var area_size_mult: float
var duration_mult: float
var current_hp: float = 0.0
var is_dead: bool = false
# --- Create passive slots ---
var passive_slots: int = Profile.MAX_PASSIVE_SLOTS
var passives: Array[OwnedPassive] = []
# --- Weapons ---
var weapons: Array[Weapon] = []
# --- Aiming ---
# The model's front is +X, so that's the starting aim.
var aim_direction: Vector3 = Vector3.RIGHT
var auto_aim: bool = false
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
add_to_group("players")
pickup_area.area_entered.connect(_on_pickup_area_entered)
passive_slots = game_state.passive_slots
if character_data == null:
character_data = game_state.selected_character
if character_data:
_apply_character(character_data)
recalculate_stats()
current_hp = max_hp
func _apply_character(character: CharacterData) -> void:
if character.model_scene:
character_model.add_child(character.model_scene.instantiate())
if character.signature_weapon:
_spawn_weapon(character.signature_weapon)
func _spawn_weapon(data: WeaponData) -> Weapon:
var weapon: Weapon = data.weapon_scene.instantiate()
weapon.setup(self, data)
weapon_holder.add_child(weapon)
weapons.append(weapon)
return weapon
func recalculate_stats() -> void:
var previous_max_hp: float = max_hp
max_hp = 1000.0
defence = 0.0
damage_mult = 1.0
cdr = 0.0
hp_regen = 10.0
move_speed = 7.0
projectile = 0
crit_chance = 5.0
pickup_range = 3.0
xp_mult = 1.0
gold_mult = 1.0
area_size_mult = 1.0
duration_mult = 1.0
if character_data:
max_hp += character_data.bonus_max_hp
defence += character_data.bonus_defence
for passive: OwnedPassive in passives:
_apply_bonus(passive.data.stat, passive.bonus())
(pickup_shape.shape as SphereShape3D).radius = pickup_range
# Raising max HP grants the difference rather than healing to full.
current_hp = minf(current_hp + maxf(0.0, max_hp - previous_max_hp), max_hp)
stats_changed.emit()
health_changed.emit(current_hp, max_hp)
# Each call is one "source". Percentage stats multiply between sources; flat stats add.
func _apply_bonus(stat: PassiveData.Stat, amount: float) -> void:
match stat:
PassiveData.Stat.MAX_HP:
max_hp += amount
PassiveData.Stat.DEFENCE:
defence += amount
PassiveData.Stat.DAMAGE_MULT:
damage_mult *= 1.0 + amount
PassiveData.Stat.CDR:
cdr += amount
PassiveData.Stat.HP_REGEN:
hp_regen += amount
PassiveData.Stat.MOVE_SPEED:
move_speed *= 1.0 + amount
PassiveData.Stat.PROJECTILE:
projectile += roundi(amount)
PassiveData.Stat.CRIT_CHANCE:
crit_chance += amount
PassiveData.Stat.PICKUP_RANGE:
pickup_range *= 1.0 + amount
PassiveData.Stat.XP_MULT:
xp_mult *= 1.0 + amount
PassiveData.Stat.GOLD_MULT:
gold_mult *= 1.0 + amount
PassiveData.Stat.AREA_SIZE_MULT:
area_size_mult *= 1.0 + amount
PassiveData.Stat.DURATION_MULT:
duration_mult *= 1.0 + amount
func get_passive(data: PassiveData) -> OwnedPassive:
for passive: OwnedPassive in passives:
if passive.data.id == data.id:
return passive
return null
func add_passive(data: PassiveData) -> bool:
var owned: OwnedPassive = get_passive(data)
if owned:
if owned.level >= MAX_PASSIVE_LEVEL:
return false
owned.level += 1
else:
if passives.size() >= passive_slots:
return false
passives.append(OwnedPassive.new(data))
recalculate_stats()
loadout_changed.emit()
return true
func get_weapon(data: WeaponData) -> Weapon:
for weapon: Weapon in weapons:
if weapon.data.id == data.id:
return weapon
return null
func add_weapon(data: WeaponData) -> bool:
var owned: Weapon = get_weapon(data)
if owned:
return level_up_weapon(owned)
if weapons.size() >= MAX_WEAPON_SLOTS:
return false
_spawn_weapon(data)
loadout_changed.emit()
return true
func level_up_weapon(weapon: Weapon) -> bool:
if weapon.is_max_level():
return false
weapon.set_level(weapon.level + 1)
loadout_changed.emit()
return true
func 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)
File: res://scripts/systems/upgrade_offer.gd
class_name UpgradeOffer
extends RefCounted
enum Kind { NEW_PASSIVE, PASSIVE_LEVEL, WEAPON_LEVEL, HEAL, GOLD, NEW_WEAPON }
const HEAL_FRACTION: float = 0.25
const FALLBACK_GOLD: int = 25
var kind: Kind = Kind.HEAL
var title: String = ""
var description: String = ""
var icon: Texture2D
var passive_data: PassiveData
var weapon: Weapon
var weapon_data: WeaponData
func apply(player: Player) -> void:
match kind:
Kind.NEW_PASSIVE, Kind.PASSIVE_LEVEL:
player.add_passive(passive_data)
Kind.WEAPON_LEVEL:
player.level_up_weapon(weapon)
Kind.HEAL:
player.heal(player.max_hp * HEAL_FRACTION)
Kind.GOLD:
game_state.add_gold(FALLBACK_GOLD)
Kind.NEW_WEAPON:
player.add_weapon(weapon_data)
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)
if player.weapons.size() >= Player.MAX_WEAPON_SLOTS:
return
for data: WeaponData in Upgrades.weapons:
if player.get_weapon(data) != null:
continue
var offer: UpgradeOffer = UpgradeOffer.new()
offer.kind = UpgradeOffer.Kind.NEW_WEAPON
offer.weapon_data = data
offer.title = "NEW %s" % data.display_name
offer.description = "%s\n%s" % [data.level_stats(1).description, data.scaling_text()]
offer.icon = data.icon
candidates.append(offer)
static func _add_passive_offers(player: Player, candidates: Array[UpgradeOffer]) -> void:
for passive: OwnedPassive in player.passives:
if passive.level < Player.MAX_PASSIVE_LEVEL:
candidates.append(_passive_offer(passive.data, passive.level + 1))
if player.passives.size() >= player.passive_slots:
return
for data: PassiveData in Upgrades.passives:
if player.get_passive(data) == null:
candidates.append(_passive_offer(data, 1))
static func _passive_offer(data: PassiveData, level: int) -> UpgradeOffer:
var offer: UpgradeOffer = UpgradeOffer.new()
offer.passive_data = data
offer.description = data.describe_level(level)
offer.icon = data.icon
if level == 1:
offer.kind = UpgradeOffer.Kind.NEW_PASSIVE
offer.title = "NEW %s" % data.display_name
else:
offer.kind = UpgradeOffer.Kind.PASSIVE_LEVEL
offer.title = "%s Lv %d" % [data.display_name, level]
return offer
static func _fallback_offers() -> Array[UpgradeOffer]:
var heal: UpgradeOffer = UpgradeOffer.new()
heal.kind = UpgradeOffer.Kind.HEAL
heal.title = "Patch Up"
heal.description = "Restore %d%% of your max health." % roundi(UpgradeOffer.HEAL_FRACTION * 100.0)
var gold: UpgradeOffer = UpgradeOffer.new()
gold.kind = UpgradeOffer.Kind.GOLD
gold.title = "Pocket Change"
gold.description = "Gain %d gold." % UpgradeOffer.FALLBACK_GOLD
var offers: Array[UpgradeOffer] = [heal, gold]
return offers
File: res://scripts/utils/targeting.gd
class_name Targeting
extends RefCounted
# Returns the closest node in the group within max_range, or null.
static func nearest_in_group(tree: SceneTree, group: StringName, from: Vector3, max_range: float = INF) -> Node3D:
var nearest: Node3D = null
var nearest_dist_sq: float = max_range * max_range
for node: Node in tree.get_nodes_in_group(group):
var candidate: Node3D = node as Node3D
var dist_sq: float = from.distance_squared_to(candidate.global_position)
if dist_sq < nearest_dist_sq:
nearest_dist_sq = dist_sq
nearest = candidate
return nearest
static func nearest_enemy(tree: SceneTree, from: Vector3, max_range: float = INF) -> Node3D:
return nearest_in_group(tree, &"enemies", from, max_range)
static func nearest_player(tree: SceneTree, from: Vector3) -> Player:
return nearest_in_group(tree, &"players", from) as Player
# Every enemy whose flat (ground) distance from center is within radius.
static func enemies_in_radius(tree: SceneTree, center: Vector3, radius: float) -> Array[Enemy]:
var found: Array[Enemy] = []
var radius_sq: float = radius * radius
for node: Node in tree.get_nodes_in_group(&"enemies"):
var enemy: Enemy = node as Enemy
var offset: Vector3 = enemy.global_position - center
offset.y = 0.0
if offset.length_squared() <= radius_sq:
found.append(enemy)
return found
# Up to `count` enemies, closest first.
static func nearest_enemies(tree: SceneTree, from: Vector3, count: int, max_range: float = INF) -> Array[Enemy]:
var found: Array[Enemy] = enemies_in_radius(tree, from, max_range)
found.sort_custom(func(a: Enemy, b: Enemy) -> bool:
return from.distance_squared_to(a.global_position) < from.distance_squared_to(b.global_position)
)
if found.size() > count:
found.resize(count)
return found
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")
# How fast knockback fades, in metres per second per second.
const KNOCKBACK_DECAY: float = 20.0
@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
var _knockback: Vector3 = Vector3.ZERO
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 + _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)
_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 apply_knockback(force: Vector3) -> void:
_knockback += force
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/characters/player/weapons/seeker_shot.gd
extends Weapon
const BULLET_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/bullet.tscn")
const RANGE: float = 20.0
const BULLET_SPEED: float = 25.0
const BULLET_LIFETIME: float = 1.2
func fire() -> bool:
var targets: Array[Enemy] = Targeting.nearest_enemies(get_tree(), global_position, get_projectile_count(), RANGE)
if targets.is_empty():
return false
for target: Enemy in targets:
var direction: Vector3 = target.global_position - global_position
direction.y = 0.0
spawn_projectile(BULLET_SCENE, direction, BULLET_SPEED, BULLET_LIFETIME)
return true
File: res://scripts/characters/player/weapons/orbit_blade.gd
class_name OrbitBlade
extends Area3D
var damage: float = 10.0
var crit_chance: float = 0.0
var knockback: float = 0.0
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D) -> void:
if not (body is Enemy):
return
var enemy: Enemy = body as Enemy
enemy.take_damage(Weapon.roll_crit(damage, crit_chance))
var center: Vector3 = (get_parent() as Node3D).global_position
var push: Vector3 = enemy.global_position - center
push.y = 0.0
enemy.apply_knockback(push.normalized() * knockback)
File: res://scripts/characters/player/weapons/orbiting_blades.gd
extends Weapon
const BLADE_SCENE: PackedScene = preload("res://scenes/characters/player/weapons/orbit_blade.tscn")
const BASE_RADIUS: float = 3.0
# Radians per second; TAU would be one full turn per second.
const SPIN_SPEED: float = 3.0
const KNOCKBACK: float = 6.0
var _pivot: Node3D
var _time_left: float = 0.0
func _ready() -> void:
_pivot = Node3D.new()
add_child(_pivot)
func _physics_process(delta: float) -> void:
super(delta)
_pivot.rotate_y(SPIN_SPEED * delta)
if _time_left > 0.0:
_time_left -= delta
if _time_left <= 0.0:
_clear_blades()
func fire() -> bool:
_clear_blades()
var count: int = get_projectile_count()
var radius: float = BASE_RADIUS * get_area_scale()
for i: int in count:
var blade: OrbitBlade = BLADE_SCENE.instantiate()
blade.damage = get_damage()
blade.crit_chance = player.crit_chance
blade.knockback = KNOCKBACK
var angle: float = TAU * i / count
blade.position = Vector3(cos(angle), 0.0, sin(angle)) * radius
blade.rotation.y = -angle
blade.scale = Vector3.ONE * get_area_scale()
_pivot.add_child(blade)
_time_left = get_duration()
return true
# Blades are out for `duration`, then the normal cooldown starts.
func get_cooldown() -> float:
return super() + get_duration()
func _clear_blades() -> void:
for blade: Node in _pivot.get_children():
blade.queue_free()
File: res://scripts/characters/player/weapons/pulse_aura.gd
extends Weapon
const BASE_RADIUS: float = 4.0
const FLASH_TIME: float = 0.15
@onready var ring: MeshInstance3D = $Ring
func _ready() -> void:
ring.visible = false
func fire() -> bool:
var radius: float = BASE_RADIUS * get_area_scale()
for enemy: Enemy in Targeting.enemies_in_radius(get_tree(), global_position, radius):
enemy.take_damage(Weapon.roll_crit(get_damage(), player.crit_chance))
_flash_ring(radius)
return true
func _flash_ring(radius: float) -> void:
ring.scale = Vector3(radius, 1.0, radius)
ring.visible = true
var tween: Tween = create_tween()
tween.tween_interval(FLASH_TIME)
tween.tween_callback(ring.hide)
File: res://scripts/resources/weapon_data.gd
class_name WeaponData
extends GameData
@export var weapon_scene: PackedScene
# One entry per level, so entry 0 is level 1.
@export var levels: Array[WeaponLevel] = []
# For tooltips only; keep in sync with which get_...() functions the weapon script calls.
@export var scales_with: Array[PassiveData.Stat] = []
func max_level() -> int:
return levels.size()
func level_stats(level: int) -> WeaponLevel:
return levels[clampi(level - 1, 0, levels.size() - 1)]
func scaling_text() -> String:
if scales_with.is_empty():
return ""
var names: PackedStringArray = []
for stat: PassiveData.Stat in scales_with:
names.append(PassiveData.STAT_NAMES[stat])
return "Scales with: " + ", ".join(names)
File: res://scripts/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 }
const STAT_NAMES: Dictionary = {
Stat.MAX_HP: "Max Health",
Stat.DEFENCE: "Defence",
Stat.DAMAGE_MULT: "Damage",
Stat.CDR: "Cooldown",
Stat.HP_REGEN: "Health Regen",
Stat.MOVE_SPEED: "Move Speed",
Stat.PROJECTILE: "Projectiles",
Stat.CRIT_CHANCE: "Crit Chance",
Stat.PICKUP_RANGE: "Pickup Range",
Stat.XP_MULT: "XP",
Stat.GOLD_MULT: "Gold",
Stat.AREA_SIZE_MULT: "Area Size",
Stat.DURATION_MULT: "Duration",
}
@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] = [
preload("res://resources/weapons/seeker_shot.tres"),
preload("res://resources/weapons/orbiting_blades.tres"),
preload("res://resources/weapons/pulse_aura.tres"),
]
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"),
preload("res://resources/passives/area.tres"),
preload("res://resources/passives/duration.tres"),
]
File: res://scripts/ui/hud.gd
class_name Hud
extends CanvasLayer
const SLOT_SIZE: Vector2 = Vector2(48, 48)
@onready var hp_bar: ProgressBar = $TopBar/HBox/HpBar
@onready var level_label: Label = $TopBar/HBox/LevelLabel
@onready var gold_label: Label = $TopBar/HBox/GoldLabel
@onready var timer_label: Label = $TopBar/HBox/TimerLabel
@onready var weapon_icons: HBoxContainer = $Loadout/WeaponIcons
@onready var passive_icons: HBoxContainer = $Loadout/PassiveIcons
@onready var xp_bar: ProgressBar = $XpBar
var player: Player
func setup(target_player: Player) -> void:
player = target_player
player.health_changed.connect(_on_health_changed)
player.loadout_changed.connect(_refresh_loadout)
game_state.xp_changed.connect(_on_xp_changed)
game_state.leveled_up.connect(_on_leveled_up)
game_state.gold_changed.connect(_on_gold_changed)
_on_health_changed(player.current_hp, player.max_hp)
_on_xp_changed(game_state.xp, game_state.xp_to_next)
_on_leveled_up(game_state.level)
_on_gold_changed(game_state.gold)
_refresh_loadout()
func _process(_delta: float) -> void:
var total_seconds: int = floori(game_state.run_time)
timer_label.text = "%02d:%02d" % [floori(total_seconds / 60.0), total_seconds % 60]
func _on_health_changed(current: float, maximum: float) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
func _on_xp_changed(current: float, required: float) -> void:
xp_bar.max_value = required
xp_bar.value = current
func _on_leveled_up(new_level: int) -> void:
level_label.text = "Lv %d" % new_level
func _on_gold_changed(total: int) -> void:
gold_label.text = "%d gold" % total
func _refresh_loadout() -> void:
for child: Node in weapon_icons.get_children():
child.queue_free()
for child: Node in passive_icons.get_children():
child.queue_free()
for weapon: Weapon in player.weapons:
var tooltip: String = "%s\n%s\n%s" % [weapon.get_display_name(), weapon.stats.description, weapon.data.scaling_text()]
weapon_icons.add_child(_make_slot(weapon.get_icon(), "Lv %d" % weapon.level, 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
Next: Phase 5: Evolutions