1b1260f371
checkpoint / checkpoint (push) Successful in 1m52s
The blocky body rendered fully black from every angle: _append_box wound its faces clockwise-from-outside, so every computed normal pointed inward -- an inside-out shell, lit from behind on all sides, and the level had ambient light disabled so nothing lifted it. Fixed the winding (normals now verify outward 234:36) and switched test_level's environment to a soft colour ambient so shaded sides read grey, not pure black. Limbs were static because the rig had no animation at all. Added scripts/avatar/limb_animator.gd: a clip-free walk cycle that swings the upper arm/leg bones with ground speed, folds the knees on the rear stride, and eases back to rest when idle. Wired into the local player (speed from velocity) and remote players in game.gd (speed from the per-snapshot authoritative position delta). Verified: avatar smoke 14/14 from source and exported pack; fresh-clone boot PASS; game + menu boot headless clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
233 lines
7.8 KiB
GDScript
233 lines
7.8 KiB
GDScript
extends Node3D
|
|
## Root controller for scenes/game.tscn.
|
|
##
|
|
## Local single-player by default (walk around the test level). When
|
|
## Session.active (set by world_list.gd after POST /api/worlds/{id}/join),
|
|
## connects a NetworkClient to the world's dedicated server and runs
|
|
## networked: the local Player keeps predicting with its own collision-aware
|
|
## movement, its input is forwarded to the server each physics frame, the
|
|
## server's authoritative position is a correction backstop, and other
|
|
## players are rendered from snapshots.
|
|
##
|
|
## Appearance sync is a follow-up -- remote players show as labelled capsules,
|
|
## not their own avatars (the server doesn't replicate loadouts yet).
|
|
|
|
const REMOTE_LERP := 12.0
|
|
## Snap the local player to the server position past this drift (metres).
|
|
## Tight enough that the client always ends up where the server actually
|
|
## spawned it (which is spread out, not the fixed marker player.gd uses).
|
|
const CORRECTION_THRESHOLD := 0.6
|
|
|
|
@onready var _player: Player = $Player
|
|
|
|
var _net: NetworkClient = null
|
|
var _networked := false
|
|
var _aligned := false # snapped to the server spawn yet?
|
|
var _remotes: Dictionary = {} # peer_id -> Node3D
|
|
var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position)
|
|
var _status_label: Label = null
|
|
|
|
|
|
func _ready() -> void:
|
|
_show_build_stamp()
|
|
if Session.active:
|
|
_setup_networked()
|
|
|
|
|
|
|
|
static func _build_label() -> String:
|
|
var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ)
|
|
if f == null:
|
|
return "dev"
|
|
var d = JSON.parse_string(f.get_as_text())
|
|
if d is Dictionary:
|
|
return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")]
|
|
return "dev"
|
|
|
|
|
|
func _show_build_stamp() -> void:
|
|
var layer := CanvasLayer.new()
|
|
layer.layer = 128
|
|
add_child(layer)
|
|
var lbl := Label.new()
|
|
lbl.text = "build %s" % _build_label()
|
|
lbl.add_theme_color_override("font_color", Color(1, 1, 0.45))
|
|
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85))
|
|
lbl.add_theme_constant_override("outline_size", 4)
|
|
lbl.add_theme_font_size_override("font_size", 14)
|
|
# Anchor a full-width strip to the bottom of the screen and left-align the
|
|
# text in it. (A bare preset + .position on a zero-size Label collapses the
|
|
# rect to negative width and nothing draws -- which is why this never showed.)
|
|
lbl.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
|
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
|
|
lbl.offset_left = 8
|
|
lbl.offset_right = -8
|
|
lbl.offset_top = -30
|
|
lbl.offset_bottom = -6
|
|
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
layer.add_child(lbl)
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
# Leaving the world -- close the ENet connection cleanly so the server
|
|
# fires peer_disconnected and removes our player (otherwise everyone else
|
|
# keeps seeing a frozen "ghost" of us).
|
|
if _net != null and is_instance_valid(_net):
|
|
_net.disconnect_from_server()
|
|
|
|
|
|
func _setup_networked() -> void:
|
|
_show_status("Connecting to %s…" % Session.host)
|
|
_net = NetworkClient.new()
|
|
_net.name = "NetworkClient"
|
|
add_child(_net)
|
|
_net.server_disconnected.connect(_on_server_lost)
|
|
_net.connection_failed.connect(_on_server_lost)
|
|
|
|
var ok: bool = await _net.connect_to_server(Session.host, Session.port)
|
|
if not ok:
|
|
_on_server_lost()
|
|
return
|
|
_networked = true
|
|
_hide_status()
|
|
# Physics-process AFTER the player so net_input reflects this frame.
|
|
process_physics_priority = 10
|
|
|
|
|
|
func _physics_process(_delta: float) -> void:
|
|
if not _networked or _net == null or not _net.is_connected_to_server():
|
|
return
|
|
|
|
# 1. Forward the local player's input to the server.
|
|
var inp: Dictionary = _player.net_input
|
|
_net.send_input(inp["move_x"], inp["move_z"], inp["jump"], inp["look_yaw"])
|
|
|
|
# 2. Server-authoritative position.
|
|
var own: Dictionary = _net.get_own_server_state()
|
|
if own.has("position"):
|
|
var server_pos: Vector3 = own["position"]
|
|
if not _aligned:
|
|
# First snapshot: hard-snap to where the server actually spawned
|
|
# us (player.gd starts everyone on the fixed marker, but the
|
|
# server spreads spawns out).
|
|
_player.apply_server_correction(server_pos)
|
|
_aligned = true
|
|
elif _player.global_position.distance_to(server_pos) > CORRECTION_THRESHOLD:
|
|
_player.apply_server_correction(server_pos)
|
|
|
|
# 3. Remote players.
|
|
_sync_remotes(_net.get_remote_states(), _delta)
|
|
|
|
|
|
func _sync_remotes(states: Dictionary, delta: float) -> void:
|
|
for pid in states:
|
|
var st: Dictionary = states[pid]
|
|
var node: Node3D = _remotes.get(pid)
|
|
if node == null:
|
|
node = _make_remote(pid)
|
|
_remotes[pid] = node
|
|
add_child(node)
|
|
node.global_position = st.get("position", Vector3.ZERO)
|
|
var target: Vector3 = st.get("position", node.global_position)
|
|
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
|
|
node.rotation.y = st.get("rotation_y", node.rotation.y)
|
|
|
|
# Drive the remote's walk cycle from how far its authoritative
|
|
# position moved this snapshot.
|
|
var prev: Vector3 = _remote_prev.get(pid, target)
|
|
var anim := node.get_node_or_null("LimbAnim")
|
|
if anim != null and delta > 0.0:
|
|
anim.set("speed", (target - prev).length() / delta)
|
|
_remote_prev[pid] = target
|
|
|
|
for pid in _remotes.keys():
|
|
if not states.has(pid):
|
|
_remotes[pid].queue_free()
|
|
_remotes.erase(pid)
|
|
_remote_prev.erase(pid)
|
|
|
|
|
|
func _make_remote(pid: int) -> Node3D:
|
|
var root := Node3D.new()
|
|
root.name = "Remote_%d" % pid
|
|
|
|
# Other players get the assembled avatar too (everyone shares the default
|
|
# look for now -- per-player loadout replication is the follow-up), tinted
|
|
# by peer id so they're still tellable apart, plus a name tag.
|
|
var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95)
|
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
|
var visual: Node3D = null
|
|
if avatars and avatars.has_method("build_current_avatar"):
|
|
visual = avatars.build_current_avatar()
|
|
if visual != null:
|
|
visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server)
|
|
for mi in _all_mesh_instances(visual):
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_color = tint
|
|
mi.material_override = m
|
|
root.add_child(visual)
|
|
var skel := visual.find_child("Skeleton3D", true, false)
|
|
if skel is Skeleton3D:
|
|
var anim := preload("res://scripts/avatar/limb_animator.gd").new()
|
|
anim.name = "LimbAnim"
|
|
root.add_child(anim)
|
|
anim.call("setup", skel)
|
|
else:
|
|
var body := MeshInstance3D.new()
|
|
var capsule := CapsuleMesh.new()
|
|
capsule.radius = 0.35
|
|
capsule.height = 1.7
|
|
body.mesh = capsule
|
|
body.position = Vector3(0, 0.85, 0)
|
|
var mat := StandardMaterial3D.new()
|
|
mat.albedo_color = tint
|
|
body.material_override = mat
|
|
root.add_child(body)
|
|
|
|
var tag := Label3D.new()
|
|
tag.text = "Player %d" % pid
|
|
tag.position = Vector3(0, 2.1, 0)
|
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
|
tag.no_depth_test = true
|
|
tag.pixel_size = 0.004
|
|
root.add_child(tag)
|
|
|
|
return root
|
|
|
|
|
|
func _all_mesh_instances(node: Node, out: Array = []) -> Array:
|
|
if node is MeshInstance3D:
|
|
out.append(node)
|
|
for c in node.get_children():
|
|
_all_mesh_instances(c, out)
|
|
return out
|
|
|
|
|
|
func _on_server_lost() -> void:
|
|
_networked = false
|
|
_show_status("Disconnected from the server. Returning to worlds…")
|
|
await get_tree().create_timer(1.5).timeout
|
|
Session.clear()
|
|
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
|
|
|
|
|
|
# ─── tiny on-screen status ────────────────────────────────────────────────────
|
|
|
|
func _show_status(text: String) -> void:
|
|
if _status_label == null:
|
|
var layer := CanvasLayer.new()
|
|
layer.name = "StatusLayer"
|
|
add_child(layer)
|
|
_status_label = Label.new()
|
|
_status_label.position = Vector2(16, 16)
|
|
_status_label.add_theme_font_size_override("font_size", 18)
|
|
layer.add_child(_status_label)
|
|
_status_label.text = text
|
|
_status_label.visible = true
|
|
|
|
|
|
func _hide_status() -> void:
|
|
if _status_label:
|
|
_status_label.visible = false
|