Bear, level selector (to be removed), new matador spawn, new bear level, saving config updates source files
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://bm6rmt3xprhjs"
|
||||
path="res://.godot/imported/bear_level_arena.glb-b6a9df3b338c8b0345fe2642d8b715eb.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://Assets/bear_level_arena.glb"
|
||||
dest_files=["res://.godot/imported/bear_level_arena.glb-b6a9df3b338c8b0345fe2642d8b715eb.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=2
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=1
|
||||
@@ -0,0 +1,29 @@
|
||||
[gd_scene load_steps=5 format=3 uid="uid://bear_scene_v1"]
|
||||
|
||||
[ext_resource type="Script" path="res://bear.gd" id="1_beargd"]
|
||||
[ext_resource type="PackedScene" uid="uid://cofv5dbavesis" path="res://Assets/Bear.glb" id="2_bearglb"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
|
||||
radius = 1.0
|
||||
height = 2.4
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_hit"]
|
||||
radius = 1.3
|
||||
height = 2.7
|
||||
|
||||
[node name="Bear" type="CharacterBody3D"]
|
||||
script = ExtResource("1_beargd")
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
|
||||
shape = SubResource("CapsuleShape3D_body")
|
||||
|
||||
[node name="HitArea" type="Area3D" parent="."]
|
||||
monitoring = true
|
||||
monitorable = false
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="HitArea"]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.35, 0)
|
||||
shape = SubResource("CapsuleShape3D_hit")
|
||||
|
||||
[node name="bear_model" parent="." instance=ExtResource("2_bearglb")]
|
||||
Binary file not shown.
@@ -12,17 +12,23 @@ I am a **Godot 4.6+ expert**. I follow current best practices for GDScript, scen
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `scene.tscn` | Main scene (arena + player) |
|
||||
| `scene.tscn` | The reusable **shell** loaded for every level: player, camera, HUD, PS1 filter, and an empty `LevelRoot`. Its `game_shell.gd` instances the active level's module into `LevelRoot` at runtime |
|
||||
| `levels/` | The level system: per-level module scenes plus the code that defines, loads and sequences them (see rows below) |
|
||||
| `levels/game_shell.gd` | Shell controller — on load, frees any current level module and instances `Run.active_level().level_scene` into `LevelRoot`. Swapping the whole subtree (never hiding it) is what keeps one level's meshes/colliders/spawners from leaking into the next |
|
||||
| `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` |
|
||||
| `Player.tscn` | Player scene root (`CharacterBody3D`) |
|
||||
| `player.gd` | Movement, charge, turning logic |
|
||||
| `camera_spring_arm.gd` | Mouse-look pivot (`Node3D` + `SpringArm3D`) |
|
||||
| `camera_follow.gd` | Smooth camera follow (`Camera3D`) |
|
||||
| `matador.gd` | Matador AI (wander / ragdoll state machine) |
|
||||
| `matador_spawn.gd` | Spawns N matadors at random arena positions |
|
||||
| `run_state.gd` | Run progression autoload (`Run`) — Slay-the-Spire map + player position across fights |
|
||||
| `level_def.gd` | `LevelDef` resource — a level's name / scene / map colour (arena only for now) |
|
||||
| `map_node.gd` | `MapNode` — one runtime node on the run map (position, level, links) |
|
||||
| `MapScreen.tscn` / `map_screen.gd` | Between-fights map screen; win → pick a node → next level |
|
||||
| `levels/matador_spawn.gd` | Arena spawner — one matador per designer-placed child `Node3D` spawn point (each enters with a jump-roll toward center); falls back to a programmatic spread if no spawn points are present. Also handles the split-on-death hydra logic |
|
||||
| `levels/bear_spawn.gd` | Bear-level spawner — instantiates `enemy_scene` at the arena center; emits the same `all_defeated` / `matador_killed` signals as `matador_spawn.gd` |
|
||||
| `Bear.tscn` / `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. Joins `&"matador"` so bull abilities already hit it. Tuning under the `Bear` DP section |
|
||||
| `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 |
|
||||
| `debug_params.gd` | Runtime-tunable parameter registry (autoload `DP`) |
|
||||
| `Assets/` | Raw 3D assets (`.glb`, `.fbx`) |
|
||||
| `Blender/` | Blender source files, animation scripts, FBX exports |
|
||||
@@ -92,10 +98,12 @@ After FBX import, animations appear as `Armature|walk` and `Armature|idle` in An
|
||||
bash run_tests.sh
|
||||
|
||||
# Individual tests:
|
||||
gdlint *.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
|
||||
gdlint *.gd levels/*.gd tests/*.gd # GDScript lint (gdtoolkit, installed via uv)
|
||||
godot --headless --script tests/logic_test.gd # pure logic, ~2 s
|
||||
godot --headless --script tests/performance_test.gd # frame-budget regression guard, ~4 s
|
||||
godot --headless --script tests/gameplay_test.gd # full scene, bone sanity, ~4 s
|
||||
godot --headless --script tests/level_switch_test.gd # no arena geometry leaks into the bear level, ~2 s
|
||||
godot --headless --script tests/bear_boss_test.gd # bear boss moveset: routines, leap landing, hyper-armour, ~10 s
|
||||
|
||||
# Gameplay test with real rendering (inspect frame strip visually):
|
||||
godot --script tests/gameplay_test.gd # opens a window, saves real screenshots
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
extends CharacterBody3D
|
||||
## Dark-Souls-style boss bear with a learnable moveset. From NEUTRAL it weighs its
|
||||
## options by the range band to the bull and commits to one telegraphed routine —
|
||||
## wind-up (tell) → active (the hit) → recovery (the player's punish window). A routine
|
||||
## always plays to the end (hyper-armour: it takes damage mid-swing but isn't knocked out
|
||||
## of it), so every attack is readable and punishable. Variety comes from which routine
|
||||
## NEUTRAL picks (weighted per range band); predictability from each routine being fixed
|
||||
## once entered. Below bear_enrage_frac HP it enters phase 2: snappier tells/recovery and
|
||||
## longer combo chains.
|
||||
##
|
||||
## The bear only wounds the bull with its swings and slams — walking or leaping into the
|
||||
## bull does nothing. It plugs into the bull's combat interface: it joins the &"matador"
|
||||
## group so every bull ability already targets it (apply_ability_hit), and mauls the bull
|
||||
## via take_sword_hit.
|
||||
|
||||
# Positioning / neutral states, then the three attack routines, then reactions.
|
||||
enum State { NEUTRAL, STALK, PROWL, SWIPE, SMASH, LEAP, STAGGER, DEAD }
|
||||
# Every attack routine runs through these three beats.
|
||||
enum Phase { WINDUP, ACTIVE, RECOVER }
|
||||
|
||||
signal killed
|
||||
signal health_changed(current: int, max_hp: int)
|
||||
|
||||
var _state: State = State.NEUTRAL
|
||||
var _phase: Phase = Phase.WINDUP
|
||||
var _bull: CharacterBody3D = null
|
||||
var _skeleton: Skeleton3D = null
|
||||
var _anim_player: AnimationPlayer = null
|
||||
var _blood_burst: CPUParticles3D = null
|
||||
|
||||
var _hp: int = 10
|
||||
var _enraged: bool = false
|
||||
var _phase_timer: float = 0.0
|
||||
var _active_len: float = 0.8
|
||||
var _think_timer: float = 0.0
|
||||
var _state_timer: float = 0.0
|
||||
var _stagger_timer: float = 0.0
|
||||
var _stagger_cd: float = 0.0 # poise window after a stagger during which hits don't stun
|
||||
var _hits_done: int = 0
|
||||
var _leap_airborne: bool = false
|
||||
# Solved per-leap so the arc lands on the clip's impact frame: the launch vertical speed
|
||||
# and the custom gravity used only while airborne (so peak height and airtime are tuned
|
||||
# independently of world gravity).
|
||||
var _leap_up: float = 0.0
|
||||
var _leap_grav: float = 0.0
|
||||
# Attack routines queued to fire back-to-back as a combo, popped when the current one
|
||||
# finishes its recovery. Element type is State.
|
||||
var _combo_queue: Array = []
|
||||
|
||||
# Resolved at runtime from the rig's clip list — the Bear.glb ships Rigify-named clips
|
||||
# (RUN_ON4LEGS, IDLE_standing, SMASH_HIT, …) that may import with any prefix, so we match
|
||||
# by substring rather than hard-coding the full track names.
|
||||
var _anim_idle: StringName = &"" # IDLE_ON4LEGS — neutral / recovery (the punish pose)
|
||||
var _anim_run: StringName = &"" # RUN_ON4LEGS — prowl approach / neutral coast
|
||||
var _anim_walk2: StringName = &"" # WALK_ON2LEGS — the 2-leg stalk-in
|
||||
var _anim_stand: StringName = &"" # IDLE_standing — reared-up wind-up / roar
|
||||
var _anim_swipe: StringName = &"" # ONE_TWO_HIT — double swipe
|
||||
var _anim_smash: StringName = &"" # SMASH_HIT — overhead smash
|
||||
var _anim_leap: StringName = &"" # JUMP_SMASH — leap slam
|
||||
|
||||
const _ACCEL_FACTOR: float = 9.0
|
||||
# Hard deceleration for planted states (wind-up / recovery / neutral settle) so the bear
|
||||
# never coasts across the ground with a stationary pose playing (no foot-sliding).
|
||||
const _PLANT_DECEL: float = 22.0
|
||||
# The Bear.glb clips are authored at 30 fps (see Bear.glb.import); leap timing is keyed by
|
||||
# frame, so convert frames→seconds with this.
|
||||
const _ANIM_FPS: float = 30.0
|
||||
# How close the bear closes on the bull before it stops advancing (roughly the bear+bull
|
||||
# body radii). Its swings still reach well past this; it just never tries to occupy the
|
||||
# bull's spot, so it doesn't shove up onto it.
|
||||
const _BULL_STANDOFF: float = 1.9
|
||||
|
||||
@onready var _mesh: Node3D = $bear_model
|
||||
@onready var _hit_area: Area3D = $HitArea
|
||||
var _dust: CPUParticles3D = null
|
||||
# Claw-slash bursts bound to the paw bones, fired one per hit of the 1-2 punch.
|
||||
var _claw_l: CPUParticles3D = null
|
||||
var _claw_r: CPUParticles3D = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(&"matador") # so every existing bull ability already targets the bear
|
||||
add_to_group(&"bear")
|
||||
_hp = maxi(1, int(DP.f("bear_max_hp")))
|
||||
health_changed.emit(_hp, _hp)
|
||||
_skeleton = _find_skeleton(_mesh)
|
||||
_anim_player = _find_anim_player(_mesh)
|
||||
if _anim_player:
|
||||
_anim_idle = _resolve_anim(["IDLE_ON4LEGS", "IDLE"])
|
||||
_anim_run = _resolve_anim(["RUN_ON4LEGS", "RUN"])
|
||||
_anim_walk2 = _resolve_anim(["WALK_ON2LEGS", "WALK", "IDLE_standing"])
|
||||
_anim_stand = _resolve_anim(["IDLE_standing", "WALK_ON2LEGS"])
|
||||
_anim_swipe = _resolve_anim(["ONE_TWO_HIT", "SMASH_HIT"])
|
||||
_anim_smash = _resolve_anim(["SMASH_HIT", "ONE_TWO_HIT"])
|
||||
_anim_leap = _resolve_anim(["JUMP_SMASH", "SMASH_HIT"])
|
||||
# Loop the locomotion / idle clips; the attack clips (swipe/smash/leap) are one-shot,
|
||||
# so their on-ground settle frames play once and then hold instead of looping.
|
||||
for a in [_anim_idle, _anim_run, _anim_walk2, _anim_stand]:
|
||||
_ensure_loop(a)
|
||||
for a in [_anim_swipe, _anim_smash, _anim_leap]:
|
||||
_set_no_loop(a)
|
||||
_anim_player.playback_default_blend_time = 0.12
|
||||
_hit_area.body_entered.connect(_on_body_entered)
|
||||
_blood_burst = preload("res://blood_burst.gd").new()
|
||||
add_child(_blood_burst)
|
||||
_dust = _make_slam_dust()
|
||||
add_child(_dust)
|
||||
_setup_claws()
|
||||
_enter_neutral()
|
||||
|
||||
|
||||
# Bind a claw-slash emitter to each paw bone (hand_l / hand_r) via a BoneAttachment3D, so
|
||||
# a burst fired on a swipe hit erupts from that paw wherever the animation has it.
|
||||
func _setup_claws() -> void:
|
||||
if _skeleton == null:
|
||||
return
|
||||
_claw_r = _attach_claw("hand_r")
|
||||
_claw_l = _attach_claw("hand_l")
|
||||
|
||||
|
||||
func _attach_claw(bone: String) -> CPUParticles3D:
|
||||
if _skeleton.find_bone(bone) == -1:
|
||||
return null
|
||||
var attach := BoneAttachment3D.new()
|
||||
attach.bone_name = bone
|
||||
_skeleton.add_child(attach)
|
||||
var claw := _make_claw_slash()
|
||||
attach.add_child(claw)
|
||||
return claw
|
||||
|
||||
|
||||
func get_hp() -> int:
|
||||
return _hp
|
||||
|
||||
|
||||
func _acquire_bull() -> void:
|
||||
if _bull != null:
|
||||
return
|
||||
var players := get_tree().get_nodes_in_group(&"player")
|
||||
if not players.is_empty():
|
||||
_bull = players[0] as CharacterBody3D
|
||||
# The bear only ever hits via distance / area checks, never body collision, so let
|
||||
# it pass through the bull. Otherwise the big capsule rides up and gets stuck on top
|
||||
# of the bull when it lunges or lands a leap on it.
|
||||
if _bull != null:
|
||||
add_collision_exception_with(_bull)
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if not is_on_floor():
|
||||
# The leap uses its own gravity mid-flight so its arc height and airtime can be
|
||||
# tuned to land on the clip's impact frame; everything else falls under world gravity.
|
||||
if _state == State.LEAP and _phase == Phase.ACTIVE and _leap_grav > 0.0:
|
||||
velocity.y -= _leap_grav * delta
|
||||
else:
|
||||
velocity += get_gravity() * delta
|
||||
|
||||
if _state == State.DEAD:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
move_and_slide()
|
||||
return
|
||||
|
||||
_acquire_bull()
|
||||
_update_enrage()
|
||||
_stagger_cd = maxf(0.0, _stagger_cd - delta)
|
||||
|
||||
match _state:
|
||||
State.NEUTRAL: _tick_neutral(delta)
|
||||
State.STALK: _tick_stalk(delta)
|
||||
State.PROWL: _tick_prowl(delta)
|
||||
State.SWIPE: _tick_swipe(delta)
|
||||
State.SMASH: _tick_smash(delta)
|
||||
State.LEAP: _tick_leap(delta)
|
||||
State.STAGGER: _tick_stagger(delta)
|
||||
|
||||
move_and_slide()
|
||||
|
||||
|
||||
# ── Phase 2 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func _update_enrage() -> void:
|
||||
if _enraged:
|
||||
return
|
||||
if _hp <= int(ceil(DP.f("bear_max_hp") * DP.f("bear_enrage_frac"))):
|
||||
_enraged = true
|
||||
# Roar: a beat of stagger-free wind-up with a screen shake, so the phase flip reads.
|
||||
_blood_burst.burst(global_position + Vector3(0.0, 1.6, 0.0), Vector3.UP)
|
||||
_shake(0.9)
|
||||
|
||||
|
||||
# Timers that shape readability (tells + recovery) shrink in phase 2; active windows and
|
||||
# the clip-locked leap timings are left alone.
|
||||
func _scaled(t: float) -> float:
|
||||
return t * (DP.f("bear_enrage_scale") if _enraged else 1.0)
|
||||
|
||||
|
||||
# ── NEUTRAL: think, then commit ────────────────────────────────────────────────────
|
||||
|
||||
func _enter_neutral() -> void:
|
||||
_state = State.NEUTRAL
|
||||
# A short, lightly-varied beat so the bear flows from one action into the next; phase 2
|
||||
# shrinks it further. Keep the jitter small so it reads as eager, not hesitant.
|
||||
var think := DP.f("bear_think_time")
|
||||
_think_timer = _scaled(think * randf_range(0.7, 1.3))
|
||||
|
||||
|
||||
func _tick_neutral(delta: float) -> void:
|
||||
_decelerate(_PLANT_DECEL, delta)
|
||||
if _bull != null:
|
||||
_face_point(_bull.global_position, delta)
|
||||
# Coast on the run clip while residual speed bleeds off, then settle to idle — never
|
||||
# hold the idle pose while still sliding.
|
||||
_play(_anim_run if _horizontal_speed() > 1.0 else _anim_idle)
|
||||
_think_timer -= delta
|
||||
if _think_timer <= 0.0:
|
||||
_decide()
|
||||
|
||||
|
||||
# Pop the next queued combo step, or pick a fresh routine weighted by the current range
|
||||
# band. Weights lean into pressure (and chaining) in phase 2.
|
||||
func _decide() -> void:
|
||||
if not _combo_queue.is_empty():
|
||||
var next: State = _combo_queue.pop_front()
|
||||
if _range_allows(next):
|
||||
_start_routine(next)
|
||||
return
|
||||
_combo_queue.clear()
|
||||
|
||||
if _bull == null:
|
||||
_start_routine(State.PROWL)
|
||||
return
|
||||
|
||||
var dist := _flat_dist_to_bull()
|
||||
var options: Array
|
||||
if dist <= DP.f("bear_close_range"):
|
||||
# In strike range: punch or overhead (both step in during the swing).
|
||||
options = [[State.SWIPE, 3.0], [State.SMASH, 3.0 if _enraged else 2.5]]
|
||||
elif dist <= DP.f("bear_mid_range"):
|
||||
# Mid: mostly walk the bull down on two legs; occasionally leap the gap.
|
||||
options = [[State.STALK, 3.0], [State.LEAP, 2.0 if _enraged else 1.5]]
|
||||
else:
|
||||
# Far: lope in on all fours, or spring across with a leap.
|
||||
options = [[State.PROWL, 3.0], [State.LEAP, 1.5]]
|
||||
_start_routine(_weighted_pick(options))
|
||||
|
||||
|
||||
# A queued combo step only fires if the bull is still roughly in its band, so a chain
|
||||
# doesn't whiff into empty air after the player rolls away.
|
||||
func _range_allows(routine: State) -> bool:
|
||||
if _bull == null:
|
||||
return routine == State.PROWL
|
||||
var dist := _flat_dist_to_bull()
|
||||
match routine:
|
||||
State.SWIPE, State.SMASH:
|
||||
return dist <= DP.f("bear_close_range") * 1.4
|
||||
State.LEAP:
|
||||
return dist <= DP.f("bear_mid_range") * 1.5
|
||||
return true
|
||||
|
||||
|
||||
func _start_routine(routine: State) -> void:
|
||||
match routine:
|
||||
State.SWIPE: _start_swipe()
|
||||
State.SMASH: _start_smash()
|
||||
State.LEAP: _start_leap()
|
||||
State.STALK: _start_stalk()
|
||||
State.PROWL: _start_prowl()
|
||||
_: _enter_neutral()
|
||||
|
||||
|
||||
# When a routine's recovery ends: fire the next combo step if one is queued, else think.
|
||||
func _finish_routine() -> void:
|
||||
if not _combo_queue.is_empty():
|
||||
_decide()
|
||||
else:
|
||||
_enter_neutral()
|
||||
|
||||
|
||||
# ── STALK: a menacing 2-leg walk-in that closes the gap into strike range ────────────
|
||||
|
||||
func _start_stalk() -> void:
|
||||
_state = State.STALK
|
||||
_state_timer = DP.f("bear_stalk_time")
|
||||
|
||||
|
||||
func _tick_stalk(delta: float) -> void:
|
||||
_state_timer -= delta
|
||||
if _bull == null or _state_timer <= 0.0 or _flat_dist_to_bull() <= DP.f("bear_close_range"):
|
||||
_enter_neutral()
|
||||
return
|
||||
var to_bull := _flat_to_bull()
|
||||
_accelerate(to_bull.normalized(), DP.f("bear_stalk_speed"), delta)
|
||||
_face_point(_bull.global_position, delta)
|
||||
_play(_anim_walk2)
|
||||
|
||||
|
||||
# ── PROWL: lope in on all fours to close a big gap ─────────────────────────────────
|
||||
|
||||
func _start_prowl() -> void:
|
||||
_state = State.PROWL
|
||||
_state_timer = 1.6
|
||||
|
||||
|
||||
func _tick_prowl(delta: float) -> void:
|
||||
_state_timer -= delta
|
||||
if _bull == null or _state_timer <= 0.0 or _flat_dist_to_bull() <= DP.f("bear_mid_range") * 0.9:
|
||||
_enter_neutral()
|
||||
return
|
||||
var to_bull := _flat_to_bull()
|
||||
_accelerate(to_bull.normalized(), DP.f("bear_run_speed"), delta)
|
||||
_face_point(_bull.global_position, delta)
|
||||
_play(_anim_run)
|
||||
|
||||
|
||||
# ── SWIPE: Double Swipe (ONE_TWO_HIT), two quick hits ──────────────────────────────
|
||||
|
||||
func _start_swipe() -> void:
|
||||
_state = State.SWIPE
|
||||
_phase = Phase.WINDUP
|
||||
_phase_timer = _scaled(DP.f("bear_swipe_windup"))
|
||||
_hits_done = 0
|
||||
_play(_anim_stand)
|
||||
# Sometimes chain the big smash after the swipe (always in phase 2) — the signature
|
||||
# close string the player learns to bait and punish.
|
||||
if _combo_queue.is_empty() and (_enraged or randf() < DP.f("bear_combo_chance")):
|
||||
_combo_queue.append(State.SMASH)
|
||||
|
||||
|
||||
func _tick_swipe(delta: float) -> void:
|
||||
match _phase:
|
||||
Phase.WINDUP:
|
||||
_decelerate(_PLANT_DECEL, delta) # rear up in place — no slide
|
||||
_face_point_if_bull(delta) # track hard before committing
|
||||
_play(_anim_stand)
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_begin_attack_active(_anim_swipe, 0.8)
|
||||
Phase.ACTIVE:
|
||||
_lunge_and_track(DP.f("bear_lunge_speed"), delta)
|
||||
_phase_timer -= delta
|
||||
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
|
||||
if _hits_done == 0 and frac >= 0.25:
|
||||
_hits_done = 1
|
||||
_try_hit(DP.f("bear_swipe_reach"))
|
||||
_claw_slash(true) # first paw (right) rakes across
|
||||
elif _hits_done == 1 and frac >= 0.65:
|
||||
_hits_done = 2
|
||||
_try_hit(DP.f("bear_swipe_reach"))
|
||||
_claw_slash(false) # back-hand with the left paw
|
||||
if _phase_timer <= 0.0:
|
||||
_begin_recover(DP.f("bear_swipe_recover"))
|
||||
Phase.RECOVER:
|
||||
_tick_recover(delta)
|
||||
|
||||
|
||||
# ── SMASH: Overhead Smash (SMASH_HIT), slower tell, big punish window ────────────────
|
||||
|
||||
func _start_smash() -> void:
|
||||
_state = State.SMASH
|
||||
_phase = Phase.WINDUP
|
||||
_phase_timer = _scaled(DP.f("bear_smash_windup"))
|
||||
_hits_done = 0
|
||||
_play(_anim_stand)
|
||||
|
||||
|
||||
func _tick_smash(delta: float) -> void:
|
||||
match _phase:
|
||||
Phase.WINDUP:
|
||||
_decelerate(_PLANT_DECEL, delta)
|
||||
_face_point_if_bull(delta)
|
||||
_play(_anim_stand)
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_begin_attack_active(_anim_smash, 0.9)
|
||||
Phase.ACTIVE:
|
||||
# The overhead steps in less than the swipe — it's a commitment, not a rush.
|
||||
_lunge_and_track(DP.f("bear_lunge_speed") * 0.5, delta)
|
||||
_phase_timer -= delta
|
||||
var frac := 1.0 - _phase_timer / maxf(_active_len, 0.001)
|
||||
if _hits_done == 0 and frac >= 0.4:
|
||||
_hits_done = 1
|
||||
_slam(DP.f("bear_smash_radius"), 0.8)
|
||||
if _phase_timer <= 0.0:
|
||||
_begin_recover(DP.f("bear_smash_recover"))
|
||||
Phase.RECOVER:
|
||||
_tick_recover(delta)
|
||||
|
||||
|
||||
# Shared wind-up → active handoff for the melee swings: start the clip (sped up for snappy
|
||||
# punches) and size the active window to the sped-up clip length.
|
||||
func _begin_attack_active(clip: StringName, fallback_len: float) -> void:
|
||||
_phase = Phase.ACTIVE
|
||||
var spd := maxf(DP.f("bear_attack_anim_speed"), 0.1)
|
||||
_active_len = _anim_length(clip, fallback_len) / spd
|
||||
_phase_timer = _active_len
|
||||
_play_attack(clip, spd)
|
||||
|
||||
|
||||
# ── LEAP: Leap Slam (JUMP_SMASH) — clip-synced ballistic gap-closer + AoE ────────────
|
||||
# The one JUMP_SMASH clip runs start-to-finish across the whole routine: it plays the
|
||||
# crouch on the ground (wind-up), springs at the takeoff frame, the arc is solved so the
|
||||
# paws hit ground exactly on the impact frame, and the last frames settle on the ground.
|
||||
|
||||
func _start_leap() -> void:
|
||||
_state = State.LEAP
|
||||
_phase = Phase.WINDUP
|
||||
_hits_done = 0
|
||||
_leap_airborne = false
|
||||
_leap_grav = 0.0
|
||||
_active_len = _anim_length(_anim_leap, 1.0)
|
||||
_play_attack(_anim_leap, 1.0) # play from frame 0 at real speed, grounded crouch
|
||||
_phase_timer = DP.f("bear_leap_takeoff_frame") / _ANIM_FPS # ground time before the spring
|
||||
# In phase 2 a leap sometimes follows straight into a swipe once it lands close.
|
||||
if _enraged and _combo_queue.is_empty() and randf() < 0.5:
|
||||
_combo_queue.append(State.SWIPE)
|
||||
|
||||
|
||||
func _tick_leap(delta: float) -> void:
|
||||
match _phase:
|
||||
Phase.WINDUP:
|
||||
_decelerate(_PLANT_DECEL, delta) # crouch in place
|
||||
_face_point_if_bull(delta) # aim the pounce, tracking the bull
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_launch_leap()
|
||||
Phase.ACTIVE:
|
||||
_phase_timer -= delta
|
||||
if not is_on_floor():
|
||||
_leap_airborne = true
|
||||
elif _leap_airborne or _phase_timer <= 0.0:
|
||||
velocity.x = 0.0
|
||||
velocity.z = 0.0
|
||||
_slam(DP.f("bear_slam_radius"), 0.7)
|
||||
_phase = Phase.RECOVER
|
||||
_phase_timer = _scaled(DP.f("bear_leap_recover"))
|
||||
Phase.RECOVER:
|
||||
_decelerate(_PLANT_DECEL, delta)
|
||||
# Let the clip's on-ground settle frames finish first, then idle out the recovery.
|
||||
if _anim_player != null and _anim_player.is_playing() \
|
||||
and _anim_player.current_animation == _anim_leap:
|
||||
return
|
||||
_play(_anim_idle)
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_finish_routine()
|
||||
|
||||
|
||||
func _launch_leap() -> void:
|
||||
_phase = Phase.ACTIVE
|
||||
_leap_airborne = false
|
||||
var target := _bull.global_position if _bull != null else global_position
|
||||
var flat := target - global_position
|
||||
flat.y = 0.0
|
||||
var dist := flat.length()
|
||||
var dir := flat.normalized() if dist > 0.1 else _facing()
|
||||
_face_dir(dir, 1.0)
|
||||
# Airtime = the clip span between the takeoff and impact frames, so the paws hit ground
|
||||
# on the impact frame (25). A custom leap gravity lets the arc reach bear_leap_height in
|
||||
# exactly that airtime regardless of world gravity: for a symmetric hop of airtime T to
|
||||
# peak height H, v_up = 4H/T and g = 8H/T².
|
||||
var air_t := maxf(
|
||||
(DP.f("bear_leap_impact_frame") - DP.f("bear_leap_takeoff_frame")) / _ANIM_FPS, 0.05)
|
||||
var h := maxf(DP.f("bear_leap_height"), 0.1)
|
||||
_leap_up = 4.0 * h / air_t
|
||||
_leap_grav = 8.0 * h / (air_t * air_t)
|
||||
# Land just in front of the bull, not on top of it — the slam radius still catches it.
|
||||
var land_dist := maxf(dist - _BULL_STANDOFF, 0.0)
|
||||
var h_speed := clampf(land_dist / air_t, 0.0, DP.f("bear_leap_speed"))
|
||||
velocity = dir * h_speed + Vector3.UP * _leap_up
|
||||
_phase_timer = air_t + 1.0 # safety fallback if a landing is missed
|
||||
|
||||
|
||||
func _begin_recover(secs: float) -> void:
|
||||
_phase = Phase.RECOVER
|
||||
_phase_timer = _scaled(secs)
|
||||
|
||||
|
||||
func _tick_recover(delta: float) -> void:
|
||||
_decelerate(_PLANT_DECEL, delta)
|
||||
_play(_anim_idle)
|
||||
_phase_timer -= delta
|
||||
if _phase_timer <= 0.0:
|
||||
_finish_routine()
|
||||
|
||||
|
||||
func _tick_stagger(delta: float) -> void:
|
||||
_decelerate(12.0, delta)
|
||||
_stagger_timer -= delta
|
||||
_play(_anim_idle)
|
||||
if _stagger_timer <= 0.0:
|
||||
_stagger_cd = DP.f("bear_stagger_cd") # poise window so it can act before the next stun
|
||||
_enter_neutral()
|
||||
|
||||
|
||||
# Step toward the bull during a swing (so a strike lands even when the bull is a touch out
|
||||
# of reach) while tracking its facing — but plant once close so the bear doesn't shove
|
||||
# through it. Motion here reads as the strike's lunge, under the attack clip.
|
||||
func _lunge_and_track(speed: float, delta: float) -> void:
|
||||
if _bull == null:
|
||||
_decelerate(_PLANT_DECEL, delta)
|
||||
return
|
||||
_face_point(_bull.global_position, delta, DP.f("bear_track_active"))
|
||||
var to_bull := _flat_to_bull()
|
||||
var dist := to_bull.length()
|
||||
if speed > 0.0 and dist > _BULL_STANDOFF:
|
||||
_accelerate(to_bull / dist, speed, delta)
|
||||
else:
|
||||
_decelerate(_PLANT_DECEL, delta)
|
||||
|
||||
|
||||
# ── Hitting the bull ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Directional strike: the bull is mauled inside `reach` of the bear that's facing it
|
||||
# (a fast bull tunnels a thin collider between frames, so distance + facing is the
|
||||
# reliable hit). Returns whether it connected.
|
||||
func _try_hit(reach: float) -> bool:
|
||||
if _bull == null:
|
||||
return false
|
||||
var to_bull := _flat_to_bull()
|
||||
var dist := to_bull.length()
|
||||
if dist > reach or dist < 0.01:
|
||||
return false
|
||||
if _facing().dot(to_bull / dist) < cos(deg_to_rad(DP.f("bear_hit_arc"))):
|
||||
return false
|
||||
_bull.call(&"take_sword_hit", "mauled")
|
||||
_shake(0.55)
|
||||
return true
|
||||
|
||||
|
||||
# Radial slam (smash / leap landing): hits any way the bull is, within `radius`, with a
|
||||
# ground-pound shake and a burst of dust kicked up at the paws. No facing arc — you're
|
||||
# caught if you're near where it comes down.
|
||||
func _slam(radius: float, shake: float) -> void:
|
||||
_slam_dust()
|
||||
if _bull == null:
|
||||
_shake(shake)
|
||||
return
|
||||
if _flat_dist_to_bull() <= radius:
|
||||
_bull.call(&"take_sword_hit", "mauled")
|
||||
_shake(shake)
|
||||
|
||||
|
||||
# Kick up a one-shot ring of dust at the bear's feet where the slam lands.
|
||||
func _slam_dust() -> void:
|
||||
if _dust == null:
|
||||
return
|
||||
_dust.global_position = global_position + Vector3(0.0, 0.15, 0.0)
|
||||
_dust.restart()
|
||||
_dust.emitting = true
|
||||
|
||||
|
||||
# Tan ground-impact dust: a fast, wide, low burst that arcs up and settles — built in code
|
||||
# so the Bear scene needs no extra particle node.
|
||||
func _make_slam_dust() -> CPUParticles3D:
|
||||
var p := CPUParticles3D.new()
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.16
|
||||
sphere.height = 0.32
|
||||
sphere.radial_segments = 4
|
||||
sphere.rings = 2
|
||||
sphere.material = mat
|
||||
|
||||
var ramp := Gradient.new()
|
||||
ramp.set_color(0, Color(0.72, 0.60, 0.40, 0.9))
|
||||
ramp.set_color(1, Color(0.55, 0.45, 0.30, 0.0))
|
||||
|
||||
var scale_curve := Curve.new()
|
||||
scale_curve.add_point(Vector2(0.0, 0.4))
|
||||
scale_curve.add_point(Vector2(0.35, 1.0))
|
||||
scale_curve.add_point(Vector2(1.0, 0.0))
|
||||
|
||||
p.mesh = sphere
|
||||
p.color_ramp = ramp
|
||||
p.scale_amount_curve = scale_curve
|
||||
p.emitting = false
|
||||
p.one_shot = true
|
||||
p.explosiveness = 1.0
|
||||
p.amount = 36
|
||||
p.lifetime = 0.9
|
||||
p.randomness = 0.5
|
||||
p.local_coords = false
|
||||
# A flat-ish disc kicked outward and up from the ground.
|
||||
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
|
||||
p.emission_sphere_radius = 0.5
|
||||
p.direction = Vector3(0.0, 1.0, 0.0)
|
||||
p.spread = 75.0
|
||||
p.gravity = Vector3(0.0, -6.0, 0.0)
|
||||
p.initial_velocity_min = 2.5
|
||||
p.initial_velocity_max = 6.5
|
||||
p.scale_amount_min = 0.6
|
||||
p.scale_amount_max = 1.4
|
||||
return p
|
||||
|
||||
|
||||
# Fire the claw-slash burst on one paw for a swipe hit (right paw first, then left).
|
||||
func _claw_slash(right: bool) -> void:
|
||||
var claw := _claw_r if right else _claw_l
|
||||
if claw == null:
|
||||
return
|
||||
claw.restart()
|
||||
claw.emitting = true
|
||||
|
||||
|
||||
# A short fan of pale streaks raked from a paw — thin boxes aligned to their velocity so
|
||||
# they read as claw marks slashing outward. Built in code and parented to a hand bone.
|
||||
func _make_claw_slash() -> CPUParticles3D:
|
||||
var p := CPUParticles3D.new()
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
|
||||
mat.vertex_color_use_as_albedo = true
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
|
||||
# A thin, long box becomes a streak once aligned to its velocity.
|
||||
var streak := BoxMesh.new()
|
||||
streak.size = Vector3(0.03, 0.55, 0.03)
|
||||
streak.material = mat
|
||||
|
||||
var ramp := Gradient.new()
|
||||
ramp.set_color(0, Color(0.85, 0.95, 1.0, 0.95))
|
||||
ramp.set_color(1, Color(0.5, 0.75, 1.0, 0.0))
|
||||
|
||||
var scale_curve := Curve.new()
|
||||
scale_curve.add_point(Vector2(0.0, 1.0))
|
||||
scale_curve.add_point(Vector2(0.7, 1.0))
|
||||
scale_curve.add_point(Vector2(1.0, 0.0))
|
||||
|
||||
p.mesh = streak
|
||||
p.color_ramp = ramp
|
||||
p.scale_amount_curve = scale_curve
|
||||
p.emitting = false
|
||||
p.one_shot = true
|
||||
p.explosiveness = 1.0 # all at once = a single rake, not a stream
|
||||
p.amount = 5 # a handful of parallel claw marks
|
||||
p.lifetime = 0.22
|
||||
p.randomness = 0.2
|
||||
p.local_coords = false # streaks stay in the air where they were raked, then fade
|
||||
p.set_particle_flag(CPUParticles3D.PARTICLE_FLAG_ALIGN_Y_TO_VELOCITY, true)
|
||||
# Rake across the paw's swing plane: a tight fan of fast streaks.
|
||||
p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE
|
||||
p.emission_sphere_radius = 0.12
|
||||
p.direction = Vector3(0.0, 0.0, 1.0)
|
||||
p.spread = 22.0
|
||||
p.gravity = Vector3.ZERO
|
||||
p.initial_velocity_min = 6.0
|
||||
p.initial_velocity_max = 9.0
|
||||
return p
|
||||
|
||||
|
||||
func _shake(amount: float) -> void:
|
||||
if _bull == null:
|
||||
return
|
||||
var cam := _bull.get_node_or_null("Camera3D")
|
||||
if cam:
|
||||
cam.call(&"trigger_hit", clampf(amount, 0.2, 1.0))
|
||||
|
||||
|
||||
# ── Taking damage ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Called by every bull ability (kick / slam / roll) that iterates the matador group.
|
||||
func apply_ability_hit(hit_dir: Vector3, strength: float, _up_boost: float = 0.0) -> void:
|
||||
_acquire_bull()
|
||||
_take_hit(hit_dir, strength)
|
||||
|
||||
|
||||
# Ramming: only a real charge from the bull wounds the bear; a gentle nudge is shrugged
|
||||
# off. The bear never damages the bull by colliding — only its swings/slams do.
|
||||
func _on_body_entered(body: Node3D) -> void:
|
||||
if _state == State.DEAD or not body.is_in_group(&"player"):
|
||||
return
|
||||
var player := body as CharacterBody3D
|
||||
if player.velocity.length() < DP.f("bear_hit_threshold"):
|
||||
return
|
||||
var flat := Vector3(player.velocity.x, 0.0, player.velocity.z)
|
||||
var dir := flat.normalized() if flat.length() > 0.5 else \
|
||||
(global_position - player.global_position).normalized()
|
||||
_take_hit(dir, player.velocity.length())
|
||||
|
||||
|
||||
func _take_hit(hit_dir: Vector3, strength: float) -> void:
|
||||
if _state == State.DEAD:
|
||||
return
|
||||
_hp -= 1
|
||||
health_changed.emit(_hp, int(DP.f("bear_max_hp")))
|
||||
hit_dir.y = 0.0
|
||||
hit_dir = hit_dir.normalized()
|
||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||
_shake(clampf(strength / 15.0, 0.4, 1.0))
|
||||
if _hp <= 0:
|
||||
_die(hit_dir)
|
||||
return
|
||||
# Hyper-armour: a committed swing takes the wound but isn't knocked out of it — only
|
||||
# the vulnerable states (neutral / positioning / a routine's recovery) stagger. This
|
||||
# is what makes the recovery the real punish window.
|
||||
if not _can_stagger():
|
||||
return
|
||||
velocity.x = hit_dir.x * 4.0
|
||||
velocity.z = hit_dir.z * 4.0
|
||||
_combo_queue.clear()
|
||||
_state = State.STAGGER
|
||||
_stagger_timer = DP.f("bear_stagger_time")
|
||||
|
||||
|
||||
func _can_stagger() -> bool:
|
||||
if _stagger_cd > 0.0:
|
||||
return false # poise: recently staggered, so it rides out hits instead of stun-locking
|
||||
match _state:
|
||||
State.NEUTRAL, State.STALK, State.PROWL, State.STAGGER:
|
||||
return true
|
||||
State.SWIPE, State.SMASH, State.LEAP:
|
||||
return _phase == Phase.RECOVER # airborne / mid-swing can't be staggered
|
||||
return false
|
||||
|
||||
|
||||
func _die(hit_dir: Vector3) -> void:
|
||||
_state = State.DEAD
|
||||
killed.emit()
|
||||
collision_layer = 0
|
||||
if _hit_area:
|
||||
_hit_area.monitoring = false
|
||||
velocity = Vector3.ZERO
|
||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||
_play(_anim_idle)
|
||||
# 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) ─────────────
|
||||
|
||||
func is_active() -> bool:
|
||||
return _state != State.DEAD
|
||||
|
||||
|
||||
func ai_state_name() -> String:
|
||||
var n := State.keys()[_state] as String
|
||||
if _state in [State.SWIPE, State.SMASH, State.LEAP]:
|
||||
n += ":" + (Phase.keys()[_phase] as String)
|
||||
if _enraged:
|
||||
n += "*"
|
||||
return n
|
||||
|
||||
|
||||
# ── Movement / geometry helpers ─────────────────────────────────────────────────────
|
||||
|
||||
func _flat_to_bull() -> Vector3:
|
||||
var to := _bull.global_position - global_position
|
||||
to.y = 0.0
|
||||
return to
|
||||
|
||||
|
||||
func _flat_dist_to_bull() -> float:
|
||||
return _flat_to_bull().length()
|
||||
|
||||
|
||||
func _horizontal_speed() -> float:
|
||||
return Vector2(velocity.x, velocity.z).length()
|
||||
|
||||
|
||||
func _facing() -> Vector3:
|
||||
return Vector3(sin(_mesh.rotation.y), 0.0, cos(_mesh.rotation.y))
|
||||
|
||||
|
||||
func _accelerate(dir: Vector3, spd: float, delta: float) -> void:
|
||||
velocity.x = move_toward(velocity.x, dir.x * spd, spd * _ACCEL_FACTOR * delta)
|
||||
velocity.z = move_toward(velocity.z, dir.z * spd, spd * _ACCEL_FACTOR * delta)
|
||||
|
||||
|
||||
func _decelerate(rate: float, delta: float) -> void:
|
||||
velocity.x = move_toward(velocity.x, 0.0, rate * delta)
|
||||
velocity.z = move_toward(velocity.z, 0.0, rate * delta)
|
||||
|
||||
|
||||
func _face_point_if_bull(delta: float, rate: float = -1.0) -> void:
|
||||
if _bull != null:
|
||||
_face_point(_bull.global_position, delta, rate)
|
||||
|
||||
|
||||
func _face_point(point: Vector3, delta: float, rate: float = -1.0) -> void:
|
||||
_face_dir(point - global_position, delta, rate)
|
||||
|
||||
|
||||
func _face_dir(dir: Vector3, delta: float, rate: float = -1.0) -> void:
|
||||
if rate < 0.0:
|
||||
rate = DP.f("bear_turn_rate")
|
||||
dir.y = 0.0
|
||||
if dir.length_squared() > 0.01:
|
||||
_mesh.rotation.y = lerp_angle(_mesh.rotation.y, atan2(dir.x, dir.z), delta * rate)
|
||||
|
||||
|
||||
func _weighted_pick(options: Array) -> State:
|
||||
var total := 0.0
|
||||
for o: Array in options:
|
||||
total += o[1]
|
||||
var r := randf() * total
|
||||
for o: Array in options:
|
||||
r -= o[1]
|
||||
if r <= 0.0:
|
||||
return o[0]
|
||||
return options[0][0]
|
||||
|
||||
|
||||
# ── Animation helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func _resolve_anim(candidates: Array) -> StringName:
|
||||
if _anim_player == null:
|
||||
return &""
|
||||
var list := _anim_player.get_animation_list()
|
||||
for want: String in candidates:
|
||||
for have: String in list:
|
||||
if have.to_upper().contains(want.to_upper()):
|
||||
return StringName(have)
|
||||
return &""
|
||||
|
||||
|
||||
func _ensure_loop(anim_name: StringName) -> void:
|
||||
if anim_name != &"" and _anim_player.has_animation(anim_name):
|
||||
_anim_player.get_animation(anim_name).loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
|
||||
func _set_no_loop(anim_name: StringName) -> void:
|
||||
if anim_name != &"" and _anim_player.has_animation(anim_name):
|
||||
_anim_player.get_animation(anim_name).loop_mode = Animation.LOOP_NONE
|
||||
|
||||
|
||||
func _anim_length(anim_name: StringName, fallback: float) -> float:
|
||||
if _anim_player and anim_name != &"" and _anim_player.has_animation(anim_name):
|
||||
return _anim_player.get_animation(anim_name).length
|
||||
return fallback
|
||||
|
||||
|
||||
func _play(anim_name: StringName) -> void:
|
||||
if _anim_player == null or anim_name == &"":
|
||||
return
|
||||
if _anim_player.current_animation == anim_name:
|
||||
return
|
||||
_anim_player.play(anim_name)
|
||||
|
||||
|
||||
# Restart a one-shot attack clip from its first frame at `speed` (even if already current).
|
||||
func _play_attack(anim_name: StringName, speed: float) -> void:
|
||||
if _anim_player == null or anim_name == &"":
|
||||
return
|
||||
_anim_player.play(anim_name, -1.0, speed)
|
||||
_anim_player.seek(0.0, true)
|
||||
|
||||
|
||||
func _find_skeleton(node: Node) -> Skeleton3D:
|
||||
if node is Skeleton3D:
|
||||
return node as Skeleton3D
|
||||
for child: Node in node.get_children():
|
||||
var r := _find_skeleton(child)
|
||||
if r:
|
||||
return r
|
||||
return null
|
||||
|
||||
|
||||
func _find_anim_player(node: Node) -> AnimationPlayer:
|
||||
if node is AnimationPlayer:
|
||||
return node as AnimationPlayer
|
||||
for child: Node in node.get_children():
|
||||
var r := _find_anim_player(child)
|
||||
if r:
|
||||
return r
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn7fhjkro7637
|
||||
+29
-2
@@ -18,6 +18,8 @@ const _COMMANDS := {
|
||||
"diff": "list params changed from default",
|
||||
"toggle": "flip a bool param",
|
||||
"list": "print params (optional section)",
|
||||
"level": "select level and reload (level <id> — arena / arena_boss / bear)",
|
||||
"restart": "reload the current level scene",
|
||||
"clear": "clear the log",
|
||||
"help": "show this help",
|
||||
}
|
||||
@@ -472,6 +474,12 @@ func _run_command(parts: PackedStringArray) -> bool:
|
||||
_cmd_diff()
|
||||
"list":
|
||||
_cmd_list(parts)
|
||||
"level":
|
||||
_cmd_level(parts)
|
||||
"restart":
|
||||
_log_ok("Restarting…")
|
||||
get_tree().paused = false
|
||||
get_tree().reload_current_scene()
|
||||
"clear":
|
||||
_log.clear()
|
||||
"help":
|
||||
@@ -581,9 +589,9 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void:
|
||||
var lo := meta["min"] as float
|
||||
var hi := meta["max"] as float
|
||||
if not raw.is_valid_float() or not is_finite(raw.to_float()):
|
||||
_log_warn("%s wants a number %s … %s" % [key, _fmt_num(lo), _fmt_num(hi)])
|
||||
_log_warn("%s wants a number" % key)
|
||||
return
|
||||
var v := snappedf(clampf(raw.to_float(), lo, hi), meta["step"] as float)
|
||||
var v := raw.to_float()
|
||||
DP.set_value(key, v)
|
||||
_log_ok("[b]%s[/b] ← [color=#ffe080]%s[/color]" % [key, _fmt_num(v)])
|
||||
elif meta["type"] == TYPE_BOOL:
|
||||
@@ -600,6 +608,25 @@ func _set_param(key: String, meta: Dictionary, raw: String) -> void:
|
||||
_log_warn("Cannot set param of type %d" % meta["type"])
|
||||
|
||||
|
||||
func _cmd_level(parts: PackedStringArray) -> void:
|
||||
if parts.size() == 1:
|
||||
var cur := Run.active_level()
|
||||
var cur_name := (cur.id as String) if cur != null else "none"
|
||||
_log_ok("Current level: [b]%s[/b]" % cur_name)
|
||||
var ids: Array[StringName] = Run.level_ids()
|
||||
_log_raw("Available: " + " ".join(ids.map(func(id: StringName) -> String: return str(id))))
|
||||
return
|
||||
var id := StringName(parts[1].to_lower())
|
||||
var level := Run.get_level(id)
|
||||
if level == null:
|
||||
_log_warn("Unknown level '%s'. Try: %s" % [parts[1], " ".join(Run.level_ids().map(func(i: StringName) -> String: return str(i)))])
|
||||
return
|
||||
Run.debug_level_override = level
|
||||
_log_ok("Level set to [b]%s[/b] — reloading…" % str(id))
|
||||
get_tree().paused = false
|
||||
get_tree().change_scene_to_file(level.scene_path)
|
||||
|
||||
|
||||
func _cmd_list(parts: PackedStringArray) -> void:
|
||||
var filter := parts[1].to_lower() if parts.size() > 1 else ""
|
||||
var all := DP.get_all()
|
||||
|
||||
+111
-18
@@ -17,7 +17,6 @@ var _values: Dictionary = {}
|
||||
|
||||
func _ready() -> void:
|
||||
_register_all()
|
||||
load_saved()
|
||||
|
||||
|
||||
func _register_all() -> void:
|
||||
@@ -46,12 +45,12 @@ func _register_all() -> void:
|
||||
_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.35, 0.05, 3.0)
|
||||
_reg_f("Dust", "walk_scale_max", 0.65, 0.05, 3.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", 1.0, 0.05, 6.0)
|
||||
_reg_f("Dust", "charge_scale_max", 2.2, 0.05, 6.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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -99,16 +98,11 @@ func _register_all() -> void:
|
||||
_reg_f("Camera", "cam_drag_speed", 3.5, 0.5, 20.0)
|
||||
_reg_f("Camera", "cam_follow_lerp", 10.0, 1.0, 40.0)
|
||||
# ── Matador ───────────────────────────────────────────────────────────────
|
||||
_reg_f("Matador", "mat_spawn_count", 1.0, 1.0, 30.0, 1.0)
|
||||
# Hydra split: a slain matador is replaced on the spot by mat_split_factor fresh
|
||||
# matadors, so the horde doubles each generation (1 → 2 → 4 → 8 …) at the default
|
||||
# 2. Capped at mat_split_generations deep so the arena stays winnable (factor <2 or
|
||||
# generations 0 = no splitting, the classic one-and-done arena).
|
||||
_reg_f("Matador", "mat_split_factor", 2.0, 1.0, 6.0, 1.0)
|
||||
_reg_f("Matador", "mat_split_generations", 3.0, 0.0, 6.0, 1.0)
|
||||
# The bull wins outright once it has slain this many matadors, even if the hydra
|
||||
# still has bodies on the sand. 0 = disabled (win only when the horde dies out)
|
||||
_reg_f("Matador", "mat_win_kills", 21.0, 0.0, 200.0, 1.0)
|
||||
# Wave system: matadors enter the arena in escalating waves (1 → 2 → … → N where N
|
||||
# is the number of placed spawn points). mat_spawn_stagger is the delay between each
|
||||
# matador leaping in within a wave; mat_wave_pause is the breather after a wave clears.
|
||||
_reg_f("Matador", "mat_spawn_stagger", 0.4, 0.0, 3.0, 0.05)
|
||||
_reg_f("Matador", "mat_wave_pause", 2.0, 0.0, 8.0, 0.25)
|
||||
_reg_f("Matador", "mat_walk_speed", 5.5, 0.5, 10.0)
|
||||
_reg_f("Matador", "mat_wander_radius", 22.0, 5.0, 50.0)
|
||||
# Radius of the inner fighting floor: bull + matadors spawn on this ring (so
|
||||
@@ -190,6 +184,76 @@ func _register_all() -> void:
|
||||
_reg_f("Matador", "mat_taunt_hold", 8.0, 1.0, 20.0, 0.5)
|
||||
# Small so it smooths dithering without locking the matador into idling.
|
||||
_reg_f("Matador", "mat_w_commit", 0.15, 0.0, 2.0, 0.05)
|
||||
# ── Bear (boss) ─────────────────────────────────────────────────────────────
|
||||
# A Dark-Souls-style boss with a learnable moveset. It soaks bear_max_hp clean hits
|
||||
# (ram or ability), staggering on each, and only topples when worn down. From NEUTRAL
|
||||
# it weighs its options by the range band to the bull and commits to one telegraphed
|
||||
# routine (wind-up → active → recovery); each routine plays to the end (the recovery
|
||||
# is the player's punish window). Below bear_enrage_frac HP it enters phase 2: faster
|
||||
# tells and recovery (× bear_enrage_scale) and longer combo chains.
|
||||
_reg_f("Bear", "bear_max_hp", 10.0, 1.0, 40.0, 1.0)
|
||||
# HP fraction at which phase 2 ("Fury") triggers, and how much it multiplies every
|
||||
# wind-up / recovery timer (lower = snappier, more dangerous).
|
||||
_reg_f("Bear", "bear_enrage_frac", 0.4, 0.0, 1.0, 0.05)
|
||||
_reg_f("Bear", "bear_enrage_scale", 0.7, 0.3, 1.0, 0.05)
|
||||
# Range bands (m) that pick the moveset: ≤close it strikes (swipe / smash), ≤mid it
|
||||
# walks in to close the gap (stalk) or leaps, beyond it lopes in (prowl) or leaps.
|
||||
# close is deliberately generous — the bear commits to melee "from a bit further" and
|
||||
# steps in during the swing (see bear_lunge_speed) rather than needing to be on top.
|
||||
_reg_f("Bear", "bear_close_range", 5.0, 1.0, 12.0, 0.1)
|
||||
_reg_f("Bear", "bear_mid_range", 9.0, 3.0, 20.0, 0.5)
|
||||
# Beat spent in NEUTRAL sizing up the bull between routines — kept short so the bear
|
||||
# flows straight from one action into the next instead of pausing (raise for a more
|
||||
# hesitant, readable boss; near-0 for relentless pressure). Shrinks again in phase 2.
|
||||
_reg_f("Bear", "bear_think_time", 0.06, 0.0, 1.0, 0.01)
|
||||
# Locomotion: the all-fours prowl (close a big gap), the menacing 2-leg walk-in
|
||||
# (stalk), the facing rate while positioning/winding up, and the softer tracking rate
|
||||
# a swing keeps on the bull mid-attack so it's harder to just walk out of.
|
||||
_reg_f("Bear", "bear_run_speed", 11.0, 1.0, 20.0, 0.1)
|
||||
_reg_f("Bear", "bear_stalk_speed", 5.5, 0.5, 12.0, 0.1)
|
||||
_reg_f("Bear", "bear_turn_rate", 7.0, 1.0, 20.0, 0.5)
|
||||
_reg_f("Bear", "bear_track_active", 3.5, 0.0, 12.0, 0.1)
|
||||
# Forward step-in during a swing so a strike lands even when the bull is a touch out of
|
||||
# reach (also carries the tracking); 0 = a purely stationary swing.
|
||||
_reg_f("Bear", "bear_lunge_speed", 7.0, 0.0, 16.0, 0.5)
|
||||
# Playback multiplier for the swipe/smash clips — >1 makes the punches snappier.
|
||||
_reg_f("Bear", "bear_attack_anim_speed", 1.35, 0.5, 3.0, 0.05)
|
||||
# Half-arc (deg) the bear must be facing the bull within for a directional swing to
|
||||
# connect; slams use their own radius instead of an arc.
|
||||
_reg_f("Bear", "bear_hit_arc", 70.0, 5.0, 180.0, 5.0)
|
||||
# Double Swipe (ONE_TWO_HIT) — bread-and-butter close combo, two quick hits.
|
||||
_reg_f("Bear", "bear_swipe_windup", 0.3, 0.05, 2.0, 0.05)
|
||||
_reg_f("Bear", "bear_swipe_recover", 0.35, 0.05, 3.0, 0.05)
|
||||
_reg_f("Bear", "bear_swipe_reach", 4.2, 1.0, 8.0, 0.1)
|
||||
# Overhead Smash (SMASH_HIT) — slower tell, biggest punish window, AoE on impact.
|
||||
_reg_f("Bear", "bear_smash_windup", 0.6, 0.1, 3.0, 0.05)
|
||||
_reg_f("Bear", "bear_smash_recover", 0.75, 0.1, 4.0, 0.05)
|
||||
_reg_f("Bear", "bear_smash_radius", 4.2, 1.0, 8.0, 0.1)
|
||||
# Leap Slam (JUMP_SMASH, 47 frames @30fps) — timed to the clip by FRAME: it plays the
|
||||
# crouch grounded, springs at the takeoff frame, and the arc is solved so the paws hit
|
||||
# ground exactly on the impact frame (25), leaving the tail frames to settle on the
|
||||
# ground — no finishing the anim in mid-air. The arc uses its own gravity so peak height
|
||||
# and airtime are independent: bear_leap_height is the peak reached in that fixed airtime.
|
||||
_reg_f("Bear", "bear_leap_takeoff_frame", 6.0, 0.0, 46.0, 1.0)
|
||||
_reg_f("Bear", "bear_leap_impact_frame", 25.0, 1.0, 47.0, 1.0)
|
||||
_reg_f("Bear", "bear_leap_height", 2.6, 0.5, 8.0, 0.1)
|
||||
_reg_f("Bear", "bear_leap_recover", 0.35, 0.0, 4.0, 0.05)
|
||||
_reg_f("Bear", "bear_leap_speed", 14.0, 4.0, 30.0, 0.5)
|
||||
_reg_f("Bear", "bear_slam_radius", 4.0, 1.0, 8.0, 0.1)
|
||||
# Stalk (WALK_ON2LEGS) — how long it will keep walking the bull down before re-deciding.
|
||||
_reg_f("Bear", "bear_stalk_time", 2.5, 0.0, 6.0, 0.1)
|
||||
# Odds a close combo chains a follow-up smash after the swipe (phase 2 always chains).
|
||||
_reg_f("Bear", "bear_combo_chance", 0.7, 0.0, 1.0, 0.05)
|
||||
# Minimum bull speed (ram) that counts as a hit on the bear — a gentle nudge is
|
||||
# shrugged off, only a real charge or an ability wounds it. (The bear itself never
|
||||
# damages the bull by colliding — only its swings and slams hurt.)
|
||||
_reg_f("Bear", "bear_hit_threshold", 6.0, 1.0, 30.0, 0.5)
|
||||
# How long (s) the bear is rocked back and can't act after taking a hit.
|
||||
_reg_f("Bear", "bear_stagger_time", 0.5, 0.0, 2.0, 0.05)
|
||||
# Poise: after a stagger the bear shrugs off staggers for this long (hits still wound
|
||||
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
|
||||
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
|
||||
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
|
||||
# ── Sword ─────────────────────────────────────────────────────────────────
|
||||
# Local seating of the sword in the right hand (drawn / fighting).
|
||||
# Defaults are identity — the bone is oriented in Blender to seat the sword
|
||||
@@ -352,11 +416,40 @@ func get_all() -> Dictionary:
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
var path := "res://debug_params.gd"
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
push_error("DP.save: cannot open " + path)
|
||||
return
|
||||
var src := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
for key: String in _params:
|
||||
var p: Dictionary = _params[key]
|
||||
cfg.set_value(p["section"], key, p["value"])
|
||||
cfg.save(SAVE_PATH)
|
||||
if p["value"] == p["default"]:
|
||||
continue
|
||||
var re := RegEx.new()
|
||||
if p["type"] == TYPE_FLOAT:
|
||||
re.compile('(_reg_f\\("[^"]+",\\s*"%s",\\s*)([-\\d.e]+)' % key)
|
||||
src = re.sub(src, "$1" + _fmt_float(p["value"] as float))
|
||||
else:
|
||||
re.compile('(_reg_b\\("[^"]+",\\s*"%s",\\s*)(true|false)' % key)
|
||||
src = re.sub(src, "$1" + ("true" if bool(p["value"]) else "false"))
|
||||
p["default"] = p["value"]
|
||||
|
||||
var wfile := FileAccess.open(path, FileAccess.WRITE)
|
||||
if wfile == null:
|
||||
push_error("DP.save: cannot write " + path)
|
||||
return
|
||||
wfile.store_string(src)
|
||||
wfile.close()
|
||||
|
||||
|
||||
func _fmt_float(v: float) -> String:
|
||||
var s := "%.6g" % v
|
||||
if not ("." in s or "e" in s):
|
||||
s += ".0"
|
||||
return s
|
||||
|
||||
|
||||
func load_saved() -> void:
|
||||
|
||||
@@ -243,7 +243,7 @@ func _continue_to_map() -> void:
|
||||
get_tree().paused = false
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
Run.complete_current_level()
|
||||
get_tree().change_scene_to_file("res://MapScreen.tscn")
|
||||
get_tree().change_scene_to_file("res://levels/MapScreen.tscn")
|
||||
|
||||
|
||||
func _toggle_controls() -> void:
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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.
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var display_name: String = "Level"
|
||||
## Scene loaded when the player enters a node with this level. Every node points at the
|
||||
## arena for now.
|
||||
@export_file("*.tscn") var scene_path: String = ""
|
||||
## Placeholder pip tint on the map until per-level icon art exists.
|
||||
@export var color: Color = Color(0.80, 0.62, 0.24)
|
||||
## A boss level caps a run — its node is drawn larger and clearing it ends the map.
|
||||
@export var is_boss: bool = false
|
||||
@@ -1,6 +1,6 @@
|
||||
[gd_scene format=3 uid="uid://map_screen_v1"]
|
||||
|
||||
[ext_resource type="Script" path="res://map_screen.gd" id="1_map"]
|
||||
[ext_resource type="Script" path="res://levels/map_screen.gd" id="1_map"]
|
||||
|
||||
[node name="MapScreen" type="Control"]
|
||||
layout_mode = 3
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,49 @@
|
||||
[gd_scene format=3 uid="uid://dfxigg3cr6nl5"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://bm6rmt3xprhjs" path="res://Assets/bear_level_arena.glb" id="1_beararena"]
|
||||
[ext_resource type="Script" path="res://levels/bear_spawn.gd" id="2_spawn"]
|
||||
[ext_resource type="PackedScene" path="res://Bear.tscn" id="3_bear"]
|
||||
[ext_resource type="Script" path="res://levels/bear_hud.gd" id="4_bearhud"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
|
||||
|
||||
[node name="bear_level" type="Node3D" unique_id=261730746]
|
||||
|
||||
[node name="Node3D" type="Node3D" parent="." unique_id=333447532]
|
||||
|
||||
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=1466540248]
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=575454169]
|
||||
transform = Transform3D(30, 0, 0, 0, 1, 0, 0, 0, 30, 0, -0.5, 0)
|
||||
shape = SubResource("BoxShape3D_floor")
|
||||
|
||||
[node name="bear_level_arena" parent="." unique_id=1529242945 instance=ExtResource("1_beararena")]
|
||||
transform = Transform3D(2.875, 0, 0, 0, 2.875, 0, 0, 0, 2.875, 0, 0, 0)
|
||||
|
||||
[node name="floor" parent="bear_level_arena/floor" parent_id_path=PackedInt32Array(1529242945, 41795043) index="0" unique_id=1653720920]
|
||||
visible = false
|
||||
|
||||
[node name="wall" parent="bear_level_arena/floor" parent_id_path=PackedInt32Array(1529242945, 41795043) index="1" unique_id=2041884474]
|
||||
visible = false
|
||||
|
||||
[node name="BearSpawner" type="Node3D" parent="." unique_id=47931969]
|
||||
script = ExtResource("2_spawn")
|
||||
enemy_scene = ExtResource("3_bear")
|
||||
|
||||
[node name="BearHud" type="CanvasLayer" parent="." unique_id=102938471]
|
||||
script = ExtResource("4_bearhud")
|
||||
|
||||
[node name="Lights" type="Node3D" parent="." unique_id=937122836]
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=1786336124]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.7071067, 0.70710677, 0, -0.70710677, 0.7071067, 0, 36.945946, 70)
|
||||
light_color = Color(1, 0.75782466, 0.7332154, 1)
|
||||
light_energy = 1.796
|
||||
shadow_enabled = true
|
||||
|
||||
[node name="DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1308313006]
|
||||
transform = Transform3D(1, 0, 0, 0, -0.8660254, 0.49999994, 0, -0.49999994, -0.8660254, 0, 112.56815, 93.37274)
|
||||
light_color = Color(0, 0.33367383, 0.57971, 1)
|
||||
light_energy = 0.616
|
||||
|
||||
[editable path="bear_level_arena"]
|
||||
@@ -0,0 +1,26 @@
|
||||
extends Node3D
|
||||
## The reusable game shell: player, camera, HUD and the PS1 filter — everything that
|
||||
## outlives a single level. On load it instances the active level's module (geometry,
|
||||
## spawners, lighting, colliders) into LevelRoot. Switching levels frees the whole
|
||||
## previous module first, so no old mesh or collider can survive into the next fight.
|
||||
|
||||
@onready var _level_root: Node3D = $LevelRoot
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
Run.ensure_run()
|
||||
load_active_level()
|
||||
|
||||
|
||||
## Tear down the current level module and build the run's active one. Freeing the whole
|
||||
## subtree (not hiding it) is the point: a level's colliders and spawners cease to exist
|
||||
## before the next level's are created.
|
||||
func load_active_level() -> void:
|
||||
for child: Node in _level_root.get_children():
|
||||
_level_root.remove_child(child)
|
||||
child.queue_free()
|
||||
var level := Run.active_level()
|
||||
if level == null or level.level_scene == null:
|
||||
push_warning("game_shell: active level has no level_scene")
|
||||
return
|
||||
_level_root.add_child(level.level_scene.instantiate())
|
||||
@@ -0,0 +1 @@
|
||||
uid://ptsfs3wq5bje
|
||||
@@ -0,0 +1,26 @@
|
||||
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.
|
||||
|
||||
@export var id: StringName = &""
|
||||
@export var display_name: String = "Level"
|
||||
## The shell scene loaded when the player enters a node with this level — the reusable
|
||||
## frame (player, camera, HUD, PS1 filter). Shared by every level; the level's own
|
||||
## geometry rides in `level_scene`, which the shell instances at runtime.
|
||||
@export_file("*.tscn") var scene_path: String = ""
|
||||
## The level module instanced into the shell's LevelRoot: spawners, lighting, meshes,
|
||||
## 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)
|
||||
## 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;
|
||||
## only the opponent changes.
|
||||
@export var enemy_scene: PackedScene = null
|
||||
@@ -0,0 +1,76 @@
|
||||
extends Node3D
|
||||
|
||||
signal matador_killed
|
||||
signal all_defeated
|
||||
|
||||
const MATADOR := preload("res://Matador.tscn")
|
||||
|
||||
var _alive: int = 0
|
||||
var _wave: int = 0
|
||||
var _resolved: bool = false
|
||||
var _spawn_points: Array[Node3D] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(&"matador_spawn")
|
||||
_spawn.call_deferred()
|
||||
|
||||
|
||||
# Each spawner node is a spawn-point marker. The first in tree order (the one the HUD
|
||||
# connects its all_defeated to) runs the wave logic and uses every marker — itself and
|
||||
# its siblings — as an entry point; the rest stay passive so waves aren't run four times.
|
||||
func _spawn() -> void:
|
||||
var markers := get_tree().get_nodes_in_group(&"matador_spawn")
|
||||
if markers.is_empty() or markers[0] != self:
|
||||
return
|
||||
for node: Node in markers:
|
||||
if node is Node3D:
|
||||
_spawn_points.append(node as Node3D)
|
||||
_place_bull(randf() * TAU, DP.f("arena_spawn_radius"))
|
||||
_start_next_wave()
|
||||
|
||||
|
||||
# Wave 1 sends 1 matador, wave 2 sends 2, … up to the number of placed spawn points.
|
||||
# Matadors are assigned to a random subset of spawn points each wave and enter staggered.
|
||||
func _start_next_wave() -> void:
|
||||
_wave += 1
|
||||
var count := mini(_wave, _spawn_points.size())
|
||||
var points: Array = _spawn_points.duplicate()
|
||||
points.shuffle()
|
||||
for i: int in count:
|
||||
var sp := points[i] as Node3D
|
||||
var m := _spawn_at(sp.global_position)
|
||||
m.call_deferred(&"enter_spawn_roll", Vector3.ZERO)
|
||||
if i < count - 1:
|
||||
await get_tree().create_timer(DP.f("mat_spawn_stagger")).timeout
|
||||
|
||||
|
||||
func _place_bull(angle: float, arena_r: float) -> void:
|
||||
var bull := get_tree().get_first_node_in_group(&"player") as Node3D
|
||||
if not bull:
|
||||
return
|
||||
bull.global_position = Vector3(
|
||||
cos(angle) * arena_r, bull.global_position.y, sin(angle) * arena_r
|
||||
)
|
||||
|
||||
|
||||
func _spawn_at(pos: Vector3) -> Node3D:
|
||||
var m: Node3D = MATADOR.instantiate()
|
||||
m.position = pos
|
||||
get_parent().add_child(m)
|
||||
m.killed.connect(_on_matador_killed)
|
||||
_alive += 1
|
||||
return m
|
||||
|
||||
|
||||
func _on_matador_killed() -> void:
|
||||
matador_killed.emit()
|
||||
_alive -= 1
|
||||
if _alive > 0 or _resolved:
|
||||
return
|
||||
if _wave >= _spawn_points.size():
|
||||
_resolved = true
|
||||
all_defeated.emit()
|
||||
else:
|
||||
await get_tree().create_timer(DP.f("mat_wave_pause")).timeout
|
||||
_start_next_wave()
|
||||
@@ -10,7 +10,11 @@ extends Node
|
||||
|
||||
signal run_started
|
||||
|
||||
const _ARENA_SCENE := "res://scene.tscn"
|
||||
# Every level loads the same reusable shell (player, camera, HUD, PS1 filter); the shell
|
||||
# then instances the level's own `level_scene` module into its LevelRoot.
|
||||
const _SHELL_SCENE := "res://scene.tscn"
|
||||
const _ARENA_LEVEL := preload("res://levels/arena_level.tscn")
|
||||
const _BEAR_LEVEL := preload("res://levels/bear_level.tscn")
|
||||
|
||||
# Map shape. Small + placeholder for now; the final row is a single boss node.
|
||||
const _ROWS := 5
|
||||
@@ -26,32 +30,68 @@ var player_col: int = -1
|
||||
## Node picked on the map whose level is being played now; committed to on the next win.
|
||||
var pending_row: int = -1
|
||||
var pending_col: int = -1
|
||||
## Debug override — when set, active_level() returns this regardless of map state.
|
||||
## Set via the console `level` command; cleared on start_new_run().
|
||||
var debug_level_override: LevelDef = null
|
||||
|
||||
# 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.
|
||||
const _BEAR_SCENE := preload("res://Bear.tscn")
|
||||
|
||||
# Level catalog. Only the arena exists today (a normal and a boss flavour, both loading
|
||||
# the same scene). Future levels — cave/bear, china shop — slot in here.
|
||||
var _arena: LevelDef
|
||||
var _arena_boss: LevelDef
|
||||
var _bear: LevelDef
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_arena = _make_level(&"arena", "The Arena", _ARENA_SCENE, Color(0.80, 0.62, 0.24), false)
|
||||
_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_SCENE, Color(0.88, 0.30, 0.20), true
|
||||
&"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)
|
||||
_bear.enemy_scene = _BEAR_SCENE
|
||||
|
||||
|
||||
func _make_level(
|
||||
id: StringName, level_name: String, scene: String, color: Color, boss: bool
|
||||
id: StringName, level_name: String, level_scene: PackedScene, color: Color, boss: bool
|
||||
) -> LevelDef:
|
||||
var l := LevelDef.new()
|
||||
l.id = id
|
||||
l.display_name = level_name
|
||||
l.scene_path = scene
|
||||
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.
|
||||
func active_level() -> LevelDef:
|
||||
if debug_level_override != null:
|
||||
return debug_level_override
|
||||
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 a level by id, or null if unknown. Used by the debug console `level` command.
|
||||
func get_level(id: StringName) -> LevelDef:
|
||||
match id:
|
||||
&"arena": return _arena
|
||||
&"arena_boss": return _arena_boss
|
||||
&"bear": return _bear
|
||||
return null
|
||||
|
||||
|
||||
## All known level ids — shown by `level` in the console.
|
||||
func level_ids() -> Array[StringName]:
|
||||
return [&"arena", &"arena_boss", &"bear"]
|
||||
|
||||
|
||||
## 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).
|
||||
func start_new_run() -> void:
|
||||
@@ -60,6 +100,7 @@ func start_new_run() -> void:
|
||||
player_col = -1
|
||||
pending_row = -1
|
||||
pending_col = -1
|
||||
debug_level_override = null
|
||||
run_started.emit()
|
||||
|
||||
|
||||
@@ -149,7 +190,12 @@ func _generate_map() -> void:
|
||||
node.row = r
|
||||
node.col = c
|
||||
node.pos = Vector2(_column_x(c, count), y)
|
||||
node.level = _arena_boss if r == _ROWS - 1 else _arena
|
||||
# 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()
|
||||
+67
-6
@@ -190,13 +190,74 @@ func _process(_delta: float) -> void:
|
||||
play_button.anchor_bottom = cape.y
|
||||
|
||||
|
||||
# Player-facing option toggles, seated above the rebind list in the Options panel:
|
||||
# the master screen-shake switch and the gore toggle (both persisted via Settings).
|
||||
# Player-facing option toggles and audio sliders, seated above the rebind list.
|
||||
func _build_options_toggles() -> void:
|
||||
var scroll_idx := options_scroll.get_index()
|
||||
# Insert in reverse so both land directly above the rebind scroll, in listed order.
|
||||
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, scroll_idx)
|
||||
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, scroll_idx)
|
||||
var idx := options_scroll.get_index()
|
||||
_add_section_label("── Audio ──", idx); idx += 1
|
||||
_add_slider_row("Master Volume", Settings.volume_master, Settings.set_volume_master, idx); idx += 1
|
||||
_add_slider_row("Ambient Volume", Settings.volume_ambient, Settings.set_volume_ambient, idx); idx += 1
|
||||
_add_slider_row("Effects Volume", Settings.volume_effects, Settings.set_volume_effects, idx); idx += 1
|
||||
_add_toggle_row("Mute All", Settings.mute_all, Settings.set_mute_all, idx); idx += 1
|
||||
_add_section_label("── Gameplay ──", idx); idx += 1
|
||||
_add_toggle_row("Screen Shake", Settings.screen_shake, Settings.set_screen_shake, idx); idx += 1
|
||||
_add_toggle_row("Blood & Gore", Settings.gore, Settings.set_gore, idx); idx += 1
|
||||
_add_section_label("── Controls ──", idx)
|
||||
|
||||
|
||||
func _add_section_label(text: String, at_index: int) -> void:
|
||||
var lbl := Label.new()
|
||||
lbl.text = text
|
||||
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
lbl.add_theme_font_override("font", UiFonts.title())
|
||||
lbl.add_theme_font_size_override("font_size", 18)
|
||||
lbl.add_theme_color_override("font_color", MENU_GOLD)
|
||||
lbl.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
lbl.add_theme_constant_override("outline_size", 4)
|
||||
options_vbox.add_child(lbl)
|
||||
options_vbox.move_child(lbl, at_index)
|
||||
|
||||
|
||||
func _add_slider_row(label_text: String, value: float, setter: Callable, at_index: int) -> void:
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = label_text
|
||||
name_label.custom_minimum_size = Vector2(160, 0)
|
||||
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
name_label.add_theme_font_override("font", UiFonts.body())
|
||||
name_label.add_theme_font_size_override("font_size", 20)
|
||||
name_label.add_theme_color_override("font_color", MENU_CREAM)
|
||||
name_label.add_theme_color_override("font_outline_color", Color.BLACK)
|
||||
name_label.add_theme_constant_override("outline_size", 5)
|
||||
row.add_child(name_label)
|
||||
|
||||
var slider := HSlider.new()
|
||||
slider.min_value = 0.0
|
||||
slider.max_value = 1.0
|
||||
slider.step = 0.01
|
||||
slider.value = value
|
||||
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
slider.custom_minimum_size = Vector2(0, 28)
|
||||
row.add_child(slider)
|
||||
|
||||
var pct_label := Label.new()
|
||||
pct_label.text = "%d%%" % roundi(value * 100.0)
|
||||
pct_label.custom_minimum_size = Vector2(46, 0)
|
||||
pct_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
pct_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
pct_label.add_theme_font_override("font", UiFonts.body())
|
||||
pct_label.add_theme_font_size_override("font_size", 18)
|
||||
pct_label.add_theme_color_override("font_color", MENU_GOLD)
|
||||
row.add_child(pct_label)
|
||||
|
||||
slider.value_changed.connect(func(v: float) -> void:
|
||||
setter.call(v)
|
||||
pct_label.text = "%d%%" % roundi(v * 100.0))
|
||||
|
||||
options_vbox.add_child(row)
|
||||
options_vbox.move_child(row, at_index)
|
||||
|
||||
|
||||
func _add_toggle_row(label_text: String, value: bool, setter: Callable, at_index: int) -> void:
|
||||
|
||||
+24
@@ -35,6 +35,7 @@ var _swing_timer: float = 0.0
|
||||
var _lunge_timer: float = 0.0
|
||||
var _roll_timer: float = 0.0
|
||||
var _roll_dir: Vector3 = Vector3.ZERO
|
||||
var _is_spawning: bool = false
|
||||
var _throw_timer: float = 0.0
|
||||
var _throw_aim_cd: float = 0.0
|
||||
var _throw_dir: Vector3 = Vector3.ZERO
|
||||
@@ -118,11 +119,13 @@ func _ready() -> void:
|
||||
_ole_player = AudioStreamPlayer.new()
|
||||
_ole_player.stream = load("res://sounds/ole.ogg")
|
||||
_ole_player.volume_db = 0.0
|
||||
_ole_player.bus = &"Effects"
|
||||
add_child(_ole_player)
|
||||
if ResourceLoader.exists("res://sounds/matador_death.ogg"):
|
||||
_death_player = AudioStreamPlayer.new()
|
||||
_death_player.stream = load("res://sounds/matador_death.ogg")
|
||||
_death_player.volume_db = 2.0
|
||||
_death_player.bus = &"Effects"
|
||||
add_child(_death_player)
|
||||
|
||||
|
||||
@@ -143,6 +146,10 @@ func _acquire_bull() -> void:
|
||||
func _physics_process(delta: float) -> void:
|
||||
_acquire_bull()
|
||||
|
||||
if _is_spawning and is_on_floor():
|
||||
_is_spawning = false
|
||||
collision_layer = 1
|
||||
|
||||
if DP.b("show_raycasts") and _steer_dir != Vector3.ZERO:
|
||||
var o := global_position + Vector3.UP * 0.6
|
||||
DebugDraw.ray(o, o + _steer_dir * _STEER_LOOKAHEAD, Color(0.3, 1.0, 0.9))
|
||||
@@ -564,6 +571,23 @@ func _enter_roll(hit_dir: Vector3) -> void:
|
||||
_anim_player.seek(0.0, true)
|
||||
|
||||
|
||||
# Called by the spawner (deferred) to make the matador leap-roll toward a target
|
||||
# point on entry — the cinematic arena entrance. After the roll it transitions to
|
||||
# normal AI via the standard ROLL → FLEE path.
|
||||
func enter_spawn_roll(toward: Vector3) -> void:
|
||||
var dir := toward - global_position
|
||||
dir.y = 0.0
|
||||
_roll_dir = dir.normalized() if dir.length() > 0.1 else -_mesh.global_transform.basis.z
|
||||
_state = State.ROLL
|
||||
_roll_timer = _anim_length(_ANIM_ROLL, DP.f("mat_roll_duration"))
|
||||
_is_spawning = true
|
||||
collision_layer = 0
|
||||
velocity.y = 4.0
|
||||
if _anim_player and _anim_player.has_animation(_ANIM_ROLL):
|
||||
_anim_player.play(_ANIM_ROLL)
|
||||
_anim_player.seek(0.0, true)
|
||||
|
||||
|
||||
func _tick_roll(delta: float) -> void:
|
||||
_roll_timer -= delta
|
||||
if _roll_timer <= 0.0:
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
extends Node3D
|
||||
|
||||
signal matador_killed
|
||||
signal all_defeated
|
||||
|
||||
const MATADOR := preload("res://Matador.tscn")
|
||||
|
||||
var _spawn_count: int = 1
|
||||
var _alive: int = 0
|
||||
var _kills: int = 0
|
||||
var _resolved: bool = false
|
||||
|
||||
# Flat (x,z) positions of the most recent batch spawned. A fresh wave keeps at least
|
||||
# _SPAWN_MIN_SEP away from these so it never lands on top of where the last one did.
|
||||
const _SPAWN_MIN_SEP: float = 3.5
|
||||
var _last_spawn_positions: Array[Vector3] = []
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group(&"matador_spawn")
|
||||
_spawn_count = maxi(1, int(DP.f("mat_spawn_count")))
|
||||
# Deferred so it's safe from _ready and matches the previous spawn timing.
|
||||
_spawn.call_deferred()
|
||||
|
||||
|
||||
func _spawn() -> void:
|
||||
# Bull and matadors start at opposite ends of a random diameter, so they always
|
||||
# begin as far apart as the arena allows (edge-to-edge across the centre).
|
||||
var arena_r := DP.f("arena_spawn_radius")
|
||||
var bull_angle := randf() * TAU
|
||||
_place_bull(bull_angle, arena_r)
|
||||
var placed: Array[Vector3] = _spread_positions(_spawn_count, bull_angle + PI, arena_r)
|
||||
for pos: Vector3 in placed:
|
||||
_spawn_at(pos)
|
||||
_last_spawn_positions = placed
|
||||
|
||||
|
||||
func _place_bull(angle: float, arena_r: float) -> void:
|
||||
var bull := get_tree().get_first_node_in_group(&"player") as Node3D
|
||||
if not bull:
|
||||
return
|
||||
bull.global_position = Vector3(
|
||||
cos(angle) * arena_r, bull.global_position.y, sin(angle) * arena_r
|
||||
)
|
||||
|
||||
|
||||
# Positions for the matadors, spread across the far side (centred on `center_angle`)
|
||||
# so every matador stays near the point diametrically opposite the bull. Each slot gets
|
||||
# its own angular band and a staggered radius, then rejection-samples inside that band so
|
||||
# no two matadors land closer than _SPAWN_MIN_SEP — otherwise they pile up on top of
|
||||
# each other on spawn. The arc widens with the crowd so a big wave doesn't get cramped.
|
||||
func _spread_positions(count: int, center_angle: float, arena_r: float) -> Array[Vector3]:
|
||||
var positions: Array[Vector3] = []
|
||||
if count <= 1:
|
||||
positions.append(Vector3(cos(center_angle) * arena_r, 1.0, sin(center_angle) * arena_r))
|
||||
return positions
|
||||
var arc := clampf(TAU * 0.33 + float(count) * 0.25, TAU * 0.33, TAU * 0.92)
|
||||
var slot := arc / float(count)
|
||||
var inner := maxf(2.5, arena_r * 0.5)
|
||||
for i: int in count:
|
||||
# Give each matador its own angular slot for an even fan, but sample the radius
|
||||
# freely across the whole far-side band so the rejection test can push crowded
|
||||
# neighbours onto a different ring instead of piling them at one distance.
|
||||
var base_angle := center_angle - arc * 0.5 + slot * (float(i) + 0.5)
|
||||
var pos := Vector3(cos(base_angle) * arena_r, 1.0, sin(base_angle) * arena_r)
|
||||
for attempt: int in 16:
|
||||
var angle := base_angle + randf_range(-0.5, 0.5) * slot
|
||||
var radius := randf_range(inner, arena_r)
|
||||
pos = Vector3(cos(angle) * radius, 1.0, sin(angle) * radius)
|
||||
if _clear_of(pos, positions, _SPAWN_MIN_SEP):
|
||||
break
|
||||
positions.append(pos)
|
||||
return positions
|
||||
|
||||
|
||||
func _spawn_at(pos: Vector3, generation: int = 0) -> void:
|
||||
var m: Node3D = MATADOR.instantiate()
|
||||
m.position = pos
|
||||
m.set_meta(&"generation", generation)
|
||||
get_parent().add_child(m)
|
||||
m.killed.connect(_on_matador_killed.bind(m))
|
||||
_alive += 1
|
||||
|
||||
|
||||
# A slain matador is replaced by mat_split_factor fresh ones on the spot (a hydra),
|
||||
# so the horde doubles each generation, up to mat_split_generations deep. The bull
|
||||
# wins only when a whole generation dies out without splitting — i.e. once the spawns
|
||||
# stop and the last body drops.
|
||||
func _on_matador_killed(dead: Node3D) -> void:
|
||||
matador_killed.emit()
|
||||
_alive -= 1
|
||||
_kills += 1
|
||||
_split(dead)
|
||||
var win_kills := int(DP.f("mat_win_kills"))
|
||||
var reached_quota := win_kills > 0 and _kills >= win_kills
|
||||
if (reached_quota or _alive <= 0) and not _resolved:
|
||||
_resolved = true
|
||||
all_defeated.emit()
|
||||
|
||||
|
||||
func _split(dead: Node3D) -> void:
|
||||
var count := int(DP.f("mat_split_factor"))
|
||||
var generation := int(dead.get_meta(&"generation", 0))
|
||||
if count < 2 or generation >= int(DP.f("mat_split_generations")):
|
||||
return
|
||||
var arena_r := DP.f("arena_spawn_radius")
|
||||
var origin := dead.global_position
|
||||
origin.y = 0.0
|
||||
var wave: Array[Vector3] = []
|
||||
for i: int in count:
|
||||
var pos := _pick_spawn(origin, arena_r, wave)
|
||||
wave.append(pos)
|
||||
_spawn_at(pos, generation + 1)
|
||||
_last_spawn_positions = wave
|
||||
|
||||
|
||||
# Rejection-sample a spawn point ringed around `origin`, pushing the ring outward on
|
||||
# each failed attempt until it clears both the previous wave (_last_spawn_positions)
|
||||
# and the spots already taken this wave — so a fresh wave never lands where the last
|
||||
# one did. Falls back to the last candidate if the arena is too cramped to satisfy it.
|
||||
func _pick_spawn(origin: Vector3, arena_r: float, taken: Array[Vector3]) -> Vector3:
|
||||
var pos := origin
|
||||
for attempt: int in 12:
|
||||
var angle := randf() * TAU
|
||||
var r := randf_range(2.0, 3.5) + float(attempt) * 0.4
|
||||
pos = origin + Vector3(cos(angle) * r, 0.0, sin(angle) * r)
|
||||
var flat := Vector2(pos.x, pos.z)
|
||||
if flat.length() > arena_r:
|
||||
flat = flat.normalized() * arena_r
|
||||
pos = Vector3(flat.x, 0.0, flat.y)
|
||||
if _clear_of(pos, _last_spawn_positions, _SPAWN_MIN_SEP) \
|
||||
and _clear_of(pos, taken, _SPAWN_MIN_SEP):
|
||||
break
|
||||
pos.y = 1.0
|
||||
return pos
|
||||
|
||||
|
||||
func _clear_of(pos: Vector3, others: Array[Vector3], min_sep: float) -> bool:
|
||||
for o: Vector3 in others:
|
||||
if Vector2(pos.x - o.x, pos.z - o.z).length() < min_sep:
|
||||
return false
|
||||
return true
|
||||
@@ -102,16 +102,16 @@ func _setup_legs() -> void:
|
||||
|
||||
|
||||
func _setup_hoof_dust() -> void:
|
||||
var sphere := _particle_sphere(0.10, 4, 2, _unshaded_material())
|
||||
var sphere := _particle_sphere(0.07, 4, 2, _unshaded_material())
|
||||
|
||||
_walk_dust_ramp = Gradient.new()
|
||||
_walk_dust_ramp.set_color(0, Color(0.80, 0.69, 0.46, 1.0))
|
||||
_walk_dust_ramp.set_color(1, Color(0.80, 0.69, 0.46, 0.0))
|
||||
_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))
|
||||
|
||||
# Light blue dust while charging
|
||||
# White-beige dust while running
|
||||
_charge_dust_ramp = Gradient.new()
|
||||
_charge_dust_ramp.set_color(0, Color(0.20, 0.40, 0.85, 1.0))
|
||||
_charge_dust_ramp.set_color(1, Color(0.20, 0.40, 0.85, 0.0))
|
||||
_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))
|
||||
@@ -215,6 +215,7 @@ func _setup_audio() -> void:
|
||||
_huff_player = AudioStreamPlayer.new()
|
||||
_huff_player.stream = load("res://sounds/bull_huff.ogg")
|
||||
_huff_player.volume_db = 0.0
|
||||
_huff_player.bus = &"Effects"
|
||||
add_child(_huff_player)
|
||||
if ResourceLoader.exists("res://sounds/crowd_ambient.ogg"):
|
||||
_crowd_player = AudioStreamPlayer.new()
|
||||
@@ -224,6 +225,7 @@ func _setup_audio() -> void:
|
||||
_crowd_player.stream = s
|
||||
_crowd_player.volume_db = -14.0
|
||||
_crowd_player.autoplay = true
|
||||
_crowd_player.bus = &"Ambient"
|
||||
add_child(_crowd_player)
|
||||
|
||||
|
||||
@@ -253,8 +255,8 @@ func _set_dust_state(new_state: DustState) -> void:
|
||||
p.initial_velocity_min = DP.f("charge_vel_min")
|
||||
p.initial_velocity_max = DP.f("charge_vel_max")
|
||||
p.emitting = true
|
||||
if _charge_trail_emitter:
|
||||
_charge_trail_emitter.emitting = _dust_state == DustState.CHARGE
|
||||
# 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:
|
||||
@@ -494,6 +496,7 @@ func _tick_roll(delta: float, _steer: Vector3) -> void:
|
||||
|
||||
_set_dust_state(DustState.CHARGE)
|
||||
if _charge_trail_emitter:
|
||||
_charge_trail_emitter.emitting = true
|
||||
_charge_trail_emitter.direction = (-_roll_dir + Vector3(0.0, 0.25, 0.0)).normalized()
|
||||
|
||||
move_and_slide()
|
||||
@@ -575,6 +578,8 @@ func _end_roll() -> void:
|
||||
_legs.set(&"external_rotation", false)
|
||||
if _roll_spark_emitter:
|
||||
_roll_spark_emitter.emitting = false
|
||||
if _charge_trail_emitter:
|
||||
_charge_trail_emitter.emitting = false
|
||||
velocity.x *= 0.15
|
||||
velocity.z *= 0.15
|
||||
_play_idle()
|
||||
|
||||
+3
-4
@@ -17,7 +17,6 @@ config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
boot_splash/bg_color=Color(0, 0, 0, 1)
|
||||
boot_splash/image="res://Assets/loading_screen.png"
|
||||
config/icon="uid://dct1d88g015el"
|
||||
boot_splash/minimum_display_time=4000
|
||||
config/run/main_scene="res://MainMenu.tscn"
|
||||
|
||||
[autoload]
|
||||
@@ -27,7 +26,7 @@ Console="*res://console.gd"
|
||||
Controls="*res://controls_manager.gd"
|
||||
DebugDraw="*res://debug_draw.gd"
|
||||
Settings="*res://settings.gd"
|
||||
Run="*res://run_state.gd"
|
||||
Run="*res://levels/run_state.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -94,13 +93,13 @@ ability_kick={
|
||||
}
|
||||
ability_slam={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":71,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":72,"key_label":0,"unicode":104,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
|
||||
]
|
||||
}
|
||||
ability_dash={
|
||||
"deadzone": 0.2,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":72,"key_label":0,"unicode":104,"location":0,"echo":false,"script":null)
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":71,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":true,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
+5
-1
@@ -8,7 +8,7 @@ SKIP_GAMEPLAY=0
|
||||
for arg in "$@"; do [[ "$arg" == "--no-gameplay" ]] && SKIP_GAMEPLAY=1; done
|
||||
|
||||
echo "=== gdlint ==="
|
||||
gdlint *.gd tests/*.gd
|
||||
gdlint *.gd levels/*.gd tests/*.gd
|
||||
|
||||
echo ""
|
||||
echo "=== Logic tests ==="
|
||||
@@ -18,6 +18,10 @@ echo ""
|
||||
echo "=== Console focus tests ==="
|
||||
"$GODOT" --headless --script tests/console_focus_test.gd
|
||||
|
||||
echo ""
|
||||
echo "=== Level switch tests (no cross-level geometry/collider leaks) ==="
|
||||
"$GODOT" --headless --script tests/level_switch_test.gd
|
||||
|
||||
echo ""
|
||||
echo "=== Performance tests (frame-budget regression guard) ==="
|
||||
"$GODOT" --headless --script tests/performance_test.gd
|
||||
|
||||
+11
-420
@@ -1,18 +1,10 @@
|
||||
[gd_scene format=3 uid="uid://b7afbx36ltb3f"]
|
||||
[gd_scene load_steps=8 format=3 uid="uid://b7afbx36ltb3f"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://crtm0xtiql3r8" path="res://Player.tscn" id="1_ulcgi"]
|
||||
[ext_resource type="PackedScene" uid="uid://dio67r8ambe7h" path="res://Assets/arena_placeholder_v01.glb" id="2_nxogm"]
|
||||
[ext_resource type="PackedScene" uid="uid://cl54dt38yj2x" path="res://Assets/Arena.glb" id="3_fy5k1"]
|
||||
[ext_resource type="Script" uid="uid://dou5ar6tkmng1" path="res://matador_spawn.gd" id="3_spawn"]
|
||||
[ext_resource type="Shader" uid="uid://d3xyyy5bh0ma" path="res://ps1_filter.gdshader" id="4_ps1shader"]
|
||||
[ext_resource type="PackedScene" uid="uid://cmxqocsdbgufr" path="res://Animations/mees_plaksutab.glb" id="5_m7jmp"]
|
||||
[ext_resource type="PackedScene" uid="uid://1y07gn65ktsi" path="res://Animations/ANIM_publik_mees.glb" id="7_xlvrw"]
|
||||
[ext_resource type="Script" uid="uid://dr6phggarm73e" path="res://Animations/anim_publik_mees.gd" id="8_73fnb"]
|
||||
[ext_resource type="Script" uid="uid://di0jafakftp3n" path="res://publikum_controller.gd" id="9_pubctrl"]
|
||||
[ext_resource type="PackedScene" uid="uid://dxe72ig8gfbcg" path="res://Assets/arena model2026.glb" id="10_5juve"]
|
||||
[ext_resource type="Script" uid="uid://butd6b3tdwxrv" path="res://hud.gd" id="10_hud"]
|
||||
[ext_resource type="Script" uid="uid://cj0ytlkuavmf4" path="res://crowd_billboards.gd" id="11_crowd"]
|
||||
[ext_resource type="Script" uid="uid://ce5n4qu88mhyx" path="res://ps1_filter.gd" id="12_ps1ctl"]
|
||||
[ext_resource type="Script" path="res://levels/game_shell.gd" id="13_shell"]
|
||||
|
||||
[sub_resource type="CameraAttributesPractical" id="CameraAttributesPractical_73fnb"]
|
||||
dof_blur_far_enabled = true
|
||||
@@ -23,8 +15,6 @@ dof_blur_near_distance = 8192.0
|
||||
dof_blur_near_transition = 8192.0
|
||||
dof_blur_amount = 1.0
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_ulcgi"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_ps1"]
|
||||
shader = ExtResource("4_ps1shader")
|
||||
shader_parameter/pixel_scale = 4.0
|
||||
@@ -34,20 +24,19 @@ shader_parameter/scanline_strength = 0.2
|
||||
shader_parameter/roll_speed = 3.0
|
||||
shader_parameter/roll_strength = 1.0
|
||||
|
||||
[sub_resource type="Environment" id="Environment_73fnb"]
|
||||
[node name="scene" type="Node3D"]
|
||||
script = ExtResource("13_shell")
|
||||
|
||||
[node name="scene" type="Node3D" unique_id=22320312]
|
||||
|
||||
[node name="Player" parent="." unique_id=165510044 instance=ExtResource("1_ulcgi")]
|
||||
[node name="Player" parent="." instance=ExtResource("1_ulcgi")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 2.2416196, 5.1711025, -4.841382)
|
||||
|
||||
[node name="Camera3D" parent="Player" index="3" unique_id=1120161087]
|
||||
[node name="Camera3D" parent="Player" index="3"]
|
||||
attributes = SubResource("CameraAttributesPractical_73fnb")
|
||||
fov = 50.0
|
||||
near = 2.636
|
||||
far = 0.01
|
||||
|
||||
[node name="Skeleton3D" parent="Player/bull/Armature" parent_id_path=PackedInt32Array(165510044, 1386495166, 547304299) index="0" unique_id=2096877109]
|
||||
[node name="Skeleton3D" parent="Player/bull/Armature" index="0"]
|
||||
bones/0/position = Vector3(0, 0, 0.0074015963)
|
||||
bones/0/rotation = Quaternion(-0.36922705, 0.6030517, -0.6030517, -0.36922705)
|
||||
bones/0/scale = Vector3(1, 1, 1)
|
||||
@@ -97,41 +86,13 @@ bones/24/position = Vector3(7.092306e-11, 0.003274492, 4.6413304e-10)
|
||||
bones/24/rotation = Quaternion(0.52910197, -0.15997829, -0.12024502, 0.82462066)
|
||||
bones/24/scale = Vector3(1, 1.0000001, 1.0000001)
|
||||
|
||||
[node name="Node3D" type="Node3D" parent="." unique_id=1081100876]
|
||||
|
||||
[node name="StaticBody3D" type="StaticBody3D" parent="Node3D" unique_id=2072095624]
|
||||
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="Node3D/StaticBody3D" unique_id=1444198204]
|
||||
transform = Transform3D(30, 0, 0, 0, 1, 0, 0, 0, 30, 0, -0.5, 0)
|
||||
shape = SubResource("BoxShape3D_ulcgi")
|
||||
|
||||
[node name="arena_placeholder_v01" parent="Node3D" unique_id=1341659233 instance=ExtResource("2_nxogm")]
|
||||
|
||||
[node name="Arena_draft" parent="Node3D/arena_placeholder_v01" index="0" unique_id=603097973]
|
||||
visible = false
|
||||
|
||||
[node name="Wall" parent="Node3D/arena_placeholder_v01" index="1" unique_id=171701243]
|
||||
visible = false
|
||||
|
||||
[node name="CollisionShape3D" parent="Node3D/arena_placeholder_v01/Wall/StaticBody3D" parent_id_path=PackedInt32Array(1341659233, 1793167121) index="0" unique_id=404958985]
|
||||
transform = Transform3D(0.98309773, 0, 0, 0, 0.9558868, 0, 0, 0, 4.061706, 0.13596812, -0.091219574, -5.4371103e-09)
|
||||
|
||||
[node name="Arena2" parent="Node3D/arena_placeholder_v01" unique_id=524718167 instance=ExtResource("3_fy5k1")]
|
||||
|
||||
[node name="arena model2026" parent="Node3D/arena_placeholder_v01" unique_id=179544183 instance=ExtResource("10_5juve")]
|
||||
transform = Transform3D(0.99999994, 0, 0, 0, 0.99999994, 0, 0, 0, 0.99999994, -0.901, -0.6, 0)
|
||||
visible = false
|
||||
|
||||
[node name="MatadorSpawner" type="Node3D" parent="." unique_id=-1293967295]
|
||||
script = ExtResource("3_spawn")
|
||||
|
||||
[node name="HUD" type="CanvasLayer" parent="." unique_id=1100000001]
|
||||
[node name="HUD" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("10_hud")
|
||||
|
||||
[node name="CanvasLayer" type="CanvasLayer" parent="." unique_id=1032421313]
|
||||
[node name="CanvasLayer" type="CanvasLayer" parent="."]
|
||||
layer = 10
|
||||
|
||||
[node name="PS1Filter" type="ColorRect" parent="CanvasLayer" unique_id=1449116873]
|
||||
[node name="PS1Filter" type="ColorRect" parent="CanvasLayer"]
|
||||
material = SubResource("ShaderMaterial_ps1")
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -140,377 +101,7 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("12_ps1ctl")
|
||||
|
||||
[node name="Lights" type="Node3D" parent="." unique_id=737767662]
|
||||
|
||||
[node name="DirectionalLight3D" type="DirectionalLight3D" parent="Lights" unique_id=818712731]
|
||||
transform = Transform3D(0.01535992, 0.56825536, -0.82270896, 0.16280846, 0.8104039, 0.56279576, 0.9865382, -0.14258848, -0.08006904, -4.08134, 9.197985, 0.97087634)
|
||||
light_energy = 0.4
|
||||
light_indirect_energy = 0.767
|
||||
shadow_blur = 0.47
|
||||
|
||||
[node name="SpotLight3D2" type="SpotLight3D" parent="Lights" unique_id=918477294]
|
||||
transform = Transform3D(0.904133, 0.30962402, -0.2944087, 0.3084627, 0.0037425472, 0.9512291, 0.2956252, -0.95085174, -0.0921237, -3.629381, 10.86347, -2.0124264)
|
||||
light_energy = 16.0
|
||||
light_indirect_energy = 26.095
|
||||
distance_fade_enabled = true
|
||||
spot_range = 32.90586
|
||||
spot_angle = 73.93
|
||||
|
||||
[node name="SpotLight3D" type="SpotLight3D" parent="Lights" unique_id=422808558]
|
||||
transform = Transform3D(0.9763736, -9.445564e-09, 0.21608931, -0.20963383, 0.24260214, 0.9472053, -0.052423734, -0.9701259, 0.23687032, 4.0400305, 13.463301, 3.9410677)
|
||||
visible = false
|
||||
light_energy = 13.695
|
||||
shadow_enabled = true
|
||||
spot_range = 32.90586
|
||||
|
||||
[node name="SUN_DirectionalLight3D2" type="DirectionalLight3D" parent="Lights" unique_id=1706919950]
|
||||
transform = Transform3D(0.45811793, 0.34843865, -0.81775206, 0.5316611, 0.62986195, 0.56622505, 0.7123655, -0.69416475, 0.10329977, 0, 8.980043, 0)
|
||||
light_energy = 0.106
|
||||
shadow_enabled = true
|
||||
shadow_blur = 10.0
|
||||
|
||||
[node name="PublikNode" type="Node3D" parent="." unique_id=1020007445]
|
||||
visible = false
|
||||
|
||||
[node name="publikum2" type="Node3D" parent="PublikNode" unique_id=813507078]
|
||||
transform = Transform3D(-0.91522944, 0, -0.4029331, 0, 1, 0, 0.4029331, 0, -0.91522944, 4.195425, 3.6553268, 13.885913)
|
||||
visible = false
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum2" unique_id=1052467743 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum2" unique_id=1015461330 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum2" unique_id=1089134459 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum2" unique_id=322028795 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum2" unique_id=482889095 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum2" unique_id=944445851 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum4" type="Node3D" parent="PublikNode" unique_id=68365083]
|
||||
transform = Transform3D(-0.629974, 0, -0.77661633, 0, 1, 0, 0.77661633, 0, -0.629974, 9.842437, 3.6553268, 10.401334)
|
||||
visible = false
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum4" unique_id=222379180 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum4" unique_id=1871765797 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum4" unique_id=10437017 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum4" unique_id=901414927 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum4" unique_id=2077358822 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum4" unique_id=1131238522 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum5" type="Node3D" parent="PublikNode" unique_id=1254884314]
|
||||
transform = Transform3D(-0.39064395, 0, 0.920542, 0, 1, 0, -0.920542, 0, -0.39064395, -14.538336, 3.6553268, 3.9378712)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum5" unique_id=1914156014 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum5" unique_id=602140062 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum5" unique_id=224404749 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum5" unique_id=958300982 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum5" unique_id=860025476 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum5" unique_id=1052988416 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum6" type="Node3D" parent="PublikNode" unique_id=709827517]
|
||||
transform = Transform3D(-0.7681126, 0, 0.64031476, 0, 1, 0, -0.64031476, 0, -0.7681126, -11.103292, 3.6553288, 9.335619)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum6" unique_id=1007939702 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum6" unique_id=860884329 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum6" unique_id=1928974579 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum6" unique_id=547889733 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum6" unique_id=1501877017 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum6" unique_id=1951764566 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum7" type="Node3D" parent="PublikNode" unique_id=1483978907]
|
||||
transform = Transform3D(0.55061686, 0, -0.8347584, 0, 1, 0, 0.8347584, 0, 0.55061686, 13.163043, 3.4379854, -5.268345)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum7" unique_id=788603784 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum7" unique_id=1537286096 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum7" unique_id=1957279061 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum7" unique_id=1379894897 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum7" unique_id=1920281402 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum7" unique_id=811906038 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum8" type="Node3D" parent="PublikNode" unique_id=1073953916]
|
||||
transform = Transform3D(0.85247177, 0, -0.5227735, 0, 1, 0, 0.5227735, 0, 0.85247177, 11.217102, 4.568224, -10.151715)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum8" unique_id=641607406 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum8" unique_id=740439664 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum8" unique_id=1896496635 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum8" unique_id=1787601370 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum8" unique_id=1153730967 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum8" unique_id=1023874478 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum3" type="Node3D" parent="PublikNode" unique_id=71842083]
|
||||
transform = Transform3D(0.8698311, 0, 0.49334967, 0, 1, 0, -0.49334967, 0, 0.8698311, -4.8910522, 4.627474, -14.224113)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum3" unique_id=1585155222 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum3" unique_id=1301960990 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum3" unique_id=1481094020 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum3" unique_id=1958614289 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum3" unique_id=318927325 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum3" unique_id=492002897 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum9" type="Node3D" parent="PublikNode" unique_id=1844757155]
|
||||
transform = Transform3D(0.9760592, 0, 0.21750516, 0, 1, 0, -0.21750516, 0, 0.9760592, -2.1318102, 3.6553268, -13.557504)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum9" unique_id=896469763 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum9" unique_id=1412552760 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum9" unique_id=59611810 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum9" unique_id=1161683450 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum9" unique_id=1706050589 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum9" unique_id=1929239115 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum11" type="Node3D" parent="PublikNode" unique_id=727404993]
|
||||
transform = Transform3D(-0.95107377, 0, -0.30896416, 0, 1, 0, 0.30896416, 0, -0.95107377, 2.4321594, 3.6553268, 14.119543)
|
||||
visible = false
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum11" unique_id=36716174 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum11" unique_id=2117684869 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum11" unique_id=684167492 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum11" unique_id=678520535 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum11" unique_id=1746700688 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum11" unique_id=462492236 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum13" type="Node3D" parent="PublikNode" unique_id=1816403209]
|
||||
transform = Transform3D(-0.2962035, 0, 0.95512486, 0, 1, 0, -0.95512486, 0, -0.2962035, -14.037817, 3.6553268, 2.0647745)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum13" unique_id=1252057421 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum13" unique_id=1096867731 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3.1134007, 0, 0.6326933)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum13" unique_id=1130631283 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum13" unique_id=2008334523 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum13" unique_id=1185367656 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum13" unique_id=649140591 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="publikum15" type="Node3D" parent="PublikNode" unique_id=926602558]
|
||||
transform = Transform3D(0.43092924, 0, -0.90238583, 0, 1, 0, 0.90238583, 0, 0.43092924, 14.766085, 4.568224, -2.378694)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum15" unique_id=2043094085 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum15" unique_id=1927429095 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum15" unique_id=1660696957 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum15" unique_id=1684596154 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum15" unique_id=1401913487 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum15" unique_id=1893327164 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="ANIM_publik_mees" parent="PublikNode" unique_id=413106028 instance=ExtResource("7_xlvrw")]
|
||||
transform = Transform3D(-0.9976194, 0, 0.0689602, 0, 1, 0, -0.0689602, 0, -0.9976194, -2.9310942, 4.1751385, 14.861324)
|
||||
script = ExtResource("8_73fnb")
|
||||
|
||||
[node name="publikum" type="Node3D" parent="PublikNode" unique_id=1311444475]
|
||||
transform = Transform3D(0.70069104, 0, 0.7134648, 0, 1, 0, -0.7134648, 0, 0.70069104, -9.04294, 3.6553268, -10.339719)
|
||||
script = ExtResource("9_pubctrl")
|
||||
|
||||
[node name="mees_plaksutab" parent="PublikNode/publikum" unique_id=1787142704 instance=ExtResource("5_m7jmp")]
|
||||
|
||||
[node name="mees_plaksutab2" parent="PublikNode/publikum" unique_id=330098781 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.8139224, 0, -0.33299255)
|
||||
|
||||
[node name="mees_plaksutab3" parent="PublikNode/publikum" unique_id=1406686735 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -5.5662975, 0, 0.3316717)
|
||||
|
||||
[node name="mees_plaksutab4" parent="PublikNode/publikum" unique_id=132494522 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -8.003071, 0, 1.5986013)
|
||||
|
||||
[node name="mees_plaksutab5" parent="PublikNode/publikum" unique_id=2101925215 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.89589787, 0, 0.44426048, 0, 1, 0, -0.44426048, 0, 0.89589787, -7.1850557, 0.8316374, 0.36604595)
|
||||
|
||||
[node name="mees_plaksutab6" parent="PublikNode/publikum" unique_id=403179328 instance=ExtResource("5_m7jmp")]
|
||||
transform = Transform3D(0.97025216, 0, 0.24209678, 0, 1, 0, -0.24209678, 0, 0.97025216, -4.039103, 0.8316374, -0.8892679)
|
||||
|
||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=755853425]
|
||||
environment = SubResource("Environment_73fnb")
|
||||
|
||||
[node name="CrowdBillboards" type="MultiMeshInstance3D" parent="." unique_id=549548313]
|
||||
visible = false
|
||||
script = ExtResource("11_crowd")
|
||||
[node name="LevelRoot" type="Node3D" parent="."]
|
||||
|
||||
[editable path="Player"]
|
||||
[editable path="Player/bull"]
|
||||
[editable path="Node3D/arena_placeholder_v01"]
|
||||
[editable path="PublikNode/publikum2/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum2/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum2/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum2/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum2/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum2/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum4/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum4/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum4/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum4/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum4/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum4/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum5/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum5/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum5/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum5/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum5/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum5/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum6/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum6/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum6/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum6/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum6/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum6/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum7/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum7/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum7/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum7/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum7/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum7/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum8/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum8/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum8/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum8/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum8/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum8/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum3/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum3/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum3/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum3/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum3/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum3/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum9/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum9/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum9/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum9/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum9/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum9/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum11/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum11/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum11/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum11/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum11/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum11/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum13/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum13/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum13/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum13/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum13/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum13/mees_plaksutab6"]
|
||||
[editable path="PublikNode/publikum15/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum15/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum15/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum15/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum15/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum15/mees_plaksutab6"]
|
||||
[editable path="PublikNode/ANIM_publik_mees"]
|
||||
[editable path="PublikNode/publikum/mees_plaksutab"]
|
||||
[editable path="PublikNode/publikum/mees_plaksutab2"]
|
||||
[editable path="PublikNode/publikum/mees_plaksutab3"]
|
||||
[editable path="PublikNode/publikum/mees_plaksutab4"]
|
||||
[editable path="PublikNode/publikum/mees_plaksutab5"]
|
||||
[editable path="PublikNode/publikum/mees_plaksutab6"]
|
||||
|
||||
+68
@@ -13,11 +13,39 @@ var screen_shake: bool = true
|
||||
## Gore toggle — when off, no blood spawns (impact spurt + ongoing spear-wound bleed).
|
||||
var gore: bool = true
|
||||
|
||||
## Audio volumes stored as linear 0–1. Applied to AudioServer buses on load/change.
|
||||
var volume_master: float = 1.0
|
||||
var volume_ambient: float = 1.0
|
||||
var volume_effects: float = 1.0
|
||||
var mute_all: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_ensure_buses()
|
||||
load_saved()
|
||||
|
||||
|
||||
## Ensure Ambient and Effects buses exist as children of Master.
|
||||
func _ensure_buses() -> void:
|
||||
for bus_name: String in ["Ambient", "Effects"]:
|
||||
if AudioServer.get_bus_index(bus_name) == -1:
|
||||
AudioServer.add_bus()
|
||||
var idx := AudioServer.bus_count - 1
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
AudioServer.set_bus_send(idx, &"Master")
|
||||
|
||||
|
||||
func _apply_audio() -> void:
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Ambient"), _vol_db(volume_ambient))
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Effects"), _vol_db(volume_effects))
|
||||
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), mute_all)
|
||||
|
||||
|
||||
func _vol_db(linear: float) -> float:
|
||||
return linear_to_db(maxf(linear, 0.0001))
|
||||
|
||||
|
||||
func set_screen_shake(value: bool) -> void:
|
||||
if screen_shake == value:
|
||||
return
|
||||
@@ -34,16 +62,56 @@ func set_gore(value: bool) -> void:
|
||||
save()
|
||||
|
||||
|
||||
func set_volume_master(value: float) -> void:
|
||||
volume_master = clampf(value, 0.0, 1.0)
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Master"), _vol_db(volume_master))
|
||||
changed.emit("volume_master", volume_master)
|
||||
save()
|
||||
|
||||
|
||||
func set_volume_ambient(value: float) -> void:
|
||||
volume_ambient = clampf(value, 0.0, 1.0)
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Ambient"), _vol_db(volume_ambient))
|
||||
changed.emit("volume_ambient", volume_ambient)
|
||||
save()
|
||||
|
||||
|
||||
func set_volume_effects(value: float) -> void:
|
||||
volume_effects = clampf(value, 0.0, 1.0)
|
||||
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Effects"), _vol_db(volume_effects))
|
||||
changed.emit("volume_effects", volume_effects)
|
||||
save()
|
||||
|
||||
|
||||
func set_mute_all(value: bool) -> void:
|
||||
if mute_all == value:
|
||||
return
|
||||
mute_all = value
|
||||
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), mute_all)
|
||||
changed.emit("mute_all", value)
|
||||
save()
|
||||
|
||||
|
||||
func save() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value(SECTION, "screen_shake", screen_shake)
|
||||
cfg.set_value(SECTION, "gore", gore)
|
||||
cfg.set_value(SECTION, "volume_master", volume_master)
|
||||
cfg.set_value(SECTION, "volume_ambient", volume_ambient)
|
||||
cfg.set_value(SECTION, "volume_effects", volume_effects)
|
||||
cfg.set_value(SECTION, "mute_all", mute_all)
|
||||
cfg.save(SAVE_PATH)
|
||||
|
||||
|
||||
func load_saved() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SAVE_PATH) != OK:
|
||||
_apply_audio()
|
||||
return
|
||||
screen_shake = cfg.get_value(SECTION, "screen_shake", true) as bool
|
||||
gore = cfg.get_value(SECTION, "gore", true) as bool
|
||||
volume_master = cfg.get_value(SECTION, "volume_master", 1.0) as float
|
||||
volume_ambient = cfg.get_value(SECTION, "volume_ambient", 1.0) as float
|
||||
volume_effects = cfg.get_value(SECTION, "volume_effects", 1.0) as float
|
||||
mute_all = cfg.get_value(SECTION, "mute_all", false) as bool
|
||||
_apply_audio()
|
||||
|
||||
+29
-5
@@ -14,7 +14,12 @@ const _DESPAWN_SEC: float = 8.0
|
||||
const _STUCK_GROUP: StringName = &"stuck_spear"
|
||||
# Shaft geometry in spear.glb local space: tip at +Z, butt behind the origin.
|
||||
const _TIP_Z: float = 1.915
|
||||
const _EMBED_DEPTH: float = 0.45 # how far past the surface the tip buries
|
||||
const _EMBED_DEPTH: float = 0.72 # how far past the surface the tip buries
|
||||
# Bone the bull's spears ride on. The collision spheres sit outside the hide, so a
|
||||
# spear stuck to the body root reads as floating; anchoring to COG also lets it bob
|
||||
# and rotate with the animated body instead of only the character root.
|
||||
const _COG_BONE: String = "COG"
|
||||
const _ANCHOR_NAME: String = "SpearAnchor"
|
||||
|
||||
var _mesh: Node3D = null
|
||||
var _stopped: bool = false
|
||||
@@ -53,10 +58,12 @@ static func shed_all(bull: Node) -> void:
|
||||
if scene == null:
|
||||
scene = bull
|
||||
var center: Vector3 = (bull as Node3D).global_position
|
||||
for child in bull.get_children():
|
||||
if not child.is_in_group(_STUCK_GROUP):
|
||||
# Stuck spears now live under a BoneAttachment3D deep in the skeleton, so walk the
|
||||
# whole subtree by group rather than only the bull's direct children.
|
||||
for node in bull.get_tree().get_nodes_in_group(_STUCK_GROUP):
|
||||
if not (bull as Node).is_ancestor_of(node):
|
||||
continue
|
||||
var mesh := child as Node3D
|
||||
var mesh := node as Node3D
|
||||
var gx := mesh.global_transform
|
||||
var rb := RigidBody3D.new()
|
||||
rb.collision_layer = 0
|
||||
@@ -195,7 +202,7 @@ func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
|
||||
if bull != null and bull.is_in_group(&"player"):
|
||||
bull.call(&"take_sword_hit", "thrown", contact)
|
||||
if is_instance_valid(_mesh) and is_instance_valid(bull):
|
||||
_mesh.reparent(bull, false)
|
||||
_mesh.reparent(_spear_anchor(bull), false)
|
||||
_mesh.global_transform = pose
|
||||
_mesh.add_to_group(_STUCK_GROUP)
|
||||
if Settings.gore:
|
||||
@@ -204,3 +211,20 @@ func _embed(other: Object, contact: Vector3, flight: Vector3) -> void:
|
||||
return
|
||||
freeze = true
|
||||
global_transform = pose
|
||||
|
||||
|
||||
# Node embedded spears ride on the bull: a BoneAttachment3D pinned to the COG bone so
|
||||
# they follow the animated body (bob, turn) rather than only the character root. One
|
||||
# anchor is shared by every spear; falls back to the bull root if there's no skeleton.
|
||||
static func _spear_anchor(bull: Node) -> Node3D:
|
||||
var existing := bull.find_child(_ANCHOR_NAME, true, false)
|
||||
if existing is Node3D:
|
||||
return existing as Node3D
|
||||
var skel := bull.find_child("Skeleton3D", true, false) as Skeleton3D
|
||||
if skel == null:
|
||||
return bull as Node3D
|
||||
var ba := BoneAttachment3D.new()
|
||||
ba.name = _ANCHOR_NAME
|
||||
skel.add_child(ba)
|
||||
ba.bone_name = _COG_BONE
|
||||
return ba
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
extends SceneTree
|
||||
## Bear boss behaviour test. Self-contained: a floor, a real bull (Player.tscn), and the
|
||||
## bear — no level/shell scene needed. Parks the bull at different range bands and checks
|
||||
## the bear commits to band-appropriate routines through wind-up→active→recover, that the
|
||||
## leap lands on the ground (not mid-air), hyper-armour holds mid-swing, and phase 2 +
|
||||
## death fire. Run: godot --headless --script res://tests/bear_boss_test.gd
|
||||
|
||||
var _pass := 0
|
||||
var _fail := 0
|
||||
var _bear: Node3D = null
|
||||
var _bull: Node3D = null
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _ok(c: bool, m: String) -> void:
|
||||
if c:
|
||||
_pass += 1
|
||||
print(" PASS: ", m)
|
||||
else:
|
||||
_fail += 1
|
||||
print(" FAIL: ", m)
|
||||
|
||||
|
||||
func _base(s: String) -> String:
|
||||
return s.split(":")[0].trim_suffix("*")
|
||||
|
||||
|
||||
func _dt() -> float:
|
||||
return 1.0 / Engine.get_physics_ticks_per_second()
|
||||
|
||||
|
||||
# Keep the bull pinned at a flat distance from the bear and alive; report the routine
|
||||
# bases the bear visits over `secs`.
|
||||
func _observe(dist: float, secs: float) -> Dictionary:
|
||||
var seen := {}
|
||||
var t := 0.0
|
||||
while t < secs and is_instance_valid(_bear):
|
||||
_pin_bull(dist)
|
||||
await physics_frame
|
||||
t += _dt()
|
||||
seen[_base(_bear.call("ai_state_name"))] = true
|
||||
return seen
|
||||
|
||||
|
||||
func _pin_bull(dist: float) -> void:
|
||||
if _bull == null or not is_instance_valid(_bull):
|
||||
return
|
||||
if dist > 0.0:
|
||||
_bull.global_position = _bear.global_position + Vector3(dist, 0, 0)
|
||||
_bull.set("_hp", 100000) # never let the bull die and trip a game-over during the test
|
||||
|
||||
|
||||
func _make_floor() -> StaticBody3D:
|
||||
var body := StaticBody3D.new()
|
||||
var cs := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = Vector3(400, 1, 400) # oversized so the bear can't chase the bull off an edge
|
||||
cs.shape = box
|
||||
cs.position = Vector3(0, -0.5, 0) # top face at y=0
|
||||
body.add_child(cs)
|
||||
return body
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
root.add_child(_make_floor())
|
||||
|
||||
_bull = (load("res://Player.tscn") as PackedScene).instantiate()
|
||||
root.add_child(_bull)
|
||||
_bull.global_position = Vector3(4, 1, 0)
|
||||
|
||||
_bear = (load("res://Bear.tscn") as PackedScene).instantiate()
|
||||
root.add_child(_bear)
|
||||
_bear.global_position = Vector3(0, 1, 0)
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_ok(_bull.is_in_group(&"player"), "bull is in the player group")
|
||||
|
||||
print(" anims: idle=%s run=%s walk2=%s stand=%s swipe=%s smash=%s leap=%s" % [
|
||||
_bear.get("_anim_idle"), _bear.get("_anim_run"), _bear.get("_anim_walk2"),
|
||||
_bear.get("_anim_stand"), _bear.get("_anim_swipe"), _bear.get("_anim_smash"),
|
||||
_bear.get("_anim_leap")])
|
||||
for k in ["_anim_idle", "_anim_run", "_anim_walk2", "_anim_stand", "_anim_swipe", "_anim_smash", "_anim_leap"]:
|
||||
_ok(String(_bear.get(k)) != "", "resolved %s" % k)
|
||||
|
||||
# Close band → melee (swipe / smash).
|
||||
var close_seen := await _observe(2.5, 6.0)
|
||||
print(" close-band states: ", close_seen.keys())
|
||||
_ok(close_seen.has("SWIPE") or close_seen.has("SMASH"), "close range → melee routine")
|
||||
# Charge was removed — the bear must never enter it.
|
||||
_ok(not close_seen.has("CHARGE"), "no charge routine (removed)")
|
||||
|
||||
# It must not climb on top of the bull: pressed right up against a pinned bull while
|
||||
# doing melee (never leaping), the grounded bear's height must stay near the floor.
|
||||
var max_y := -INF
|
||||
var ct := 0.0
|
||||
while ct < 4.0 and is_instance_valid(_bear):
|
||||
_pin_bull(1.5)
|
||||
await physics_frame
|
||||
ct += _dt()
|
||||
if not String(_bear.call("ai_state_name")).begins_with("LEAP"):
|
||||
max_y = maxf(max_y, _bear.global_position.y)
|
||||
_ok(max_y < 0.6, "bear does not climb on top of the bull (max grounded y=%.2f)" % max_y)
|
||||
|
||||
# Claw slashes: an emitter is bound to each paw, and one fires during the 1-2 punch.
|
||||
_ok(_bear.get("_claw_l") != null and _bear.get("_claw_r") != null, "claw emitters bound to both paws")
|
||||
var clawed := false
|
||||
var wt := 0.0
|
||||
while wt < 8.0 and is_instance_valid(_bear) and not clawed:
|
||||
_pin_bull(2.5)
|
||||
await physics_frame
|
||||
wt += _dt()
|
||||
var cl: CPUParticles3D = _bear.get("_claw_l")
|
||||
var cr: CPUParticles3D = _bear.get("_claw_r")
|
||||
if (cl != null and cl.emitting) or (cr != null and cr.emitting):
|
||||
clawed = true
|
||||
_ok(clawed, "claw slash emits during the 1-2 punch")
|
||||
|
||||
# Mid band → walk-in (stalk) or leap.
|
||||
var mid_seen := await _observe(7.0, 8.0)
|
||||
print(" mid-band states: ", mid_seen.keys())
|
||||
_ok(mid_seen.has("STALK") or mid_seen.has("LEAP"), "mid range → stalk/leap")
|
||||
|
||||
# Routines reach ACTIVE (hit) and RECOVER (punish) phases.
|
||||
var saw_active := false
|
||||
var saw_recover := false
|
||||
var t := 0.0
|
||||
while t < 8.0 and is_instance_valid(_bear):
|
||||
_pin_bull(2.5)
|
||||
await physics_frame
|
||||
t += _dt()
|
||||
var s: String = _bear.call("ai_state_name")
|
||||
if s.contains(":ACTIVE"): saw_active = true
|
||||
if s.contains(":RECOVER"): saw_recover = true
|
||||
_ok(saw_active, "routines reach their ACTIVE (hit) phase")
|
||||
_ok(saw_recover, "routines reach their RECOVER (punish) phase")
|
||||
|
||||
# Leap must actually get airborne and then LAND before it recovers — the slam lands on
|
||||
# the ground (impact frame), not mid-air. Reset the bear to a known grounded spot, then
|
||||
# force one leap deterministically and follow it.
|
||||
_bear.call("_enter_neutral")
|
||||
_bear.global_position = Vector3(0, 1, 0)
|
||||
_bear.velocity = Vector3.ZERO
|
||||
var settle := 0
|
||||
while settle < 30 and not _bear.is_on_floor():
|
||||
_pin_bull(7.0)
|
||||
await physics_frame
|
||||
settle += 1
|
||||
var start_y: float = _bear.global_position.y
|
||||
_bear.call("_start_leap")
|
||||
var peak_y := start_y
|
||||
var recovered_on_floor := false
|
||||
var recovered_airborne := false
|
||||
var reached_recover := false
|
||||
t = 0.0
|
||||
while t < 4.0 and is_instance_valid(_bear):
|
||||
_pin_bull(7.0)
|
||||
await physics_frame
|
||||
t += _dt()
|
||||
peak_y = maxf(peak_y, _bear.global_position.y)
|
||||
var s: String = _bear.call("ai_state_name")
|
||||
if s.begins_with("LEAP:RECOVER"):
|
||||
reached_recover = true
|
||||
if _bear.is_on_floor(): recovered_on_floor = true
|
||||
else: recovered_airborne = true
|
||||
break
|
||||
_ok(peak_y > start_y + 0.3, "leap gets airborne (peak +%.2fm)" % (peak_y - start_y))
|
||||
_ok(reached_recover, "leap reaches its slam/recovery")
|
||||
_ok(recovered_on_floor and not recovered_airborne,
|
||||
"leap lands on the ground before recovering (frame-25 contact, no mid-air finish)")
|
||||
|
||||
# Hyper-armour: let the bear commit to a swing, then hit it — it must NOT drop into
|
||||
# STAGGER mid-swing (only its recovery is punishable), yet it must still take the wound.
|
||||
var in_active := false
|
||||
t = 0.0
|
||||
while t < 8.0 and is_instance_valid(_bear) and not in_active:
|
||||
_pin_bull(2.5)
|
||||
await physics_frame
|
||||
t += _dt()
|
||||
in_active = String(_bear.call("ai_state_name")).contains(":ACTIVE")
|
||||
_ok(in_active, "bear commits to an ACTIVE swing")
|
||||
|
||||
var hp_before := int(_bear.get("_hp"))
|
||||
var staggered_mid_swing := false
|
||||
var i := 0
|
||||
while i < 8 and is_instance_valid(_bear) and String(_bear.call("ai_state_name")).contains(":ACTIVE"):
|
||||
_pin_bull(2.5)
|
||||
_bear.call("apply_ability_hit", Vector3.RIGHT, 10.0, 0.0)
|
||||
await physics_frame
|
||||
if String(_bear.call("ai_state_name")).begins_with("STAGGER"):
|
||||
staggered_mid_swing = true
|
||||
i += 1
|
||||
_ok(not staggered_mid_swing, "hyper-armour: hits during a swing don't stagger it")
|
||||
_ok(int(_bear.get("_hp")) < hp_before, "bear still takes damage during the swing")
|
||||
|
||||
# Drive HP down (spaced out so it isn't stun-locked) and confirm phase 2 engages.
|
||||
t = 0.0
|
||||
while t < 8.0 and is_instance_valid(_bear) and not bool(_bear.get("_enraged")):
|
||||
_pin_bull(2.5)
|
||||
_bear.call("apply_ability_hit", Vector3.RIGHT, 10.0, 0.0)
|
||||
for _f in range(18): # ~0.3s gap between hits
|
||||
_pin_bull(2.5)
|
||||
await physics_frame
|
||||
t += _dt()
|
||||
_ok(bool(_bear.get("_enraged")), "phase 2 (enrage) triggered below the HP fraction")
|
||||
|
||||
# Finish it off and confirm death + free.
|
||||
for _k in range(int(_bear.get("_hp")) + 2):
|
||||
if is_instance_valid(_bear):
|
||||
_bear.call("apply_ability_hit", Vector3.RIGHT, 12.0, 0.0)
|
||||
for _f in range(3):
|
||||
await physics_frame
|
||||
await physics_frame
|
||||
_ok(is_instance_valid(_bear) and not _bear.call("is_active"), "bear dead after HP drained")
|
||||
await create_timer(6.0).timeout
|
||||
_ok(not is_instance_valid(_bear), "bear freed after death topple")
|
||||
|
||||
print("Results: %d passed, %d failed" % [_pass, _fail])
|
||||
quit(1 if _fail > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7wjoqgkpsepj
|
||||
@@ -0,0 +1,72 @@
|
||||
extends SceneTree
|
||||
## Level-isolation test: when the shell loads the Bear's Den, none of the arena level's
|
||||
## geometry — crucially its wall/floor colliders and audience — may survive into it.
|
||||
## Guards the modular-level contract: switching levels swaps the whole module, it never
|
||||
## hides old nodes (hidden colliders still collide). Run:
|
||||
## godot --headless --script tests/level_switch_test.gd
|
||||
|
||||
var _pass := 0
|
||||
var _fail := 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_run.call_deferred()
|
||||
|
||||
|
||||
func _ok(c: bool, m: String) -> void:
|
||||
if c:
|
||||
_pass += 1
|
||||
print(" PASS: ", m)
|
||||
else:
|
||||
_fail += 1
|
||||
print(" FAIL: ", m)
|
||||
|
||||
|
||||
func _count_named(node: Node, wanted: String, acc: int = 0) -> int:
|
||||
if node.name == wanted:
|
||||
acc += 1
|
||||
for child: Node in node.get_children():
|
||||
acc = _count_named(child, wanted, acc)
|
||||
return acc
|
||||
|
||||
|
||||
func _count_type(node: Node, klass: String, acc: int = 0) -> int:
|
||||
if node.is_class(klass):
|
||||
acc += 1
|
||||
for child: Node in node.get_children():
|
||||
acc = _count_type(child, klass, acc)
|
||||
return acc
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var run: Node = root.get_node("Run")
|
||||
run.ensure_run()
|
||||
# Force the shell to build the Bear's Den regardless of the generated map.
|
||||
run.debug_level_override = run.get_level(&"bear")
|
||||
|
||||
var scene: Node = load("res://scene.tscn").instantiate()
|
||||
root.add_child(scene)
|
||||
current_scene = scene
|
||||
# Let the shell instance the level and the spawner's deferred spawn run.
|
||||
await create_timer(0.8).timeout
|
||||
|
||||
# Shell survives and hosts exactly one level module.
|
||||
var level_root: Node = scene.get_node_or_null("LevelRoot")
|
||||
_ok(level_root != null, "shell has a LevelRoot")
|
||||
_ok(level_root != null and level_root.get_child_count() == 1, "exactly one level module loaded")
|
||||
_ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
|
||||
|
||||
# The leak we are guarding against: no arena geometry may exist in the bear level.
|
||||
_ok(_count_named(scene, "arena_placeholder_v01") == 0, "arena placeholder (+ its wall collider) gone")
|
||||
_ok(_count_named(scene, "Arena2") == 0, "arena mesh 'Arena2' gone")
|
||||
_ok(_count_named(scene, "PublikNode") == 0, "arena audience gone from bear level")
|
||||
|
||||
# The bear level brings its own geometry, one floor collider, and its boss.
|
||||
_ok(_count_named(scene, "bear_level_arena") == 1, "bear arena mesh present")
|
||||
_ok(_count_type(level_root, "StaticBody3D") >= 1, "bear level has its own floor collider")
|
||||
|
||||
await create_timer(0.4).timeout
|
||||
_ok(not get_nodes_in_group(&"bear").is_empty(), "bear boss spawned in bear level")
|
||||
|
||||
print("Results: %d passed, %d failed" % [_pass, _fail])
|
||||
quit(1 if _fail > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6ud4uune1clf
|
||||
Reference in New Issue
Block a user