From 6abf5355c2b4631fe6bd3e4745fa5753500b4307 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Mon, 25 May 2026 20:51:48 +0300 Subject: [PATCH] tests & can charge up thrust --- .gdlintrc | 4 + Blender/create_matador_anims.py | 151 +++++++++++++++++ Blender/render_anim_preview.py | 136 +++++++++++++++ debug_params.gd | 3 + player.gd | 42 +++-- run_tests.sh | 25 +++ tests/gameplay_test.gd | 259 +++++++++++++++++++++++++++++ tests/logic_test.gd | 286 ++++++++++++++++++++++++++++++++ tests/ragdoll_throw_test.gd | 83 +++++++++ tests/screenshot_test.gd | 48 ++++++ 10 files changed, 1022 insertions(+), 15 deletions(-) create mode 100644 .gdlintrc create mode 100644 Blender/create_matador_anims.py create mode 100644 Blender/render_anim_preview.py create mode 100755 run_tests.sh create mode 100644 tests/gameplay_test.gd create mode 100644 tests/logic_test.gd create mode 100644 tests/ragdoll_throw_test.gd create mode 100644 tests/screenshot_test.gd diff --git a/.gdlintrc b/.gdlintrc new file mode 100644 index 0000000..615dc0e --- /dev/null +++ b/.gdlintrc @@ -0,0 +1,4 @@ +disable: + - class-definitions-order + - no-else-return +max-line-length: 120 diff --git a/Blender/create_matador_anims.py b/Blender/create_matador_anims.py new file mode 100644 index 0000000..a83fafc --- /dev/null +++ b/Blender/create_matador_anims.py @@ -0,0 +1,151 @@ +""" +Blender 5.1 script: create walk + idle animations for matador_v02. +Run: blender --background matador_v02.blend --python create_matador_anims.py +""" +import bpy, math, os + + +# ── setup ────────────────────────────────────────────────────────────────────── +arm_obj = next(o for o in bpy.data.objects if o.type == 'ARMATURE') +bpy.context.view_layer.objects.active = arm_obj +bpy.ops.object.mode_set(mode='POSE') + +if arm_obj.animation_data is None: + arm_obj.animation_data_create() + +pb = arm_obj.pose.bones + +# Ensure all animated bones use XYZ Euler +for bname in ['leg_L', 'leg_R', 'shin_L', 'shin_R', + 'arm_L', 'arm_R', 'forearm_L', 'forearm_R', + 'collarbone_L', 'collarbone_R']: + pb[bname].rotation_mode = 'XYZ' + +CYCLE = 30 # frames (1 s at 30 fps) +LEG_SWING = 25 # degrees +KNEE_BEND = 30 +ARM_SWING = 15 +ARM_DOWN = 55 # lower arms from T-pose around bone-local Y +ELBOW_BEND = 25 + + +# ── WALK action ──────────────────────────────────────────────────────────────── +# Remove old actions if they exist +for old_name in ['walk', 'idle']: + if old_name in bpy.data.actions: + bpy.data.actions.remove(bpy.data.actions[old_name]) + +walk = bpy.data.actions.new(name='walk') +walk.use_fake_user = True +walk.use_cyclic = True +arm_obj.animation_data.action = walk + +# Clear all pose bone transforms first +bpy.ops.pose.select_all(action='SELECT') +bpy.ops.pose.rot_clear() + +arm_down_l = math.radians(ARM_DOWN) +arm_down_r = math.radians(-ARM_DOWN) + +for f in range(0, CYCLE + 1, 2): + t = (f / CYCLE) * math.tau + + # Legs – swing forward/back on bone-local X + pb['leg_L'].rotation_euler = (math.sin(t) * math.radians(LEG_SWING), 0, 0) + pb['leg_L'].keyframe_insert(data_path='rotation_euler', frame=f) + + pb['leg_R'].rotation_euler = (math.sin(t + math.pi) * math.radians(LEG_SWING), 0, 0) + pb['leg_R'].keyframe_insert(data_path='rotation_euler', frame=f) + + # Shins – only bend backward when leg is back + val_l = max(0.0, -math.sin(t)) * math.radians(KNEE_BEND) + val_r = max(0.0, -math.sin(t + math.pi)) * math.radians(KNEE_BEND) + pb['shin_L'].rotation_euler = (val_l, 0, 0) + pb['shin_L'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['shin_R'].rotation_euler = (val_r, 0, 0) + pb['shin_R'].keyframe_insert(data_path='rotation_euler', frame=f) + + # Arms – lower from T-pose (Y) + swing (X, opposite phase to legs) + swing_l = math.sin(t + math.pi) * math.radians(ARM_SWING) + swing_r = math.sin(t) * math.radians(ARM_SWING) + pb['arm_L'].rotation_euler = (swing_l, arm_down_l, 0) + pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['arm_R'].rotation_euler = (swing_r, arm_down_r, 0) + pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f) + + # Forearms – constant slight bend + pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0) + pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0) + pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f) + + # Collarbones – identity + pb['collarbone_L'].rotation_euler = (0, 0, 0) + pb['collarbone_L'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['collarbone_R'].rotation_euler = (0, 0, 0) + pb['collarbone_R'].keyframe_insert(data_path='rotation_euler', frame=f) + +print("Created 'walk' action") + + +# ── IDLE action ──────────────────────────────────────────────────────────────── +idle = bpy.data.actions.new(name='idle') +idle.use_fake_user = True +arm_obj.animation_data.action = idle + +bpy.ops.pose.select_all(action='SELECT') +bpy.ops.pose.rot_clear() + +# Arms lowered, everything else at rest – 2 frames for a valid action +for f in [0, 1]: + pb['arm_L'].rotation_euler = (0, arm_down_l, 0) + pb['arm_L'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['arm_R'].rotation_euler = (0, arm_down_r, 0) + pb['arm_R'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['forearm_L'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0) + pb['forearm_L'].keyframe_insert(data_path='rotation_euler', frame=f) + pb['forearm_R'].rotation_euler = (math.radians(ELBOW_BEND), 0, 0) + pb['forearm_R'].keyframe_insert(data_path='rotation_euler', frame=f) + for bname in ['collarbone_L', 'collarbone_R', 'leg_L', 'leg_R', 'shin_L', 'shin_R']: + pb[bname].rotation_euler = (0, 0, 0) + pb[bname].keyframe_insert(data_path='rotation_euler', frame=f) + +print("Created 'idle' action") + + +# ── NLA tracks for export ───────────────────────────────────────────────────── +bpy.ops.object.mode_set(mode='OBJECT') + +# Clear existing NLA tracks +if arm_obj.animation_data.nla_tracks: + for t in list(arm_obj.animation_data.nla_tracks): + arm_obj.animation_data.nla_tracks.remove(t) + +tw = arm_obj.animation_data.nla_tracks.new() +tw.name = 'walk' +sw = tw.strips.new('walk', 0, walk) + +ti = arm_obj.animation_data.nla_tracks.new() +ti.name = 'idle' +si = ti.strips.new('idle', 0, idle) + +# ── export FBX ───────────────────────────────────────────────────────────────── +fbx_path = os.path.join(os.path.dirname(bpy.data.filepath), 'matador_v02.fbx') +bpy.ops.export_scene.fbx( + filepath=fbx_path, + use_selection=False, + bake_anim=True, + bake_anim_use_all_actions=True, + bake_anim_use_nla_strips=False, + bake_anim_force_startend_keying=True, + bake_anim_step=1.0, + bake_anim_simplify_factor=0.0, + add_leaf_bones=False, + primary_bone_axis='Y', + secondary_bone_axis='X', + apply_scale_options='FBX_SCALE_ALL', +) +print('Exported FBX to:', fbx_path) + +bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath) +print('Saved .blend') diff --git a/Blender/render_anim_preview.py b/Blender/render_anim_preview.py new file mode 100644 index 0000000..4b29250 --- /dev/null +++ b/Blender/render_anim_preview.py @@ -0,0 +1,136 @@ +""" +Blender 5.1 script: render preview frames of matador animations to PNG. +Run: blender --background matador_v02.blend --python render_anim_preview.py + +Outputs to Blender/preview/ directory: + walk_frame_00.png .. walk_frame_09.png + idle_frame_00.png + +Use this to visually verify bone orientations BEFORE exporting to Godot. +""" +import bpy +import os +import math + +# ── Configuration ───────────────────────────────────────────────────────────── +PREVIEW_DIR = os.path.join(os.path.dirname(bpy.data.filepath), "preview") +RENDER_RES_X = 512 +RENDER_RES_Y = 512 +WALK_FRAMES = 10 # number of evenly-spaced frames to render from walk cycle +CAMERA_DISTANCE = 5.0 +CAMERA_HEIGHT = 1.0 +CAMERA_ANGLES = [0, 90] # degrees — front and side views + +os.makedirs(PREVIEW_DIR, exist_ok=True) + +# ── Find armature ───────────────────────────────────────────────────────────── +arm_obj = next((o for o in bpy.data.objects if o.type == 'ARMATURE'), None) +if arm_obj is None: + raise RuntimeError("No armature found in scene") + +# ── Setup render settings ───────────────────────────────────────────────────── +scene = bpy.context.scene +scene.render.resolution_x = RENDER_RES_X +scene.render.resolution_y = RENDER_RES_Y +scene.render.resolution_percentage = 100 +scene.render.film_transparent = True +scene.render.image_settings.file_format = 'PNG' +scene.render.image_settings.color_mode = 'RGBA' +scene.render.engine = 'BLENDER_EEVEE' + +# ── Create or reuse camera ──────────────────────────────────────────────────── +cam_data = bpy.data.cameras.get("PreviewCam") or bpy.data.cameras.new("PreviewCam") +cam_data.lens = 50 +cam_obj = bpy.data.objects.get("PreviewCamObj") +if cam_obj is None: + cam_obj = bpy.data.objects.new("PreviewCamObj", cam_data) + scene.collection.objects.link(cam_obj) +scene.camera = cam_obj + +# ── Create or reuse key light ───────────────────────────────────────────────── +light_data = bpy.data.lights.get("PreviewLight") or bpy.data.lights.new("PreviewLight", type='SUN') +light_data.energy = 3.0 +light_obj = bpy.data.objects.get("PreviewLightObj") +if light_obj is None: + light_obj = bpy.data.objects.new("PreviewLightObj", light_data) + scene.collection.objects.link(light_obj) +light_obj.rotation_euler = (math.radians(45), math.radians(30), 0) + +# ── Create or reuse fill light ──────────────────────────────────────────────── +fill_data = bpy.data.lights.get("PreviewFill") or bpy.data.lights.new("PreviewFill", type='SUN') +fill_data.energy = 1.0 +fill_obj = bpy.data.objects.get("PreviewFillObj") +if fill_obj is None: + fill_obj = bpy.data.objects.new("PreviewFillObj", fill_data) + scene.collection.objects.link(fill_obj) +fill_obj.rotation_euler = (math.radians(60), math.radians(-45), 0) + +# ── Ensure armature is visible ──────────────────────────────────────────────── +arm_obj.hide_render = False +arm_obj.hide_viewport = False +for child in arm_obj.children: + child.hide_render = False + child.hide_viewport = False + + +def position_camera(angle_deg: float) -> None: + """Place camera at given horizontal angle around the armature center.""" + rad = math.radians(angle_deg) + cam_obj.location = ( + math.sin(rad) * CAMERA_DISTANCE, + CAMERA_HEIGHT, + math.cos(rad) * CAMERA_DISTANCE, + ) + # Point camera at armature center (roughly hip height) + target = arm_obj.location.copy() + target.y = 0.9 + direction = target - cam_obj.location + rot_quat = direction.to_track_quat('-Z', 'Y') + cam_obj.rotation_euler = rot_quat.to_euler() + + +def render_action(action_name: str, frame_count: int) -> None: + """Render frame_count evenly-spaced frames of the named action.""" + action = bpy.data.actions.get(action_name) + if action is None: + print(f"WARNING: Action '{action_name}' not found, skipping") + return + + # Assign action + if arm_obj.animation_data is None: + arm_obj.animation_data_create() + arm_obj.animation_data.action = action + + frame_start = int(action.frame_range[0]) + frame_end = int(action.frame_range[1]) + total_frames = max(frame_end - frame_start, 1) + + for angle_deg in CAMERA_ANGLES: + position_camera(angle_deg) + angle_label = f"{angle_deg:03d}deg" + + for i in range(frame_count): + t = i / max(frame_count - 1, 1) if frame_count > 1 else 0 + frame = frame_start + int(t * total_frames) + scene.frame_set(frame) + + filename = f"{action_name}_{angle_label}_frame_{i:02d}.png" + filepath = os.path.join(PREVIEW_DIR, filename) + scene.render.filepath = filepath + bpy.ops.render.render(write_still=True) + print(f" Rendered: {filename}") + + +# ── Render all animations ───────────────────────────────────────────────────── +print("=" * 60) +print("RENDERING ANIMATION PREVIEWS") +print("=" * 60) + +render_action("walk", WALK_FRAMES) +render_action("idle", 1) + +# ── Cleanup preview objects (optional — keep them for re-runs) ──────────────── +print("=" * 60) +print(f"Preview images saved to: {PREVIEW_DIR}") +print(f"Total actions rendered: {len([a for a in ['walk', 'idle'] if bpy.data.actions.get(a)])}") +print("=" * 60) diff --git a/debug_params.gd b/debug_params.gd index f1911a5..68da7c9 100644 --- a/debug_params.gd +++ b/debug_params.gd @@ -19,6 +19,9 @@ func _register_all() -> void: # ── Movement ────────────────────────────────────────────────────────────── _reg_f("Movement", "thrust_impulse", 18.0, 1.0, 200.0) _reg_f("Movement", "thrust_vertical", 2.0, 0.0, 20.0) + _reg_f("Movement", "thrust_min_angle", 15.0, 0.0, 90.0, 1.0) + _reg_f("Movement", "thrust_max_angle", 90.0, 0.0, 180.0, 1.0) + _reg_f("Movement", "thrust_charge_time", 0.5, 0.05, 2.0, 0.05) _reg_f("Movement", "max_speed", 30.0, 2.0, 1000.0) _reg_f("Movement", "roll_damping", 0.15, 0.01, 0.99, 0.01) _reg_f("Movement", "jump_velocity", 4.5, 1.0, 200.0) diff --git a/player.gd b/player.gd index b454a27..750c8c1 100644 --- a/player.gd +++ b/player.gd @@ -14,11 +14,13 @@ const HOOF_OFFSETS: Array = [ var _hoof_emitters: Array[CPUParticles3D] = [] var _legs: Node -var _kick_timer: float = 0.0 -var _was_on_floor: bool = false -var _pre_slide_vel_y: float = 0.0 -var _charge_dir: Vector3 = Vector3.ZERO -var _both_held: bool = false +var _kick_timer: float = 0.0 +var _was_on_floor: bool = false +var _pre_slide_vel_y: float = 0.0 +var _charge_dir: Vector3 = Vector3.ZERO +var _both_held: bool = false +var _thrust_left_charge: float = 0.0 +var _thrust_right_charge: float = 0.0 enum DustState { NONE, WALK, CHARGE } var _dust_state: DustState = DustState.NONE @@ -192,6 +194,8 @@ func _physics_process(delta: float) -> void: _charge_dir = Vector3(raw.x, 0.0, raw.z).normalized() if both_held: + _thrust_left_charge = 0.0 + _thrust_right_charge = 0.0 velocity.x += _charge_dir.x * DP.f("bull_charge_accel") * delta velocity.z += _charge_dir.z * DP.f("bull_charge_accel") * delta var flat_c := Vector2(velocity.x, velocity.z) @@ -202,16 +206,24 @@ func _physics_process(delta: float) -> void: _legs.set(&"charge_pitch_target", DP.f("charge_head_pitch")) else: _legs.set(&"charge_pitch_target", 0.0) - if Input.is_action_just_pressed(&"thrust_left") and not right_held: - var raw := cube_guy.global_transform.basis.z - var bull_fwd := Vector3(raw.x, 0.0, raw.z).normalized() - var bull_right := bull_fwd.cross(Vector3.UP) - _apply_thrust((bull_fwd + bull_right).normalized()) - elif Input.is_action_just_pressed(&"thrust_right") and not left_held: - var raw := cube_guy.global_transform.basis.z - var bull_fwd := Vector3(raw.x, 0.0, raw.z).normalized() - var bull_right := bull_fwd.cross(Vector3.UP) - _apply_thrust((bull_fwd - bull_right).normalized()) + + if left_held and not right_held: + _thrust_left_charge = minf(_thrust_left_charge + delta, DP.f("thrust_charge_time")) + elif Input.is_action_just_released(&"thrust_left") and _thrust_left_charge > 0.0: + var t := _thrust_left_charge / DP.f("thrust_charge_time") + var angle := lerpf(deg_to_rad(DP.f("thrust_min_angle")), deg_to_rad(DP.f("thrust_max_angle")), t) + var raw := cube_guy.global_transform.basis.z + _apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, -angle)) + _thrust_left_charge = 0.0 + + if right_held and not left_held: + _thrust_right_charge = minf(_thrust_right_charge + delta, DP.f("thrust_charge_time")) + elif Input.is_action_just_released(&"thrust_right") and _thrust_right_charge > 0.0: + var t := _thrust_right_charge / DP.f("thrust_charge_time") + var angle := lerpf(deg_to_rad(DP.f("thrust_min_angle")), deg_to_rad(DP.f("thrust_max_angle")), t) + var raw := cube_guy.global_transform.basis.z + _apply_thrust(Vector3(raw.x, 0.0, raw.z).normalized().rotated(Vector3.UP, angle)) + _thrust_right_charge = 0.0 _both_held = both_held diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..478b92c --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Run all project tests. Exit code 0 = all passed. +# Usage: bash run_tests.sh [--no-gameplay] +set -euo pipefail + +GODOT="${GODOT:-godot}" +SKIP_GAMEPLAY=0 +for arg in "$@"; do [[ "$arg" == "--no-gameplay" ]] && SKIP_GAMEPLAY=1; done + +echo "=== gdlint ===" +gdlint *.gd tests/*.gd + +echo "" +echo "=== Logic tests ===" +"$GODOT" --headless --script tests/logic_test.gd + +if [[ "$SKIP_GAMEPLAY" -eq 0 ]]; then + echo "" + echo "=== Gameplay tests (headless — assertions only, screenshots may be blank) ===" + "$GODOT" --headless --script tests/gameplay_test.gd + echo "Frame strip: tests/output/gameplay/" +fi + +echo "" +echo "=== All tests passed ===" diff --git a/tests/gameplay_test.gd b/tests/gameplay_test.gd new file mode 100644 index 0000000..4ca21a4 --- /dev/null +++ b/tests/gameplay_test.gd @@ -0,0 +1,259 @@ +extends SceneTree +## Gameplay regression test — runs the full scene, captures a motion frame strip, +## and asserts that physics/IK stays within sane bounds. +## +## Run (headless, assertions only — screenshots will be blank): +## godot --headless --script tests/gameplay_test.gd +## +## Run (with display for real screenshots): +## godot --script tests/gameplay_test.gd +## +## Outputs: tests/output/gameplay/*.png + tests/output/gameplay/report.txt +## Exit code 0 = all assertions passed, 1 = any failure. + +const OUTPUT_DIR := "res://tests/output/gameplay" + +# Tail bone names mirrored from bull_legs.gd — update both if rig changes. +const TAIL_BONES: Array[StringName] = [ + &"tail_1", &"tail_2", &"tail_3", &"tail_4", + &"tail_5", &"tail_6", &"tail_tip_1", &"tail_tip_2", +] + +var _passed: int = 0 +var _failed: int = 0 +var _report: PackedStringArray = [] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + DirAccess.make_dir_recursive_absolute(OUTPUT_DIR) + + var scene_res := load("res://scene.tscn") + if not scene_res: + push_error("gameplay_test: failed to load scene.tscn") + quit(1) + return + + var scene: Node = scene_res.instantiate() + root.add_child(scene) + + # Let physics, AI, and IK warm up. + await create_timer(0.5).timeout + + # Capture 5 frames ~0.4 s apart (covers roughly one wander step and walk cycle). + for i in range(5): + _capture_frame("motion_%02d.png" % i) + await create_timer(0.4).timeout + + # After ~2.5 s the tail Verlet chain and IK should be stable. + _check_tail_integrity() + + # Trigger ragdoll on the first matador and check that bones don't explode. + var matadors := get_nodes_in_group(&"matador") + if matadors.size() > 0: + var mat: Node3D = matadors[0] as Node3D + var start_pos: Vector3 = mat.global_position + mat._enter_ragdoll(Vector3(0.0, 0.0, 1.0), 10.0) + _capture_frame("ragdoll_trigger.png") + await create_timer(0.8).timeout + _capture_frame("ragdoll_result.png") + _check_ragdoll_sanity(mat, start_pos) + else: + _note("ragdoll check skipped — no matadors found in scene") + + _finish() + + +# ── Tail integrity ──────────────────────────────────────────────────────────── + +func _check_tail_integrity() -> void: + print("\n-- check_tail_integrity --") + var players := get_nodes_in_group(&"player") + if players.is_empty(): + _note("tail check: no player in scene") + return + + # Prefer reading from the live Verlet chain in the legs node. + var legs: Node = _find_legs_node(players[0]) + if legs: + _check_tail_verlet(legs) + return + + # Fallback: sample bone global poses from Skeleton3D. + var skel: Skeleton3D = _find_skeleton(players[0]) as Skeleton3D + if not skel: + _note("tail check: no Skeleton3D found under player") + return + _check_tail_skeleton(skel) + + +func _check_tail_verlet(legs: Node) -> void: + var chain: Array = legs._tail_world + if chain.size() < 2: + _note("tail Verlet: chain has fewer than 2 nodes — skipping") + return + + for i in range(chain.size()): + var p: Vector3 = chain[i] + _assert_true(p.is_finite(), "tail Verlet node %d is finite" % i) + + for i in range(1, chain.size()): + var dist: float = (chain[i] as Vector3).distance_to(chain[i - 1]) + _assert_true(dist > 0.005, + "tail Verlet segment %d–%d not collapsed (%.4f m)" % [i - 1, i, dist]) + + var span: float = (chain[0] as Vector3).distance_to(chain[-1]) + _assert_true(span > 0.05, + "tail chain root-to-tip span non-zero (%.3f m)" % span) + + # Verify no two nodes are at exactly the same position (bunching symptom). + for i in range(1, chain.size()): + for j in range(i + 1, chain.size()): + var d: float = (chain[i] as Vector3).distance_to(chain[j]) + _assert_true(d > 0.001, + "tail nodes %d and %d are distinct (%.4f m apart)" % [i, j, d]) + + _note("tail Verlet: %d nodes, span %.3f m" % [chain.size(), span]) + + +func _check_tail_skeleton(skel: Skeleton3D) -> void: + var positions: Array[Vector3] = [] + for bone_name: StringName in TAIL_BONES: + var idx: int = skel.find_bone(bone_name) + if idx == -1: + continue + positions.append(skel.to_global(skel.get_bone_global_pose(idx).origin)) + + if positions.size() < 2: + _note("tail skeleton: fewer than 2 bones found") + return + + for i in range(positions.size()): + _assert_true(positions[i].is_finite(), "tail bone %d finite" % i) + + for i in range(1, positions.size()): + var dist: float = positions[i].distance_to(positions[i - 1]) + _assert_true(dist > 0.005, + "tail bone segment %d–%d not collapsed (%.4f m)" % [i - 1, i, dist]) + + _note("tail skeleton: %d bones checked" % positions.size()) + + +# ── Ragdoll sanity ──────────────────────────────────────────────────────────── + +func _check_ragdoll_sanity(matador: Node3D, start_pos: Vector3) -> void: + print("\n-- check_ragdoll_sanity --") + var sim: Node = _find_class_recursive(matador, "PhysicalBoneSimulator3D") + if not sim: + _note("ragdoll: no PhysicalBoneSimulator3D — check skipped") + return + + var checked := 0 + var all_at_origin := true + for child in sim.get_children(): + if not (child is PhysicalBone3D): + continue + var bone := child as PhysicalBone3D + var bp: Vector3 = bone.global_position + checked += 1 + + if bp.distance_to(Vector3.ZERO) > 0.1: + all_at_origin = false + + _assert_true(bp.is_finite(), + "ragdoll '%s' position finite" % bone.bone_name) + + if bp.is_finite(): + var dist: float = bp.distance_to(start_pos) + # 6 m: impulse + 0.8 s of movement should stay inside this. + # Regression guard: bodies stuck at world origin fail (spawn is 10-20 m away). + _assert_true(dist < 6.0, + "ragdoll '%s' near spawn (%.1f m)" % [bone.bone_name, dist]) + _assert_true(bp.y > -1.0, + "ragdoll '%s' above floor (y = %.2f)" % [bone.bone_name, bp.y]) + _assert_true(bp.y < 3.0, + "ragdoll '%s' settled below 3 m (y = %.2f)" % [bone.bone_name, bp.y]) + + if checked == 0: + _note("ragdoll: simulator has no PhysicalBone3D children") + elif all_at_origin: + _note("ragdoll: all bones at world origin — physics may not have simulated (headless?)") + else: + _note("ragdoll: %d bones checked" % checked) + + +# ── Frame capture ───────────────────────────────────────────────────────────── + +func _capture_frame(filename: String) -> void: + var img: Image = root.get_viewport().get_texture().get_image() + if img == null: + _note("frame capture failed: %s" % filename) + return + var path := OUTPUT_DIR + "/" + filename + img.save_png(path) + _note("captured %s (%d×%d)" % [filename, img.get_width(), img.get_height()]) + + +# ── Scene helpers ───────────────────────────────────────────────────────────── + +func _find_legs_node(player: Node) -> Node: + for child in player.get_children(): + var s: Script = child.get_script() as Script + if s and s.resource_path.ends_with("bull_legs.gd"): + return child + return null + + +func _find_skeleton(node: Node) -> Node: + if node is Skeleton3D: + return node + for child in node.get_children(): + var r := _find_skeleton(child) + if r: + return r + return null + + +func _find_class_recursive(node: Node, class_name_str: String) -> Node: + if node.get_class() == class_name_str: + return node + for child in node.get_children(): + var r := _find_class_recursive(child, class_name_str) + if r: + return r + return null + + +# ── Assertions & report ─────────────────────────────────────────────────────── + +func _assert_true(condition: bool, desc: String) -> void: + if condition: + _passed += 1 + print(" PASS: %s" % desc) + _report.append("PASS: " + desc) + else: + _failed += 1 + print(" FAIL: %s" % desc) + _report.append("FAIL: " + desc) + + +func _note(msg: String) -> void: + print(" NOTE: %s" % msg) + _report.append("NOTE: " + msg) + + +func _finish() -> void: + var f := FileAccess.open(OUTPUT_DIR + "/report.txt", FileAccess.WRITE) + if f: + f.store_string("\n".join(Array(_report))) + f.close() + + print("") + print("=".repeat(60)) + print("GAMEPLAY TESTS: %d passed, %d failed" % [_passed, _failed]) + print("Frame strip: %s" % OUTPUT_DIR) + print("=".repeat(60)) + quit(1 if _failed > 0 else 0) diff --git a/tests/logic_test.gd b/tests/logic_test.gd new file mode 100644 index 0000000..5007569 --- /dev/null +++ b/tests/logic_test.gd @@ -0,0 +1,286 @@ +extends SceneTree +## Headless logic tests — validates game logic without visuals. +## Run: godot --headless --script tests/logic_test.gd +## +## Exit code 0 = all passed, 1 = any failure. + +var _passed: int = 0 +var _failed: int = 0 + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + print("=" .repeat(60)) + print("LOGIC TESTS") + print("=" .repeat(60)) + + test_debug_params_defaults() + test_debug_params_clamp_values() + test_debug_params_save_load_cycle() + test_matador_spawn_positions() + test_matador_wander_target_in_bounds() + test_matador_state_transitions() + test_roll_damping() + test_velocity_capping() + test_kick_spread() + test_gait_phase_crossing() + test_tail_verlet_constraint() + + print("=" .repeat(60)) + print("Results: %d passed, %d failed" % [_passed, _failed]) + print("=" .repeat(60)) + + quit(1 if _failed > 0 else 0) + + +# ── Test helpers ────────────────────────────────────────────────────────────── + +func _assert_true(condition: bool, desc: String) -> void: + if condition: + _passed += 1 + print(" PASS: %s" % desc) + else: + _failed += 1 + print(" FAIL: %s" % desc) + + +func _assert_eq(a: Variant, b: Variant, desc: String) -> void: + _assert_true(a == b, "%s (got %s, expected %s)" % [desc, str(a), str(b)]) + + +# ── Player physics math ─────────────────────────────────────────────────────── + +func test_roll_damping() -> void: + print("\n-- test_roll_damping --") + var damping := 0.15 # default roll_damping + var retain := pow(damping, 1.0 / 60.0) + # One frame at 60 fps should retain ~96-99% of speed. + _assert_in_range(retain, 0.90, 0.999, "single-frame retain factor is reasonable") + + # After exactly 1 s, speed should equal starting_speed * damping (pow identity). + # With damping=0.15: pow(0.15, 1/60)^60 = 0.15, so 30 × 0.15 = 4.5. + var speed := 30.0 + for _i: int in 60: + speed *= pow(damping, 1.0 / 60.0) + var expected := 30.0 * damping + _assert_in_range(speed, expected * 0.95, expected * 1.05, + "speed after 1 s equals starting_speed × damping (±5%%)") + + # Lower damping value damps more aggressively per frame. + var fast_retain := pow(0.01, 1.0 / 60.0) + _assert_true(fast_retain < retain, "damping=0.01 retains less per frame than damping=0.15") + + +func test_velocity_capping() -> void: + print("\n-- test_velocity_capping --") + var max_speed := 30.0 + var impulse := 18.0 + + # Thrust from rest: should never exceed max_speed. + var vel := Vector3.ZERO + vel.x += impulse + var flat := Vector2(vel.x, vel.z) + if flat.length() > max_speed: + vel.x *= max_speed / flat.length() + vel.z *= max_speed / flat.length() + _assert_in_range(Vector2(vel.x, vel.z).length(), 0.0, max_speed + 0.001, + "thrust from rest: capped at max_speed") + + # Thrust while already at max_speed: still capped. + vel = Vector3(max_speed, 0.0, 0.0) + vel.x += impulse + flat = Vector2(vel.x, vel.z) + if flat.length() > max_speed: + vel.x *= max_speed / flat.length() + vel.z *= max_speed / flat.length() + _assert_in_range(Vector2(vel.x, vel.z).length(), 0.0, max_speed + 0.001, + "thrust from max_speed: still capped at max_speed") + + +func test_kick_spread() -> void: + print("\n-- test_kick_spread --") + var max_deg := 10.0 # default kick_spread + var forward := Vector3.FORWARD + for _i: int in 100: + var spread := deg_to_rad(randf_range(-max_deg, max_deg)) + var kick_dir := forward.rotated(Vector3.UP, spread) + var angle_deg := rad_to_deg(forward.angle_to(kick_dir)) + _assert_in_range(angle_deg, 0.0, max_deg + 0.01, + "kick direction stays within spread cone") + + +func test_gait_phase_crossing() -> void: + print("\n-- test_gait_phase_crossing --") + # Wrap-around: phase 0.95 should be crossed when advancing from 0.9 → 0.1. + _assert_true(_gait_phase_crossed(0.9, 0.1, 0.95), + "wrap-around: 0.95 crossed from 0.9 to 0.1") + # Normal advance: phase 0.05 is crossed from 0.0 → 0.1. + _assert_true(_gait_phase_crossed(0.0, 0.1, 0.05), + "normal: 0.05 crossed from 0.0 to 0.1") + # Not crossed: target outside window. + _assert_true(not _gait_phase_crossed(0.0, 0.1, 0.15), + "not crossed: 0.15 outside window 0.0–0.1") + _assert_true(not _gait_phase_crossed(0.9, 0.1, 0.5), + "not crossed: 0.5 not in wrap range 0.9–>0.1") + # All four leg phase offsets must be in [0, 1) and distinct. + var phases := [0.0, 0.2, 0.5, 0.7] # LEG_PHASES values from bull_legs.gd + var seen := {} + for p: float in phases: + _assert_in_range(p, 0.0, 0.9999, "leg phase in [0, 1)") + _assert_true(not seen.has(p), "leg phase %.2f is unique" % p) + seen[p] = true + + +func _gait_phase_crossed(prev: float, curr: float, target: float) -> bool: + if curr >= prev: + return target >= prev and target < curr + else: + return target >= prev or target < curr + + +func test_tail_verlet_constraint() -> void: + print("\n-- test_tail_verlet_constraint --") + var target_len := 0.3 + + # Constraint should snap an over-extended segment to exactly target_len. + var a := Vector3.ZERO + var b := Vector3(5.0, 0.0, 0.0) + var dir := b - a + var result: Vector3 = a + dir * (target_len / dir.length()) + _assert_in_range(result.distance_to(a), target_len - 0.001, target_len + 0.001, + "Verlet constraint enforces exact segment length") + + # Near-zero distance: code takes else branch — fallback should also be target_len. + var b_near := Vector3(0.00005, 0.0, 0.0) + var dir_near := b_near - a + var fallback: Vector3 + if dir_near.length() > 0.0001: + fallback = a + dir_near * (target_len / dir_near.length()) + else: + fallback = a + Vector3(0.0, -target_len, 0.0) + _assert_in_range(fallback.distance_to(a), target_len - 0.001, target_len + 0.001, + "Verlet fallback (near-zero) preserves segment length") + + # Damping formula: velocity carries ~90-100% of a normal segment per step. + var damping_factor := 1.0 - clampf(0.08, 0.0, 0.99) # default tail_damping + _assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable") + + +# ── Shared helpers ──────────────────────────────────────────────────────────── + +func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void: + _assert_true(val >= lo and val <= hi, + "%s (got %.4f, expected [%.4f, %.4f])" % [desc, val, lo, hi]) + + +# ── Debug params tests ─────────────────────────────────────────────────────── + +func test_debug_params_defaults() -> void: + print("\n-- test_debug_params_defaults --") + var dp: Node = root.get_node_or_null("/root/DP") + if dp == null: + _assert_true(false, "DP autoload should exist") + return + _assert_in_range(dp.f("mat_walk_speed"), 0.5, 10.0, + "mat_walk_speed default in valid range") + _assert_in_range(dp.f("mat_wander_radius"), 5.0, 50.0, + "mat_wander_radius default in valid range") + _assert_in_range(dp.f("mat_spawn_count"), 1.0, 30.0, + "mat_spawn_count default in valid range") + _assert_in_range(dp.f("mat_hit_threshold"), 1.0, 30.0, + "mat_hit_threshold default in valid range") + _assert_in_range(dp.f("mat_ragdoll_impulse"), 1.0, 50.0, + "mat_ragdoll_impulse default in valid range") + _assert_in_range(dp.f("mat_idle_min"), 0.0, 5.0, + "mat_idle_min default in valid range") + _assert_in_range(dp.f("mat_idle_max"), 0.5, 10.0, + "mat_idle_max default in valid range") + _assert_true(dp.f("mat_idle_min") <= dp.f("mat_idle_max"), + "mat_idle_min <= mat_idle_max") + + +func test_debug_params_clamp_values() -> void: + print("\n-- test_debug_params_clamp_values --") + var dp: Node = root.get_node_or_null("/root/DP") + if dp == null: + _assert_true(false, "DP autoload should exist") + return + var all_params: Dictionary = dp.get_all() + for key: String in all_params: + var p: Dictionary = all_params[key] + if p["type"] == TYPE_FLOAT: + var val: float = p["value"] as float + _assert_in_range(val, p["min"] as float, p["max"] as float, + "param '%s' value within [min, max]" % key) + + +func test_debug_params_save_load_cycle() -> void: + print("\n-- test_debug_params_save_load_cycle --") + var dp: Node = root.get_node_or_null("/root/DP") + if dp == null: + _assert_true(false, "DP autoload should exist") + return + var original: float = dp.f("mat_walk_speed") + dp.set_value("mat_walk_speed", 7.77) + _assert_eq(dp.f("mat_walk_speed"), 7.77, "set_value updates immediately") + dp.set_value("mat_walk_speed", original) + _assert_eq(dp.f("mat_walk_speed"), original, "restore original value") + + +func test_matador_spawn_positions() -> void: + print("\n-- test_matador_spawn_positions --") + var dp: Node = root.get_node_or_null("/root/DP") + if dp == null: + _assert_true(false, "DP autoload should exist") + return + var radius: float = dp.f("mat_wander_radius") + # Simulate the spawn logic from matador_spawn.gd + for i: int in 50: + var angle := randf() * TAU + var dist := randf_range(6.0, radius) + var pos := Vector3(cos(angle) * dist, 1.0, sin(angle) * dist) + var horiz_dist := Vector2(pos.x, pos.z).length() + _assert_in_range(horiz_dist, 5.0, radius + 1.0, + "spawn %d horizontal dist within bounds" % i) + _assert_eq(pos.y, 1.0, "spawn %d Y position is 1.0" % i) + + +func test_matador_wander_target_in_bounds() -> void: + print("\n-- test_matador_wander_target_in_bounds --") + var dp: Node = root.get_node_or_null("/root/DP") + if dp == null: + _assert_true(false, "DP autoload should exist") + return + var radius: float = dp.f("mat_wander_radius") + # Simulate _pick_wander_target logic from matador.gd + for i: int in 100: + var angle := randf() * TAU + var dist := randf_range(2.0, radius) + var target := Vector3(cos(angle) * dist, 0.0, sin(angle) * dist) + var horiz_dist := Vector2(target.x, target.z).length() + _assert_in_range(horiz_dist, 1.0, radius + 1.0, + "wander target %d within radius" % i) + + +func test_matador_state_transitions() -> void: + print("\n-- test_matador_state_transitions --") + # Verify that the State enum values match expected constants + # Load the matador scene to check its script + var matador_scene := load("res://Matador.tscn") + if matador_scene == null: + _assert_true(false, "Matador.tscn should load") + return + _assert_true(true, "Matador.tscn loads successfully") + var matador: Node = matador_scene.instantiate() + _assert_true(matador is CharacterBody3D, + "Matador root is CharacterBody3D") + _assert_true(matador.has_method("_physics_process"), + "Matador has _physics_process") + _assert_true(matador.has_method("_ready"), + "Matador has _ready") + # Check initial state + _assert_eq(matador._state, 0, "initial state is WANDER (0)") + matador.free() diff --git a/tests/ragdoll_throw_test.gd b/tests/ragdoll_throw_test.gd new file mode 100644 index 0000000..f286dd8 --- /dev/null +++ b/tests/ragdoll_throw_test.gd @@ -0,0 +1,83 @@ +/extends SceneTree +## Ragdoll throw visual test — triggers ragdoll on the matador and captures a +## frame strip so you can inspect the throw arc and settling. +## +## Run (needs a display for real screenshots): +## godot --script tests/ragdoll_throw_test.gd +## +## Outputs: tests/output/ragdoll_throw/frame_00..09.png +## Each frame is 0.2 s apart → strip covers 2 s of flight and tumble. + +const OUTPUT_DIR := "res://tests/output/ragdoll_throw" +const BULL_SPEED := 22.0 # simulate a solid charge + +const TRACKED_BONES: Array[StringName] = [&"chest", &"head", &"COG"] + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + DirAccess.make_dir_recursive_absolute(OUTPUT_DIR) + + var scene_res := load("res://scene.tscn") + if not scene_res: + push_error("ragdoll_throw_test: failed to load scene.tscn") + quit(1) + return + + var scene: Node = scene_res.instantiate() + root.add_child(scene) + + # Let physics and AI settle + await create_timer(0.8).timeout + + var matadors := get_nodes_in_group(&"matador") + if matadors.is_empty(): + push_error("ragdoll_throw_test: no matadors in scene") + quit(1) + return + + var mat := matadors[0] as Node3D + var spawn := mat.global_position + print("matador spawn: %s" % spawn) + + # Throw toward +Z with a realistic charge speed + mat.call(&"_enter_ragdoll", Vector3(0.0, 0.0, 1.0), BULL_SPEED) + print("ragdoll triggered bull_speed=%.1f" % BULL_SPEED) + + # Strip: 10 frames every 0.2 s + for i: int in range(10): + await create_timer(0.2).timeout + var t := (i + 1) * 0.2 + + var img: Image = root.get_viewport().get_texture().get_image() + if img: + img.save_png(OUTPUT_DIR + "/frame_%02d.png" % i) + + var sim: Node = _find_class_recursive(mat, "PhysicalBoneSimulator3D") + var bone_info := "" + if sim: + for bone_name: StringName in TRACKED_BONES: + for child: Node in sim.get_children(): + if child is PhysicalBone3D and \ + (child as PhysicalBone3D).bone_name == bone_name: + var bp: Vector3 = (child as PhysicalBone3D).global_position + var dist: float = bp.distance_to(spawn) + bone_info += " %s y=%.2f dist=%.2f" % [bone_name, bp.y, dist] + + print("t=%.1fs frame %02d%s" % [t, i, bone_info]) + + print("\nStrip saved → %s" % OUTPUT_DIR) + quit(0) + + +func _find_class_recursive(node: Node, class_name_str: String) -> Node: + if node.get_class() == class_name_str: + return node + for child: Node in node.get_children(): + var r := _find_class_recursive(child, class_name_str) + if r: + return r + return null diff --git a/tests/screenshot_test.gd b/tests/screenshot_test.gd new file mode 100644 index 0000000..363aa35 --- /dev/null +++ b/tests/screenshot_test.gd @@ -0,0 +1,48 @@ +extends SceneTree +## Headless screenshot test — captures a viewport frame after the scene loads. +## Run: godot --headless --script tests/screenshot_test.gd +## +## Outputs: tests/output/screenshot.png +## Exit code 0 = success, 1 = failure. + +const OUTPUT_DIR := "res://tests/output" +const SETTLE_TIME := 2.0 # seconds to let physics/animations settle + + +func _init() -> void: + _run.call_deferred() + + +func _run() -> void: + # Ensure output directory exists + DirAccess.make_dir_recursive_absolute(OUTPUT_DIR) + + # Load and switch to the main scene + var scene_res := load("res://scene.tscn") + if scene_res == null: + push_error("screenshot_test: Failed to load scene.tscn") + quit(1) + return + + var scene_instance: Node = scene_res.instantiate() + root.add_child(scene_instance) + + # Wait for the scene to settle (physics, spawning, animations) + await create_timer(SETTLE_TIME).timeout + + # Capture viewport + var img: Image = root.get_viewport().get_texture().get_image() + if img == null: + push_error("screenshot_test: Failed to capture viewport image") + quit(1) + return + + var path := OUTPUT_DIR + "/screenshot.png" + var err := img.save_png(path) + if err != OK: + push_error("screenshot_test: Failed to save PNG (error %d)" % err) + quit(1) + return + + print("screenshot_test: Saved %s (%dx%d)" % [path, img.get_width(), img.get_height()]) + quit(0)