Files
modbison/scripts/game.gd
daddy eb4da62f99
checkpoint / checkpoint (push) Successful in 1m47s
Fix ESC-crashes-the-game, avatar facing backward, invisible build stamp
ESC: player.gd changed scene directly while game.gd._exit_tree closed the
socket, which re-fired server_disconnected -> _on_server_lost ran mid-
teardown (add_child on an exiting node, await on a null get_tree(), a
second change_scene_to_file). Moved ESC to game.gd as one ordered path:
_leave_world() sets a _leaving guard, disconnects the _on_server_lost
handlers before closing the connection, then changes scene. _on_server_lost
and _exit_tree now respect the guard. Verified headless: ESC -> world_list,
no errors; 2p multiplayer still connects.

Avatar faced +Z but a Godot node's forward is -Z, so walking forward
showed the character its own face. Rebuilt the placeholder facing -Z (face
plates, eyes, jaw, toes, and front-mounted attachments all moved to
negative Z; backpack/ponytail to +Z). The procedural walk was already
written for -Z forward, so it's unchanged.

Build stamp was there but tiny/low-contrast bottom-left -- bumped to 20px
opaque yellow with a heavy outline, moved to the top-left strip.

limb_animator: guard _process with is_instance_valid(_skel) so a frame
landing during avatar teardown can't touch a freed skeleton.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 14:24:45 +00:00

266 lines
8.9 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 _leaving := false # a scene change is already in flight
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()
func _unhandled_input(event: InputEvent) -> void:
# ESC leaves the world back to the browser. Handled here (the scene root
# that owns the connection) rather than in player.gd so the teardown is
# a single ordered path -- doing it from two places raced _on_server_lost
# against the scene change and looked like a crash.
if event.is_action_pressed("ui_cancel"):
get_viewport().set_input_as_handled()
_leave_world()
func _leave_world() -> void:
if _leaving:
return
_leaving = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_teardown_net()
Session.clear()
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
## Close the ENet connection and stop it from calling us back mid-teardown.
func _teardown_net() -> void:
if _net == null or not is_instance_valid(_net):
return
if _net.server_disconnected.is_connected(_on_server_lost):
_net.server_disconnected.disconnect(_on_server_lost)
if _net.connection_failed.is_connected(_on_server_lost):
_net.connection_failed.disconnect(_on_server_lost)
_net.disconnect_from_server()
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.35))
lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1))
lbl.add_theme_constant_override("outline_size", 6)
lbl.add_theme_font_size_override("font_size", 20)
# Full-width strip pinned to the TOP-left. (A bare preset + .position on a
# zero-size Label collapses the rect to negative width and nothing draws --
# which is why this never showed; explicit offsets fix it.)
lbl.set_anchors_preset(Control.PRESET_TOP_WIDE)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.offset_left = 12
lbl.offset_right = -12
lbl.offset_top = 8
lbl.offset_bottom = 40
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(lbl)
func _exit_tree() -> void:
# Backstop for any exit path that didn't go through _leave_world (e.g. the
# app quitting): close the ENet connection so the server fires
# peer_disconnected and drops our player instead of leaving a ghost.
_teardown_net()
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:
if _leaving:
return
_leaving = true
_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