8 Commits

Author SHA1 Message Date
richard 91d88cd5c7 Bump version to 0.5.0
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 16:44:04 +03:00
richard 6dff45c659 Add thank-you ending screen and a run timer
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Defeating the bear (the run's final boss) now plays a dedicated
"Thank You For Playing" clip on the completion screen instead of the
generic bull-win video, with the run's finish time shown in place of
the usual "Bull Won!" title.

A run clock also starts when the player enters the arena (the run's
first fight), shown top-left on desktop only, and freezes the instant
the bear falls. Re-entering the arena after finishing a run restarts
the clock instead of leaving it stuck on the previous run's time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MXBNaEBmrYDSMcv4NgUWtV
2026-09-10 16:28:57 +03:00
siim 2fd41f4f69 bull follow light
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
2026-09-08 19:15:14 +03:00
richard a147527e91 Remove dead code: the map system and editor cruft
CI / Decide whether to run (push) Has been cancelled
CI / Lint & headless tests (push) Has been cancelled
Delete the unused Slay-the-Spire map screen (superseded by the lobby hub),
leftover editor autosave *.tmp scenes, and a stray reference-link file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:56:22 +03:00
richard 09d4a60f43 Reorganize into role-based folders and consolidate art
Move every script and scene into role-based directories (actors/, systems/, ui/,
levels/, props/) and all raw art under art/ (grouped by role, snake_case,
Extract-Textures GLBs), dropping Blender version-suffix noise. Filenames are
snake_case (PascalCase reserved for class_name) to avoid case-insensitive
filesystem aliasing on the web/macOS targets. Routes the bull's hits through the
typed DamageEvent pipeline and wires the bull's abilities to the Fx library.
Updates CLAUDE.md's folder-layout overview to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:56:22 +03:00
richard c0a022fed4 Add scene-authored FX library and bull ability effects
An Fx autoload + one_shot_effect base so every effect is an inspector-editable
.tscn (the systems/fx/ authoring dir): blood/slam/spark/flash primitives and the
bull's kick/dash/slam/roll + movement effects (bull_*.tscn), fired via
Fx.spawn() / Fx.attach(). Plus a preview gallery + smoke test, and
bull_impact_fx.gd for the coded, non-particle slam rings + kick flash-pop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:56:01 +03:00
richard 9458085abb Add typed DamageEvent hit pipeline
A typed DamageEvent contract (systems/combat/damage_event.gd) as a single
entry point for every attacker, plus a characterization test locking the bull's
damage reception. Attacker/receiver wiring rides along with the moved actor
scripts in the reorg commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:55:46 +03:00
richard 565c132848 Add CI pipeline and reusable test harness
A Gitea Actions workflow (.gitea/workflows/ci.yml) running the headless test
suite on push, plus shared test scaffolding: seeded-RNG / approx / tally helpers
(test_helpers.gd), a harness self-test, and a resolution + pseudo-locale
UI-layout robustness guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCyaJPTHCH9EDthpf4A5Sj
2026-09-08 14:55:38 +03:00
49 changed files with 755 additions and 1029 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -7
View File
@@ -43,7 +43,9 @@ art/ all raw art, grouped by role (snake_case):
| `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` | | `levels/arena_level.tscn` | The arena **level module**: floor + wall colliders, arena meshes, `MatadorSpawner` (with child `Node3D` spawn points placed by the designer), lighting, audience (`PublikNode`). Instanced into the shell's `LevelRoot` |
| `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` | | `levels/bear_level.tscn` | The Bear's Den **level module**: bear-arena mesh, its own floor collider, lighting, and a `BearSpawner` (`bear_spawn.gd`) that places the bear at center. Instanced into the shell's `LevelRoot` |
| `actors/player/player.tscn` | Player scene root (`CharacterBody3D`) | | `actors/player/player.tscn` | Player scene root (`CharacterBody3D`) |
| `actors/player/player.gd` | Movement, charge, turning logic | | `actors/player/player.gd` | Movement, charge, turning, and abilities (kick/dash/slam/roll). Ability & movement particle FX are **not** built here — they're authored scenes in `systems/fx/` (`bull_*.tscn`), fired via `Fx.spawn()` (one-shots) / `Fx.attach()` (continuous) |
| `actors/player/bull_impact_fx.gd` | Bull's coded, non-particle impact FX (the slam shockwave rings + kick flash-pop — tweened meshes, not CPUParticles3D, so they can't be `systems/fx/` scenes). Created by `player.gd` |
| `actors/player/bull_legs.gd` | Bull body-wobble + tail Verlet-chain simulation. Created by `player.gd` |
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) | | `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) | | `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
| `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) | | `actors/matador/matador.gd` | Matador AI (wander / ragdoll state machine) |
@@ -52,10 +54,8 @@ art/ all raw art, grouped by role (snake_case):
| `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section | | `actors/bear/bear.tscn` / `actors/bear/bear.gd` | Dark-Souls-style bear boss (`CharacterBody3D`). Routine state machine (wind-up → active → recovery) picking telegraphed moves by range band: Double Swipe / Overhead Smash (close), Leap Slam (mid-far), plus Stalk (2-leg walk-in) / Prowl (4-leg lope) positioning. Melee steps in during the swing (`bear_lunge_speed`) + tracks the bull; the leap is frame-synced to JUMP_SMASH so the paws hit ground on the impact frame (`bear_leap_impact_frame`); slams kick up dust + shake. Combos chain, hyper-armour mid-swing + poise (`bear_stagger_cd`), phase 2 enrage under `bear_enrage_frac` HP. Never damages the bull by colliding — only swings/slams. A horns-first bull charge wounds it (`_sweep_bull_ram`: swept prev→now so a tunnelling/point-blank charge still lands, gated by `bear_ram_reach` + `bull_gore_arc`, edge-triggered = one charge per wound). Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
| `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) | | `actors/bear/bear_attack_hitboxes.tscn` | The bear's attack colliders as a self-contained sub-scene (instanced under `bear_model`): a `LeapSlamHitbox` (Hitbox + cylinder = ground circle) and `SmashHitbox` (Hitbox + box = forward shockwave lane), plus an `AnimationPlayer` whose `leap_slam` / `smash_travel` clips author each collider's size + travel — scrub to tweak, like `Bear_FX`. `actors/bear/bear.gd` only fires the clip, arms the knock-up shaping, and sweeps for overlaps while it runs (see `_setup_attack_hitboxes` / `_start_attack_hitbox`) |
| `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher | | `actors/matador/sword.tscn` / `systems/combat/hitbox.gd` | The matador's sword wrapper (glb + editor-tweakable `BladeHitbox`). `systems/combat/hitbox.gd` (`class_name Hitbox`, Area3D) is the shared attack collider: `activate()`/`deactivate()` gate an active window, `sweep()` catches bodies already inside (AoE), and knock-up can ramp by distance (`knock_up_min_dist`/`_max_dist`) for the bear's outer-ring launch / further-hit-flies-higher |
| `levels/run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights | | `levels/run_state.gd` | Run progression autoload (`Run`) — the lobby hub and which level is being fought, across scene reloads |
| `levels/level_def.gd` | `LevelDef` resource — a level's name / map colour / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) | | `levels/level_def.gd` | `LevelDef` resource — a level's name / `scene_path` (always the shell) / `level_scene` (the level module instanced into the shell) / optional `enemy_scene` (arena wave vs. a boss like the bear) |
| `levels/map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
| `levels/MapScreen.tscn` / `levels/map_screen.gd` | Between-fights map screen; win → pick a node → next level |
| `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group | | `systems/crowd/crowd_marker.gd` | `CrowdMarker` (`@tool Node3D`, `class_name`) — designer drops one per billboard spectator via the editor's Add-Node dialog; picks one of six 60°-spaced facings from the `orientation` dropdown. Draws an editor-only preview (slab + forward arrow, never serialised) and joins the `&"crowd_marker"` group |
| `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed | | `systems/crowd/crowd_billboards.gd` / `systems/crowd/crowd_billboard.gdshader` | `MultiMeshInstance3D` that harvests every `CrowdMarker` (group `&"crowd_marker"`) into one draw call, using each marker's world position + chosen facing. Front/back sprite sheet baked by `tools/bake_crowd_sheet.gd`; falls back to a generated inward-facing ring when no markers are placed |
| `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag | | `systems/debug/debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`). Mobile-relevant flags: `force_touch_controls`, `touch_debug` (also enabled on-device with `?debug=1` in the page URL — shows the live profiler overlay in `systems/input/touch_controls.gd`), and `use_rigid_skin` (default **off** — native GPU skinning everywhere now that the 4.7 web templates fixed the Mali/ANGLE invisible-skinning bug; `systems/render/rigid_skin.gd` is a retained opt-in fallback). Rigid-skin is gated through `Controls.rigid_skin_enabled()`, which a page URL param overrides for on-device A/B with no console: `?skin=1` forces the workaround on (for a device where native skinning still fails), `?noskin=1` forces native off. Turning the workaround off also removes its per-spawn SurfaceTool rebuild, which was the arena's matador-spawn lag |
@@ -101,7 +101,7 @@ art/ all raw art, grouped by role (snake_case):
## Animation & bone conventions ## Animation & bone conventions
### Blender rig (matador_v03) ### Blender rig (matador)
- **Armature bones:** `matador` (root) → `COG` → `chest` → `head`, `collarbone_L/R` → `arm_L/R` → `forearm_L/R` → `hand_L/R`, `leg_L/R` → `shin_L/R` → `foot_L/R` - **Armature bones:** `matador` (root) → `COG` → `chest` → `head`, `collarbone_L/R` → `arm_L/R` → `forearm_L/R` → `hand_L/R`, `leg_L/R` → `shin_L/R` → `foot_L/R`
- **Rotation mode:** All animated bones use **XYZ Euler** (set in `create_matador_anims.py`) - **Rotation mode:** All animated bones use **XYZ Euler** (set in `create_matador_anims.py`)
- **Bone-local axes:** Swing (forward/back) is on **local X**. Arm lowering from T-pose is on **local Y** (positive = left arm down, negative = right arm down). Knee bend is **positive X** (shins only bend backward). - **Bone-local axes:** Swing (forward/back) is on **local X**. Arm lowering from T-pose is on **local Y** (positive = left arm down, negative = right arm down). Knee bend is **positive X** (shins only bend backward).
@@ -119,7 +119,7 @@ After FBX import, animations appear as `Armature|walk` and `Armature|idle` in An
### Verification workflow ### Verification workflow
1. Edit bones/animations in Blender (`create_matador_anims.py`) 1. Edit bones/animations in Blender (`create_matador_anims.py`)
2. Run render preview: `blender --background art/blender/matador_v03.blend --python art/blender/render_anim_preview.py` 2. Run render preview: `blender --background art/blender/matador.blend --python art/blender/render_anim_preview.py`
3. Inspect PNGs in `art/blender/preview/` — check bone orientations **before** exporting to Godot 3. Inspect PNGs in `art/blender/preview/` — check bone orientations **before** exporting to Godot
4. Export FBX (done automatically by `create_matador_anims.py`) 4. Export FBX (done automatically by `create_matador_anims.py`)
5. Re-import in Godot and test in-game 5. Re-import in Godot and test in-game
+3 -15
View File
@@ -26,11 +26,11 @@ var _phase: Phase = Phase.WINDUP
var _bull: CharacterBody3D = null var _bull: CharacterBody3D = null
var _skeleton: Skeleton3D = null var _skeleton: Skeleton3D = null
var _anim_player: AnimationPlayer = null var _anim_player: AnimationPlayer = null
# Separate player baked into Assets/fx/Bear_FX.glb: each clip scales one FX mesh up from 0 # Separate player baked into art/fx/bear_fx.glb: each clip scales one FX mesh up from 0
# and back at its strike frame (self-hiding), so bear.gd just fires the matching clip. # and back at its strike frame (self-hiding), so bear.gd just fires the matching clip.
var _fx_player: AnimationPlayer = null var _fx_player: AnimationPlayer = null
var _fx_root: Node = null var _fx_root: Node = null
# Shaped attack colliders live in BearAttackHitboxes.tscn (under bear_model, so they track the # Shaped attack colliders live in bear_attack_hitboxes.tscn (under bear_model, so they track the
# bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash. # bear's facing): a ground-circle cylinder for the leap slam and a forward box lane for the smash.
# Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips # Their sizes AND their travel/pop motion are authored in that scene's AnimationPlayer (clips
# "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the # "leap_slam" / "smash_travel") — scrub them there to tweak. bear.gd only fires the clip, sets the
@@ -635,7 +635,7 @@ func _try_hit(reach: float) -> bool:
return true return true
# Grab the designer-authored collider sub-scene (BearAttackHitboxes.tscn instanced under # Grab the designer-authored collider sub-scene (bear_attack_hitboxes.tscn instanced under
# bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene. # bear_model) and park both hitboxes deactivated. Shapes + travel are authored in that scene.
func _setup_attack_hitboxes() -> void: func _setup_attack_hitboxes() -> void:
var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null var root := _mesh.get_node_or_null("AttackHitboxes") if _mesh else null
@@ -920,18 +920,6 @@ func _die(hit_dir: Vector3) -> void:
velocity = Vector3.ZERO velocity = Vector3.ZERO
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
_play(_anim_death) _play(_anim_death)
# No death clip on the rig — topple the beast onto its side (about the hit direction,
# so it falls the way it was struck) and sink it away, then free.
#var fall_axis := Vector3(-hit_dir.z, 0.0, hit_dir.x)
#if fall_axis.length() < 0.01:
# fall_axis = Vector3.RIGHT
#var tween := create_tween()
#tween.tween_property(_mesh, "rotation",
# _mesh.rotation + fall_axis.normalized() * deg_to_rad(88.0), 0.7)\
# .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
#tween.tween_interval(2.5)
#tween.tween_property(_mesh, "position:y", _mesh.position.y - 3.0, 1.0)
#tween.tween_callback(queue_free)
# ── Interface shared with the matador (guarded elsewhere via has_method) ───────────── # ── Interface shared with the matador (guarded elsewhere via has_method) ─────────────
+3 -3
View File
@@ -74,7 +74,7 @@ const _ANIM_TAUNTS: Array[StringName] = [&"Taunt", &"Taunt_B", &"Taunt_C"]
# reparents from holster to hand at this point in the animation. # reparents from holster to hand at this point in the animation.
const _DRAW_GRAB_AT: float = 0.55 const _DRAW_GRAB_AT: float = 0.55
# Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see Sword.tscn / Spear.tscn. # Wrapper scenes (glb + an editor-tweakable hitbox / collider) — see sword.tscn / spear.tscn.
const _SWORD_SCENE: PackedScene = preload("res://actors/matador/sword.tscn") const _SWORD_SCENE: PackedScene = preload("res://actors/matador/sword.tscn")
const _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn") const _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn")
@@ -603,7 +603,7 @@ func _tick_attack(delta: float) -> void:
if _swing_timer > 0.0: if _swing_timer > 0.0:
_decelerate(22.0, delta) _decelerate(22.0, delta)
_play_anim(_ANIM_ATTACK) _play_anim(_ANIM_ATTACK)
# Damage off the sword's own BladeHitbox (Sword.tscn), riding the animated blade — gored only # Damage off the sword's own BladeHitbox (sword.tscn), riding the animated blade — gored only
# when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap. # when the thrust reaches it. Arm at the thrust (mat_stab_strike_at) and sweep for the overlap.
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"): if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
_sword_blade_area.activate() _sword_blade_area.activate()
@@ -1189,7 +1189,7 @@ func _spawn_sword() -> void:
_drawing = false _drawing = false
_apply_sword_grip() _apply_sword_grip()
# The blade hitbox (Sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see # The blade hitbox (sword.tscn, hitbox.gd) is armed only during the ATTACK thrust (see
# _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull. # _tick_attack); off while merely carried, so brushing a drawn matador never gores the bull.
_sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox _sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
if _sword_blade_area != null: if _sword_blade_area != null:
+110
View File
@@ -0,0 +1,110 @@
extends Node
## Procedural (non-particle) bull impact FX: the expanding ground-slam shockwave rings and the
## kick flash-pop. These are tween-animated MeshInstance3D flashes, not CPUParticles3D, so they
## can't be authored as systems/fx/ scenes and live in code here. Created by player.gd; the
## transient meshes are parented into the level (the bull's parent) so they hold their world
## position while the bull keeps moving.
var _player: Node3D
var _ring_mesh: ArrayMesh = null
func setup(player: Node3D) -> void:
_player = player
# Three nested shockwave rings racing outward from a ground slam, each fading as it grows.
func slam_rings(origin: Vector3, max_r: float) -> void:
if _ring_mesh == null:
_ring_mesh = _make_ring_mesh()
_launch_ring(origin, max_r, 0.00, 0.55, Color(1.0, 0.70, 0.10, 0.85))
_launch_ring(origin, max_r, 0.10, 0.52, Color(1.0, 0.50, 0.10, 0.65))
_launch_ring(origin, max_r, 0.20, 0.48, Color(0.9, 0.30, 0.10, 0.50))
# A quick bright flash-pop at the kick's contact point.
func kick_flash(origin: Vector3) -> void:
var mat := _flat_material(Color(1.0, 0.97, 0.40, 0.0))
var sphere := SphereMesh.new()
sphere.radius = 1.0
sphere.height = 2.0
sphere.radial_segments = 8
sphere.rings = 4
sphere.material = mat
var mi := MeshInstance3D.new()
mi.mesh = sphere
mi.material_override = mat
mi.scale = Vector3(0.05, 0.05, 0.05)
_host().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, 0.92, 0.0, 0.14)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(0.16)
func _launch_ring(origin: Vector3, max_r: float, delay: float, duration: float, col: Color) -> void:
var mat := _flat_material(Color(col.r, col.g, col.b, 0.0))
var mi := MeshInstance3D.new()
mi.mesh = _ring_mesh
mi.material_override = mat
mi.scale = Vector3(0.02, 1.0, 0.02)
_host().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(max_r, 1.0, max_r), duration)\
.set_delay(delay).set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, col.a, 0.0, duration)\
.set_delay(delay).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05)
func _flat_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.albedo_color = color
return mat
# A flat annulus in the XZ plane (unit radius), scaled up at runtime into the shockwave ring.
func _make_ring_mesh() -> ArrayMesh:
const INNER_R: float = 0.82
const OUTER_R: float = 1.00
const SEGMENTS: int = 48
var verts := PackedVector3Array()
var indices := PackedInt32Array()
for i: int in SEGMENTS:
var a0 := i / float(SEGMENTS) * TAU
var a1 := (i + 1) / float(SEGMENTS) * TAU
var vi := i * 4
verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R))
verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R))
verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R))
verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R))
indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2])
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _host() -> Node:
if _player != null and _player.get_parent() != null:
return _player.get_parent()
return _player
+1
View File
@@ -0,0 +1 @@
uid://sf5etma4kjt0
+61 -424
View File
@@ -8,7 +8,7 @@ const HOOF_OFFSETS: Array = [
Vector3( 0.30, -0.25, 0.60), Vector3( 0.30, -0.25, 0.60),
] ]
# Bull animation clips (Armature|* action names in Assets/bull.fbx). IDLE is the # Bull animation clips (Armature|* action names in art/characters/bull.fbx). IDLE is the
# looping base pose; the others are one-shot ability clips that return to it. # looping base pose; the others are one-shot ability clips that return to it.
const _ANIM_IDLE: StringName = &"Armature|IDLE" const _ANIM_IDLE: StringName = &"Armature|IDLE"
const _ANIM_KICK: StringName = &"Armature|KICK" const _ANIM_KICK: StringName = &"Armature|KICK"
@@ -27,12 +27,11 @@ var _legs: Node
var _was_on_floor: bool = false var _was_on_floor: bool = false
var _pre_slide_vel_y: float = 0.0 var _pre_slide_vel_y: float = 0.0
var _slam_ring_mesh: ArrayMesh = null # Built once and reused: a fresh StandardMaterial3D/SphereMesh per spawn makes a mobile WebGL
# Attack-FX resources are identical every swing, so build them once and reuse. Recreating a fresh # driver recompile the shader + re-upload the mesh — a hitch "when attacking" absent on desktop.
# StandardMaterial3D / SphereMesh per attack makes a mobile WebGL driver recompile the shader and
# re-upload the mesh each time — a main-thread hitch "when attacking" that never shows on desktop.
var _unshaded_mat: StandardMaterial3D = null var _unshaded_mat: StandardMaterial3D = null
var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared) var _particle_sphere_cache: Dictionary = {} # "radius:seg:ring" -> SphereMesh (shared)
var _impact_fx: Node = null # bull_impact_fx.gd — the coded (non-particle) slam rings + kick flash
# Ability system — kick=0, dash=1, slam=2, roll=3 # Ability system — kick=0, dash=1, slam=2, roll=3
var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0] var ability_cd: Array[float] = [0.0, 0.0, 0.0, 0.0]
@@ -83,8 +82,12 @@ var _huff_timer: float = 0.0
enum DustState { NONE, WALK, CHARGE } enum DustState { NONE, WALK, CHARGE }
var _dust_state: DustState = DustState.NONE var _dust_state: DustState = DustState.NONE
var _walk_dust_ramp: Gradient = null # Hoof-dust scale/velocity per state (the look — mesh, colour, curve — lives in bull_hoof_dust.tscn).
var _charge_dust_ramp: Gradient = null # A charge sprint kicks up bigger, faster dust than a walk.
const _WALK_DUST_SCALE: Vector2 = Vector2(0.20, 0.40)
const _WALK_DUST_VEL: Vector2 = Vector2(0.8, 1.8)
const _CHARGE_DUST_SCALE: Vector2 = Vector2(0.35, 0.70)
const _CHARGE_DUST_VEL: Vector2 = Vector2(2.8, 5.5)
const RigidSkin = preload("res://systems/render/rigid_skin.gd") const RigidSkin = preload("res://systems/render/rigid_skin.gd")
@@ -106,6 +109,7 @@ func _ready() -> void:
_max_hp += _bonus_hp _max_hp += _bonus_hp
_hp += _bonus_hp _hp += _bonus_hp
_cube_scale = cube_guy.scale _cube_scale = cube_guy.scale
_setup_impact_fx()
_setup_hoof_dust() _setup_hoof_dust()
_setup_legs() _setup_legs()
_setup_charge_trail() _setup_charge_trail()
@@ -113,7 +117,6 @@ func _ready() -> void:
_setup_audio() _setup_audio()
_bull_anim = _find_anim_player(cube_guy) _bull_anim = _find_anim_player(cube_guy)
_setup_bull_idle() _setup_bull_idle()
DP.any_changed.connect(_on_dp_changed)
func _setup_legs() -> void: func _setup_legs() -> void:
@@ -123,113 +126,35 @@ func _setup_legs() -> void:
_legs.call(&"setup", self, cube_guy) _legs.call(&"setup", self, cube_guy)
func _setup_impact_fx() -> void:
_impact_fx = Node.new()
_impact_fx.set_script(load("res://actors/player/bull_impact_fx.gd"))
add_child(_impact_fx)
_impact_fx.call(&"setup", self)
# The bull's ability/movement particle effects are authored as scenes in systems/fx/ (so Siim can
# tune them in the inspector). The continuous ones follow the bull — instanced once here as
# children, then toggled via `emitting`. The one-shots are fired on demand through Fx.spawn().
func _setup_hoof_dust() -> void: func _setup_hoof_dust() -> void:
var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material())
_walk_dust_ramp = Gradient.new()
_walk_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
_walk_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
# White-beige dust while running
_charge_dust_ramp = Gradient.new()
_charge_dust_ramp.set_color(0, Color(0.94, 0.90, 0.83, 1.0))
_charge_dust_ramp.set_color(1, Color(0.94, 0.90, 0.83, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.2))
scale_curve.add_point(Vector2(0.4, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
for offset: Vector3 in HOOF_OFFSETS: for offset: Vector3 in HOOF_OFFSETS:
var p := CPUParticles3D.new() var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D
if p == null:
continue
p.position = offset p.position = offset
p.emitting = false
p.amount = 12
p.lifetime = DP.f("dust_lifetime")
p.explosiveness = DP.f("dust_explosiveness")
p.randomness = 0.6
p.local_coords = false
p.direction = Vector3(0.0, 1.0, 0.0)
p.spread = DP.f("dust_spread")
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max")
p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max")
p.mesh = sphere
p.color_ramp = _walk_dust_ramp
p.scale_amount_curve = scale_curve
cube_guy.add_child(p)
_hoof_emitters.append(p) _hoof_emitters.append(p)
func _setup_charge_trail() -> void: func _setup_charge_trail() -> void:
# Blue energy trail — streams backward during a fast run / roll _charge_trail_emitter = Fx.attach(&"bull_charge_trail", cube_guy) as CPUParticles3D
var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material()) if _charge_trail_emitter != null:
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
var trail_ramp := Gradient.new()
trail_ramp.set_color(0, Color(0.30, 0.65, 1.0, 1.0))
trail_ramp.set_color(1, Color(0.30, 0.65, 1.0, 0.0))
var trail_scale := Curve.new()
trail_scale.add_point(Vector2(0.0, 1.0))
trail_scale.add_point(Vector2(0.6, 0.8))
trail_scale.add_point(Vector2(1.0, 0.0))
_charge_trail_emitter = CPUParticles3D.new()
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
_charge_trail_emitter.emitting = false
_charge_trail_emitter.one_shot = false
_charge_trail_emitter.amount = 18
_charge_trail_emitter.lifetime = 0.30
_charge_trail_emitter.randomness = 0.4
_charge_trail_emitter.local_coords = false
_charge_trail_emitter.direction = Vector3(0.0, 0.2, 1.0).normalized()
_charge_trail_emitter.spread = 25.0
_charge_trail_emitter.gravity = Vector3(0.0, 2.0, 0.0)
_charge_trail_emitter.initial_velocity_min = 4.0
_charge_trail_emitter.initial_velocity_max = 9.0
_charge_trail_emitter.scale_amount_min = 0.4
_charge_trail_emitter.scale_amount_max = 1.2
_charge_trail_emitter.mesh = trail_sphere
_charge_trail_emitter.color_ramp = trail_ramp
_charge_trail_emitter.scale_amount_curve = trail_scale
cube_guy.add_child(_charge_trail_emitter)
func _setup_roll_sparks() -> void: func _setup_roll_sparks() -> void:
# Rammus Powerball ground spray — dirt/sparks flung outward from the spinning _roll_spark_emitter = Fx.attach(&"bull_roll_sparks", cube_guy) as CPUParticles3D
# ball. World-space (local_coords off) so they peel off into a trail behind it. if _roll_spark_emitter != null:
var spark := _particle_sphere(0.05, 4, 2, _unshaded_material()) _roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
var ramp := Gradient.new()
ramp.set_color(0, Color(1.00, 0.78, 0.32, 1.0))
ramp.set_color(1, Color(0.70, 0.42, 0.14, 0.0))
var sc := Curve.new()
sc.add_point(Vector2(0.0, 0.30))
sc.add_point(Vector2(0.3, 1.00))
sc.add_point(Vector2(1.0, 0.00))
_roll_spark_emitter = CPUParticles3D.new()
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
_roll_spark_emitter.emitting = false
_roll_spark_emitter.one_shot = false
_roll_spark_emitter.amount = 46
_roll_spark_emitter.lifetime = 0.45
_roll_spark_emitter.randomness = 0.7
_roll_spark_emitter.local_coords = false
_roll_spark_emitter.direction = Vector3(0.0, 1.0, 0.0)
_roll_spark_emitter.spread = 180.0
_roll_spark_emitter.gravity = Vector3(0.0, -6.0, 0.0)
_roll_spark_emitter.initial_velocity_min = 3.0
_roll_spark_emitter.initial_velocity_max = 8.0
_roll_spark_emitter.scale_amount_min = 0.5
_roll_spark_emitter.scale_amount_max = 1.3
_roll_spark_emitter.mesh = spark
_roll_spark_emitter.color_ramp = ramp
_roll_spark_emitter.scale_amount_curve = sc
cube_guy.add_child(_roll_spark_emitter)
func _setup_audio() -> void: func _setup_audio() -> void:
@@ -251,42 +176,20 @@ func _setup_audio() -> void:
add_child(_crowd_player) add_child(_crowd_player)
# Toggle the hoof emitters and swap their scale/velocity for the walk vs charge feel. The blue
# charge trail is the roll/dash ability's, driven from the roll loop, not by dust state.
func _set_dust_state(new_state: DustState) -> void: func _set_dust_state(new_state: DustState) -> void:
if new_state == _dust_state: if new_state == _dust_state:
return return
_dust_state = new_state _dust_state = new_state
var scale := _CHARGE_DUST_SCALE if new_state == DustState.CHARGE else _WALK_DUST_SCALE
var vel := _CHARGE_DUST_VEL if new_state == DustState.CHARGE else _WALK_DUST_VEL
for p: CPUParticles3D in _hoof_emitters: for p: CPUParticles3D in _hoof_emitters:
p.lifetime = DP.f("dust_lifetime") p.scale_amount_min = scale.x
p.explosiveness = DP.f("dust_explosiveness") p.scale_amount_max = scale.y
p.spread = DP.f("dust_spread") p.initial_velocity_min = vel.x
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0) p.initial_velocity_max = vel.y
match new_state: p.emitting = new_state != DustState.NONE
DustState.NONE:
p.emitting = false
DustState.WALK:
p.color_ramp = _walk_dust_ramp
p.scale_amount_min = DP.f("walk_scale_min")
p.scale_amount_max = DP.f("walk_scale_max")
p.initial_velocity_min = DP.f("walk_vel_min")
p.initial_velocity_max = DP.f("walk_vel_max")
p.emitting = true
DustState.CHARGE:
p.color_ramp = _charge_dust_ramp
p.scale_amount_min = DP.f("charge_scale_min")
p.scale_amount_max = DP.f("charge_scale_max")
p.initial_velocity_min = DP.f("charge_vel_min")
p.initial_velocity_max = DP.f("charge_vel_max")
p.emitting = true
# Blue energy trail belongs to the roll/dash ability only, not to fast WASD
# running — driven explicitly in the roll loop / _end_roll, not by dust state.
func _on_dp_changed(_key: String, _val: Variant) -> void:
if _hoof_emitters.is_empty() or _dust_state == DustState.NONE:
return
var prev := _dust_state
_dust_state = DustState.NONE
_set_dust_state(prev)
func _find_anim_player(node: Node) -> AnimationPlayer: func _find_anim_player(node: Node) -> AnimationPlayer:
@@ -681,13 +584,10 @@ func _hit_matadors_radius(range_m: float, strength: float, up_boost: float = 0.0
mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost) mat.call(&"apply_ability_hit", (away + Vector3.UP * 0.6).normalized(), strength, up_boost)
# ── Particle helpers ────────────────────────────────────────────────────────── # ── Blood-spurt particle helpers ──────────────────────────────────────────────
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles; # The ability effects now live in systems/fx/; only the spear-wound blood below is still built in
# these three helpers remove the boilerplate each spawn function used to repeat. # code. Shared material, built once so the mobile driver compiles this particle shader a single
# time (colour comes per-particle from the colour_ramp via vertex colour, not this material).
# One shared unshaded material for every particle FX (per-particle colour comes from the
# CPUParticles3D colour_ramp via vertex colour, not the material) — built once so the mobile
# driver compiles this shader a single time instead of on every attack.
func _unshaded_material() -> StandardMaterial3D: func _unshaded_material() -> StandardMaterial3D:
if _unshaded_mat == null: if _unshaded_mat == null:
_unshaded_mat = StandardMaterial3D.new() _unshaded_mat = StandardMaterial3D.new()
@@ -698,8 +598,7 @@ func _unshaded_material() -> StandardMaterial3D:
return _unshaded_mat return _unshaded_mat
# Low-poly sphere sized for a particle mesh (height is always the diameter). Cached per size so # Low-poly particle-sphere mesh (height = diameter), cached per size so repeats reuse one GPU upload.
# repeat attacks reuse the same mesh (one GPU upload) instead of generating + uploading a new one.
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh: func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
var key := "%.4f:%d:%d" % [radius, segments, rings] var key := "%.4f:%d:%d" % [radius, segments, rings]
var cached: SphereMesh = _particle_sphere_cache.get(key) var cached: SphereMesh = _particle_sphere_cache.get(key)
@@ -724,281 +623,31 @@ func _free_after(node: Node, delay: float) -> void:
func _spawn_slam_fx() -> void: func _spawn_slam_fx() -> void:
if not _slam_ring_mesh:
_slam_ring_mesh = _make_slam_ring_mesh()
var max_r := DP.f("slam_range")
var origin := global_position + Vector3(0.0, -0.5, 0.0) var origin := global_position + Vector3(0.0, -0.5, 0.0)
_launch_slam_ring(origin, max_r, 0.00, 0.55, Color(1.00, 0.70, 0.10, 0.85)) _impact_fx.call(&"slam_rings", origin, DP.f("slam_range"))
_launch_slam_ring(origin, max_r, 0.10, 0.52, Color(1.00, 0.50, 0.10, 0.65)) Fx.spawn(&"bull_slam", Transform3D(Basis(), origin))
_launch_slam_ring(origin, max_r, 0.20, 0.48, Color(0.90, 0.30, 0.10, 0.50))
_spawn_slam_dust(origin)
camera_pivot.call(&"trigger_hit", 0.60) camera_pivot.call(&"trigger_hit", 0.60)
func _launch_slam_ring(origin: Vector3, max_r: float,
delay: float, duration: float, col: Color) -> void:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(col.r, col.g, col.b, 0.0)
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
var mi := MeshInstance3D.new()
mi.mesh = _slam_ring_mesh
mi.material_override = mat
mi.scale = Vector3(0.02, 1.0, 0.02)
get_parent().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale",
Vector3(max_r, 1.0, max_r), duration)\
.set_delay(delay)\
.set_trans(Tween.TRANS_EXPO)\
.set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, col.a, 0.0, duration)\
.set_delay(delay)\
.set_trans(Tween.TRANS_SINE)\
.set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(delay + duration + 0.05)
func _spawn_slam_dust(origin: Vector3) -> void:
var sphere := _particle_sphere(0.09, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.88, 0.74, 0.46, 1.0))
ramp.set_color(1, Color(0.88, 0.74, 0.46, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.25))
scale_curve.add_point(Vector2(0.35, 1.6))
scale_curve.add_point(Vector2(1.00, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.85
p.amount = 48
p.lifetime = 1.1
p.randomness = 0.5
p.local_coords = false
p.direction = Vector3.UP
p.spread = 85.0
p.gravity = Vector3(0.0, -7.0, 0.0)
p.initial_velocity_min = 3.5
p.initial_velocity_max = 10.0
p.scale_amount_min = 0.5
p.scale_amount_max = 2.0
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 2.5)
func _spawn_kick_fx(back_dir: Vector3) -> void: func _spawn_kick_fx(back_dir: Vector3) -> void:
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0) var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
_spawn_kick_dust(origin, back_dir) Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir))
_spawn_kick_stones(origin, back_dir) _impact_fx.call(&"kick_flash", origin)
_spawn_kick_sparks(origin, back_dir)
_spawn_kick_flash(origin)
camera_pivot.call(&"trigger_hit", 0.35) camera_pivot.call(&"trigger_hit", 0.35)
func _spawn_kick_dust(origin: Vector3, back_dir: Vector3) -> void:
var sphere := _particle_sphere(0.12, 4, 2, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(1.0, 0.78, 0.18, 0.95))
ramp.set_color(1, Color(1.0, 0.45, 0.05, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.3))
scale_curve.add_point(Vector2(0.3, 1.5))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.80
p.amount = 40
p.lifetime = 1.2
p.randomness = 0.5
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.7, 0.0)).normalized()
p.spread = 65.0
p.gravity = Vector3(0.0, -6.0, 0.0)
p.initial_velocity_min = 3.0
p.initial_velocity_max = 9.0
p.scale_amount_min = 0.5
p.scale_amount_max = 2.2
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 2.5)
func _spawn_kick_stones(origin: Vector3, back_dir: Vector3) -> void:
var sphere := _particle_sphere(0.055, 3, 1, _unshaded_material())
var ramp := Gradient.new()
ramp.set_color(0, Color(0.62, 0.58, 0.52, 1.0))
ramp.set_color(1, Color(0.62, 0.58, 0.52, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.6, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.92
p.amount = 14
p.lifetime = 0.65
p.randomness = 0.6
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.35, 0.0)).normalized()
p.spread = 45.0
p.gravity = Vector3(0.0, -9.8, 0.0)
p.initial_velocity_min = 5.0
p.initial_velocity_max = 14.0
p.scale_amount_min = 0.5
p.scale_amount_max = 1.8
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 2.0)
func _spawn_kick_flash(origin: Vector3) -> void:
var mat := _unshaded_material()
mat.albedo_color = Color(1.0, 0.97, 0.40, 0.0)
var sphere := _particle_sphere(1.0, 8, 4, mat)
var mi := MeshInstance3D.new()
mi.mesh = sphere
mi.material_override = mat
mi.scale = Vector3(0.05, 0.05, 0.05)
get_parent().add_child(mi)
mi.global_position = origin
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(mi, "scale", Vector3(0.7, 0.7, 0.7), 0.13)\
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_OUT)
var fade := func(a: float) -> void: mat.albedo_color.a = a
tween.tween_method(fade, 0.92, 0.0, 0.14)\
.set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.tween_callback(mi.queue_free).set_delay(0.16)
func _spawn_dash_burst() -> void: func _spawn_dash_burst() -> void:
var box := BoxMesh.new() Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir))
box.size = Vector3(0.045, 0.045, 0.30)
box.material = _unshaded_material()
var ramp := Gradient.new()
ramp.set_color(0, Color(0.50, 0.82, 1.0, 1.0))
ramp.set_color(1, Color(0.50, 0.82, 1.0, 0.0))
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.92
p.amount = 18
p.lifetime = 0.22
p.randomness = 0.15
p.local_coords = false
p.direction = _dash_dir
p.spread = 15.0
p.gravity = Vector3.ZERO
p.initial_velocity_min = 18.0
p.initial_velocity_max = 35.0
p.scale_amount_min = 0.8
p.scale_amount_max = 2.8
p.mesh = box
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = global_position + Vector3(0.0, 0.2, 0.0)
p.restart()
_free_after(p, 0.6)
func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void: # A transform at `origin` whose local -Z faces `forward` (flattened to horizontal). The kick/dash
var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material()) # effects bake their spray in local space, so aiming the spawn transform points the burst the
# right way. Falls back to an unrotated transform when `forward` has no horizontal component.
var ramp := Gradient.new() func _aimed_xform(origin: Vector3, forward: Vector3) -> Transform3D:
ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0)) var flat := Vector3(forward.x, 0.0, forward.z)
ramp.set_color(1, Color(1.0, 1.0, 1.0, 0.0)) if flat.length_squared() < 0.0001:
return Transform3D(Basis(), origin)
var scale_curve := Curve.new() return Transform3D(Basis(), origin).looking_at(origin + flat.normalized(), Vector3.UP)
scale_curve.add_point(Vector2(0.0, 1.0))
scale_curve.add_point(Vector2(0.5, 0.6))
scale_curve.add_point(Vector2(1.0, 0.0))
var p := CPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.95
p.amount = 22
p.lifetime = 0.28
p.randomness = 0.4
p.local_coords = false
p.direction = (back_dir + Vector3(0.0, 0.5, 0.0)).normalized()
p.spread = 30.0
p.gravity = Vector3(0.0, -14.0, 0.0)
p.initial_velocity_min = 8.0
p.initial_velocity_max = 18.0
p.scale_amount_min = 0.6
p.scale_amount_max = 2.0
p.mesh = sphere
p.color_ramp = ramp
p.scale_amount_curve = scale_curve
get_parent().add_child(p)
p.global_position = origin
p.restart()
_free_after(p, 1.0)
func _make_slam_ring_mesh() -> ArrayMesh:
const INNER_R: float = 0.82
const OUTER_R: float = 1.00
const SEGMENTS: int = 48
var verts := PackedVector3Array()
var indices := PackedInt32Array()
for i: int in SEGMENTS:
var a0 := i / float(SEGMENTS) * TAU
var a1 := (i + 1) / float(SEGMENTS) * TAU
var vi := i * 4
verts.append(Vector3(cos(a0) * INNER_R, 0.0, sin(a0) * INNER_R))
verts.append(Vector3(cos(a0) * OUTER_R, 0.0, sin(a0) * OUTER_R))
verts.append(Vector3(cos(a1) * OUTER_R, 0.0, sin(a1) * OUTER_R))
verts.append(Vector3(cos(a1) * INNER_R, 0.0, sin(a1) * INNER_R))
indices.append_array([vi, vi + 2, vi + 1, vi, vi + 3, vi + 2])
var arrays: Array = []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
@@ -1112,23 +761,14 @@ func _physics_process(delta: float) -> void:
velocity.z = flat_n.z * exit_spd velocity.z = flat_n.z * exit_spd
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z) pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
# --- ADD THIS BREAKABLE BARREL DETECTION CODE --- # Ram straight through a barrel that formed the wall we bounced off.
var collision = get_slide_collision(i) var hit := col.get_collider()
var collider = collision.get_collider() if hit != null and hit.has_method(&"destroy_barrel"):
hit.call(&"destroy_barrel")
# Check if the object we hit has the "destroy_barrel" function
if collider and collider.has_method("destroy_barrel"):
# Optional: Only break if moving fast (e.g., sprinting or dashing)
# if velocity.length() > 5.0:
collider.destroy_barrel()
# ── Landing. Don't bounce ───────────────────────────────────────────────────── # ── Landing. Don't bounce ─────────────────────────────────────────────────────
if is_on_floor() and not _was_on_floor: if is_on_floor() and not _was_on_floor:
velocity.y = 0.0 velocity.y = 0.0
#Bouncing landing below
#var impact := -_pre_slide_vel_y
#if impact > DP.f("bounce_min_impact") and not _slam_pending:
#velocity.y = impact * DP.f("bounce_restitution")
# ── Slam landing ────────────────────────────────────────────────────────── # ── Slam landing ──────────────────────────────────────────────────────────
if _slam_pending and is_on_floor() and not _was_on_floor: if _slam_pending and is_on_floor() and not _was_on_floor:
@@ -1157,8 +797,6 @@ func _physics_process(delta: float) -> void:
if vel_flat.length_squared() > 0.1: if vel_flat.length_squared() > 0.1:
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized() _charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
# ── Hoof sounds ───────────────────────────────────────────────────────────
func get_hp() -> int: func get_hp() -> int:
return _hp return _hp
@@ -1274,7 +912,6 @@ func _spawn_blood(hit_pos: Vector3) -> void:
_free_after(p, 1.5) _free_after(p, 1.5)
#hit flashing
func _hit_flash() -> void: func _hit_flash() -> void:
var meshes := find_children("*", "MeshInstance3D", true, false) var meshes := find_children("*", "MeshInstance3D", true, false)
+1 -1
View File
@@ -23,7 +23,7 @@ script = ExtResource("2_camera_iso")
[node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")] [node name="bull" parent="." unique_id=1386495166 instance=ExtResource("4_e80uo")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.655, 0.23)
[node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1942410469) index="0" unique_id=550138996] [node name="Skeleton3D" parent="bull/Armature" parent_id_path=PackedInt32Array(1386495166, 1057197468) index="0" unique_id=840295370]
bones/0/position = Vector3(0, 0, 0.009963839) bones/0/position = Vector3(0, 0, 0.009963839)
bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067) bones/0/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
bones/0/scale = Vector3(1, 1, 1) bones/0/scale = Vector3(1, 1, 1)
+14
View File
@@ -0,0 +1,14 @@
extends Node3D
var player: Node3D = null
func _process(delta: float) -> void:
# If we don't have the player yet, try to find them in the group
if not is_instance_valid(player):
var players = get_tree().get_nodes_in_group("player")
if players.size() > 0:
player = players[0] as Node3D
# If the player exists, follow their position
if is_instance_valid(player):
global_position = player.global_position
+1
View File
@@ -0,0 +1 @@
uid://cmapsaj010q40
+14
View File
@@ -0,0 +1,14 @@
[gd_scene format=3 uid="uid://4v25hb0qu5l0"]
[ext_resource type="Script" uid="uid://cmapsaj010q40" path="res://actors/player/player_follow_light.gd" id="1_w7wrt"]
[node name="PlayerFollowLight" type="Node3D" unique_id=1889971494]
script = ExtResource("1_w7wrt")
[node name="SpotLight3D" type="SpotLight3D" parent="." unique_id=1523840927]
transform = Transform3D(1, 0, 0, 0, -4.371139e-08, 1, 0, -1, -4.371139e-08, 0, 2.6136062, 1.0431258e-07)
light_color = Color(0.9999998, 0.8702623, 0.60548127, 1)
light_energy = 8.0
shadow_enabled = true
distance_fade_length = 30.45
spot_angle = 72.62192
Binary file not shown.
+3 -3
View File
@@ -1,6 +1,6 @@
""" """
Blender 5.1 script: create walk + idle animations for matador_v03. Blender 5.1 script: create walk + idle animations for matador.
Run: blender --background matador_v03.blend --python create_matador_anims.py Run: blender --background matador.blend --python create_matador_anims.py
""" """
import bpy, math, os import bpy, math, os
@@ -130,7 +130,7 @@ ti.name = 'idle'
si = ti.strips.new('idle', 0, idle) si = ti.strips.new('idle', 0, idle)
# ── export FBX ───────────────────────────────────────────────────────────────── # ── export FBX ─────────────────────────────────────────────────────────────────
fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador_v03.fbx') fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador.fbx')
bpy.ops.export_scene.fbx( bpy.ops.export_scene.fbx(
filepath=fbx_path, filepath=fbx_path,
use_selection=False, use_selection=False,
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
""" """
Blender 5.1 script: render preview frames of matador animations to PNG. Blender 5.1 script: render preview frames of matador animations to PNG.
Run: blender --background matador_v03.blend --python render_anim_preview.py Run: blender --background matador.blend --python render_anim_preview.py
Outputs to art/blender/preview/ directory: Outputs to art/blender/preview/ directory:
walk_frame_00.png .. walk_frame_09.png walk_frame_00.png .. walk_frame_09.png
-10
View File
@@ -1,10 +0,0 @@
[gd_scene format=3 uid="uid://map_screen_v1"]
[ext_resource type="Script" path="res://levels/map_screen.gd" id="1_map"]
[node name="MapScreen" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_map")
+3 -8
View File
@@ -1,9 +1,8 @@
class_name LevelDef class_name LevelDef
extends Resource extends Resource
## Definition of one playable level on the run map — the arena today, later a cave with ## Definition of one playable level — the arena, the bear's den, the lobby hub, and later
## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and ## a china shop to smash. The flow code never hard-codes a scene: a level is a LevelDef
## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its ## (plus its scene) built in run_state.gd and served through the lobby.
## scene) handed out by the map generator in run_state.gd.
@export var id: StringName = &"" @export var id: StringName = &""
@export var display_name: String = "Level" @export var display_name: String = "Level"
@@ -15,14 +14,10 @@ extends Resource
## colliders and any level-specific subscripts. Swapped wholesale between levels so no ## colliders and any level-specific subscripts. Swapped wholesale between levels so no
## previous level's geometry (or its colliders) can leak into the next. ## previous level's geometry (or its colliders) can leak into the next.
@export var level_scene: PackedScene = null @export var level_scene: PackedScene = null
## Placeholder pip tint on the map until per-level icon art exists.
@export var color: Color = Color(0.80, 0.62, 0.24)
## Camera framing multiplier when this level loads — 1.0 is the shell camera's default ## Camera framing multiplier when this level loads — 1.0 is the shell camera's default
## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole ## shot; >1 dollies the camera back for a wider view (the lobby pulls out so the whole
## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom. ## hub reads). Applied by game_shell; independent of the player's scroll-wheel zoom.
@export var camera_zoom: float = 1.0 @export var camera_zoom: float = 1.0
## A boss level caps a run — its node is drawn larger and clearing it ends the map.
@export var is_boss: bool = false
## The enemy this level pits the bull against. Null → the usual matador wave (the ## The enemy this level pits the bull against. Null → the usual matador wave (the
## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g. ## spawner's default). Set → a single boss enemy of this scene is spawned instead (e.g.
## the bear), and the fight is won when it falls. The arena scene is shared either way; ## the bear), and the fight is won when it falls. The arena scene is shared either way;
+7 -3
View File
@@ -2,6 +2,7 @@
[ext_resource type="PackedScene" uid="uid://bbfvwolhomilp" path="res://props/torch.tscn" id="1_w7uyf"] [ext_resource type="PackedScene" uid="uid://bbfvwolhomilp" path="res://props/torch.tscn" id="1_w7uyf"]
[ext_resource type="PackedScene" uid="uid://bd56iuttyiypv" path="res://props/barrel_simple.tscn" id="2_3o0md"] [ext_resource type="PackedScene" uid="uid://bd56iuttyiypv" path="res://props/barrel_simple.tscn" id="2_3o0md"]
[ext_resource type="PackedScene" uid="uid://4v25hb0qu5l0" path="res://actors/player/player_follow_light.tscn" id="2_8bh2m"]
[ext_resource type="PackedScene" uid="uid://dctmlh4hnuux1" path="res://art/levels/lobby.glb" id="2_w7uyf"] [ext_resource type="PackedScene" uid="uid://dctmlh4hnuux1" path="res://art/levels/lobby.glb" id="2_w7uyf"]
[ext_resource type="Script" uid="uid://cwbf7jfkr8j10" path="res://levels/lobby_underground.gd" id="4_73452"] [ext_resource type="Script" uid="uid://cwbf7jfkr8j10" path="res://levels/lobby_underground.gd" id="4_73452"]
[ext_resource type="Script" uid="uid://dn77mjyxllgl6" path="res://levels/to_the_bear_level.gd" id="5_6j2sa"] [ext_resource type="Script" uid="uid://dn77mjyxllgl6" path="res://levels/to_the_bear_level.gd" id="5_6j2sa"]
@@ -45,8 +46,8 @@ size = Vector3(4.1274414, 8.299622, 6.9958496)
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=955995990] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=955995990]
transform = Transform3D(-0.502238, -0.82287306, 0.26577574, 0.36594617, 0.07621761, 0.9275096, -0.78347975, 0.5630903, 0.26284787, 0, 0, 0) transform = Transform3D(-0.502238, -0.82287306, 0.26577574, 0.36594617, 0.07621761, 0.9275096, -0.78347975, 0.5630903, 0.26284787, 0, 0, 0)
light_color = Color(0.78431374, 0.8117647, 1, 1) light_color = Color(0.5266291, 0.5549962, 0.9999985, 1)
light_energy = 0.228 light_energy = 0.466
shadow_enabled = true shadow_enabled = true
[node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=1783040386] [node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=1783040386]
@@ -99,6 +100,9 @@ transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.3
[node name="Torch8" parent="Lights/Torches" unique_id=987701581 instance=ExtResource("1_w7uyf")] [node name="Torch8" parent="Lights/Torches" unique_id=987701581 instance=ExtResource("1_w7uyf")]
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.349617, 1.9073486e-06, -0.31505227) transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, -0.349617, 1.9073486e-06, -0.31505227)
[node name="PlayerFollowLight" parent="Lights" unique_id=1889971494 instance=ExtResource("2_8bh2m")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.1712027, 0)
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=939963056] [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=939963056]
environment = SubResource("Environment_lobby") environment = SubResource("Environment_lobby")
@@ -240,7 +244,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.1928787, 0.11255789, 2.8063
transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 0, 0, 0) transform = Transform3D(-4.371139e-08, 0, -1, 0, 1, 0, 1, 0, -4.371139e-08, 0, 0, 0)
script = ExtResource("4_73452") script = ExtResource("4_73452")
[node name="Plane" parent="Lobby_underground" index="1" unique_id=1962555538] [node name="Plane" parent="Lobby_underground" index="1" unique_id=2038841618]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2352347, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2352347, 0)
[node name="AnimationPlayer" type="AnimationPlayer" parent="Lobby_underground" unique_id=2070502042] [node name="AnimationPlayer" type="AnimationPlayer" parent="Lobby_underground" unique_id=2070502042]
-13
View File
@@ -1,13 +0,0 @@
class_name MapNode
extends RefCounted
## One node on the run map. Regenerated every run and never persisted, so RefCounted
## rather than Resource. `pos` is normalized (0-1) so the map screen can lay the node out
## at any resolution; `links` are column indices in the *next* row this node connects to
## (the Slay-the-Spire branching path).
var row: int = 0
var col: int = 0
var pos: Vector2 = Vector2.ZERO
var level: LevelDef = null
var links: PackedInt32Array = PackedInt32Array()
var cleared: bool = false
-1
View File
@@ -1 +0,0 @@
uid://emq8i2bmkekn
-280
View File
@@ -1,280 +0,0 @@
extends Control
## Slay-the-Spire-style run map shown between fights. Placeholder art — coloured pips
## joined by paths — but fully interactive: the player picks one of the reachable nodes
## and is sent into that level. Every node is the arena for now (see run_state.gd); the
## screen is level-agnostic, so richer levels just need a LevelDef with its own colour.
const _GOLD := Color(1.00, 0.78, 0.22)
const _CREAM := Color(0.93, 0.88, 0.72)
const _EDGE_DIM := Color(0.45, 0.36, 0.18, 0.55)
const _EDGE_ACTIVE := Color(1.00, 0.82, 0.32, 0.95)
const _NODE_SIZE := 58.0
const _BOSS_SIZE := 88.0
const _MENU_SCENE := "res://ui/main_menu.tscn"
var _map_area: Control
var _completion: PanelContainer
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().paused = false
Run.ensure_run()
_build_ui()
_relayout.call_deferred() # defer so _map_area has its real size first
func _build_ui() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
var bg := ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.09, 0.06, 0.03) # opaque leather, no scene showing through
bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(bg)
var title := Label.new()
title.text = "Choose Your Path"
title.set_anchors_preset(Control.PRESET_TOP_WIDE)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.offset_top = 22.0
title.add_theme_font_override("font", UiFonts.title())
title.add_theme_font_size_override("font_size", 46)
title.add_theme_color_override("font_color", _GOLD)
title.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
title.add_theme_constant_override("outline_size", 6)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(title)
_map_area = Control.new()
_map_area.set_anchors_preset(Control.PRESET_FULL_RECT)
_map_area.offset_top = 108.0
_map_area.offset_bottom = -92.0
_map_area.offset_left = 48.0
_map_area.offset_right = -48.0
_map_area.clip_contents = false
add_child(_map_area)
_map_area.resized.connect(_relayout)
var menu_btn := _make_button("Main Menu")
menu_btn.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
menu_btn.grow_vertical = Control.GROW_DIRECTION_BEGIN
menu_btn.offset_left = 32.0
menu_btn.offset_bottom = -26.0
menu_btn.pressed.connect(_to_menu)
add_child(menu_btn)
_build_completion()
# ── Layout ──────────────────────────────────────────────────────────────────
# Rebuilt from the run state on first show and whenever the map area resizes. Edges are
# added first so the node pips draw on top of the paths.
func _relayout() -> void:
if _map_area == null:
return
for child in _map_area.get_children():
child.queue_free()
var size := _map_area.size
if size.x <= 0.0 or size.y <= 0.0:
return
var reachable := Run.reachable()
var reach_set := {}
for n: MapNode in reachable:
reach_set[n] = true
var here := Run.current_node()
if Run.player_row < 0:
var start_pos := Vector2(size.x * 0.5, size.y - 8.0)
_add_start_pip(start_pos)
for n: MapNode in reachable:
_add_edge(start_pos, _local(n, size), true)
for row: Array in Run.map:
for from_node: MapNode in row:
for to_col: int in from_node.links:
var to_node := Run.node_at(from_node.row + 1, to_col)
if to_node == null:
continue
var active := from_node == here and reach_set.has(to_node)
_add_edge(_local(from_node, size), _local(to_node, size), active)
for row: Array in Run.map:
for node: MapNode in row:
_add_node(node, size, reach_set.has(node))
_completion.visible = Run.run_complete() or reachable.is_empty()
func _local(node: MapNode, size: Vector2) -> Vector2:
return Vector2(node.pos.x * size.x, node.pos.y * size.y)
func _add_edge(a: Vector2, b: Vector2, active: bool) -> void:
var line := Line2D.new()
line.add_point(a)
line.add_point(b)
line.width = 4.0 if active else 2.5
line.default_color = _EDGE_ACTIVE if active else _EDGE_DIM
line.antialiased = true
_map_area.add_child(line)
func _add_start_pip(center: Vector2) -> void:
var pip := PanelContainer.new()
var sz := 22.0
pip.custom_minimum_size = Vector2(sz, sz)
pip.size = Vector2(sz, sz)
pip.position = center - Vector2(sz, sz) * 0.5
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
pip.add_theme_stylebox_override("panel", UiTheme.disc(_GOLD, _CREAM, 2))
_map_area.add_child(pip)
func _add_node(node: MapNode, size: Vector2, is_reachable: bool) -> void:
var is_current := node.row == Run.player_row and node.col == Run.player_col
var sz := _BOSS_SIZE if node.level.is_boss else _NODE_SIZE
var center := _local(node, size)
var btn := Button.new()
btn.custom_minimum_size = Vector2(sz, sz)
btn.size = Vector2(sz, sz)
btn.position = center - Vector2(sz, sz) * 0.5
btn.focus_mode = Control.FOCUS_NONE
btn.disabled = not is_reachable
btn.mouse_filter = Control.MOUSE_FILTER_STOP if is_reachable else Control.MOUSE_FILTER_IGNORE
var fill := node.level.color
var border := UiTheme.BORDER
if is_current:
border = _CREAM
elif is_reachable:
border = _GOLD
elif node.cleared:
fill = fill.darkened(0.35)
border = Color(_GOLD.r, _GOLD.g, _GOLD.b, 0.5)
else: # locked — a future row not yet reachable
fill = fill.darkened(0.6)
border = Color(0.40, 0.34, 0.20, 0.6)
var border_w := 4 if (is_reachable or is_current) else 2
var normal := UiTheme.disc(fill, border, border_w)
var hover := UiTheme.disc(fill.lightened(0.18), _CREAM, border_w)
btn.add_theme_stylebox_override("normal", normal)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_stylebox_override("disabled", normal)
_map_area.add_child(btn)
if is_reachable:
btn.pressed.connect(_enter_node.bind(node))
_pulse(btn)
if is_reachable or is_current or node.level.is_boss:
_add_caption(node.level.display_name, center + Vector2(0.0, sz * 0.5 + 6.0), is_reachable)
if is_current:
_add_caption("You are here", center - Vector2(0.0, sz * 0.5 + 18.0), false)
func _add_caption(text: String, center_top: Vector2, bright: bool) -> void:
var lbl := Label.new()
lbl.text = text
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
lbl.size = Vector2(200.0, 0.0)
lbl.position = center_top - Vector2(100.0, 0.0)
lbl.add_theme_font_override("font", UiFonts.body())
lbl.add_theme_font_size_override("font_size", 15)
lbl.add_theme_color_override("font_color", _CREAM if bright else UiTheme.MUTED)
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
lbl.add_theme_constant_override("outline_size", 4)
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_map_area.add_child(lbl)
# Slow glow on the reachable pips so the eye lands on where the player can go next.
func _pulse(node: CanvasItem) -> void:
var tw := create_tween().set_loops()
tw.tween_property(node, "modulate:a", 0.6, 0.75).set_trans(Tween.TRANS_SINE)
tw.tween_property(node, "modulate:a", 1.0, 0.75).set_trans(Tween.TRANS_SINE)
# ── Actions ───────────────────────────────────────────────────────────────────
func _enter_node(node: MapNode) -> void:
Run.select_node(node)
get_tree().paused = false
get_tree().change_scene_to_file(node.level.scene_path)
func _to_menu() -> void:
get_tree().paused = false
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file(_MENU_SCENE)
# ── Run-complete overlay ──────────────────────────────────────────────────────
func _build_completion() -> void:
_completion = PanelContainer.new()
_completion.set_anchors_preset(Control.PRESET_CENTER)
_completion.grow_horizontal = Control.GROW_DIRECTION_BOTH
_completion.grow_vertical = Control.GROW_DIRECTION_BOTH
_completion.add_theme_stylebox_override(
"panel", UiTheme.plaque(UiTheme.LEATHER, _GOLD, 4, 32.0, 24.0)
)
_completion.visible = false
add_child(_completion)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 16)
col.alignment = BoxContainer.ALIGNMENT_CENTER
_completion.add_child(col)
var heading := Label.new()
heading.text = "Run Complete!"
heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
heading.add_theme_font_override("font", UiFonts.title())
heading.add_theme_font_size_override("font_size", 40)
heading.add_theme_color_override("font_color", _GOLD)
heading.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.9))
heading.add_theme_constant_override("outline_size", 5)
col.add_child(heading)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 16)
col.add_child(row)
var again := _make_button("New Run")
again.pressed.connect(_new_run)
row.add_child(again)
var menu := _make_button("Main Menu")
menu.pressed.connect(_to_menu)
row.add_child(menu)
func _new_run() -> void:
Run.start_new_run()
_relayout()
# Shared thematic button, matching the HUD / menu plaques.
func _make_button(text: String) -> Button:
var btn := Button.new()
btn.text = text
btn.focus_mode = Control.FOCUS_NONE
btn.add_theme_stylebox_override(
"normal", UiTheme.plaque(Color(0.10, 0.07, 0.03, 0.90), _GOLD, 3, 18.0, 9.0)
)
var hover := UiTheme.plaque(Color(0.16, 0.10, 0.04, 0.96), _CREAM, 3, 18.0, 9.0)
btn.add_theme_stylebox_override("hover", hover)
btn.add_theme_stylebox_override("pressed", hover)
btn.add_theme_font_override("font", UiFonts.body())
btn.add_theme_color_override("font_color", _GOLD)
btn.add_theme_color_override("font_hover_color", _CREAM)
btn.add_theme_font_size_override("font_size", 18)
return btn
-1
View File
@@ -1 +0,0 @@
uid://dt3ffakbwlkmc
+69 -182
View File
@@ -1,12 +1,11 @@
extends Node extends Node
## Persistent run progression across scene changes — the Slay-the-Spire-style map and ## Persistent run progression across scene changes — which hub/level the player is in.
## where the player sits on it. The arena scene is reloaded for every fight, so this ## The level scene is reloaded for every fight, so this state must outlive scene changes:
## state must outlive scene changes: genuinely global run state earns an autoload (`Run`). ## genuinely global run state earns an autoload (`Run`).
## ##
## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the ## Flow: menu Play → start_new_run() → lobby hub → enter a gate → that level → win →
## first frontier → pick a node → its level scene → win → back to MapScreen advanced one ## return_to_lobby() (clearing a level opens its follow-on gates, e.g. clearing the arena
## row, and on until a boss node ends the map. Every node is the arena for now; adding a ## unlocks the bear). Adding a level is a new LevelDef in the catalog plus a lobby gate.
## level later is a new LevelDef in the catalog and pointing the generator at it.
signal run_started signal run_started
@@ -17,21 +16,7 @@ const _ARENA_LEVEL := preload("res://levels/arena_level.tscn")
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn") const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn") const _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn")
# Map shape. Small + placeholder for now; the final row is a single boss node. ## Debug override — when set, active_level() returns this regardless of run state.
const _ROWS := 5
const _MIN_PER_ROW := 2
const _MAX_PER_ROW := 4
## map[row] is an Array[MapNode]; empty until a run starts.
var map: Array = []
## Node the player currently occupies. (-1, -1) = the start gate below the first row —
## i.e. straight after the intro fight, before any map node has been chosen.
var player_row: int = -1
var player_col: int = -1
## Node picked on the map whose level is being played now; committed to on the next win.
var pending_row: int = -1
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(). ## 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 the hub the run starts and returns to: a walk-around room whose gates lead ## The lobby is the hub the run starts and returns to: a walk-around room whose gates lead
@@ -49,10 +34,21 @@ var cleared: Dictionary = {}
var bonus_hp: int = 0 var bonus_hp: int = 0
const BONUS_HP_CAP: int = 2 const BONUS_HP_CAP: int = 2
## Wall-clock run timer: starts the moment the player walks through the arena gate (the
## run's first fight) and freezes the instant the bear falls, so the completion screen can
## show a final time. Time.get_ticks_msec()-based — real time, not delta-summed — so it
## keeps ticking correctly across the scene reload between the arena and the bear's den.
var _timer_start_msec: int = -1
var _timer_end_msec: int = -1
var _timer_running: bool = false
## True once start_new_run() has built a run. Lets ensure_run() avoid wiping an active run
## when the shell reloads for each fight.
var _run_active: bool = false
# 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
# flavours) and the bear (a lone boss enemy that can turn up as a normal node) live here. # flavours) and the bear (a lone boss enemy) live here. Future levels — china shop — slot
# Future levels — china shop — slot in the same way: a new module + a LevelDef entry. # in the same way: a new module + a LevelDef entry.
const _BEAR_SCENE := preload("res://actors/bear/bear.tscn") const _BEAR_SCENE := preload("res://actors/bear/bear.tscn")
var _arena: LevelDef var _arena: LevelDef
@@ -62,33 +58,27 @@ var _lobby: LevelDef
func _ready() -> void: func _ready() -> void:
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false) _arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL)
_arena_boss = _make_level( _arena_boss = _make_level(&"arena_boss", "Grand Arena", _ARENA_LEVEL)
&"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true _bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL)
)
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false)
_bear.enemy_scene = _BEAR_SCENE _bear.enemy_scene = _BEAR_SCENE
# The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose. # The lobby is a fightless hub — no enemy_scene, so no wave spawns and no win/lose.
_lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL, Color(0.62, 0.66, 0.72), false) _lobby = _make_level(&"lobby", "Lobby", _LOBBY_LEVEL)
# It's a roomy hub with nothing to fight — pull the camera back so more of it reads. # It's a roomy hub with nothing to fight — pull the camera back so more of it reads.
_lobby.camera_zoom = 1.6 _lobby.camera_zoom = 1.6
func _make_level( func _make_level(id: StringName, level_name: String, level_scene: PackedScene) -> LevelDef:
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
) -> LevelDef:
var l := LevelDef.new() var l := LevelDef.new()
l.id = id l.id = id
l.display_name = level_name l.display_name = level_name
l.scene_path = _SHELL_SCENE l.scene_path = _SHELL_SCENE
l.level_scene = level_scene l.level_scene = level_scene
l.color = color
l.is_boss = boss
return l return l
## The level being fought right now: debug override wins, then the pending/current map ## The level being served right now: debug override wins, then the lobby hub or the level
## node, falling back to the plain arena for the intro fight or a direct scene launch. ## picked at a gate, falling back to the plain arena for a direct scene launch.
func active_level() -> LevelDef: func active_level() -> LevelDef:
if debug_level_override != null: if debug_level_override != null:
return debug_level_override return debug_level_override
@@ -96,10 +86,7 @@ func active_level() -> LevelDef:
return _lobby return _lobby
if lobby_choice != null: if lobby_choice != null:
return lobby_choice return lobby_choice
var n := node_at(pending_row, pending_col) return _arena
if n == null:
n = current_node()
return n.level if n != null and n.level != null else _arena
## Return a level by id, or null if unknown. Used by the debug console `level` command. ## Return a level by id, or null if unknown. Used by the debug console `level` command.
@@ -117,80 +104,30 @@ func level_ids() -> Array[StringName]:
return [&"arena", &"arena_boss", &"bear", &"lobby"] return [&"arena", &"arena_boss", &"bear", &"lobby"]
## Begin a fresh run: build a new map and place the player at the start gate. Called from ## Begin a fresh run: reset progression and open in the lobby hub. Called from the main-menu
## the main-menu Play button, not from the arena scene (which reloads for every fight). ## Play button, not from a level scene (which reloads for every fight).
func start_new_run() -> void: func start_new_run() -> void:
_generate_map()
player_row = -1
player_col = -1
pending_row = -1
pending_col = -1
debug_level_override = null debug_level_override = null
# The run opens in the lobby hub; its gates lead into the fights. # The run opens in the lobby hub; its gates lead into the fights.
enter_lobby = true enter_lobby = true
lobby_choice = null lobby_choice = null
cleared = {} cleared = {}
bonus_hp = 0 bonus_hp = 0
_timer_start_msec = -1
_timer_end_msec = -1
_timer_running = false
_run_active = true
run_started.emit() run_started.emit()
## Build a run if none exists — a safety net for entering the map or arena directly (e.g. ## Build a run if none is active — a safety net for entering a level directly (e.g. running
## running scene.tscn straight from the editor) without coming through the menu. ## game_shell.tscn straight from the editor) without coming through the menu. Guarded so the
## shell reloading for each fight doesn't wipe the run in progress.
func ensure_run() -> void: func ensure_run() -> void:
if map.is_empty(): if not _run_active:
start_new_run() start_new_run()
func has_run() -> bool:
return not map.is_empty()
func node_at(row: int, col: int) -> MapNode:
if row < 0 or row >= map.size():
return null
var r: Array = map[row]
if col < 0 or col >= r.size():
return null
return r[col]
func current_node() -> MapNode:
return node_at(player_row, player_col)
## Nodes the player may pick next: the whole first row from the start gate, otherwise the
## nodes the current node links forward to.
func reachable() -> Array:
if map.is_empty():
return []
if player_row < 0:
return (map[0] as Array).duplicate()
var here := current_node()
if here == null or player_row + 1 >= map.size():
return []
var out: Array = []
for c: int in here.links:
var n := node_at(player_row + 1, c)
if n != null:
out.append(n)
return out
## True once the player has cleared a boss node — the map is finished.
func run_complete() -> bool:
var here := current_node()
return here != null and here.level != null and here.level.is_boss
## Mark a node as the one being played (its level scene is about to load).
func select_node(node: MapNode) -> void:
if node == null:
return
enter_lobby = false
pending_row = node.row
pending_col = node.col
## 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 gate is entered. ## until a gate is entered.
func go_to_lobby() -> void: func go_to_lobby() -> void:
@@ -230,87 +167,37 @@ func add_bonus_hp(amount: int) -> int:
return bonus_hp - before return bonus_hp - before
## Commit the level just won: move the player onto the pending node (or leave them at the ## Begin the run clock. No-op while the clock is already running, so walking back through
## start gate after the intro fight, which has no pending node). ## the arena gate mid-run can't restart it — but once it's been stopped (the bear is dead
func complete_current_level() -> void: ## and the player has looped back to the arena to go again), the next call restarts it from
if pending_row >= 0: ## zero rather than staying stuck showing the previous run's final time.
player_row = pending_row func start_run_timer() -> void:
player_col = pending_col if _timer_running:
var n := current_node()
if n != null:
n.cleared = true
pending_row = -1
pending_col = -1
# ── Map generation ────────────────────────────────────────────────────────────
# A small branching lattice: each row gets a handful of nodes, each node links to the
# nearest node(s) in the next row, and every next-row node is guaranteed a parent so the
# path is always connected (no orphan the player can never reach). The final row is a
# single boss node the whole map funnels toward.
func _generate_map() -> void:
map.clear()
for r: int in _ROWS:
var count := 1 if r == _ROWS - 1 else randi_range(_MIN_PER_ROW, _MAX_PER_ROW)
var y := 1.0 - float(r + 1) / float(_ROWS + 1) # row 0 near the bottom, boss on top
var row_nodes: Array = []
for c: int in count:
var node := MapNode.new()
node.row = r
node.col = c
node.pos = Vector2(_column_x(c, count), y)
# Boss row is always the Grand Arena; normal nodes are a mix of matador
# arenas and the occasional Bear's Den for variety.
if r == _ROWS - 1:
node.level = _arena_boss
else:
node.level = _bear if randf() < 0.35 else _arena
row_nodes.append(node)
map.append(row_nodes)
_link_rows()
func _column_x(col: int, count: int) -> float:
if count <= 1:
return 0.5
return lerpf(0.18, 0.82, float(col) / float(count - 1))
func _link_rows() -> void:
for r: int in _ROWS - 1:
var cur: Array = map[r]
var nxt: Array = map[r + 1]
var incoming := PackedInt32Array()
incoming.resize(nxt.size()) # zero-filled
for from_node: MapNode in cur:
var nearest := _nearest_index(from_node, nxt)
_add_link(from_node, nearest, incoming)
# Occasionally fan out to an adjacent next-row node so paths branch.
if nxt.size() > 1 and randf() < 0.45:
var step := 1 if randf() < 0.5 else -1
_add_link(from_node, clampi(nearest + step, 0, nxt.size() - 1), incoming)
# Guarantee every next-row node has at least one parent (no orphans).
for c: int in nxt.size():
if incoming[c] == 0:
_add_link(cur[_nearest_index(nxt[c], cur)], c, incoming)
func _add_link(from_node: MapNode, to_col: int, incoming: PackedInt32Array) -> void:
if to_col in from_node.links:
return return
from_node.links.append(to_col) _timer_start_msec = Time.get_ticks_msec()
incoming[to_col] += 1 _timer_end_msec = -1
_timer_running = true
# Index of the node in `others` whose column position is closest to `node` on the x axis — ## Freeze the run clock — called the instant the bear falls, so the recorded time is the
# used both to link a node forward and to adopt an orphan back to its nearest parent. ## kill moment itself, not whatever the win-screen's celebratory delay adds on top.
func _nearest_index(node: MapNode, others: Array) -> int: func stop_run_timer() -> void:
var best := 0 if not _timer_running:
var best_d := INF return
for i: int in others.size(): _timer_end_msec = Time.get_ticks_msec()
var d: float = absf((others[i] as MapNode).pos.x - node.pos.x) _timer_running = false
if d < best_d:
best_d = d
best = i ## Seconds elapsed since the timer started (live while running, held steady once
return best ## stop_run_timer() has been called). 0 before the timer has ever started.
func run_elapsed() -> float:
if _timer_start_msec < 0:
return 0.0
var end_msec := Time.get_ticks_msec() if _timer_running else _timer_end_msec
return float(end_msec - _timer_start_msec) / 1000.0
## Whether the run clock has been started (the player has entered the arena at least once
## this run). Lets the HUD hide the timer readout until there's a time worth showing.
func run_timer_started() -> bool:
return _timer_start_msec >= 0
+1
View File
@@ -8,5 +8,6 @@ func _ready() -> void:
func _on_body_entered(body: Node3D) -> void: func _on_body_entered(body: Node3D) -> void:
if not body.is_in_group("player"): if not body.is_in_group("player"):
return return
Run.start_run_timer()
Run.choose_level(&"arena") Run.choose_level(&"arena")
get_tree().change_scene_to_file(Run.active_level().scene_path) get_tree().change_scene_to_file(Run.active_level().scene_path)
+1 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="Bullosseum" config/name="Bullosseum"
config/version="0.4.0" config/version="0.5.0"
run/main_scene="uid://bmnmenu0intr1" run/main_scene="uid://bmnmenu0intr1"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
boot_splash/bg_color=Color(0, 0, 0, 1) boot_splash/bg_color=Color(0, 0, 0, 1)
+1
View File
@@ -167,6 +167,7 @@ transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.011785, -2.371254, 16.2322
[node name="OmniLight3D" type="OmniLight3D" parent="MeshInstance3D/TorchLight" unique_id=1094582672] [node name="OmniLight3D" type="OmniLight3D" parent="MeshInstance3D/TorchLight" unique_id=1094582672]
transform = Transform3D(0.9999986, -0.001596809, 0.00045388562, 0.0014380338, 0.96984345, 0.24372463, -0.00082937966, -0.24372365, 0.9698444, 9.096397, 6.2542057, -15.77264) transform = Transform3D(0.9999986, -0.001596809, 0.00045388562, 0.0014380338, 0.96984345, 0.24372463, -0.00082937966, -0.24372365, 0.9698444, 9.096397, 6.2542057, -15.77264)
light_color = Color(0.92773837, 0.6304423, 0, 1) light_color = Color(0.92773837, 0.6304423, 0, 1)
light_energy = 2.0
shadow_enabled = true shadow_enabled = true
omni_range = 13.657384 omni_range = 13.657384
+2 -13
View File
@@ -46,21 +46,10 @@ func _register_all() -> void:
# (The dash ability bypasses this.) # (The dash ability bypasses this.)
_reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0) _reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0)
# ── Dust speed thresholds ───────────────────────────────────────────────── # ── Dust speed thresholds ─────────────────────────────────────────────────
# The dust LOOK (colour, size, velocity) lives in systems/fx/bull_hoof_dust.tscn now; these
# two only decide when walk vs charge dust kicks in by ground speed.
_reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0) _reg_f("Dust", "dust_walk_spd", 1.5, 0.1, 10.0)
_reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0) _reg_f("Dust", "dust_charge_spd", 7.0, 1.0, 20.0)
# ── Dust ──────────────────────────────────────────────────────────────────
_reg_f("Dust", "dust_lifetime", 0.9, 0.1, 4.0)
_reg_f("Dust", "dust_explosiveness", 0.35, 0.0, 1.0)
_reg_f("Dust", "dust_spread", 45.0, 5.0, 90.0)
_reg_f("Dust", "dust_gravity_y", -1.5,-15.0, 0.0)
_reg_f("Dust", "walk_scale_min", 0.20, 0.05, 3.0)
_reg_f("Dust", "walk_scale_max", 0.40, 0.05, 3.0)
_reg_f("Dust", "walk_vel_min", 0.8, 0.1, 12.0)
_reg_f("Dust", "walk_vel_max", 1.8, 0.1, 12.0)
_reg_f("Dust", "charge_scale_min", 0.35, 0.05, 6.0)
_reg_f("Dust", "charge_scale_max", 0.70, 0.05, 6.0)
_reg_f("Dust", "charge_vel_min", 2.8, 0.1, 20.0)
_reg_f("Dust", "charge_vel_max", 5.5, 0.1, 20.0)
# ── Tail ────────────────────────────────────────────────────────────────── # ── Tail ──────────────────────────────────────────────────────────────────
_reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0) _reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0)
_reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01) _reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01)
+32 -3
View File
@@ -6,8 +6,9 @@ change to how a hit *feels* is a `.tscn` Siim edits in the inspector — no code
```gdscript ```gdscript
# anywhere in gameplay code # anywhere in gameplay code
Fx.spawn(&"blood_burst", global_transform) # at a node Fx.spawn(&"blood_burst", global_transform) # one-shot: fire and forget (self-frees)
Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point
var trail := Fx.attach(&"bull_charge_trail", node) # continuous: caller keeps it + toggles .emitting
``` ```
## Siim's workflow ## Siim's workflow
@@ -25,6 +26,33 @@ Fx.spawn(&"slam_dust", Transform3D(Basis(), point)) # at a world point
New effect? Add a `fx/<name>.tscn` (root = `CPUParticles3D` with `one_shot_effect.gd` attached — copy New effect? Add a `fx/<name>.tscn` (root = `CPUParticles3D` with `one_shot_effect.gd` attached — copy
an existing one), then register its name → path in `fx/fx.gd`. an existing one), then register its name → path in `fx/fx.gd`.
## Bull ability effects
The bull's moves are wired to effect scenes here, so **you can retune every one in the inspector** —
open the `.tscn`, drag its colour ramp / curves / emission, save. No code.
| Ability | Effect scene(s) | Kind |
|---|---|---|
| Ground slam | `bull_slam.tscn` (dust) | one-shot burst |
| Back-kick | `bull_kick.tscn` — dust **+** its `stones` and `sparks` child emitters | one-shot burst |
| Dash / roll launch | `bull_dash.tscn` | one-shot burst |
| Hoof dust (walking / sprinting) | `bull_hoof_dust.tscn` | continuous |
| Charge trail (the blue streak) | `bull_charge_trail.tscn` | continuous |
| Roll sparks | `bull_roll_sparks.tscn` | continuous |
**Two kinds, edited the same way.** *One-shot* effects fire once and clean themselves up — they show
up in the gallery, so preview and tune them there. *Continuous* effects follow the bull and switch on
and off with what it's doing (dust while moving, the trail during a roll); the gallery can't show
those, so preview one by opening its `.tscn` and ticking **Emitting** on in the inspector's viewport.
Two things stay in code and aren't yours to tune here: the slam's expanding shockwave **rings** and the
kick's white **flash-pop** — those are stretched flat meshes, not particles (`actors/player/bull_impact_fx.gd`).
The dust's *walk-vs-sprint size* is also code (a fixed multiplier); its colour and shape are the `.tscn`.
**Composite effect** (like the kick): one scene, several `CPUParticles3D` — a root plus child emitters.
`one_shot_effect.gd` fires every emitter in the scene together, so you can build a layered hit
(dust + debris + sparks) as one file and tune each layer separately.
## House style (keep effects reading as one game) ## House style (keep effects reading as one game)
**Palette** — pull from these, don't invent per-effect colours: **Palette** — pull from these, don't invent per-effect colours:
@@ -52,8 +80,9 @@ the inspector and safe on-device. Keep this constraint when adding effects.
| File | What it is | | File | What it is |
|---|---| |---|---|
| `fx.gd` | The `Fx` autoload — `spawn(name, xform, parent=null)`; name→path registry | | `fx.gd` | The `Fx` autoload — `spawn()` (one-shot) / `attach()` (continuous); name→path registry |
| `one_shot_effect.gd` | Root helper: fires the emitter(s) once, frees the scene when done | | `one_shot_effect.gd` | Root helper: fires every emitter in the scene once, frees it when done |
| `blood_burst.tscn` etc. | The effects themselves — **edit these** | | `blood_burst.tscn` etc. | The effects themselves — **edit these** |
| `bull_*.tscn` | The bull's ability effects (see the table above) — **edit these** |
| `effect_gallery.tscn` | Preview stage (run it to review) | | `effect_gallery.tscn` | Preview stage (run it to review) |
| `_seed_placeholders.gd` | One-off generator that wrote the first-draft defaults; not used at runtime | | `_seed_placeholders.gd` | One-off generator that wrote the first-draft defaults; not used at runtime |
+42
View File
@@ -0,0 +1,42 @@
[gd_scene format=3]
; Bull charge trail — the blue energy streak that streams off the bull during a roll/dash.
; Continuous: the player instances it once under the bull, toggles `emitting`, and steers
; `direction` opposite the travel direction each frame. Tune the look here; see README.md.
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.065
height = 0.13
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 0.8), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.3, 0.65, 1, 1, 0.3, 0.65, 1, 0)
[node name="bull_charge_trail" type="CPUParticles3D"]
emitting = false
amount = 18
lifetime = 0.3
local_coords = false
randomness = 0.4
mesh = SubResource("mesh")
direction = Vector3(0, 0.2, 1)
spread = 25.0
gravity = Vector3(0, 2, 0)
initial_velocity_min = 4.0
initial_velocity_max = 9.0
scale_amount_min = 0.4
scale_amount_max = 1.2
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
+44
View File
@@ -0,0 +1,44 @@
[gd_scene format=3]
; Bull dash burst — the one-shot streak of speed shards left when a dash/roll launches. Fired
; via Fx.spawn(&"bull_dash", xform); the spawn transform aims local -Z along the dash direction,
; so the shards streak the right way. Tune the look here; see systems/fx/README.md.
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"]
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="BoxMesh" id="mesh"]
material = SubResource("mat")
size = Vector3(0.045, 0.045, 0.3)
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 2
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.5, 0.82, 1, 1, 0.5, 0.82, 1, 0)
[node name="bull_dash" type="CPUParticles3D"]
emitting = false
amount = 18
lifetime = 0.22
one_shot = true
local_coords = false
explosiveness = 0.92
randomness = 0.15
mesh = SubResource("mesh")
direction = Vector3(0, 0, -1)
spread = 15.0
gravity = Vector3(0, 0, 0)
initial_velocity_min = 18.0
initial_velocity_max = 35.0
scale_amount_min = 0.8
scale_amount_max = 2.8
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
script = ExtResource("oneshot")
+43
View File
@@ -0,0 +1,43 @@
[gd_scene format=3]
; Bull hoof dust — the little kick-up under each hoof while moving. Continuous (not one-shot):
; the player instances one per hoof, positions it, and toggles `emitting`. Charge sprint bumps
; scale/velocity in code. Tune the look here; see systems/fx/README.md.
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.07
height = 0.14
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 0.2), 0.0, 0.0, 0, 0, Vector2(0.4, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.94, 0.9, 0.83, 1, 0.94, 0.9, 0.83, 0)
[node name="bull_hoof_dust" type="CPUParticles3D"]
emitting = false
amount = 12
lifetime = 0.9
local_coords = false
explosiveness = 0.35
randomness = 0.6
mesh = SubResource("mesh")
direction = Vector3(0, 1, 0)
spread = 45.0
gravity = Vector3(0, -1.5, 0)
initial_velocity_min = 0.8
initial_velocity_max = 1.8
scale_amount_min = 0.2
scale_amount_max = 0.4
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
+115
View File
@@ -0,0 +1,115 @@
[gd_scene format=3]
; Bull back-kick impact — the one-shot combined dust + stones + sparks thrown behind the kick.
; A composite effect: the root emitter is the dust; the "stones" and "sparks" child emitters fire
; with it (one_shot_effect.gd arms every CPUParticles3D in the scene). Fired via
; Fx.spawn(&"bull_kick", xform); the spawn transform aims local -Z along the kick-back direction.
; The bright flash-pop is a separate coded effect (bull_impact_fx.gd). Tune all three looks here.
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"]
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="dust_mesh"]
material = SubResource("mat")
radius = 0.12
height = 0.24
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="dust_curve"]
_data = [Vector2(0, 0.3), 0.0, 0.0, 0, 0, Vector2(0.3, 1.5), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="dust_grad"]
colors = PackedColorArray(1, 0.78, 0.18, 0.95, 1, 0.45, 0.05, 0)
[sub_resource type="SphereMesh" id="stone_mesh"]
material = SubResource("mat")
radius = 0.055
height = 0.11
radial_segments = 3
rings = 1
[sub_resource type="Curve" id="stone_curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.6, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="stone_grad"]
colors = PackedColorArray(0.62, 0.58, 0.52, 1, 0.62, 0.58, 0.52, 0)
[sub_resource type="SphereMesh" id="spark_mesh"]
material = SubResource("mat")
radius = 0.04
height = 0.08
radial_segments = 3
rings = 1
[sub_resource type="Curve" id="spark_curve"]
_data = [Vector2(0, 1), 0.0, 0.0, 0, 0, Vector2(0.5, 0.6), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="spark_grad"]
colors = PackedColorArray(1, 0.98, 0.6, 1, 1, 1, 1, 0)
[node name="bull_kick" type="CPUParticles3D"]
emitting = false
amount = 40
lifetime = 1.2
one_shot = true
local_coords = false
explosiveness = 0.8
randomness = 0.5
mesh = SubResource("dust_mesh")
direction = Vector3(0, 0.7, -1)
spread = 65.0
gravity = Vector3(0, -6, 0)
initial_velocity_min = 3.0
initial_velocity_max = 9.0
scale_amount_min = 0.5
scale_amount_max = 2.2
scale_amount_curve = SubResource("dust_curve")
color_ramp = SubResource("dust_grad")
script = ExtResource("oneshot")
[node name="stones" type="CPUParticles3D" parent="."]
emitting = false
amount = 14
lifetime = 0.65
one_shot = true
local_coords = false
explosiveness = 0.92
randomness = 0.6
mesh = SubResource("stone_mesh")
direction = Vector3(0, 0.35, -1)
spread = 45.0
gravity = Vector3(0, -9.8, 0)
initial_velocity_min = 5.0
initial_velocity_max = 14.0
scale_amount_min = 0.5
scale_amount_max = 1.8
scale_amount_curve = SubResource("stone_curve")
color_ramp = SubResource("stone_grad")
[node name="sparks" type="CPUParticles3D" parent="."]
emitting = false
amount = 22
lifetime = 0.28
one_shot = true
local_coords = false
explosiveness = 0.95
randomness = 0.4
mesh = SubResource("spark_mesh")
direction = Vector3(0, 0.5, -1)
spread = 30.0
gravity = Vector3(0, -14, 0)
initial_velocity_min = 8.0
initial_velocity_max = 18.0
scale_amount_min = 0.6
scale_amount_max = 2.0
scale_amount_curve = SubResource("spark_curve")
color_ramp = SubResource("spark_grad")
+42
View File
@@ -0,0 +1,42 @@
[gd_scene format=3]
; Bull roll sparks — the dirt/ember spray flung outward from the spinning roll ("Powerball").
; Continuous: the player instances it once under the bull and toggles `emitting` during a roll.
; World-space so the spray peels off into a trail. Tune the look here; see README.md.
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.05
height = 0.1
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 0.3), 0.0, 0.0, 0, 0, Vector2(0.3, 1), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(1, 0.78, 0.32, 1, 0.7, 0.42, 0.14, 0)
[node name="bull_roll_sparks" type="CPUParticles3D"]
emitting = false
amount = 46
lifetime = 0.45
local_coords = false
randomness = 0.7
mesh = SubResource("mesh")
direction = Vector3(0, 1, 0)
spread = 180.0
gravity = Vector3(0, -6, 0)
initial_velocity_min = 3.0
initial_velocity_max = 8.0
scale_amount_min = 0.5
scale_amount_max = 1.3
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
+47
View File
@@ -0,0 +1,47 @@
[gd_scene format=3]
; Bull ground-slam dust — the one-shot dirt burst thrown up when the slam lands. Fired via
; Fx.spawn(&"bull_slam", xform) at the impact point. The expanding shockwave RINGS are a
; separate coded effect (actors/player/bull_impact_fx.gd), not this. Tune the look here.
[ext_resource type="Script" path="res://systems/fx/one_shot_effect.gd" id="oneshot"]
[sub_resource type="StandardMaterial3D" id="mat"]
transparency = 1
cull_mode = 2
shading_mode = 0
vertex_color_use_as_albedo = true
[sub_resource type="SphereMesh" id="mesh"]
material = SubResource("mat")
radius = 0.09
height = 0.18
radial_segments = 4
rings = 2
[sub_resource type="Curve" id="curve"]
_data = [Vector2(0, 0.25), 0.0, 0.0, 0, 0, Vector2(0.35, 1.6), 0.0, 0.0, 0, 0, Vector2(1, 0), 0.0, 0.0, 0, 0]
point_count = 3
[sub_resource type="Gradient" id="grad"]
colors = PackedColorArray(0.88, 0.74, 0.46, 1, 0.88, 0.74, 0.46, 0)
[node name="bull_slam" type="CPUParticles3D"]
emitting = false
amount = 48
lifetime = 1.1
one_shot = true
local_coords = false
explosiveness = 0.85
randomness = 0.5
mesh = SubResource("mesh")
direction = Vector3(0, 1, 0)
spread = 85.0
gravity = Vector3(0, -7, 0)
initial_velocity_min = 3.5
initial_velocity_max = 10.0
scale_amount_min = 0.5
scale_amount_max = 2.0
scale_amount_curve = SubResource("curve")
color_ramp = SubResource("grad")
script = ExtResource("oneshot")
+4 -1
View File
@@ -4,7 +4,10 @@ extends Node3D
## press Play, then edit any effect .tscn in the inspector and re-run to compare. See fx/README.md. ## press Play, then edit any effect .tscn in the inspector and re-run to compare. See fx/README.md.
const _CYCLE_SEC := 1.6 const _CYCLE_SEC := 1.6
const _EFFECTS: Array[StringName] = [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"] const _EFFECTS: Array[StringName] = [
&"blood_burst", &"slam_dust", &"spark", &"hit_flash",
&"bull_slam", &"bull_kick", &"bull_dash",
]
@onready var _label: Label = $UI/Label @onready var _label: Label = $UI/Label
# Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this # Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this
+20
View File
@@ -7,10 +7,18 @@ extends Node
## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd. ## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd.
const _PATHS: Dictionary = { const _PATHS: Dictionary = {
# Generic hit effects.
&"blood_burst": "res://systems/fx/blood_burst.tscn", &"blood_burst": "res://systems/fx/blood_burst.tscn",
&"slam_dust": "res://systems/fx/slam_dust.tscn", &"slam_dust": "res://systems/fx/slam_dust.tscn",
&"spark": "res://systems/fx/spark.tscn", &"spark": "res://systems/fx/spark.tscn",
&"hit_flash": "res://systems/fx/hit_flash.tscn", &"hit_flash": "res://systems/fx/hit_flash.tscn",
# Bull ability effects — one-shots fired by spawn(), continuous ones held via attach().
&"bull_slam": "res://systems/fx/bull_slam.tscn",
&"bull_kick": "res://systems/fx/bull_kick.tscn",
&"bull_dash": "res://systems/fx/bull_dash.tscn",
&"bull_hoof_dust": "res://systems/fx/bull_hoof_dust.tscn",
&"bull_charge_trail": "res://systems/fx/bull_charge_trail.tscn",
&"bull_roll_sparks": "res://systems/fx/bull_roll_sparks.tscn",
} }
var _cache: Dictionary = {} var _cache: Dictionary = {}
@@ -32,6 +40,18 @@ func spawn(effect: StringName, xform: Transform3D, parent: Node = null) -> Node:
return inst return inst
# Instance a CONTINUOUS effect as a child of `parent` and hand it back. Unlike spawn(), it neither
# positions nor auto-frees the effect — the caller keeps the reference to place it and toggle
# `emitting` (hoof dust that follows the bull, the charge trail). Returns null if unknown/missing.
func attach(effect: StringName, parent: Node) -> Node:
var scene := _scene(effect)
if scene == null or parent == null:
return null
var inst := scene.instantiate()
parent.add_child(inst)
return inst
func _scene(effect: StringName) -> PackedScene: func _scene(effect: StringName) -> PackedScene:
if _cache.has(effect): if _cache.has(effect):
return _cache[effect] return _cache[effect]
+1 -1
View File
@@ -1,6 +1,6 @@
extends SceneTree extends SceneTree
## Bear attack-FX test. The four attack effects (crater, smash debris, left/right claw slash) ## Bear attack-FX test. The four attack effects (crater, smash debris, left/right claw slash)
## live in Assets/fx/Bear_FX.glb as meshes that rest at scale 0 at Siim's baked positions. The ## live in art/fx/bear_fx.glb as meshes that rest at scale 0 at Siim's baked positions. The
## GLB carries its own AnimationPlayer whose clips scale one mesh up at its strike frame and ## GLB carries its own AnimationPlayer whose clips scale one mesh up at its strike frame and
## back to 0 (self-hiding) while pinning the others at 0. bear.gd fires the matching clip on ## back to 0 (self-hiding) while pinning the others at 0. bear.gd fires the matching clip on
## each strike (see _play_fx). This asserts each clip pops its own mesh, self-hides by the end, ## each strike (see _play_fx). This asserts each clip pops its own mesh, self-hides by the end,
+7 -1
View File
@@ -26,10 +26,16 @@ func _run() -> void:
root.add_child(host) root.add_child(host)
current_scene = host current_scene = host
for id: StringName in [&"blood_burst", &"slam_dust", &"spark", &"hit_flash"]: for id: StringName in [&"blood_burst", &"slam_dust", &"spark", &"hit_flash",
&"bull_slam", &"bull_kick", &"bull_dash"]:
var inst: Node = fx.call("spawn", id, Transform3D.IDENTITY, host) var inst: Node = fx.call("spawn", id, Transform3D.IDENTITY, host)
_ok(inst != null and is_instance_valid(inst), "spawns %s" % id) _ok(inst != null and is_instance_valid(inst), "spawns %s" % id)
# Continuous bull effects are held (attach), not fired-and-forgotten (spawn).
for id: StringName in [&"bull_hoof_dust", &"bull_charge_trail", &"bull_roll_sparks"]:
var inst: Node = fx.call("attach", id, host)
_ok(inst != null and is_instance_valid(inst), "attaches %s" % id)
var missing: Node = fx.call("spawn", &"does_not_exist", Transform3D.IDENTITY, host) var missing: Node = fx.call("spawn", &"does_not_exist", Transform3D.IDENTITY, host)
_ok(missing == null, "unknown effect returns null instead of crashing") _ok(missing == null, "unknown effect returns null instead of crashing")
-55
View File
@@ -30,7 +30,6 @@ func _run() -> void:
test_kick_spread() test_kick_spread()
test_gait_phase_crossing() test_gait_phase_crossing()
test_tail_verlet_constraint() test_tail_verlet_constraint()
test_run_map()
print("=" .repeat(60)) print("=" .repeat(60))
print("Results: %d passed, %d failed" % [_passed, _failed]) print("Results: %d passed, %d failed" % [_passed, _failed])
@@ -172,60 +171,6 @@ func test_tail_verlet_constraint() -> void:
_assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable") _assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable")
# ── Run map (Slay-the-Spire progression) ──────────────────────────────────────
func test_run_map() -> void:
print("\n-- test_run_map --")
var run: Node = root.get_node_or_null("/root/Run")
if run == null:
_assert_true(false, "Run autoload should exist")
return
run.start_new_run()
_assert_true(run.has_run(), "start_new_run builds a map")
_assert_true(run.map.size() >= 2, "map has multiple rows")
# Final row is a single boss node the map funnels toward.
var last: Array = run.map[run.map.size() - 1]
_assert_eq(last.size(), 1, "final row is a single boss node")
_assert_true(last[0].level.is_boss, "final node is a boss level")
# Connectivity: every node links forward, and every next-row node has a parent.
for r: int in run.map.size() - 1:
var nxt: Array = run.map[r + 1]
var reached := {}
for from_node in run.map[r]:
_assert_true(from_node.links.size() >= 1, "row %d node links forward" % r)
for c: int in from_node.links:
_assert_true(c >= 0 and c < nxt.size(), "link column in range")
reached[c] = true
for c: int in nxt.size():
_assert_true(reached.has(c), "row %d col %d has a parent" % [r + 1, c])
# From the start gate the whole first row is the frontier.
_assert_eq(run.reachable().size(), run.map[0].size(), "start frontier is row 0")
# Advancing follows the chosen node's links.
var first = run.map[0][0]
run.select_node(first)
run.complete_current_level()
_assert_eq(run.player_row, 0, "player advanced to row 0")
_assert_true(first.cleared, "cleared node is flagged")
_assert_eq(run.reachable().size(), first.links.size(),
"frontier follows the chosen node's links")
# Walking to a boss node latches run completion.
var guard := 0
while not run.run_complete() and guard < 64:
var opts: Array = run.reachable()
if opts.is_empty():
break
run.select_node(opts[0])
run.complete_current_level()
guard += 1
_assert_true(run.run_complete(), "reaching a boss node completes the run")
# ── Shared helpers ──────────────────────────────────────────────────────────── # ── Shared helpers ────────────────────────────────────────────────────────────
func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void: func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void:
+54 -2
View File
@@ -66,6 +66,10 @@ const _BULL_FACE_PAD: float = 1.15 # breathing room around the cluster within
# records which way it went (read by the gameplay test). # records which way it went (read by the gameplay test).
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull triumphs
const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs const _LOSS_VIDEO := "res://videos/matador_win.ogv" # the matador triumphs
# Clearing the bear — the run's final boss — replaces the usual bull-win clip with the
# closing "Thank You For Playing" screen. That clip already bakes its own title text in,
# so _over_label is left blank for this outcome (see _show_game_over).
const _THANKYOU_VIDEO := "res://videos/thankyou.ogv"
# Beat between the run ending and the outcome screen. A loss is a short freeze on the # Beat between the run ending and the outcome screen. A loss is a short freeze on the
# dead bull; a win gives the player a longer stretch to strut a victory lap around the # dead bull; a win gives the player a longer stretch to strut a victory lap around the
@@ -81,6 +85,12 @@ var _over_label: Label
var _over_continue: Button var _over_continue: Button
var _over_again: Button var _over_again: Button
# Run clock readout — top-left, desktop only (touch's top-left corner is already the
# mobile HP readout from mobile_hud.gd). Tracks Run.run_elapsed(); hidden until the timer
# has actually started (the player hasn't entered the arena yet) and while the game-over
# screen is up.
var _run_timer_label: Label
func _ready() -> void: func _ready() -> void:
layer = 11 layer = 11
@@ -92,6 +102,7 @@ func _ready() -> void:
_build_ability_bar() _build_ability_bar()
_build_health() _build_health()
_build_version_label() _build_version_label()
_build_run_timer()
_build_damage_vignette() _build_damage_vignette()
_build_game_over() _build_game_over()
_build_touch_controls() _build_touch_controls()
@@ -206,8 +217,41 @@ func _build_version_label() -> void:
add_child(lbl) add_child(lbl)
# Run clock, top-left. Desktop only — touch already owns that corner for the mobile HP
# readout, and a run timer is a nice-to-have there, not worth the crowding.
func _build_run_timer() -> void:
if Controls.use_touch_ui():
return
_run_timer_label = Label.new()
_run_timer_label.text = _format_time(0.0)
_run_timer_label.visible = false
_run_timer_label.add_theme_font_override("font", UiFonts.body())
_run_timer_label.add_theme_color_override("font_color", _CREAM)
_run_timer_label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
_run_timer_label.add_theme_constant_override("outline_size", 4)
_run_timer_label.add_theme_font_size_override("font_size", 20)
_run_timer_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_run_timer_label.offset_left = 20.0
_run_timer_label.offset_top = 16.0
_run_timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_run_timer_label)
# mm:ss.hh — matches how a speedrun-style clock reads at a glance.
static func _format_time(seconds: float) -> String:
var total_cs := int(round(seconds * 100.0))
var minutes := total_cs / 6000
var secs := (total_cs / 100) % 60
var centis := total_cs % 100
return "%02d:%02d.%02d" % [minutes, secs, centis]
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
_update_control_hints() _update_control_hints()
if _run_timer_label != null:
_run_timer_label.visible = Run.run_timer_started() and not _game_over
if _run_timer_label.visible:
_run_timer_label.text = _format_time(Run.run_elapsed())
if _player == null: if _player == null:
var players := get_tree().get_nodes_in_group(&"player") var players := get_tree().get_nodes_in_group(&"player")
if not players.is_empty(): if not players.is_empty():
@@ -434,6 +478,10 @@ func _on_player_died(cause: String = "") -> void:
func _on_all_defeated() -> void: func _on_all_defeated() -> void:
# Freeze the run clock the instant the bear falls — before _show_game_over's
# celebratory delay — so the recorded time is the kill moment, not the victory lap.
if Run.active_level().id == &"bear":
Run.stop_run_timer()
_show_game_over(true) _show_game_over(true)
@@ -450,12 +498,16 @@ func _show_game_over(win: bool, _cause: String = "") -> void:
await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout await get_tree().create_timer(_WIN_DELAY if win else _LOSS_DELAY, false, false, true).timeout
_over_label.text = "Bull Won!" if _result_win else "Matadors Won!" var is_final_boss := _result_win and Run.active_level().id == &"bear"
# The thank-you clip already bakes in its own title, so this label carries the
# winning time instead — the one thing that clip can't show.
_over_label.text = ("Your Time: %s" % _format_time(Run.run_elapsed())) if is_final_boss \
else ("Bull Won!" if _result_win else "Matadors Won!")
# A win advances the run (on to the map); a loss ends it (retry from the top). # A win advances the run (on to the map); a loss ends it (retry from the top).
_over_continue.visible = _result_win _over_continue.visible = _result_win
_over_again.visible = not _result_win _over_again.visible = not _result_win
var path := _WIN_VIDEO if _result_win else _LOSS_VIDEO var path := _THANKYOU_VIDEO if is_final_boss else (_WIN_VIDEO if _result_win else _LOSS_VIDEO)
if ResourceLoader.exists(path): if ResourceLoader.exists(path):
_over_video.stream = load(path) _over_video.stream = load(path)
_over_video.play() _over_video.play()
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
uid://b5rupuoe1lk5j