7c37a0dc2f
Implements the in-game editor's shared, data-native core and a Control-based UI on top of it, per docs/architecture/editor.md §7.1/§7.2, scoped to 2D per §8's own risk mitigation (the default world is flat, CHUNK_SIZE_Z=1 -- no 3D gizmos here). EditorCore (scripts/editor/): - ChunkEditorModel wraps a ChunkDataDecoder.ChunkPayload directly (no parallel data model) and adds a TileLayerData.set_tile() mutator alongside the existing get_tile(). - EditorUndoRedoStack wraps Godot's built-in UndoRedo (game-usable, not the editor-only EditorUndoRedoManager) so every mutation is a real, revertible do/undo Callable pair over ChunkEditorModel's raw setters. - SelectionManager tracks the selected object id as UI state, deliberately outside the undo/redo stack. - ToolRegistry + 5 tools (BrushTool, ObjectPlaceTool, SelectTool, EraseTool, TransformTool -- the 5th covers move/rotate/scale, which the doc's tool list doesn't literally name but the task requires) is the single choke point every mutation goes through. - EditorObjectCatalog (assets/editor/object_catalog.json) is a new, small "placeable object" catalog -- AvatarCatalog and SpawnCatalog were checked first and are both scoped to other concerns, so this mirrors their loading convention rather than reusing either. InGameEditor UI (scripts/editor/ui/): EditorViewport (2D top-down tile grid + object dots), EditorToolbar, PalettePanel, InspectorPanel, wired together in InGameEditor. Built from Control nodes in code (matching game.gd's existing dynamic-UI convention) rather than a hand-authored .tscn. Entry point: game.gd gates build mode behind a new toggle_build_mode input action (F2), freezing the player and opening a fresh empty ChunkEditorModel. Loading a *real* chunk from the backend for editing, and saving edits back, are explicitly out of scope -- a sibling task owns the batch-PUT/conflict-handling save path (world_persistence_impl.md). Verification: scripts/smoke_test_editor_core.gd decodes a real backend fixture (backend/tests/fixtures/chunks/simple_tile_chunk.bin), drives place/move/rotate/scale/delete/paint/erase entirely through EditorCore.use_tool()/ToolRegistry (never touching ChunkEditorModel's raw setters directly), undoes and redoes every step, and round-trips the result through encode_binary()/decode_binary() to prove the edited model stays a valid, byte-compatible ChunkPayload. 69/69 assertions pass. Full existing Godot smoke suite (16 other scripts/smoke_test_*.gd, backend + dedicated-server-dependent ones included) still passes with no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
325 lines
11 KiB
GDScript
325 lines
11 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
|
|
|
|
## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever
|
|
## instantiated once the player actually enters build mode
|
|
## (docs/architecture/editor.md §7.2: "Instantiated only when the player
|
|
## enters 'build mode'... so the runtime footprint is negligible during
|
|
## normal play"). Editing is in-memory only here; the save path to the
|
|
## backend is a sibling task (world_persistence_impl.md).
|
|
var _build_editor: InGameEditor = null
|
|
|
|
|
|
func _ready() -> void:
|
|
_show_build_stamp()
|
|
if Session.active:
|
|
_setup_networked()
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if event.is_action_pressed("toggle_build_mode"):
|
|
get_viewport().set_input_as_handled()
|
|
_toggle_build_mode()
|
|
return
|
|
|
|
# ESC leaves the world back to the browser (or, while build mode is
|
|
# open, just closes build mode instead). 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()
|
|
if _build_editor != null and is_instance_valid(_build_editor):
|
|
_close_build_mode()
|
|
else:
|
|
_leave_world()
|
|
|
|
|
|
## Entry point into the in-game editor (docs/architecture/editor.md §7.2).
|
|
## Freezes the player and opens a fresh, empty in-memory chunk to edit --
|
|
## loading a *real* chunk from the backend for editing is the sibling
|
|
## save-path task's territory (it owns the HTTP round-trip); this task's
|
|
## contract stops at "a correctly-editable in-memory ChunkEditorModel".
|
|
func _toggle_build_mode() -> void:
|
|
if _build_editor != null and is_instance_valid(_build_editor):
|
|
_close_build_mode()
|
|
else:
|
|
_open_build_mode()
|
|
|
|
|
|
func _open_build_mode() -> void:
|
|
var model := ChunkEditorModel.create_empty(32, 32, str(Session.world_id), "main")
|
|
var catalog := EditorObjectCatalog.new()
|
|
var err := catalog.load_catalog()
|
|
if err != OK:
|
|
push_error("game.gd: failed to load editor object catalog (%d)" % err)
|
|
var core := EditorCore.new(model, catalog)
|
|
|
|
_build_editor = InGameEditor.new(core)
|
|
_build_editor.closed.connect(_close_build_mode)
|
|
add_child(_build_editor)
|
|
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
_player.set_physics_process(false)
|
|
_player.set_process_unhandled_input(false)
|
|
|
|
|
|
## Shared teardown for both close paths (F2 again, or the editor's own
|
|
## Close button/`closed` signal) -- safe to call twice: `queue_free()` on an
|
|
## already-freeing node and restoring already-restored player state are
|
|
## both no-ops.
|
|
func _close_build_mode() -> void:
|
|
if _build_editor != null and is_instance_valid(_build_editor):
|
|
_build_editor.queue_free()
|
|
_build_editor = null
|
|
_player.set_physics_process(true)
|
|
_player.set_process_unhandled_input(true)
|
|
_player._refresh_mouse_mode()
|
|
|
|
|
|
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
|