Files
Papay-Shooter/debug/test_level_builder.gd
T
Nicholas ButzkeandClaude Opus 5 c4bcbc7fd1 feat(ui): the first-person HUD is one thing, in the game's own hand
The reticle was five white ColorRects, pasted byte-identically into three
level runtime scripts. The vitals were two stock ProgressBars with a flat
colour override, inlined 1200 lines into the movement controller. The ammo
count — the number a shooter's player looks at most — was drawn by the LEVEL,
in a black rounded panel that shared nothing with the menus, and it had a
special case in it (`elif active_weapon is DoubleBarrelShotgun`) because that
weapon never declared a name or a capacity.

Everything that describes A PLAYER now belongs to ui/player_hud.gd, and a
level owns the level. The immediate symptom that fixed: the screen had two
ammo panels on it at once, in two different styles, overlapping in the corner.

What each piece now says, rather than merely shows:

  ui/crosshair.gd    one drawn reticle instead of five rectangles, so it can
                     BLOOM — open with speed, airtime and each shot, snap shut
                     on ADS. That is the accuracy readout of the whole game and
                     five ColorRects could not express it. Every stroke is
                     drawn twice, ink underneath, because a 2 px white line
                     disappears over pale concrete exactly when aim matters.
                     The hit confirmation is the same cross at 45 degrees, so
                     it lands where the eye already is.
  ui/vital_bar.gd    segmented, so remaining health can be COUNTED rather than
                     estimated, with a drain ghost that holds the old value for
                     a beat — the gap between fill and ghost is the size of the
                     hit, which a bar that merely gets shorter never tells you.
  ui/ability_chip.gd dash and grapple as a wipe across a chip rather than a
                     tinted JPEG with a 12 px number under it. A shape changing
                     size is readable in peripheral vision; 12 px type is not.
  chain meter        promoted out of the debug panel. Movement is this game's
                     first stated pillar and chaining is its skill expression,
                     so the count is a score, not a diagnostic.

The numerals moved OFF the bars and beside them. Text centred on a two-tone
bar cannot be given a colour that beats both the fill and the trough — that is
the 2.4:1 debug/ui_contrast_check.gd measured on the old HUD — so this fixes it
at the source rather than leaning on an outline to rescue it.

debug/hud_layout_check.gd measures where every element actually lands, which is
how three real bugs were found rather than squinted at: `set_anchors_preset`
moves the anchors and LEAVES THE OFFSETS, so the reticle spanned the viewport
with a size of exactly (0,0) and drew itself in the top-left corner; a
PRESET_CENTER applied after the ring's own `_ready` undid its centring; and a
BOX CONTAINER's own `alignment` is what pushes content to an edge, not a
SHRINK_END flag on the box, whose minimum width depends on children that may be
hidden. The ammo card was hanging off the right edge of the screen because of
the last one.

DoubleBarrelShotgun now declares `weapon_name` and `max_shells` like every
other weapon, and the four inline `2`s are gone.

spawn smoke 0 failures, 11/11 movement tests, contrast 108/108, layout PASS.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 11:51:22 -04:00

413 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")
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.