Files
modbison/scripts/game.gd
T
daddy 1b1260f371
checkpoint / checkpoint (push) Successful in 1m52s
Avatar: fix inside-out black shell + add a procedural walk cycle
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
2026-09-06 13:07:27 +00:00

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