From 400ae24f92271eed884c74df8203a32c39985f7c Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Fri, 4 Sep 2026 22:41:00 +0300 Subject: [PATCH] Remove the persistent blood-decal system Strip only the floor/wall blood-splat decals added in 981ebf1, keeping the older death-spray, spear-wound spurt, and "Blood & Gore" toggle: - Delete blood_decals.gd + shader, the Gore autoload (gore.gd), and tests/blood_decals_test.gd - Drop the Gore autoload and the Blood DP section - Remove all Gore.splat() call sites (matador.gd, bear.gd, player.gd); the _blood_burst spray and _spawn_blood spurt stay - game_shell no longer spawns a per-level decal pool - Update level_switch test + run_tests.sh accordingly Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1 --- bear.gd | 2 - blood_decals.gd | 194 --------------------------------- blood_decals.gd.uid | 1 - blood_decals.gdshader | 26 ----- blood_decals.gdshader.uid | 1 - debug_params.gd | 12 -- gore.gd | 15 --- gore.gd.uid | 1 - levels/game_shell.gd | 3 - matador.gd | 1 - player.gd | 3 - project.godot | 1 - run_tests.sh | 4 - tests/blood_decals_test.gd | 97 ----------------- tests/blood_decals_test.gd.uid | 1 - tests/level_switch_test.gd | 9 +- 16 files changed, 2 insertions(+), 369 deletions(-) delete mode 100644 blood_decals.gd delete mode 100644 blood_decals.gd.uid delete mode 100644 blood_decals.gdshader delete mode 100644 blood_decals.gdshader.uid delete mode 100644 gore.gd delete mode 100644 gore.gd.uid delete mode 100644 tests/blood_decals_test.gd delete mode 100644 tests/blood_decals_test.gd.uid diff --git a/bear.gd b/bear.gd index bd5a4bc..956246f 100644 --- a/bear.gd +++ b/bear.gd @@ -884,7 +884,6 @@ func _take_hit(hit_dir: Vector3, strength: float) -> void: hit_dir.y = 0.0 hit_dir = hit_dir.normalized() _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) - Gore.splat(global_position, hit_dir, 1.1) _shake(clampf(strength / 15.0, 0.4, 1.0)) if _hp <= 0: _die(hit_dir) @@ -920,7 +919,6 @@ func _die(hit_dir: Vector3) -> void: _hit_area.monitoring = false velocity = Vector3.ZERO _blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir) - Gore.splat(global_position, hit_dir, 1.4) _play(_anim_death) # No death clip on the rig — topple the beast onto its side (about the hit direction, # so it falls the way it was struck) and sink it away, then free. diff --git a/blood_decals.gd b/blood_decals.gd deleted file mode 100644 index 64e2f5c..0000000 --- a/blood_decals.gd +++ /dev/null @@ -1,194 +0,0 @@ -extends MultiMeshInstance3D -## A whole level's worth of persistent blood splats rendered in one draw call. Each stain -## is a flat quad laid on the surface it hit — floor or a nearby wall — picked from a -## procedurally-baked atlas of splat shapes and jittered per instance (random shape, yaw, -## size, brightness) so no two read alike. -## -## The pool is a fixed ring buffer: new splats overwrite the oldest, so the instance count -## and fill cost stay bounded no matter how long a fight runs — the property that keeps it -## cheap on the web/mobile target. The game shell rebuilds this node into LevelRoot on every -## level load, so the stains die with the level (exactly one level's worth, never leaked -## into the next). Fire splats through the `Gore` autoload, not this node directly. - -const PhysicsLayers = preload("res://physics_layers.gd") -const SHADER_PATH := "res://blood_decals.gdshader" -const GROUP := &"blood_decals" - -# Atlas: ATLAS_CELLS distinct splat shapes packed into one row, CELL_PX square each. -const ATLAS_CELLS := 8 -const CELL_PX := 64 - -var _rng := RandomNumberGenerator.new() -var _mm: MultiMesh -var _cap: int = 0 -var _next: int = 0 # ring-buffer write cursor -var _filled: int = 0 # how many ring-buffer slots have ever been stamped, capped at _cap - - -func _ready() -> void: - add_to_group(GROUP) - _rng.randomize() - _cap = maxi(int(DP.f("blood_cap")), 0) - _build() - - -func _build() -> void: - var quad := QuadMesh.new() - quad.size = Vector2.ONE # unit quad; the instance basis carries the real size - - var mat := ShaderMaterial.new() - mat.shader = load(SHADER_PATH) as Shader - mat.set_shader_parameter("atlas", _bake_atlas()) - mat.set_shader_parameter("cells", ATLAS_CELLS) - material_override = mat - - _mm = MultiMesh.new() - _mm.transform_format = MultiMesh.TRANSFORM_3D - _mm.use_custom_data = true - _mm.mesh = quad - _mm.instance_count = _cap - # Godot renders only the first `visible_instance_count` instances, so unstained slots simply - # aren't drawn — no need to park them off-map. (Parking them via set_instance_transform doesn't - # work anyway: MultiMesh's per-instance buffer lives server-side and isn't reliably readable - # back through get_instance_transform, so a "hide by moving far away" scheme can't even be - # verified, let alone trusted.) Starts at 0 and grows as splats land. - _mm.visible_instance_count = 0 - multimesh = _mm - - -## Stain the world at `world_pos`: one splat cluster on the floor beneath it, plus a splat -## on any wall within `blood_wall_reach`. `dir` is the spray heading (used to seed the wall -## fan); `size` is the base quad size in metres. -func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void: - if not is_instance_valid(_mm) or _cap <= 0: - return - var space := get_world_3d().direct_space_state - if space == null: - return - - # Floor directly under the hit. - var floor_hit := _ray(space, world_pos + Vector3.UP * 0.5, world_pos + Vector3.DOWN * 4.0) - if not floor_hit.is_empty(): - _stamp_cluster(floor_hit.position, floor_hit.normal, size) - - # Walls near the hit: a fan of outward rays, seeded on the spray heading. Only surfaces - # within reach catch blood, so an open-arena hit stains nothing but the floor. - var rays := maxi(int(DP.f("blood_wall_rays")), 0) - if rays <= 0: - return - var reach := DP.f("blood_wall_reach") - var seed_dir := Vector3(dir.x, 0.0, dir.z) - seed_dir = seed_dir.normalized() if seed_dir.length_squared() > 0.01 else Vector3.FORWARD - var origin := world_pos + Vector3.UP * 0.6 - for k in rays: - var ang := TAU * (float(k) + 0.5) / float(rays) - var out := seed_dir.rotated(Vector3.UP, ang) - var wall_hit := _ray(space, origin, origin + out * reach) - # Only stain near-vertical surfaces here; the floor is already handled above. - if not wall_hit.is_empty() and absf((wall_hit.normal as Vector3).y) < 0.6: - _stamp(wall_hit.position, wall_hit.normal, size * 0.85) - - -func _ray(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3) -> Dictionary: - var q := PhysicsRayQueryParameters3D.create( - from, to, PhysicsLayers.WORLD | PhysicsLayers.CORPSE - ) - q.collide_with_bodies = true - return space.intersect_ray(q) - - -# A main splat plus a scatter of smaller droplets around it, all lying on the same surface. -func _stamp_cluster(pos: Vector3, normal: Vector3, size: float) -> void: - _stamp(pos, normal, size) - var n := normal.normalized() - if n.length_squared() < 0.5: - n = Vector3.UP - var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD - var tx := up.cross(n).normalized() - var ty := n.cross(tx).normalized() - var count := maxi(int(DP.f("blood_satellites")), 0) - for s in count: - var rad := size * _rng.randf_range(0.4, 1.2) - var a := _rng.randf() * TAU - var off := tx * (cos(a) * rad) + ty * (sin(a) * rad) - _stamp(pos + off, n, size * _rng.randf_range(0.25, 0.5)) - - -# Write one splat into the ring buffer, oriented flat on the surface and lifted a hair along -# its normal (layered by write index) so coplanar quads don't z-fight the surface or each other. -func _stamp(pos: Vector3, normal: Vector3, size: float) -> void: - var n := normal.normalized() - if n.length_squared() < 0.5: - n = Vector3.UP - var i := _next - _next = (_next + 1) % _cap - var eps := 0.015 + float(i) * 0.00015 - var yaw := _rng.randf() * TAU - _mm.set_instance_transform(i, Transform3D(_surface_basis(n, yaw, size), pos + n * eps)) - var cell := float(_rng.randi_range(0, ATLAS_CELLS - 1)) - var shade := _rng.randf_range(0.6, 1.0) - _mm.set_instance_custom_data(i, Color(cell, shade, 0.0, 0.0)) - _filled = mini(_filled + 1, _cap) - _mm.visible_instance_count = _filled - - -# Basis for a QuadMesh (face along local +Z) lying flat on a surface with the given normal, -# spun by `yaw` about that normal and uniformly scaled to `size`. -func _surface_basis(n: Vector3, yaw: float, size: float) -> Basis: - var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD - var x := up.cross(n).normalized() - var y := n.cross(x).normalized() - var c := cos(yaw) - var s := sin(yaw) - var b := Basis() - b.x = (x * c + y * s) * size - b.y = (y * c - x * s) * size - b.z = n - return b - - -# ── Atlas baking ────────────────────────────────────────────────────────────── -# Draw ATLAS_CELLS irregular blood shapes into one row. Each shape is a metaball field — -# a big central blob, a few overlapping lobes and some flung droplets — thresholded to an -# organic silhouette. Baked once per pool (once per level load, ~ms) with a fixed seed so -# the shape set is deterministic and every level's stains match. - -func _bake_atlas() -> ImageTexture: - var img := Image.create(ATLAS_CELLS * CELL_PX, CELL_PX, false, Image.FORMAT_RGBA8) - img.fill(Color(0.0, 0.0, 0.0, 0.0)) - var rng := RandomNumberGenerator.new() - rng.seed = hash("bullosseum-blood") - for c in ATLAS_CELLS: - _draw_splat(img, c * CELL_PX, rng) - return ImageTexture.create_from_image(img) - - -func _draw_splat(img: Image, ox: int, rng: RandomNumberGenerator) -> void: - var px := float(CELL_PX) - var mid := px * 0.5 - # blobs are (centre_x, centre_y, radius) — a central mass, lobes, then far droplets. - var blobs: Array[Vector3] = [] - blobs.append(Vector3(mid, mid, px * rng.randf_range(0.22, 0.30))) - for i in rng.randi_range(3, 6): - var a := rng.randf() * TAU - var d := px * rng.randf_range(0.10, 0.34) - blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.06, 0.16))) - for i in rng.randi_range(2, 5): - var a := rng.randf() * TAU - var d := px * rng.randf_range(0.30, 0.46) - blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.02, 0.05))) - - for y in CELL_PX: - for x in CELL_PX: - var field := 0.0 - for b in blobs: - var dx := float(x) - b.x - var dy := float(y) - b.y - field += (b.z * b.z) / (dx * dx + dy * dy + 1.0) - var a := smoothstep(0.75, 1.15, field) - if a <= 0.004: - continue - # Denser field (splat interior) reads a touch richer/darker than the thin edges. - var t := clampf(field * 0.5, 0.0, 1.0) - var col := Color(0.62 - 0.16 * t, 0.05 - 0.03 * t, 0.04 - 0.02 * t, a) - img.set_pixel(ox + x, y, col) diff --git a/blood_decals.gd.uid b/blood_decals.gd.uid deleted file mode 100644 index 261d4e0..0000000 --- a/blood_decals.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cs8fhiasw7q7j diff --git a/blood_decals.gdshader b/blood_decals.gdshader deleted file mode 100644 index 498f3ea..0000000 --- a/blood_decals.gdshader +++ /dev/null @@ -1,26 +0,0 @@ -shader_type spatial; -// Persistent blood splats, drawn as one MultiMesh of flat quads (see blood_decals.gd). -// Unshaded and nearest-filtered to sit inside the PS1 look; alpha-blended so overlapping -// splats pool darker. depth_draw_never + the small normal offset the pool bakes into each -// transform keep the coplanar floor/wall quads from z-fighting the surface they stain. -render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_never, blend_mix; - -uniform sampler2D atlas : source_color, filter_nearest; -// Number of splat shapes packed side-by-side in the atlas (one row). -uniform int cells = 8; - -varying flat float v_cell; -varying flat float v_shade; - -void vertex() { - // x = which atlas shape this instance uses; y = per-instance brightness (fresh vs old). - v_cell = INSTANCE_CUSTOM.x; - v_shade = INSTANCE_CUSTOM.y; -} - -void fragment() { - float u = (UV.x + v_cell) / float(cells); - vec4 c = texture(atlas, vec2(u, UV.y)); - ALBEDO = c.rgb * v_shade; - ALPHA = c.a; -} diff --git a/blood_decals.gdshader.uid b/blood_decals.gdshader.uid deleted file mode 100644 index eb93930..0000000 --- a/blood_decals.gdshader.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cj5rmaywacvbj diff --git a/debug_params.gd b/debug_params.gd index 1b7e744..1c04cc3 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -306,18 +306,6 @@ func _register_all() -> void: # 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) - # ── Blood ───────────────────────────────────────────────────────────────── - # Persistent floor/wall splats (blood_decals.gd), fired via the Gore autoload and - # gated by the player's Settings.gore. blood_cap is the ring-buffer size: past it, - # new splats overwrite the oldest so fill/memory stay bounded. blood_scale is a - # global size multiplier over each hit's own size; satellites are extra droplets - # scattered per floor cluster; the wall fan casts blood_wall_rays outward and stains - # any wall within blood_wall_reach metres of the hit. - _reg_f("Blood", "blood_cap", 192.0, 0.0, 512.0, 16.0) - _reg_f("Blood", "blood_scale", 1.0, 0.1, 4.0, 0.05) - _reg_f("Blood", "blood_satellites", 3.0, 0.0, 8.0, 1.0) - _reg_f("Blood", "blood_wall_reach", 2.5, 0.0, 6.0, 0.1) - _reg_f("Blood", "blood_wall_rays", 8.0, 0.0, 16.0, 1.0) # ── Sword ───────────────────────────────────────────────────────────────── # Local seating of the sword in the right hand (drawn / fighting). # The blade model runs along its local +Z, but weapon_bone (like every rig bone) diff --git a/gore.gd b/gore.gd deleted file mode 100644 index 26abc3f..0000000 --- a/gore.gd +++ /dev/null @@ -1,15 +0,0 @@ -extends Node -## Stateless façade (autoload `Gore`) for spawning persistent blood splats. Callers just say -## Gore.splat(pos, dir, size) at each wound; this routes to the level's BloodDecals pool -## (group &"blood_decals"), which the game shell rebuilds into LevelRoot on every load so the -## stains die with the level. No-ops when there's no pool (menus, headless tests without a -## level) or when the player has turned gore off — so the gate lives in one place. - - -func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void: - if not Settings.gore: - return - var pool := get_tree().get_first_node_in_group(&"blood_decals") - if pool == null: - return - pool.call(&"splat", world_pos, dir, size * DP.f("blood_scale")) diff --git a/gore.gd.uid b/gore.gd.uid deleted file mode 100644 index 4872c9e..0000000 --- a/gore.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b3dobof8qule3 diff --git a/levels/game_shell.gd b/levels/game_shell.gd index 62a6ab1..27c9450 100644 --- a/levels/game_shell.gd +++ b/levels/game_shell.gd @@ -30,9 +30,6 @@ func load_active_level() -> void: var module := level.level_scene.instantiate() _tag_environment_for_corpses(module) _level_root.add_child(module) - # A fresh blood-splat pool for this level, alongside the module under LevelRoot so it's - # torn down with everything else on the next load — stains never leak into the next fight. - _level_root.add_child(preload("res://blood_decals.gd").new()) ## Ground and walls stay on WORLD so the bull and live matadors collide with them; here diff --git a/matador.gd b/matador.gd index 36b053f..6829385 100644 --- a/matador.gd +++ b/matador.gd @@ -974,7 +974,6 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0) var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized() _blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir) - Gore.splat(global_position, hit_dir, 0.55) if _death_player: _death_player.pitch_scale = randf_range(0.9, 1.1) _death_player.play() diff --git a/player.gd b/player.gd index db05e2a..f3a4d2a 100644 --- a/player.gd +++ b/player.gd @@ -1205,9 +1205,6 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void # bull). Melee gores pass no position and skip the spurt; gore can be turned off. if cause == "thrown" and Settings.gore: _spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position) - # Any hit — melee or thrown — leaves a lasting stain on the ground the bull bled on. - var splat_dir := global_position - hit_pos if hit_pos != Vector3.ZERO else Vector3.ZERO - Gore.splat(global_position, splat_dir, 0.5) if _huff_player: _huff_player.pitch_scale = randf_range(0.7, 0.9) _huff_player.play() diff --git a/project.godot b/project.godot index 673b3d4..fffe493 100644 --- a/project.godot +++ b/project.godot @@ -29,7 +29,6 @@ Settings="*res://settings.gd" Run="*res://levels/run_state.gd" OrientationGuard="*res://orientation_guard.gd" WebStartGate="*res://web_start_gate.gd" -Gore="*res://gore.gd" [display] diff --git a/run_tests.sh b/run_tests.sh index 7c6d409..050c0a0 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -26,10 +26,6 @@ echo "" echo "=== Touch control tests (joystick self-heals, never strands) ===" "$GODOT" --headless --script tests/touch_controls_test.gd -echo "" -echo "=== Blood decal tests (floor+wall stamps, ring-buffer cap, gore gate) ===" -"$GODOT" --headless --script tests/blood_decals_test.gd - echo "" echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ===" "$GODOT" --headless --script tests/rigid_skin_test.gd diff --git a/tests/blood_decals_test.gd b/tests/blood_decals_test.gd deleted file mode 100644 index 330a1f2..0000000 --- a/tests/blood_decals_test.gd +++ /dev/null @@ -1,97 +0,0 @@ -extends SceneTree -## Persistent blood splats (blood_decals.gd + the Gore autoload). Guards four contracts: -## • a splat stamps quads onto the floor and onto a wall within reach of the hit; -## • the ring buffer caps the instance count no matter how many hits land; -## • Settings.gore = false spawns nothing; -## • the pool is a plain node, so it frees with its parent (a level swap clears the stains). -## blood_decals.gd references the DP autoload, so it's load()ed at runtime here (not a -## top-level preload) — under --script a preload compiles before autoloads register. -## Run: godot --headless --script tests/blood_decals_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) - - -# Count instances actually placed on the map. Only the first `visible_instance_count` slots of -# the ring buffer are drawn, so that's the live count once the buffer has wrapped. -func _live_instances(pool: Node) -> int: - var mm: MultiMesh = pool.multimesh - if mm == null: - return 0 - return mm.visible_instance_count - - -func _static_box(size: Vector3, pos: Vector3) -> StaticBody3D: - var body := StaticBody3D.new() - body.collision_layer = 1 # PhysicsLayers.WORLD - body.position = pos - var shape := CollisionShape3D.new() - var box := BoxShape3D.new() - box.size = size - shape.shape = box - body.add_child(shape) - return body - - -func _run() -> void: - var dp: Node = root.get_node("DP") - var settings: Node = root.get_node("Settings") - var cap := int(dp.f("blood_cap")) - - # A minimal world: a floor plane and one vertical wall, both on WORLD so the splat rays hit. - var world := Node3D.new() - root.add_child(world) - world.add_child(_static_box(Vector3(40, 1, 40), Vector3(0, -0.5, 0))) # floor - world.add_child(_static_box(Vector3(1, 6, 40), Vector3(1.4, 3, 0))) # wall at x≈1.4 - - var pool: Node = (load("res://blood_decals.gd") as GDScript).new() - world.add_child(pool) - await create_timer(0.4).timeout - - # Baked atlas exists and is one row of 8 shapes. - var atlas: Texture2D = pool.material_override.get_shader_parameter("atlas") - _ok(atlas != null and atlas.get_width() == 8 * 64, "splat atlas baked (8 cells)") - - # A hit next to the wall stains both the floor beneath it and the wall. - settings.gore = true - pool.splat(Vector3(0.8, 0.1, 0.0), Vector3(1, 0, 0), 0.6) - var after_one := _live_instances(pool) - _ok(after_one > 0, "a splat stamps the floor (got %d quads)" % after_one) - # Floor cluster is 1 + satellites; anything beyond that came from the wall fan. - var floor_max := 1 + int(dp.f("blood_satellites")) - _ok( - after_one > floor_max, - "a nearby wall also catches blood (%d > floor-only %d)" % [after_one, floor_max] - ) - - # Ring buffer: hammer far past the cap; live count never exceeds it. - for i in cap * 2: - pool.splat(Vector3(0.0, 0.1, 0.0), Vector3(1, 0, 0), 0.4) - _ok(_live_instances(pool) <= cap, "ring buffer holds at the cap (%d)" % cap) - - # Gore off: no new stains. Route through the Gore autoload to prove the gate lives there. - var before_off := _live_instances(pool) - settings.gore = false - root.get_node("Gore").splat(Vector3(5, 0.1, 0.0), Vector3.ZERO, 0.6) - _ok(_live_instances(pool) == before_off, "gore off spawns nothing") - - # Lifetime: the pool is a plain node, so freeing its parent (a level swap) clears it. - world.queue_free() - await create_timer(0.2).timeout - _ok(not is_instance_valid(pool), "pool frees with its parent (no leak into next level)") - - print("Results: %d passed, %d failed" % [_pass, _fail]) - quit(1 if _fail > 0 else 0) diff --git a/tests/blood_decals_test.gd.uid b/tests/blood_decals_test.gd.uid deleted file mode 100644 index f25c170..0000000 --- a/tests/blood_decals_test.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8em8poj26a57 diff --git a/tests/level_switch_test.gd b/tests/level_switch_test.gd index e09c7a8..a8508d1 100644 --- a/tests/level_switch_test.gd +++ b/tests/level_switch_test.gd @@ -53,13 +53,8 @@ func _run() -> void: # 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") - # LevelRoot holds the module plus the level's blood-splat pool; exactly one of them - # is the geometry module (the other is BloodDecals). - var module_children := 0 - if level_root != null: - for child: Node in level_root.get_children(): - if not child.is_in_group(&"blood_decals"): - module_children += 1 + # LevelRoot holds exactly the one level module. + var module_children := level_root.get_child_count() if level_root != null else 0 _ok(module_children == 1, "exactly one level module loaded") _ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")