From 34dc5b026e47016b16dc6932b946d509f5ee4712 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Thu, 3 Sep 2026 01:38:57 +0300 Subject: [PATCH] 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 --- Assets/lobby_underground.gd | 9 ++- hud.gd | 124 +++++++++++++++++++++++++++++++++-- levels/run_state.gd | 61 +++++++++++++++-- levels/to_the_arena_level.gd | 13 ++-- levels/to_the_bear_level.gd | 17 +++-- player.gd | 74 +++++++++++++++++++-- tests/gameplay_test.gd | 7 ++ tests/performance_test.gd | 7 ++ tscn_s/barrel_simple.gd | 32 +++++++-- 9 files changed, 301 insertions(+), 43 deletions(-) diff --git a/Assets/lobby_underground.gd b/Assets/lobby_underground.gd index 42ad0d6..95cdd1b 100644 --- a/Assets/lobby_underground.gd +++ b/Assets/lobby_underground.gd @@ -1,8 +1,13 @@ 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 -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 $AnimationPlayer.play("Gate_opening") diff --git a/hud.gd b/hud.gd index 30d8ac8..a5d9672 100644 --- a/hud.gd +++ b/hud.gd @@ -42,6 +42,13 @@ var _ability_bar: HBoxContainer var _ability_panels: Array[PanelContainer] = [] 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. const _VIGNETTE_SHADER := "res://damage_vignette.gdshader" var _vignette: ColorRect @@ -90,6 +97,7 @@ func _ready() -> void: _build_touch_controls() _update_control_hints() _find_spawner.call_deferred() + _watch_barrels.call_deferred() # 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) +# ── 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: if event is InputEventKey and event.pressed and not event.echo: 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() -# A cleared level advances the run and drops the player into the lobby hub: commit the -# win, flag the lobby as the active level, then reload the shell so it builds the lobby. -# (Choosing the next map node happens from the lobby via trigger areas, added later.) +# A cleared level drops the player back into the lobby hub: bank the win (which opens the +# gates it unlocks) and reload the shell so it rebuilds the lobby. func _continue_to_lobby() -> void: get_tree().paused = false Input.mouse_mode = Input.MOUSE_MODE_CAPTURED - Run.complete_current_level() - Run.go_to_lobby() + Run.return_to_lobby() 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_EMPTY: Color = Color(0.35, 0.05, 0.05) 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_H: float = 14.0 @@ -500,10 +607,15 @@ func _on_health_changed(current: int, max_hp: int) -> void: 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 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(): var fill := _hp_seg_fills[i] fill.visible = i < cur - fill.color = lit + fill.color = _HP_BONUS if i >= base_max else lit const _SLOT_SEPARATION: int = 10 diff --git a/levels/run_state.gd b/levels/run_state.gd index a69faf2..662e543 100644 --- a/levels/run_state.gd +++ b/levels/run_state.gd @@ -34,11 +34,20 @@ var pending_col: int = -1 ## Debug override — when set, active_level() returns this regardless of map state. ## Set via the console `level` command; cleared on start_new_run(). var debug_level_override: LevelDef = null -## The lobby is an interstitial hub between fights, not a map node: the player is sent -## here after clearing a level (go_to_lobby()) to move around freely before choosing the -## next node. While set, active_level() serves the lobby; cleared once a node is picked -## (select_node) or a fresh run begins. +## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead +## into the fights. While set, active_level() serves the lobby; cleared once a gate is +## entered (choose_level) and set again on a win (return_to_lobby). 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 # (its geometry + spawners) and the opponent. The arena (matador wave, normal + boss @@ -85,6 +94,8 @@ func active_level() -> LevelDef: return debug_level_override if enter_lobby: return _lobby + if lobby_choice != null: + return lobby_choice var n := node_at(pending_row, pending_col) if n == null: n = current_node() @@ -115,7 +126,11 @@ func start_new_run() -> void: pending_row = -1 pending_col = -1 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() @@ -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 -## until a node is picked. Called after clearing a level so the player lands in the lobby -## rather than jumping straight to the next fight. +## until a gate is entered. func go_to_lobby() -> void: 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 diff --git a/levels/to_the_arena_level.gd b/levels/to_the_arena_level.gd index 5a367db..6b4152e 100644 --- a/levels/to_the_arena_level.gd +++ b/levels/to_the_arena_level.gd @@ -1,13 +1,12 @@ extends Area3D - +## Lobby gate into the arena fight. Always open — the arena is the run's first door. func _ready() -> void: - # Connect the signal via code body_entered.connect(_on_body_entered) + func _on_body_entered(body: Node3D) -> void: - print("Something entered the trigger: ", body.name) - # Check if the colliding object is the player - if body.is_in_group("player"): - #CHANGE TO BEAR LEVEL - print("go to arena level") + if not body.is_in_group("player"): + return + Run.choose_level(&"arena") + get_tree().change_scene_to_file(Run.active_level().scene_path) diff --git a/levels/to_the_bear_level.gd b/levels/to_the_bear_level.gd index bcaf0ac..4c6c0ff 100644 --- a/levels/to_the_bear_level.gd +++ b/levels/to_the_bear_level.gd @@ -1,13 +1,16 @@ 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: - # Connect the signal via code body_entered.connect(_on_body_entered) + func _on_body_entered(body: Node3D) -> void: - print("Something entered the trigger: ", body.name) - # Check if the colliding object is the player - if body.is_in_group("player"): - #CHANGE TO BEAR LEVEL - print("go to bear level") + if not body.is_in_group("player"): + return + if not Run.bear_gate_open(): + return + Run.choose_level(&"bear") + get_tree().change_scene_to_file(Run.active_level().scene_path) diff --git a/player.gd b/player.gd index 55474b4..6166a4e 100644 --- a/player.gd +++ b/player.gd @@ -67,6 +67,9 @@ signal health_changed(current: int, max_hp: int) signal hit_taken(cause: String) var _max_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 _huff_player: AudioStreamPlayer = null @@ -93,6 +96,11 @@ func _ready() -> void: RigidSkin.convert_tree(self) _max_hp = maxi(1, int(DP.f("bull_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 _setup_hoof_dust() _setup_legs() @@ -511,6 +519,7 @@ func _tick_roll(delta: float, _steer: Vector3) -> void: move_and_slide() _was_on_floor = is_on_floor() + _smash_barrels_in_radius(DP.f("roll_hit_radius")) _roll_try_pop(speed) @@ -549,22 +558,37 @@ func _roll_try_pop(speed: float) -> bool: 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: var best: Node3D = null var best_d := INF - for mat: Node in get_tree().get_nodes_in_group(&"matador"): - if _roll_hit.has(mat) or not _mat_active(mat): - continue - var d: float = (mat as Node3D).global_position.distance_squared_to(global_position) + for cand: Node3D in _roll_targets(): + var d: float = cand.global_position.distance_squared_to(global_position) if d < best_d: best_d = d - best = mat as Node3D + best = cand 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: - 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 @@ -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") +# 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 # (is_rolling guard in matador._take_bull_charge) so the ball pops instead of quietly goring. func is_rolling() -> bool: @@ -1084,6 +1127,7 @@ func _physics_process(delta: float) -> void: _play_idle() _spawn_slam_fx() _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 ────────────────────────────────────────────────────────────────── if flat_speed > DP.f("dust_charge_spd") and on_floor: @@ -1110,6 +1154,22 @@ func get_max_hp() -> int: 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 # 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, diff --git a/tests/gameplay_test.gd b/tests/gameplay_test.gd index e79d04a..9097426 100644 --- a/tests/gameplay_test.gd +++ b/tests/gameplay_test.gd @@ -46,6 +46,13 @@ func _run() -> void: quit(1) 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() root.add_child(scene) current_scene = scene # match runtime: game code (sword throw, overlays) uses current_scene diff --git a/tests/performance_test.gd b/tests/performance_test.gd index b036218..ea82cf5 100644 --- a/tests/performance_test.gd +++ b/tests/performance_test.gd @@ -38,6 +38,13 @@ func _run() -> void: if dp: 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") if not scene_res: push_error("performance_test: failed to load scene.tscn") diff --git a/tscn_s/barrel_simple.gd b/tscn_s/barrel_simple.gd index 5d4966a..77e3dc0 100644 --- a/tscn_s/barrel_simple.gd +++ b/tscn_s/barrel_simple.gd @@ -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 const BARREL_BROKEN_SCENE = preload("res://tscn_s/BarrelBroken.tscn") +var _destroyed := false + + +func _ready() -> void: + add_to_group(&"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 var broken_instance = BARREL_BROKEN_SCENE.instantiate() - + # 2. Place the fragments exactly where the intact barrel currently is broken_instance.global_transform = self.global_transform - + # 3. Add the fragments to the main game tree get_parent().add_child(broken_instance) - + # 4. (Optional) Apply a physics push to the fragments if you want an explosive effect for child in broken_instance.get_children(): if child is RigidBody3D: # 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() - child.apply_central_impulse(random_direction * 5.0) - - # 5. Delete the intact barrel + child.apply_central_impulse(random_direction * 5.0) + + # 5. Announce the smash (the HUD counts down remaining barrels) and remove the barrel + destroyed.emit() queue_free()