Compare commits
8 Commits
78efca445a
..
0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 91d88cd5c7 | |||
| 6dff45c659 | |||
| 2fd41f4f69 | |||
| a147527e91 | |||
| 09d4a60f43 | |||
| c0a022fed4 | |||
| 9458085abb | |||
| 565c132848 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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/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.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_follow.gd` | Smooth camera follow (`Camera3D`) |
|
||||
| `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_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 |
|
||||
| `levels/run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights |
|
||||
| `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/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 |
|
||||
| `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 / `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) |
|
||||
| `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/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
|
||||
|
||||
### 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`
|
||||
- **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).
|
||||
@@ -119,7 +119,7 @@ After FBX import, animations appear as `Armature|walk` and `Armature|idle` in An
|
||||
|
||||
### Verification workflow
|
||||
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
|
||||
4. Export FBX (done automatically by `create_matador_anims.py`)
|
||||
5. Re-import in Godot and test in-game
|
||||
|
||||
+3
-15
@@ -26,11 +26,11 @@ var _phase: Phase = Phase.WINDUP
|
||||
var _bull: CharacterBody3D = null
|
||||
var _skeleton: Skeleton3D = 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.
|
||||
var _fx_player: AnimationPlayer = 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.
|
||||
# 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
|
||||
@@ -635,7 +635,7 @@ func _try_hit(reach: float) -> bool:
|
||||
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.
|
||||
func _setup_attack_hitboxes() -> void:
|
||||
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
|
||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||
_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) ─────────────
|
||||
|
||||
@@ -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.
|
||||
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 _SPEAR_SCENE: PackedScene = preload("res://actors/matador/spear.tscn")
|
||||
|
||||
@@ -603,7 +603,7 @@ func _tick_attack(delta: float) -> void:
|
||||
if _swing_timer > 0.0:
|
||||
_decelerate(22.0, delta)
|
||||
_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.
|
||||
if _sword_blade_area != null and _swing_phase() >= DP.f("mat_stab_strike_at"):
|
||||
_sword_blade_area.activate()
|
||||
@@ -1189,7 +1189,7 @@ func _spawn_sword() -> void:
|
||||
_drawing = false
|
||||
_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.
|
||||
_sword_blade_area = _sword_node.get_node_or_null("BladeHitbox") as Hitbox
|
||||
if _sword_blade_area != null:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://sf5etma4kjt0
|
||||
+61
-424
@@ -8,7 +8,7 @@ const HOOF_OFFSETS: Array = [
|
||||
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.
|
||||
const _ANIM_IDLE: StringName = &"Armature|IDLE"
|
||||
const _ANIM_KICK: StringName = &"Armature|KICK"
|
||||
@@ -27,12 +27,11 @@ var _legs: Node
|
||||
var _was_on_floor: bool = false
|
||||
var _pre_slide_vel_y: float = 0.0
|
||||
|
||||
var _slam_ring_mesh: ArrayMesh = null
|
||||
# Attack-FX resources are identical every swing, so build them once and reuse. Recreating a fresh
|
||||
# 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.
|
||||
# Built once and reused: a fresh StandardMaterial3D/SphereMesh per spawn makes a mobile WebGL
|
||||
# driver recompile the shader + re-upload the mesh — a hitch "when attacking" absent on desktop.
|
||||
var _unshaded_mat: StandardMaterial3D = null
|
||||
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
|
||||
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 }
|
||||
var _dust_state: DustState = DustState.NONE
|
||||
var _walk_dust_ramp: Gradient = null
|
||||
var _charge_dust_ramp: Gradient = null
|
||||
# Hoof-dust scale/velocity per state (the look — mesh, colour, curve — lives in bull_hoof_dust.tscn).
|
||||
# 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")
|
||||
@@ -106,6 +109,7 @@ func _ready() -> void:
|
||||
_max_hp += _bonus_hp
|
||||
_hp += _bonus_hp
|
||||
_cube_scale = cube_guy.scale
|
||||
_setup_impact_fx()
|
||||
_setup_hoof_dust()
|
||||
_setup_legs()
|
||||
_setup_charge_trail()
|
||||
@@ -113,7 +117,6 @@ func _ready() -> void:
|
||||
_setup_audio()
|
||||
_bull_anim = _find_anim_player(cube_guy)
|
||||
_setup_bull_idle()
|
||||
DP.any_changed.connect(_on_dp_changed)
|
||||
|
||||
|
||||
func _setup_legs() -> void:
|
||||
@@ -123,113 +126,35 @@ func _setup_legs() -> void:
|
||||
_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:
|
||||
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:
|
||||
var p := CPUParticles3D.new()
|
||||
var p := Fx.attach(&"bull_hoof_dust", cube_guy) as CPUParticles3D
|
||||
if p == null:
|
||||
continue
|
||||
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)
|
||||
|
||||
|
||||
func _setup_charge_trail() -> void:
|
||||
# Blue energy trail — streams backward during a fast run / roll
|
||||
var trail_sphere := _particle_sphere(0.065, 4, 2, _unshaded_material())
|
||||
|
||||
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)
|
||||
_charge_trail_emitter = Fx.attach(&"bull_charge_trail", cube_guy) as CPUParticles3D
|
||||
if _charge_trail_emitter != null:
|
||||
_charge_trail_emitter.position = Vector3(0.0, 0.25, 0.3)
|
||||
|
||||
|
||||
func _setup_roll_sparks() -> void:
|
||||
# Rammus Powerball ground spray — dirt/sparks flung outward from the spinning
|
||||
# ball. World-space (local_coords off) so they peel off into a trail behind it.
|
||||
var spark := _particle_sphere(0.05, 4, 2, _unshaded_material())
|
||||
|
||||
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)
|
||||
_roll_spark_emitter = Fx.attach(&"bull_roll_sparks", cube_guy) as CPUParticles3D
|
||||
if _roll_spark_emitter != null:
|
||||
_roll_spark_emitter.position = Vector3(0.0, 0.10, 0.0)
|
||||
|
||||
|
||||
func _setup_audio() -> void:
|
||||
@@ -251,42 +176,20 @@ func _setup_audio() -> void:
|
||||
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:
|
||||
if new_state == _dust_state:
|
||||
return
|
||||
_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:
|
||||
p.lifetime = DP.f("dust_lifetime")
|
||||
p.explosiveness = DP.f("dust_explosiveness")
|
||||
p.spread = DP.f("dust_spread")
|
||||
p.gravity = Vector3(0.0, DP.f("dust_gravity_y"), 0.0)
|
||||
match new_state:
|
||||
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)
|
||||
p.scale_amount_min = scale.x
|
||||
p.scale_amount_max = scale.y
|
||||
p.initial_velocity_min = vel.x
|
||||
p.initial_velocity_max = vel.y
|
||||
p.emitting = new_state != DustState.NONE
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── Particle helpers ──────────────────────────────────────────────────────────
|
||||
# All FX in this script use unshaded, vertex-coloured, alpha-blended particles;
|
||||
# these three helpers remove the boilerplate each spawn function used to repeat.
|
||||
|
||||
# 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.
|
||||
# ── Blood-spurt particle helpers ──────────────────────────────────────────────
|
||||
# The ability effects now live in systems/fx/; only the spear-wound blood below is still built in
|
||||
# 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).
|
||||
func _unshaded_material() -> StandardMaterial3D:
|
||||
if _unshaded_mat == null:
|
||||
_unshaded_mat = StandardMaterial3D.new()
|
||||
@@ -698,8 +598,7 @@ func _unshaded_material() -> StandardMaterial3D:
|
||||
return _unshaded_mat
|
||||
|
||||
|
||||
# Low-poly sphere sized for a particle mesh (height is always the diameter). Cached per size so
|
||||
# repeat attacks reuse the same mesh (one GPU upload) instead of generating + uploading a new one.
|
||||
# Low-poly particle-sphere mesh (height = diameter), cached per size so repeats reuse one GPU upload.
|
||||
func _particle_sphere(radius: float, segments: int, rings: int, mat: Material) -> SphereMesh:
|
||||
var key := "%.4f:%d:%d" % [radius, segments, rings]
|
||||
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:
|
||||
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)
|
||||
_launch_slam_ring(origin, max_r, 0.00, 0.55, Color(1.00, 0.70, 0.10, 0.85))
|
||||
_launch_slam_ring(origin, max_r, 0.10, 0.52, Color(1.00, 0.50, 0.10, 0.65))
|
||||
_launch_slam_ring(origin, max_r, 0.20, 0.48, Color(0.90, 0.30, 0.10, 0.50))
|
||||
_spawn_slam_dust(origin)
|
||||
_impact_fx.call(&"slam_rings", origin, DP.f("slam_range"))
|
||||
Fx.spawn(&"bull_slam", Transform3D(Basis(), origin))
|
||||
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:
|
||||
var origin := global_position + back_dir * 0.55 + Vector3(0.0, -0.25, 0.0)
|
||||
_spawn_kick_dust(origin, back_dir)
|
||||
_spawn_kick_stones(origin, back_dir)
|
||||
_spawn_kick_sparks(origin, back_dir)
|
||||
_spawn_kick_flash(origin)
|
||||
Fx.spawn(&"bull_kick", _aimed_xform(origin, back_dir))
|
||||
_impact_fx.call(&"kick_flash", origin)
|
||||
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:
|
||||
var box := BoxMesh.new()
|
||||
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)
|
||||
Fx.spawn(&"bull_dash", _aimed_xform(global_position + Vector3(0.0, 0.2, 0.0), _dash_dir))
|
||||
|
||||
|
||||
func _spawn_kick_sparks(origin: Vector3, back_dir: Vector3) -> void:
|
||||
var sphere := _particle_sphere(0.04, 3, 1, _unshaded_material())
|
||||
|
||||
var ramp := Gradient.new()
|
||||
ramp.set_color(0, Color(1.0, 0.98, 0.60, 1.0))
|
||||
ramp.set_color(1, Color(1.0, 1.0, 1.0, 0.0))
|
||||
|
||||
var scale_curve := Curve.new()
|
||||
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
|
||||
# A transform at `origin` whose local -Z faces `forward` (flattened to horizontal). The kick/dash
|
||||
# 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.
|
||||
func _aimed_xform(origin: Vector3, forward: Vector3) -> Transform3D:
|
||||
var flat := Vector3(forward.x, 0.0, forward.z)
|
||||
if flat.length_squared() < 0.0001:
|
||||
return Transform3D(Basis(), origin)
|
||||
return Transform3D(Basis(), origin).looking_at(origin + flat.normalized(), Vector3.UP)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
@@ -1112,23 +761,14 @@ func _physics_process(delta: float) -> void:
|
||||
velocity.z = flat_n.z * exit_spd
|
||||
pre_wall_vel = Vector3(velocity.x, 0.0, velocity.z)
|
||||
|
||||
# --- ADD THIS BREAKABLE BARREL DETECTION CODE ---
|
||||
var collision = get_slide_collision(i)
|
||||
var collider = collision.get_collider()
|
||||
|
||||
# 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()
|
||||
# Ram straight through a barrel that formed the wall we bounced off.
|
||||
var hit := col.get_collider()
|
||||
if hit != null and hit.has_method(&"destroy_barrel"):
|
||||
hit.call(&"destroy_barrel")
|
||||
|
||||
# ── Landing. Don't bounce ─────────────────────────────────────────────────────
|
||||
if is_on_floor() and not _was_on_floor:
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
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:
|
||||
_charge_trail_emitter.direction = (-vel_flat.normalized() + Vector3(0.0, 0.25, 0.0)).normalized()
|
||||
|
||||
# ── Hoof sounds ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
func get_hp() -> int:
|
||||
return _hp
|
||||
@@ -1274,7 +912,6 @@ func _spawn_blood(hit_pos: Vector3) -> void:
|
||||
_free_after(p, 1.5)
|
||||
|
||||
|
||||
#hit flashing
|
||||
func _hit_flash() -> void:
|
||||
var meshes := find_children("*", "MeshInstance3D", true, false)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ script = ExtResource("2_camera_iso")
|
||||
[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)
|
||||
|
||||
[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/rotation = Quaternion(0.7071068, 0, 0, 0.7071067)
|
||||
bones/0/scale = Vector3(1, 1, 1)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmapsaj010q40
|
||||
@@ -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.
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Blender 5.1 script: create walk + idle animations for matador_v03.
|
||||
Run: blender --background matador_v03.blend --python create_matador_anims.py
|
||||
Blender 5.1 script: create walk + idle animations for matador.
|
||||
Run: blender --background matador.blend --python create_matador_anims.py
|
||||
"""
|
||||
import bpy, math, os
|
||||
|
||||
@@ -130,7 +130,7 @@ ti.name = 'idle'
|
||||
si = ti.strips.new('idle', 0, idle)
|
||||
|
||||
# ── 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(
|
||||
filepath=fbx_path,
|
||||
use_selection=False,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
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:
|
||||
walk_frame_00.png .. walk_frame_09.png
|
||||
|
||||
@@ -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
@@ -1,9 +1,8 @@
|
||||
class_name LevelDef
|
||||
extends Resource
|
||||
## Definition of one playable level on the run map — the arena today, later a cave with
|
||||
## a bear, a china shop to smash, and so on. Every map node references a LevelDef, and
|
||||
## the map/flow code never hard-codes a scene: adding a level is a new LevelDef (plus its
|
||||
## scene) handed out by the map generator in run_state.gd.
|
||||
## Definition of one playable level — the arena, the bear's den, the lobby hub, and later
|
||||
## a china shop to smash. The flow code never hard-codes a scene: a level is a LevelDef
|
||||
## (plus its scene) built in run_state.gd and served through the lobby.
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var display_name: String = "Level"
|
||||
@@ -15,14 +14,10 @@ extends Resource
|
||||
## colliders and any level-specific subscripts. Swapped wholesale between levels so no
|
||||
## previous level's geometry (or its colliders) can leak into the next.
|
||||
@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
|
||||
## 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.
|
||||
@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
|
||||
## 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;
|
||||
|
||||
@@ -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://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="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"]
|
||||
@@ -45,8 +46,8 @@ size = Vector3(4.1274414, 8.299622, 6.9958496)
|
||||
|
||||
[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)
|
||||
light_color = Color(0.78431374, 0.8117647, 1, 1)
|
||||
light_energy = 0.228
|
||||
light_color = Color(0.5266291, 0.5549962, 0.9999985, 1)
|
||||
light_energy = 0.466
|
||||
shadow_enabled = true
|
||||
|
||||
[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")]
|
||||
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]
|
||||
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)
|
||||
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)
|
||||
|
||||
[node name="AnimationPlayer" type="AnimationPlayer" parent="Lobby_underground" unique_id=2070502042]
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
uid://emq8i2bmkekn
|
||||
@@ -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 +0,0 @@
|
||||
uid://dt3ffakbwlkmc
|
||||
+69
-182
@@ -1,12 +1,11 @@
|
||||
extends Node
|
||||
## Persistent run progression across scene changes — the Slay-the-Spire-style map and
|
||||
## where the player sits on it. The arena scene is reloaded for every fight, so this
|
||||
## state must outlive scene changes: genuinely global run state earns an autoload (`Run`).
|
||||
## Persistent run progression across scene changes — which hub/level the player is in.
|
||||
## The level scene is reloaded for every fight, so this state must outlive scene changes:
|
||||
## genuinely global run state earns an autoload (`Run`).
|
||||
##
|
||||
## Flow: menu Play → start_new_run() → arena (intro fight) → win → MapScreen shows the
|
||||
## first frontier → pick a node → its level scene → win → back to MapScreen advanced one
|
||||
## row, and on until a boss node ends the map. Every node is the arena for now; adding a
|
||||
## level later is a new LevelDef in the catalog and pointing the generator at it.
|
||||
## Flow: menu Play → start_new_run() → lobby hub → enter a gate → that level → win →
|
||||
## return_to_lobby() (clearing a level opens its follow-on gates, e.g. clearing the arena
|
||||
## unlocks the bear). Adding a level is a new LevelDef in the catalog plus a lobby gate.
|
||||
|
||||
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 _LOBBY_LEVEL := preload("res://levels/lobby_level.tscn")
|
||||
|
||||
# Map shape. Small + placeholder for now; the final row is a single boss node.
|
||||
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.
|
||||
## Debug override — when set, active_level() returns this regardless of run state.
|
||||
## Set via the console `level` command; cleared on start_new_run().
|
||||
var debug_level_override: LevelDef = null
|
||||
## 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
|
||||
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
|
||||
# (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.
|
||||
# Future levels — china shop — slot in the same way: a new module + a LevelDef entry.
|
||||
# flavours) and the bear (a lone boss enemy) live here. Future levels — china shop — slot
|
||||
# in the same way: a new module + a LevelDef entry.
|
||||
const _BEAR_SCENE := preload("res://actors/bear/bear.tscn")
|
||||
|
||||
var _arena: LevelDef
|
||||
@@ -62,33 +58,27 @@ var _lobby: LevelDef
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL, Color(0.80, 0.62, 0.24), false)
|
||||
_arena_boss = _make_level(
|
||||
&"arena_boss", "Grand Arena", _ARENA_LEVEL, Color(0.88, 0.30, 0.20), true
|
||||
)
|
||||
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL, Color(0.55, 0.38, 0.22), false)
|
||||
_arena = _make_level(&"arena", "The Arena", _ARENA_LEVEL)
|
||||
_arena_boss = _make_level(&"arena_boss", "Grand Arena", _ARENA_LEVEL)
|
||||
_bear = _make_level(&"bear", "Bear's Den", _BEAR_LEVEL)
|
||||
_bear.enemy_scene = _BEAR_SCENE
|
||||
# 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.
|
||||
_lobby.camera_zoom = 1.6
|
||||
|
||||
|
||||
func _make_level(
|
||||
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
|
||||
) -> LevelDef:
|
||||
func _make_level(id: StringName, level_name: String, level_scene: PackedScene) -> LevelDef:
|
||||
var l := LevelDef.new()
|
||||
l.id = id
|
||||
l.display_name = level_name
|
||||
l.scene_path = _SHELL_SCENE
|
||||
l.level_scene = level_scene
|
||||
l.color = color
|
||||
l.is_boss = boss
|
||||
return l
|
||||
|
||||
|
||||
## The level being fought right now: debug override wins, then the pending/current map
|
||||
## node, falling back to the plain arena for the intro fight or a direct scene launch.
|
||||
## The level being served right now: debug override wins, then the lobby hub or the level
|
||||
## picked at a gate, falling back to the plain arena for a direct scene launch.
|
||||
func active_level() -> LevelDef:
|
||||
if debug_level_override != null:
|
||||
return debug_level_override
|
||||
@@ -96,10 +86,7 @@ func active_level() -> LevelDef:
|
||||
return _lobby
|
||||
if lobby_choice != null:
|
||||
return lobby_choice
|
||||
var n := node_at(pending_row, pending_col)
|
||||
if n == null:
|
||||
n = current_node()
|
||||
return n.level if n != null and n.level != null else _arena
|
||||
return _arena
|
||||
|
||||
|
||||
## 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"]
|
||||
|
||||
|
||||
## Begin a fresh run: build a new map and place the player at the start gate. Called from
|
||||
## the main-menu Play button, not from the arena scene (which reloads for every fight).
|
||||
## Begin a fresh run: reset progression and open in the lobby hub. Called from the main-menu
|
||||
## Play button, not from a level scene (which reloads for every fight).
|
||||
func start_new_run() -> void:
|
||||
_generate_map()
|
||||
player_row = -1
|
||||
player_col = -1
|
||||
pending_row = -1
|
||||
pending_col = -1
|
||||
debug_level_override = null
|
||||
# The run opens in the lobby hub; its gates lead into the fights.
|
||||
enter_lobby = true
|
||||
lobby_choice = null
|
||||
cleared = {}
|
||||
bonus_hp = 0
|
||||
_timer_start_msec = -1
|
||||
_timer_end_msec = -1
|
||||
_timer_running = false
|
||||
_run_active = true
|
||||
run_started.emit()
|
||||
|
||||
|
||||
## Build a run if none exists — a safety net for entering the map or arena directly (e.g.
|
||||
## running scene.tscn straight from the editor) without coming through the menu.
|
||||
## Build a run if none is active — a safety net for entering a level directly (e.g. running
|
||||
## 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:
|
||||
if map.is_empty():
|
||||
if not _run_active:
|
||||
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
|
||||
## until a gate is entered.
|
||||
func go_to_lobby() -> void:
|
||||
@@ -230,87 +167,37 @@ func add_bonus_hp(amount: int) -> int:
|
||||
return bonus_hp - before
|
||||
|
||||
|
||||
## Commit the level just won: move the player onto the pending node (or leave them at the
|
||||
## start gate after the intro fight, which has no pending node).
|
||||
func complete_current_level() -> void:
|
||||
if pending_row >= 0:
|
||||
player_row = pending_row
|
||||
player_col = pending_col
|
||||
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:
|
||||
## Begin the run clock. No-op while the clock is already running, so walking back through
|
||||
## the arena gate mid-run can't restart it — but once it's been stopped (the bear is dead
|
||||
## and the player has looped back to the arena to go again), the next call restarts it from
|
||||
## zero rather than staying stuck showing the previous run's final time.
|
||||
func start_run_timer() -> void:
|
||||
if _timer_running:
|
||||
return
|
||||
from_node.links.append(to_col)
|
||||
incoming[to_col] += 1
|
||||
_timer_start_msec = Time.get_ticks_msec()
|
||||
_timer_end_msec = -1
|
||||
_timer_running = true
|
||||
|
||||
|
||||
# Index of the node in `others` whose column position is closest to `node` on the x axis —
|
||||
# used both to link a node forward and to adopt an orphan back to its nearest parent.
|
||||
func _nearest_index(node: MapNode, others: Array) -> int:
|
||||
var best := 0
|
||||
var best_d := INF
|
||||
for i: int in others.size():
|
||||
var d: float = absf((others[i] as MapNode).pos.x - node.pos.x)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = i
|
||||
return best
|
||||
## Freeze the run clock — called the instant the bear falls, so the recorded time is the
|
||||
## kill moment itself, not whatever the win-screen's celebratory delay adds on top.
|
||||
func stop_run_timer() -> void:
|
||||
if not _timer_running:
|
||||
return
|
||||
_timer_end_msec = Time.get_ticks_msec()
|
||||
_timer_running = false
|
||||
|
||||
|
||||
## Seconds elapsed since the timer started (live while running, held steady once
|
||||
## 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
|
||||
|
||||
@@ -8,5 +8,6 @@ func _ready() -> void:
|
||||
func _on_body_entered(body: Node3D) -> void:
|
||||
if not body.is_in_group("player"):
|
||||
return
|
||||
Run.start_run_timer()
|
||||
Run.choose_level(&"arena")
|
||||
get_tree().change_scene_to_file(Run.active_level().scene_path)
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="Bullosseum"
|
||||
config/version="0.4.0"
|
||||
config/version="0.5.0"
|
||||
run/main_scene="uid://bmnmenu0intr1"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
boot_splash/bg_color=Color(0, 0, 0, 1)
|
||||
|
||||
@@ -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]
|
||||
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_energy = 2.0
|
||||
shadow_enabled = true
|
||||
omni_range = 13.657384
|
||||
|
||||
|
||||
@@ -46,21 +46,10 @@ func _register_all() -> void:
|
||||
# (The dash ability bypasses this.)
|
||||
_reg_f("Bull", "bull_gore_arc", 75.0, 10.0, 180.0, 5.0)
|
||||
# ── 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_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 ──────────────────────────────────────────────────────────────────
|
||||
_reg_f("Tail", "tail_gravity", 9.8, 0.0, 30.0)
|
||||
_reg_f("Tail", "tail_damping", 0.08, 0.0, 0.5, 0.01)
|
||||
|
||||
+32
-3
@@ -6,8 +6,9 @@ change to how a hit *feels* is a `.tscn` Siim edits in the inspector — no code
|
||||
|
||||
```gdscript
|
||||
# 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
|
||||
var trail := Fx.attach(&"bull_charge_trail", node) # continuous: caller keeps it + toggles .emitting
|
||||
```
|
||||
|
||||
## 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
|
||||
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)
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
| `fx.gd` | The `Fx` autoload — `spawn(name, xform, parent=null)`; name→path registry |
|
||||
| `one_shot_effect.gd` | Root helper: fires the emitter(s) once, frees the scene when done |
|
||||
| `fx.gd` | The `Fx` autoload — `spawn()` (one-shot) / `attach()` (continuous); name→path registry |
|
||||
| `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** |
|
||||
| `bull_*.tscn` | The bull's ability effects (see the table above) — **edit these** |
|
||||
| `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 |
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,7 +4,10 @@ extends Node3D
|
||||
## 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 _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
|
||||
# Reached by path rather than the Fx global so the placeholder-seeder can compile and attach this
|
||||
|
||||
@@ -7,10 +7,18 @@ extends Node
|
||||
## placeholder .tscn are seeded — see tools/seed_fx_placeholders.gd.
|
||||
|
||||
const _PATHS: Dictionary = {
|
||||
# Generic hit effects.
|
||||
&"blood_burst": "res://systems/fx/blood_burst.tscn",
|
||||
&"slam_dust": "res://systems/fx/slam_dust.tscn",
|
||||
&"spark": "res://systems/fx/spark.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 = {}
|
||||
@@ -32,6 +40,18 @@ func spawn(effect: StringName, xform: Transform3D, parent: Node = null) -> Node:
|
||||
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:
|
||||
if _cache.has(effect):
|
||||
return _cache[effect]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
extends SceneTree
|
||||
## 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
|
||||
## 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,
|
||||
|
||||
@@ -26,10 +26,16 @@ func _run() -> void:
|
||||
root.add_child(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)
|
||||
_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)
|
||||
_ok(missing == null, "unknown effect returns null instead of crashing")
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ func _run() -> void:
|
||||
test_kick_spread()
|
||||
test_gait_phase_crossing()
|
||||
test_tail_verlet_constraint()
|
||||
test_run_map()
|
||||
|
||||
print("=" .repeat(60))
|
||||
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")
|
||||
|
||||
|
||||
# ── 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void:
|
||||
|
||||
@@ -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).
|
||||
const _WIN_VIDEO := "res://videos/bull_win.ogv" # the bull 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
|
||||
# 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_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:
|
||||
layer = 11
|
||||
@@ -92,6 +102,7 @@ func _ready() -> void:
|
||||
_build_ability_bar()
|
||||
_build_health()
|
||||
_build_version_label()
|
||||
_build_run_timer()
|
||||
_build_damage_vignette()
|
||||
_build_game_over()
|
||||
_build_touch_controls()
|
||||
@@ -206,8 +217,41 @@ func _build_version_label() -> void:
|
||||
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:
|
||||
_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:
|
||||
var players := get_tree().get_nodes_in_group(&"player")
|
||||
if not players.is_empty():
|
||||
@@ -434,6 +478,10 @@ func _on_player_died(cause: String = "") -> 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
_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).
|
||||
_over_continue.visible = _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):
|
||||
_over_video.stream = load(path)
|
||||
_over_video.play()
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
uid://b5rupuoe1lk5j
|
||||
Reference in New Issue
Block a user