tests & can charge up thrust
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
disable:
|
||||
- class-definitions-order
|
||||
- no-else-return
|
||||
max-line-length: 120
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+25
@@ -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 ==="
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user