The shared clip library ships exactly one `Dance_Loop`, and five copies of one
clip is not five dances. What the runtime does have is a procedural pose layer
over a real skeleton with spring-driven hair and cloth, which is enough — if
the motion is constructed the way an animator would construct it rather than
the way a programmer reaches for first.
Wiring sine waves to bones is that first reach, and everyone can tell. A raw
sine moves fastest through the middle and slowest at the ends by the same
amount on every channel, all in phase, forever. It floats. It has no weight, no
accent, and no sense that one part of the body is driving and the rest is
following. Four principles fix it, and all four are cheap:
OVERLAP the body is a chain. Hips lead, spine follows a beat later,
head last. One subtraction — `beat - lag * i` — and the spring
solver then carries it out through the hair and skirt for free,
because the dance layer runs before it.
ACCENT a dance HITS poses. `shape` bends the wave so it hangs at the
extremes and snaps between them, which is what a key-and-
breakdown pass produces by hand.
WEIGHT the HIPS translate, not just rotate. A body that never leaves
its own axis reads as a puppet on a stick.
CONTRAST Robot deliberately breaks all of the above — zero lag,
quantised motion — and reads as mechanical precisely because
the other four do not.
Spin spots its head: it holds a heading against the turn and whips round to
catch up, which is what a real dancer does to keep from getting dizzy and the
most recognisable thing about a turn.
The dial is a radial menu because every option is then the SAME DISTANCE from
where the pointer starts — the choice is a direction, and a direction becomes
muscle memory in a way "the fourth row down" does not. Selection is by ANGLE
alone, so a flick and a careful nudge do the same thing. HOLD to open, release
to commit; a tap too short to have aimed replays the last emote, which is what
the button did before, so the old habit still works. Pressing while already
dancing just stops — having to aim at something in order to STOP would be the
most annoying possible way to build this.
debug/dance_check.gd asserts the overlap, and getting it to measure that took
four wrong measurements, each of which is now a comment where it was made:
- correlating the hips' TRANSLATION against the head's position relative to
them compared two different quantities at different periods; it ranked the
Robot, whose lag is zero by construction, as the most overlapped routine.
- a signed scalar `angle * sign of the axis's largest component` is
DISCONTINUOUS — as a rocking bone passes back through rest the axis flips —
so smooth Two-Step measured a full-range jump per frame, which is exactly
what quantised motion looks like.
- a bone's GLOBAL rotation carries every ancestor's, so the head correlates
with the hips at lag zero however delayed the head itself is.
- and the hips and head are driven by different channels anyway.
Measuring two links of the SAME chain, as local rotation vectors, agrees with
the authored lag: Spin measures 9 frames against 8.4 authored, Two-Step 7
against 6.6, Robot 0. The Robot is checked on the property it actually has —
its jump per frame is 0.41 of its range against 0.03-0.06 for the others.
RigRoles is pulled out of ShooterPoseModifier so the dance layer resolves bones
the same way rather than carrying a second copy. Two copies is how a rig ends up
animating correctly under one modifier and not the other.
spawn smoke 0 failures, 11/11 movement, 21/21 weapon-hold pairs, contrast 108/108.
Co-Authored-By: Claude Opus 5 <[email protected]>
415 lines
16 KiB
GDScript
415 lines
16 KiB
GDScript
extends Node3D
|
|
class_name TestLevelBuilder
|
|
|
|
## Builds a full test environment from code — parkour geometry, lighting, player, HUD.
|
|
## Attach to TestLevel root, press F5.
|
|
|
|
var _player: CharacterBody3D
|
|
|
|
|
|
func _ready() -> void:
|
|
# Hide mouse
|
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
|
|
|
_build_materials()
|
|
_build_geometry()
|
|
|
|
# Multiplayer Spawning
|
|
var spawner = MultiplayerSpawner.new()
|
|
spawner.name = "PlayerSpawner"
|
|
spawner.spawn_path = "." # Spawn directly as children of the level
|
|
spawner.spawn_function = _spawn_player
|
|
add_child(spawner)
|
|
|
|
var nm = get_node_or_null("/root/NetworkManager")
|
|
if nm and nm.multiplayer.is_server():
|
|
# Spawn existing players
|
|
for pid in nm.player_stats.keys():
|
|
spawner.spawn(pid)
|
|
|
|
# Spawn late joiners
|
|
nm.player_connected.connect(func(pid):
|
|
spawner.spawn(pid)
|
|
)
|
|
|
|
# Despawn on disconnect
|
|
nm.player_disconnected.connect(func(pid):
|
|
var p = get_node_or_null(str(pid))
|
|
if p:
|
|
p.queue_free()
|
|
)
|
|
|
|
|
|
func _build_materials() -> void:
|
|
pass
|
|
|
|
|
|
func _build_geometry() -> void:
|
|
_build_environment()
|
|
_build_floor()
|
|
_build_walls_arena()
|
|
_build_ramps()
|
|
_build_platforms()
|
|
_build_wall_run_corridor()
|
|
_build_speed_corridor()
|
|
_build_target_dummy()
|
|
_build_lighting()
|
|
|
|
|
|
# ── Utility ───────────────────────────────────────────────────────────────────
|
|
|
|
func _box_static(pos: Vector3, size: Vector3, color: Color, node_name: String = "",
|
|
acoustic: String = "") -> StaticBody3D:
|
|
var body := StaticBody3D.new()
|
|
body.name = node_name if not node_name.is_empty() else "Static_%s" % str(pos)
|
|
body.position = pos
|
|
if acoustic != "":
|
|
body.set_meta("acoustic_material", acoustic)
|
|
add_child(body)
|
|
var shape := CollisionShape3D.new()
|
|
shape.shape = BoxShape3D.new()
|
|
shape.shape.size = size
|
|
body.add_child(shape)
|
|
var mesh := MeshInstance3D.new()
|
|
mesh.mesh = BoxMesh.new()
|
|
mesh.mesh.size = size
|
|
mesh.mesh.surface_set_material(0, LevelMaterials.tinted(color))
|
|
body.add_child(mesh)
|
|
return body
|
|
|
|
|
|
func _ramp_static(pos: Vector3, size: Vector3, rot_deg: Vector3, color: Color, node_name: String = "",
|
|
acoustic: String = "") -> StaticBody3D:
|
|
var body := _box_static(pos, size, color, node_name, acoustic)
|
|
body.rotation_degrees = rot_deg
|
|
return body
|
|
|
|
|
|
# ── Environment ───────────────────────────────────────────────────────────────
|
|
|
|
func _build_environment() -> void:
|
|
# Shared stylized environment (anime sky, bloom, cel color grade).
|
|
# Also creates the Sun/FillLight pair since none exists yet.
|
|
LevelEnvironment.add_to(self)
|
|
|
|
|
|
# ── Floor ─────────────────────────────────────────────────────────────────────
|
|
|
|
func _build_floor() -> void:
|
|
# Main arena floor (80x80)
|
|
_box_static(Vector3(0, -0.5, 0), Vector3(80, 1.0, 80), Color(0.22, 0.22, 0.25), "Floor_Main")
|
|
|
|
|
|
# ── Arena Walls ───────────────────────────────────────────────────────────────
|
|
|
|
func _build_walls_arena() -> void:
|
|
var wall_color := Color(0.35, 0.28, 0.22)
|
|
var wall_height := 10.0
|
|
# North
|
|
_box_static(Vector3(0, wall_height * 0.5, -40), Vector3(80, wall_height, 0.5), wall_color, "Wall_North", "brick")
|
|
# South
|
|
_box_static(Vector3(0, wall_height * 0.5, 40), Vector3(80, wall_height, 0.5), wall_color, "Wall_South", "brick")
|
|
# East
|
|
_box_static(Vector3(40, wall_height * 0.5, 0), Vector3(0.5, wall_height, 80), wall_color, "Wall_East", "brick")
|
|
# West
|
|
_box_static(Vector3(-40, wall_height * 0.5, 0), Vector3(0.5, wall_height, 80), wall_color, "Wall_West", "brick")
|
|
|
|
|
|
# ── Ramps ─────────────────────────────────────────────────────────────────────
|
|
|
|
func _build_ramps() -> void:
|
|
var ramp_color := Color(0.6, 0.35, 0.15) # Orange-brown
|
|
|
|
# Gentle ramp (15 degrees)
|
|
_ramp_static(Vector3(-25, 1.0, -25), Vector3(8, 0.4, 12), Vector3(15, 0, 0), ramp_color, "Ramp_Gentle")
|
|
|
|
# Medium ramp (30 degrees)
|
|
_ramp_static(Vector3(-25, 2.0, -15), Vector3(8, 0.4, 10), Vector3(30, 0, 0), ramp_color, "Ramp_Medium")
|
|
|
|
# Steep ramp (45 degrees)
|
|
_ramp_static(Vector3(-25, 3.0, -5), Vector3(8, 0.4, 8), Vector3(45, 0, 0), ramp_color, "Ramp_Steep")
|
|
|
|
# Quarter pipe (curved ramp approximation)
|
|
for i in range(6):
|
|
var angle := float(i) * 15.0
|
|
var x_off := sin(deg_to_rad(angle)) * 5.0
|
|
var y_off := (1.0 - cos(deg_to_rad(angle))) * 5.0
|
|
_ramp_static(
|
|
Vector3(-25 + x_off, y_off + 0.2, 8 + float(i) * 0.5),
|
|
Vector3(8, 0.3, 2),
|
|
Vector3(angle, 0, 0),
|
|
Color(0.7, 0.4, 0.2),
|
|
"QuarterPipe_%d" % i
|
|
)
|
|
|
|
|
|
# ── Platforms ─────────────────────────────────────────────────────────────────
|
|
|
|
func _build_platforms() -> void:
|
|
var plat_color := Color(0.2, 0.45, 0.55) # Teal
|
|
|
|
# Low platforms (jump height test)
|
|
_box_static(Vector3(10, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_1", "wood")
|
|
_box_static(Vector3(16, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_2", "wood")
|
|
_box_static(Vector3(22, 0.6, -20), Vector3(4, 1.2, 4), plat_color, "Plat_Low_3", "wood")
|
|
|
|
# Medium platforms (double jump required)
|
|
var med_color := Color(0.25, 0.5, 0.4)
|
|
_box_static(Vector3(10, 2.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_1", "wood")
|
|
_box_static(Vector3(16, 3.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_2", "wood")
|
|
_box_static(Vector3(22, 4.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_3", "wood")
|
|
_box_static(Vector3(28, 5.5, -28), Vector3(3, 0.5, 3), med_color, "Plat_Med_4", "wood")
|
|
|
|
# High tower
|
|
_box_static(Vector3(28, 4.0, -20), Vector3(5, 8.0, 5), Color(0.3, 0.3, 0.5), "Tower_1")
|
|
|
|
# Floating platforms (dash required)
|
|
var dash_color := Color(0.6, 0.2, 0.5) # Purple
|
|
_box_static(Vector3(10, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_1", "metal")
|
|
_box_static(Vector3(18, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_2", "metal")
|
|
_box_static(Vector3(26, 5.0, -35), Vector3(2.5, 0.3, 2.5), dash_color, "Plat_Dash_3", "metal")
|
|
|
|
|
|
# ── Wall Run Corridor ────────────────────────────────────────────────────────
|
|
|
|
func _build_wall_run_corridor() -> void:
|
|
var wall_color := Color(0.45, 0.25, 0.55) # Purple walls
|
|
|
|
# Two parallel walls — spaced for wall running
|
|
# Left wall
|
|
_box_static(Vector3(-5, 5, 15), Vector3(0.5, 10, 25), wall_color, "WallRun_Left", "metal")
|
|
# Right wall
|
|
_box_static(Vector3(5, 5, 15), Vector3(0.5, 10, 25), wall_color, "WallRun_Right", "metal")
|
|
|
|
# Obstacles in corridor to jump over
|
|
_box_static(Vector3(0, 0.75, 10), Vector3(10, 1.5, 1), Color(0.6, 0.3, 0.3), "Obstacle_1", "wood")
|
|
_box_static(Vector3(0, 0.75, 20), Vector3(10, 1.5, 1), Color(0.6, 0.3, 0.3), "Obstacle_2", "wood")
|
|
|
|
# Gap in floor (forces wall run)
|
|
# Just raise the floor slightly on each side with a gap
|
|
_box_static(Vector3(-3, -0.75, 25), Vector3(4, 0.5, 4), Color(0.22, 0.22, 0.25), "WallRun_Floor_L")
|
|
_box_static(Vector3(3, -0.75, 25), Vector3(4, 0.5, 4), Color(0.22, 0.22, 0.25), "WallRun_Floor_R")
|
|
|
|
# Label pillar at entrance
|
|
_box_static(Vector3(0, 2, 2), Vector3(1, 4, 1), Color(0.55, 0.35, 0.65), "WallRun_Marker")
|
|
|
|
|
|
# ── Speed Corridor ────────────────────────────────────────────────────────────
|
|
|
|
func _build_speed_corridor() -> void:
|
|
# Long straight for sprint→slide→bhop chains
|
|
var corridor_color := Color(0.2, 0.35, 0.2) # Dark green
|
|
|
|
# Side rails
|
|
_box_static(Vector3(30, 1.5, 0), Vector3(0.3, 3, 60), corridor_color, "SpeedRail_Left", "metal")
|
|
_box_static(Vector3(38, 1.5, 0), Vector3(0.3, 3, 60), corridor_color, "SpeedRail_Right", "metal")
|
|
|
|
# Speed bumps (small obstacles to hop over)
|
|
for i in range(-25, 26, 10):
|
|
_box_static(Vector3(34, 0.15, float(i)), Vector3(8, 0.3, 0.5), Color(0.8, 0.8, 0.2), "SpeedBump_%d" % i)
|
|
|
|
|
|
# ── Dummies ───────────────────────────────────────────────────────────────────
|
|
|
|
func _build_target_dummy() -> void:
|
|
var dummy = load("res://entities/target_dummy.gd").new()
|
|
dummy.name = "TargetDummy"
|
|
# Place it somewhat centrally in the main arena, but out of the immediate spawn area
|
|
dummy.position = Vector3(0, 0, -10)
|
|
add_child(dummy)
|
|
|
|
var killable = load("res://entities/killable_dummy.gd").new()
|
|
killable.name = "KillableDummy"
|
|
killable.position = Vector3(5, 0, -10)
|
|
add_child(killable)
|
|
|
|
var walking = load("res://entities/walking_dummy.gd").new()
|
|
walking.name = "WalkingDummy"
|
|
walking.position = Vector3(15, 0, -10)
|
|
walking.point_a = Vector3(15, 0, -10)
|
|
walking.point_b = Vector3(15, 0, 10)
|
|
add_child(walking)
|
|
|
|
# ── Lighting ──────────────────────────────────────────────────────────────────
|
|
|
|
func _build_lighting() -> void:
|
|
# Sun and fill are created by LevelEnvironment.add_to in _build_environment;
|
|
# kept as a hook for subclasses that re-style them (see dust2).
|
|
pass
|
|
|
|
|
|
# ── Player ────────────────────────────────────────────────────────────────────
|
|
|
|
func _spawn_player(pid: int) -> CharacterBody3D:
|
|
var player := CharacterBody3D.new()
|
|
player.name = str(pid)
|
|
player.set_multiplayer_authority(pid)
|
|
|
|
# Use a slightly random spawn position to avoid exact overlapping
|
|
player.position = Vector3(randf_range(-2, 2), 2.0, randf_range(-2, 2))
|
|
|
|
# Server Synchronizer (Host is the ground truth)
|
|
var server_sync = MultiplayerSynchronizer.new()
|
|
server_sync.name = "ServerSynchronizer"
|
|
server_sync.set_multiplayer_authority(1) # Host always controls these
|
|
var server_rep_config = SceneReplicationConfig.new()
|
|
server_rep_config.add_property(":health")
|
|
server_rep_config.add_property(":shield")
|
|
server_rep_config.add_property(":is_dead")
|
|
server_sync.replication_config = server_rep_config
|
|
player.add_child(server_sync)
|
|
|
|
# Client Synchronizer (Client dictates their aim and loadout setup)
|
|
var client_sync = MultiplayerSynchronizer.new()
|
|
client_sync.name = "MultiplayerSynchronizer" # Keep original name for compatibility if needed elsewhere
|
|
client_sync.set_multiplayer_authority(pid)
|
|
var client_rep_config = SceneReplicationConfig.new()
|
|
client_rep_config.add_property(":synced_position")
|
|
client_rep_config.add_property(":synced_velocity")
|
|
client_rep_config.add_property(":rotation")
|
|
client_rep_config.add_property("HeadPivot:rotation")
|
|
client_rep_config.add_property(":synced_movement_state")
|
|
client_rep_config.add_property(":synced_movement_speed")
|
|
client_rep_config.add_property(":synced_is_crouching")
|
|
client_rep_config.add_property(":synced_is_ads")
|
|
client_rep_config.add_property(":synced_wall_side")
|
|
client_rep_config.add_property(":synced_is_dancing")
|
|
# Which of the five emotes, so other players see the one that was chosen.
|
|
client_rep_config.add_property(":synced_dance_index")
|
|
client_rep_config.add_property(":synced_grapple_point")
|
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
|
client_rep_config.add_property(":synced_skin_id")
|
|
client_rep_config.add_property(":synced_weapon_path")
|
|
client_rep_config.add_property(":synced_loadout_p1")
|
|
client_rep_config.add_property(":synced_loadout_p2")
|
|
client_rep_config.add_property(":synced_loadout_sp")
|
|
client_rep_config.add_property(":synced_loadout_melee")
|
|
client_rep_config.add_property(":synced_loadout_ready")
|
|
client_rep_config.add_property(":synced_action")
|
|
client_rep_config.add_property(":synced_action_seq")
|
|
client_rep_config.add_property(":synced_is_targeting")
|
|
client_rep_config.add_property(":synced_homing_target_pos")
|
|
client_sync.replication_config = client_rep_config
|
|
player.add_child(client_sync)
|
|
|
|
# Collision shape
|
|
var col_shape := CollisionShape3D.new()
|
|
col_shape.name = "CollisionShape3D"
|
|
var capsule := CapsuleShape3D.new()
|
|
capsule.radius = 0.4
|
|
capsule.height = 1.8
|
|
col_shape.shape = capsule
|
|
player.add_child(col_shape)
|
|
|
|
# Apply movement controller script
|
|
var mover_script = preload("res://movement/player_movement_controller.gd")
|
|
if mover_script:
|
|
player.set_script(mover_script)
|
|
|
|
# Humanoid Model for Player (Shadows only locally, visible to others)
|
|
var humanoid = load("res://characters/humanoid_model.gd").new()
|
|
humanoid.name = "HumanoidModel"
|
|
humanoid.color = Color(0.2, 0.4, 0.8) # Blueish for player
|
|
humanoid.shadows_only = (pid == multiplayer.get_unique_id())
|
|
humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet
|
|
player.add_child(humanoid)
|
|
|
|
# Movement State Machine
|
|
var sm := Node.new()
|
|
sm.name = "MovementStateMachine"
|
|
player.add_child(sm)
|
|
|
|
var sm_script = preload("res://movement/movement_state_machine.gd")
|
|
if sm_script:
|
|
sm.set_script(sm_script)
|
|
|
|
# Add all movement states
|
|
var state_scripts := {
|
|
"ground": "res://movement/states/state_ground.gd",
|
|
"air": "res://movement/states/state_air.gd",
|
|
"wall_run": "res://movement/states/state_wall_run.gd",
|
|
"wall_cling": "res://movement/states/state_wall_cling.gd",
|
|
"slide": "res://movement/states/state_slide.gd",
|
|
"dash": "res://movement/states/state_dash.gd",
|
|
}
|
|
for state_name in state_scripts:
|
|
var st := Node.new()
|
|
st.name = "state_" + state_name
|
|
sm.add_child(st)
|
|
var st_script = load(state_scripts[state_name])
|
|
if st_script:
|
|
st.set_script(st_script)
|
|
|
|
# FPS Camera Rig (HeadPivot → Camera3D)
|
|
var head_pivot := Node3D.new()
|
|
head_pivot.name = "HeadPivot"
|
|
head_pivot.position = Vector3(0, 0.7, 0) # Eye height
|
|
player.add_child(head_pivot)
|
|
|
|
var camera := Camera3D.new()
|
|
camera.name = "Camera3D"
|
|
camera.current = (pid == multiplayer.get_unique_id())
|
|
camera.fov = 90.0
|
|
head_pivot.add_child(camera)
|
|
|
|
# ── Weapon Setup ────────────────────────────────────────────────────────────
|
|
var wmanager_script = preload("res://weapons/weapon_manager.gd")
|
|
if wmanager_script:
|
|
var wman = wmanager_script.new()
|
|
wman.name = "WeaponManager"
|
|
wman.player = player
|
|
wman.camera = camera
|
|
camera.add_child(wman)
|
|
|
|
var rig_script = preload("res://characters/player/fps_camera_rig.gd")
|
|
if rig_script:
|
|
head_pivot.set_script(rig_script)
|
|
|
|
# Store original capsule height
|
|
sm.original_capsule_height = capsule.height
|
|
|
|
# ── Dependencies ────────────────────────────────────────────────────────
|
|
# Set references before the node enters the tree. Godot will call _ready()
|
|
# automatically when MultiplayerSpawner adds the node to the scene.
|
|
sm.player = player
|
|
sm.params = player.params
|
|
head_pivot.params = player.params
|
|
|
|
# Authority configuration
|
|
var is_local = (pid == multiplayer.get_unique_id())
|
|
if is_local:
|
|
_player = player
|
|
player.set_physics_process(true)
|
|
player.set_process(true)
|
|
sm.set_physics_process(true)
|
|
else:
|
|
# Disable physics processing/input for remote players
|
|
# but keep _process enabled so synced state updates the HumanoidModel
|
|
player.set_physics_process(false)
|
|
player.set_process(true)
|
|
player.set_process_input(false)
|
|
sm.set_physics_process(false)
|
|
sm.set_process(false)
|
|
sm.set_process_input(false)
|
|
head_pivot.set_process_input(false)
|
|
head_pivot.set_process(false)
|
|
head_pivot.set_physics_process(false)
|
|
var wman = camera.get_node_or_null("WeaponManager")
|
|
if wman:
|
|
wman.set_process_input(false)
|
|
wman.set_process(false)
|
|
wman.set_physics_process(false)
|
|
wman.visible = false
|
|
var cv = wman.get("canvas_layer")
|
|
if cv:
|
|
cv.visible = false
|
|
|
|
return player
|
|
|
|
|
|
# ── HUD ───────────────────────────────────────────────────────────────
|
|
#
|
|
# There is no HUD here any more, and no _process to drive one. See the note in
|
|
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
|
|
# ui/player_hud.gd, which that player spawns for itself.
|