Lobby barrel-smash reward + roll/slam barrel destruction

Bull's boulder roll now homes on and ploughs through lobby barrels, and
the slam shockwave bursts them within its radius. HUD tallies barrels on
load and fires a confetti "WOooOoW!" + two gold bonus HP pips when the
last one breaks; gates open from run state as fights are cleared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 01:38:57 +03:00
parent 5c1e881a53
commit 34dc5b026e
9 changed files with 301 additions and 43 deletions
+7 -2
View File
@@ -1,8 +1,13 @@
extends Node3D extends Node3D
## Lobby geometry — watches the run state and raises the bear-gate grid once the arena has
## been cleared. The DP flag stays as a designer override for testing the animation.
var bear_gate_open := false var bear_gate_open := false
func _process(_delta):
if DP.b("gate_unlocked_bear") and not bear_gate_open: func _process(_delta: float) -> void:
if bear_gate_open:
return
if Run.bear_gate_open() or DP.b("gate_unlocked_bear"):
bear_gate_open = true bear_gate_open = true
$AnimationPlayer.play("Gate_opening") $AnimationPlayer.play("Gate_opening")
+118 -6
View File
@@ -42,6 +42,13 @@ var _ability_bar: HBoxContainer
var _ability_panels: Array[PanelContainer] = [] var _ability_panels: Array[PanelContainer] = []
var _bull_bg: TextureRect var _bull_bg: TextureRect
# Lobby barrel-smash reward — tally the barrels present on load; when the last one bursts,
# throw confetti + a "WOooOoW!" and grant the bull two yellow bonus pips. Latched so it
# fires once per lobby visit.
var _barrels_remaining: int = 0
var _barrel_reward_given: bool = false
const _BARREL_BONUS_HP: int = 2
# Red damage vignette — a full-screen shader ColorRect flashed on every hit. # Red damage vignette — a full-screen shader ColorRect flashed on every hit.
const _VIGNETTE_SHADER := "res://damage_vignette.gdshader" const _VIGNETTE_SHADER := "res://damage_vignette.gdshader"
var _vignette: ColorRect var _vignette: ColorRect
@@ -90,6 +97,7 @@ func _ready() -> void:
_build_touch_controls() _build_touch_controls()
_update_control_hints() _update_control_hints()
_find_spawner.call_deferred() _find_spawner.call_deferred()
_watch_barrels.call_deferred()
# On-screen joystick + ability buttons for touch devices. Self-gating: the overlay # On-screen joystick + ability buttons for touch devices. Self-gating: the overlay
@@ -213,6 +221,104 @@ func _find_spawner() -> void:
_spawner.all_defeated.connect(_on_all_defeated) _spawner.all_defeated.connect(_on_all_defeated)
# ── Lobby barrel-smash reward ───────────────────────────────────────────────────
# Tally the barrels the current level loaded with and listen for each one bursting. Only
# the lobby has barrels, so on every other level this simply finds none and stands down.
func _watch_barrels() -> void:
var barrels := get_tree().get_nodes_in_group(&"barrel")
_barrels_remaining = barrels.size()
if _barrels_remaining == 0:
return
for barrel: Node in barrels:
barrel.destroyed.connect(_on_barrel_destroyed)
func _on_barrel_destroyed() -> void:
_barrels_remaining -= 1
if _barrels_remaining > 0 or _barrel_reward_given:
return
_barrel_reward_given = true
# Bank the reward run-wide (capped at +2). Only heal the bull for the pips that were
# actually added — if it already carried the cap over from an earlier lobby, it keeps
# the cheer but no new health.
var granted := Run.add_bonus_hp(_BARREL_BONUS_HP)
if granted > 0 and _player != null and _player.has_method(&"grant_bonus_hp"):
_player.call(&"grant_bonus_hp", granted)
_celebrate()
# The "cleared the lobby" flourish: a burst of confetti and a big springy "WOooOoW!".
func _celebrate() -> void:
_spawn_confetti()
_show_wow()
func _show_wow() -> void:
var wow := Label.new()
wow.text = "WOooOoW!"
wow.set_anchors_preset(Control.PRESET_CENTER)
wow.grow_horizontal = Control.GROW_DIRECTION_BOTH
wow.grow_vertical = Control.GROW_DIRECTION_BOTH
wow.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wow.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
wow.mouse_filter = Control.MOUSE_FILTER_IGNORE
wow.add_theme_font_override("font", UiFonts.title())
wow.add_theme_font_size_override("font_size", 96)
wow.add_theme_color_override("font_color", _HP_BONUS)
wow.add_theme_color_override("font_outline_color", Color(0.0, 0.0, 0.0, 0.9))
wow.add_theme_constant_override("outline_size", 8)
wow.pivot_offset = Vector2.ZERO
wow.scale = Vector2(0.2, 0.2)
add_child(wow)
# Keep the pop growing from the label's centre once it has a real size.
await get_tree().process_frame
wow.pivot_offset = wow.size * 0.5
var tw := create_tween()
tw.tween_property(wow, "scale", Vector2.ONE, 0.35).set_trans(Tween.TRANS_BACK).set_ease(
Tween.EASE_OUT)
tw.tween_interval(1.1)
tw.parallel().tween_property(wow, "modulate:a", 0.0, 0.6)
tw.parallel().tween_property(wow, "position:y", wow.position.y - 40.0, 0.6)
tw.tween_callback(wow.queue_free)
func _spawn_confetti() -> void:
const _CONFETTI_COLORS: Array[Color] = [
Color(0.98, 0.24, 0.28), # red
Color(1.00, 0.78, 0.20), # gold
Color(0.30, 0.80, 0.40), # green
Color(0.30, 0.62, 1.00), # blue
Color(0.85, 0.40, 0.95), # violet
Color(1.00, 1.00, 1.00), # white
]
var vp := get_viewport().get_visible_rect().size
# One emitter per colour, raining down across the top of the screen.
for col: Color in _CONFETTI_COLORS:
var p := CPUParticles2D.new()
p.position = Vector2(vp.x * 0.5, -20.0)
p.emission_shape = CPUParticles2D.EMISSION_SHAPE_RECTANGLE
p.emission_rect_extents = Vector2(vp.x * 0.5, 6.0)
p.amount = 40
p.lifetime = 2.6
p.one_shot = true
p.explosiveness = 0.35
p.direction = Vector2(0.0, 1.0)
p.spread = 25.0
p.gravity = Vector2(0.0, 420.0)
p.initial_velocity_min = 120.0
p.initial_velocity_max = 260.0
p.angular_velocity_min = -360.0
p.angular_velocity_max = 360.0
p.scale_amount_min = 4.0
p.scale_amount_max = 8.0
p.color = col
add_child(p)
# Self-clean once the burst has fully fallen.
get_tree().create_timer(p.lifetime + 0.5).timeout.connect(p.queue_free)
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo: if event is InputEventKey and event.pressed and not event.echo:
if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]: if _game_over and event.physical_keycode in [KEY_ENTER, KEY_KP_ENTER]:
@@ -238,14 +344,12 @@ func _restart() -> void:
get_tree().reload_current_scene() get_tree().reload_current_scene()
# A cleared level advances the run and drops the player into the lobby hub: commit the # A cleared level drops the player back into the lobby hub: bank the win (which opens the
# win, flag the lobby as the active level, then reload the shell so it builds the lobby. # gates it unlocks) and reload the shell so it rebuilds the lobby.
# (Choosing the next map node happens from the lobby via trigger areas, added later.)
func _continue_to_lobby() -> void: func _continue_to_lobby() -> void:
get_tree().paused = false get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
Run.complete_current_level() Run.return_to_lobby()
Run.go_to_lobby()
get_tree().change_scene_to_file(Run.active_level().scene_path) get_tree().change_scene_to_file(Run.active_level().scene_path)
@@ -422,6 +526,9 @@ func _build_game_over() -> void:
const _HP_FULL: Color = Color(0.82, 0.16, 0.12) const _HP_FULL: Color = Color(0.82, 0.16, 0.12)
const _HP_EMPTY: Color = Color(0.35, 0.05, 0.05) const _HP_EMPTY: Color = Color(0.35, 0.05, 0.05)
const _HP_SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92) const _HP_SOCKET: Color = Color(0.05, 0.02, 0.02, 0.92)
# Bonus pips (barrel-smash reward) read as bright gold-yellow so they stand apart from the
# red life bar.
const _HP_BONUS: Color = Color(1.00, 0.84, 0.18)
const _HP_PIP_W: float = 22.0 const _HP_PIP_W: float = 22.0
const _HP_PIP_H: float = 14.0 const _HP_PIP_H: float = 14.0
@@ -500,10 +607,15 @@ func _on_health_changed(current: int, max_hp: int) -> void:
var cur := maxi(current, 0) var cur := maxi(current, 0)
var frac := 0.0 if max_hp <= 0 else clampf(float(cur) / float(max_hp), 0.0, 1.0) var frac := 0.0 if max_hp <= 0 else clampf(float(cur) / float(max_hp), 0.0, 1.0)
var lit := _HP_FULL.lerp(_HP_EMPTY, 1.0 - frac) var lit := _HP_FULL.lerp(_HP_EMPTY, 1.0 - frac)
# Bonus pips sit at the high end of the bar; paint those top pips yellow when lit.
var bonus := 0
if _player != null and _player.has_method(&"get_bonus_hp"):
bonus = _player.call(&"get_bonus_hp")
var base_max := max_hp - bonus
for i: int in _hp_seg_fills.size(): for i: int in _hp_seg_fills.size():
var fill := _hp_seg_fills[i] var fill := _hp_seg_fills[i]
fill.visible = i < cur fill.visible = i < cur
fill.color = lit fill.color = _HP_BONUS if i >= base_max else lit
const _SLOT_SEPARATION: int = 10 const _SLOT_SEPARATION: int = 10
+54 -7
View File
@@ -34,11 +34,20 @@ var pending_col: int = -1
## Debug override — when set, active_level() returns this regardless of map state. ## Debug override — when set, active_level() returns this regardless of map state.
## Set via the console `level` command; cleared on start_new_run(). ## Set via the console `level` command; cleared on start_new_run().
var debug_level_override: LevelDef = null var debug_level_override: LevelDef = null
## The lobby is an interstitial hub between fights, not a map node: the player is sent ## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead
## here after clearing a level (go_to_lobby()) to move around freely before choosing the ## into the fights. While set, active_level() serves the lobby; cleared once a gate is
## next node. While set, active_level() serves the lobby; cleared once a node is picked ## entered (choose_level) and set again on a win (return_to_lobby).
## (select_node) or a fresh run begins.
var enter_lobby: bool = false var enter_lobby: bool = false
## Level picked at a lobby gate and being fought now — active_level() serves it while
## enter_lobby is false. Null in the lobby itself.
var lobby_choice: LevelDef = null
## Ids of levels the player has cleared this run (StringName -> true). Drives which lobby
## gates are open: the bear gate stays shut until the arena is beaten.
var cleared: Dictionary = {}
## Run-long bonus health (yellow pips) earned from lobby rewards. Persists across level
## loads — the player reads it on spawn — and is capped so it can't stack past BONUS_HP_CAP.
var bonus_hp: int = 0
const BONUS_HP_CAP: int = 2
# Level catalog. Every level shares the shell scene; what changes is the level module # Level catalog. Every level shares the shell scene; what changes is the level module
# (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss # (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss
@@ -85,6 +94,8 @@ func active_level() -> LevelDef:
return debug_level_override return debug_level_override
if enter_lobby: if enter_lobby:
return _lobby return _lobby
if lobby_choice != null:
return lobby_choice
var n := node_at(pending_row, pending_col) var n := node_at(pending_row, pending_col)
if n == null: if n == null:
n = current_node() n = current_node()
@@ -115,7 +126,11 @@ func start_new_run() -> void:
pending_row = -1 pending_row = -1
pending_col = -1 pending_col = -1
debug_level_override = null debug_level_override = null
enter_lobby = false # The run opens in the lobby hub; its gates lead into the fights.
enter_lobby = true
lobby_choice = null
cleared = {}
bonus_hp = 0
run_started.emit() run_started.emit()
@@ -177,10 +192,42 @@ func select_node(node: MapNode) -> void:
## Send the player to the lobby hub instead of a fight — active_level() serves the lobby ## Send the player to the lobby hub instead of a fight — active_level() serves the lobby
## until a node is picked. Called after clearing a level so the player lands in the lobby ## until a gate is entered.
## rather than jumping straight to the next fight.
func go_to_lobby() -> void: func go_to_lobby() -> void:
enter_lobby = true enter_lobby = true
lobby_choice = null
## Enter the fight behind a lobby gate: active_level() serves it until the player wins and
## returns to the lobby. Ignored for an unknown id.
func choose_level(id: StringName) -> void:
var l := get_level(id)
if l == null:
return
enter_lobby = false
lobby_choice = l
## Land back in the lobby after a win, banking the cleared level so its follow-on gates
## open (clearing the arena unlocks the bear gate).
func return_to_lobby() -> void:
if lobby_choice != null:
cleared[lobby_choice.id] = true
go_to_lobby()
## Whether the bear gate should be open — the arena must be cleared first.
func bear_gate_open() -> bool:
return cleared.has(&"arena")
## Bank bonus health for the rest of the run, clamped to BONUS_HP_CAP. Returns how many
## pips were actually added (0 once the cap is reached) so the HUD only heals the bull when
## there's headroom — a repeat barrel clear still cheers but grants nothing.
func add_bonus_hp(amount: int) -> int:
var before := bonus_hp
bonus_hp = clampi(bonus_hp + amount, 0, BONUS_HP_CAP)
return bonus_hp - before
## Commit the level just won: move the player onto the pending node (or leave them at the ## Commit the level just won: move the player onto the pending node (or leave them at the
+6 -7
View File
@@ -1,13 +1,12 @@
extends Area3D extends Area3D
## Lobby gate into the arena fight. Always open — the arena is the run's first door.
func _ready() -> void: func _ready() -> void:
# Connect the signal via code
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
print("Something entered the trigger: ", body.name) if not body.is_in_group("player"):
# Check if the colliding object is the player return
if body.is_in_group("player"): Run.choose_level(&"arena")
#CHANGE TO BEAR LEVEL get_tree().change_scene_to_file(Run.active_level().scene_path)
print("go to arena level")
+10 -7
View File
@@ -1,13 +1,16 @@
extends Area3D extends Area3D
## Lobby gate into the Bear's Den. Shut until the arena is cleared (Run.bear_gate_open()),
## which is also what plays the grid-opening animation — so the trigger only fires once the
## gate has actually risen.
func _ready() -> void: func _ready() -> void:
# Connect the signal via code
body_entered.connect(_on_body_entered) body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
print("Something entered the trigger: ", body.name) if not body.is_in_group("player"):
# Check if the colliding object is the player return
if body.is_in_group("player"): if not Run.bear_gate_open():
#CHANGE TO BEAR LEVEL return
print("go to bear level") Run.choose_level(&"bear")
get_tree().change_scene_to_file(Run.active_level().scene_path)
+67 -7
View File
@@ -67,6 +67,9 @@ signal health_changed(current: int, max_hp: int)
signal hit_taken(cause: String) signal hit_taken(cause: String)
var _max_hp: int = 10 var _max_hp: int = 10
var _hp: int = 10 var _hp: int = 10
# Bonus pips granted by lobby rewards (e.g. smashing every barrel). Tacked on top of the
# base bar; the HUD paints these top pips yellow instead of red.
var _bonus_hp: int = 0
var _hit_iframes: float = 0.0 var _hit_iframes: float = 0.0
var _huff_player: AudioStreamPlayer = null var _huff_player: AudioStreamPlayer = null
@@ -93,6 +96,11 @@ func _ready() -> void:
RigidSkin.convert_tree(self) RigidSkin.convert_tree(self)
_max_hp = maxi(1, int(DP.f("bull_max_hp"))) _max_hp = maxi(1, int(DP.f("bull_max_hp")))
_hp = _max_hp _hp = _max_hp
# Carry any run-long bonus pips (barrel reward) into this level so they survive the
# scene reload between fights; the HUD paints these top pips yellow.
_bonus_hp = Run.bonus_hp
_max_hp += _bonus_hp
_hp += _bonus_hp
_cube_scale = cube_guy.scale _cube_scale = cube_guy.scale
_setup_hoof_dust() _setup_hoof_dust()
_setup_legs() _setup_legs()
@@ -511,6 +519,7 @@ func _tick_roll(delta: float, _steer: Vector3) -> void:
move_and_slide() move_and_slide()
_was_on_floor = is_on_floor() _was_on_floor = is_on_floor()
_smash_barrels_in_radius(DP.f("roll_hit_radius"))
_roll_try_pop(speed) _roll_try_pop(speed)
@@ -549,22 +558,37 @@ func _roll_try_pop(speed: float) -> bool:
return false return false
# Closest active matador we haven't already popped this roll, or null if none left. # Closest thing the roll should home on — an active matador (arena) or an intact barrel
# (lobby) we haven't already smashed this roll. Null if nothing is left to hit.
func _roll_pick_target() -> Node3D: func _roll_pick_target() -> Node3D:
var best: Node3D = null var best: Node3D = null
var best_d := INF var best_d := INF
for mat: Node in get_tree().get_nodes_in_group(&"matador"): for cand: Node3D in _roll_targets():
if _roll_hit.has(mat) or not _mat_active(mat): var d: float = cand.global_position.distance_squared_to(global_position)
continue
var d: float = (mat as Node3D).global_position.distance_squared_to(global_position)
if d < best_d: if d < best_d:
best_d = d best_d = d
best = mat as Node3D best = cand
return best return best
# Live roll targets: active matadors plus standing barrels, minus anything already popped.
func _roll_targets() -> Array[Node3D]:
var out: Array[Node3D] = []
for mat: Node in get_tree().get_nodes_in_group(&"matador"):
if not _roll_hit.has(mat) and _mat_active(mat):
out.append(mat as Node3D)
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
if _barrel_alive(barrel):
out.append(barrel as Node3D)
return out
func _roll_target_valid() -> bool: func _roll_target_valid() -> bool:
return is_instance_valid(_roll_target) and _mat_active(_roll_target) if not is_instance_valid(_roll_target):
return false
if _roll_target.is_in_group(&"barrel"):
return _barrel_alive(_roll_target)
return _mat_active(_roll_target)
# A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report # A matador still in play — valid, not ragdolled/consumed. Ragdolled bodies report
@@ -573,6 +597,25 @@ func _mat_active(mat: Node) -> bool:
return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active") return is_instance_valid(mat) and mat.has_method(&"is_active") and mat.call(&"is_active")
# A lobby barrel still standing — valid and not already queued for removal by an earlier hit.
func _barrel_alive(barrel: Node) -> bool:
return is_instance_valid(barrel) and not barrel.is_queued_for_deletion()
# Burst every barrel whose centre sits within range_m on the ground plane. Shared by the
# boulder roll (which ploughs straight through them) and the slam shockwave. Each barrel
# self-removes and leaves the &"barrel" group, so the HUD's clear-the-lobby tally counts it
# exactly once.
func _smash_barrels_in_radius(range_m: float) -> void:
for barrel: Node in get_tree().get_nodes_in_group(&"barrel"):
if not _barrel_alive(barrel):
continue
var to_b: Vector3 = (barrel as Node3D).global_position - global_position
to_b.y = 0.0
if to_b.length() <= range_m:
barrel.call(&"destroy_barrel")
# True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop # True while the boulder roll is live — matadors defer their charge-gore to the roll's own pop
# (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring. # (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring.
func is_rolling() -> bool: func is_rolling() -> bool:
@@ -1084,6 +1127,7 @@ func _physics_process(delta: float) -> void:
_play_idle() _play_idle()
_spawn_slam_fx() _spawn_slam_fx()
_hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up")) _hit_matadors_radius(DP.f("slam_range"), DP.f("slam_strength"), DP.f("slam_launch_up"))
_smash_barrels_in_radius(DP.f("slam_range"))
# ── Dust ────────────────────────────────────────────────────────────────── # ── Dust ──────────────────────────────────────────────────────────────────
if flat_speed > DP.f("dust_charge_spd") and on_floor: if flat_speed > DP.f("dust_charge_spd") and on_floor:
@@ -1110,6 +1154,22 @@ func get_max_hp() -> int:
return _max_hp return _max_hp
# Bonus pips currently on the bar — the HUD colours this many top pips yellow.
func get_bonus_hp() -> int:
return _bonus_hp
# Award extra health (from a lobby reward): grows both the cap and the current bar so the
# new pips arrive full, and marks them as bonus so the HUD paints them yellow.
func grant_bonus_hp(amount: int) -> void:
if amount <= 0:
return
_bonus_hp += amount
_max_hp += amount
_hp += amount
health_changed.emit(_hp, _max_hp)
# A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only # A clean hit — thrown or swung blade — costs the bull one HP pip; the run ends only
# when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the # when the last pip is gone. `cause` ("gored" / "thrown") tags how it happened for the
# death notice. I-frames absorb a flurry so several blades in one pass drop one pip, # death notice. I-frames absorb a flurry so several blades in one pass drop one pip,
+7
View File
@@ -46,6 +46,13 @@ func _run() -> void:
quit(1) quit(1)
return return
# The run now opens in the fightless lobby; force the matador arena so the scene loads
# with live matadors for the checks below.
var run: Node = root.get_node_or_null("/root/Run")
if run != null:
run.ensure_run() # build the run first — the shell's own ensure_run would wipe the override
run.debug_level_override = run.get_level(&"arena")
var scene: Node = scene_res.instantiate() var scene: Node = scene_res.instantiate()
root.add_child(scene) root.add_child(scene)
current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene
+7
View File
@@ -38,6 +38,13 @@ func _run() -> void:
if dp: if dp:
dp.set_value("mat_spawn_count", STRESS_MATADORS) dp.set_value("mat_spawn_count", STRESS_MATADORS)
# The run now opens in the fightless lobby; force the matador arena so the stress scene
# loads with matadors.
var run: Node = root.get_node_or_null("/root/Run")
if run != null:
run.ensure_run() # build the run first — the shell's own ensure_run would wipe the override
run.debug_level_override = run.get_level(&"arena")
var scene_res := load("res://scene.tscn") var scene_res := load("res://scene.tscn")
if not scene_res: if not scene_res:
push_error("performance_test: failed to load scene.tscn") push_error("performance_test: failed to load scene.tscn")
+25 -7
View File
@@ -1,23 +1,41 @@
extends StaticBody3D extends StaticBody3D
## A smashable lobby barrel. Joins the &"barrel" group so the HUD can tally how many
## remain and fire the "cleared the lobby" reward once the last one bursts.
signal destroyed
# Preload the broken barrel scene # Preload the broken barrel scene
const BARREL_BROKEN_SCENE = preload("res://tscn_s/BarrelBroken.tscn") const BARREL_BROKEN_SCENE = preload("res://tscn_s/BarrelBroken.tscn")
var _destroyed := false
func _ready() -> void:
add_to_group(&"barrel")
func destroy_barrel(): func destroy_barrel():
# Guard against a double hit in the same frame counting the same barrel twice.
if _destroyed:
return
_destroyed = true
# 1. Instantiate the broken pieces # 1. Instantiate the broken pieces
var broken_instance = BARREL_BROKEN_SCENE.instantiate() var broken_instance = BARREL_BROKEN_SCENE.instantiate()
# 2. Place the fragments exactly where the intact barrel currently is # 2. Place the fragments exactly where the intact barrel currently is
broken_instance.global_transform = self.global_transform broken_instance.global_transform = self.global_transform
# 3. Add the fragments to the main game tree # 3. Add the fragments to the main game tree
get_parent().add_child(broken_instance) get_parent().add_child(broken_instance)
# 4. (Optional) Apply a physics push to the fragments if you want an explosive effect # 4. (Optional) Apply a physics push to the fragments if you want an explosive effect
for child in broken_instance.get_children(): for child in broken_instance.get_children():
if child is RigidBody3D: if child is RigidBody3D:
# Pushes pieces slightly outward and upward # Pushes pieces slightly outward and upward
var random_direction = Vector3(randf_range(-1, 1), randf_range(0.5, 1.5), randf_range(-1, 1)).normalized() var random_direction = Vector3(randf_range(-1, 1), randf_range(0.5, 1.5), randf_range(-1, 1)).normalized()
child.apply_central_impulse(random_direction * 5.0) child.apply_central_impulse(random_direction * 5.0)
# 5. Delete the intact barrel # 5. Announce the smash (the HUD counts down remaining barrels) and remove the barrel
destroyed.emit()
queue_free() queue_free()