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
Implements the DSL that docs/architecture/scripting_sandbox.md recommends
as the primary (and, for now, sufficient) approach in place of running
untrusted GDScript in-process: a fixed set of trigger/condition/action
verbs, expressed as plain JSON data, interpreted by trusted GDScript with
zero ability to reference engine singletons, call arbitrary methods, or
execute code.
- ScriptBehavior (scripts/scripting/script_behavior.gd): parses and
validates rules from JSON/Dictionary data. Every trigger, condition
type/op, and verb is checked against an explicit whitelist at parse
time; malformed or unknown entries are dropped and recorded in
rejected_rules, never partially executed. Carries an optional
content_flags passthrough so this data type isn't hostile to future
moderation-queue integration (docs/architecture/content_moderation.md).
- ScriptInterpreterNode (scripts/scripting/script_interpreter.gd): the
only code that executes rules. Triggers: on_interact (proximity +
"interact" input action), on_enter (edge-triggered Area3D overlap),
on_touch (continuous overlap), on_timer (physics-clocked Timer). Verbs:
give_item, play_sound, move, rotate, spawn_from_catalog, set_timer --
each a fixed, hand-written function; verb dispatch is an explicit
match(), never call()-by-name from data. move/rotate animate via Tween
rather than teleporting.
- PlayerInventory (scripts/scripting/player_inventory.gd): a small
per-player item-count stub for give_item to target, since no real
inventory/entity-state system exists yet (M9 Economy). Attached to
scenes/player.tscn as "Inventory".
- SpawnCatalog (scripts/scripting/spawn_catalog.gd): trusted catalog_id ->
asset_id lookup for spawn_from_catalog, mirroring the AvatarCatalog
data-driven-JSON convention. Rule data only ever supplies a catalog_id
key, never a resource path.
- Wired a real demo ("ScriptedChest") into scenes/worlds/test_level.tscn
with an on_interact rule (give_item + play_sound + rotate), plus an
"interact" input action (project.godot).
- scripts/smoke_test_script_behavior.gd: exercises every trigger type,
every verb, and every condition type against the real interpreter and
the wired demo scene, and proves unknown/malformed/unsafe rules
(unknown verb, unknown trigger, non-scalar smuggled arguments, etc.)
are rejected at parse time and never run -- 50/50 checks pass.
Three dead controls on the customization screen:
- Skin tone slider did nothing: neither the visualizer nor AvatarSystem
ever read loadout.skin_tone. Added AvatarLoader.apply_skin_tone() -- it
retints the base body's skin surfaces (matched by the placeholder skin
albedo, i.e. head + arms) -- and both build paths call it. Slider now
maps across a deep-to-pale skin range.
- "Cyber Head" did nothing: its catalog entry was type "body_part", which
load_attachment() rejects, and the placeholder body is one mesh so there
was nothing to hide either. Retyped it as an attachment -> it mounts on
the Head bone as a cyber visor.
- Mohawk / ponytail sat inside the head: the Head and HeadTop bones were
well below the visual head (bone at 1.58, head crown at ~2.03). Moved
Head to the head's centre and HeadTop just above the crown (mesh is
rigidly bound, so it doesn't move), and retuned the hair/visor/glasses
meshes and offsets to match.
Verified: avatar smoke 14/14 (source + pack), fresh-clone, 2p multiplayer,
and drove the real customization screen in rendered screenshots (equip
head+mohawk+mid skin, then ponytail+glasses+pale skin).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- The preview is now a turntable: drag it to rotate the avatar and see all
sides; it slowly auto-spins after ~2.5s of no input. Starts facing the
camera so you see the face. "drag to rotate" hint under the viewport.
- The big grey void to the right of the controls is gone -- the preview
panel now expands to fill the leftover width; the controls dock to the
right edge at a fixed width.
- Camera reframed to fit the whole ~2 m body; softer preview lights.
- Placeholder avatar: torso/arms/legs now share one 0.46 m depth so a limb
always covers the torso slice behind it -- kills the bright "cyan seam"
(it was a lit sliver of the torso side peeking past the thinner arm).
Verified headless (boot, 2p multiplayer, avatar smoke source+pack,
fresh-clone) + eyeballed the customization screen and the avatar seams in
rendered screenshots.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
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
Now that cloudbison can render (Radeon GPU via Xvfb, or llvmpipe software),
added scripts/tools/screenshot_rig.gd + scenes/dev/ so a scene can be booted,
run a few frames, and its rendered frame saved to PNG -- caught three things
the headless smoke never could:
- limbs floated detached from the torso: widened the torso and overlapped the
arm/leg boxes into it, and pulled limb depth in behind the torso front so
they stop z-fighting.
- proportions were scrawny: reworked to Roblox R6 ratios (2-stud torso, 2-stud
arms/legs, 1-stud head) with the head clearly pegged on top.
- the walk swing was a 40-deg lunge: STRIDE 0.7->0.42 rad, separate 0.32 arm
swing, KNEE 1.0->0.5. Reads as a walk now, not a crouch.
test_level lighting was near-black on the shaded side (ambient was disabled) --
colour ambient at 0.8 + a stronger key light, so the avatar reads in-world.
avatar_preview.tscn (AGP_PREVIEW_WALK=1) renders the assembled avatar walking
in place for quick visual checks.
Verified: avatar smoke 14/14 source + exported pack; fresh-clone boot PASS;
eyeballed in rendered screenshots (game scene + walk sequence).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
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
Avatar: rebuild the placeholder base body as a Roblox-classic (R6) figure --
six chunky solid box parts (head, torso, two arms, two legs) skinned one
part per bone onto the same 24-bone canonical rig, arms hanging at the
sides instead of a T-pose, plus a painted-on smile face. Classic
yellow/blue/green "noob" palette, flat single-colour surfaces (the old
capsule-segment chain read as a banded scarecrow). Attachments resized to
match the new proportions; head/hair/glove primitives are now boxes too
(dropped ~130KB spheres to ~3KB each).
Build stamp: it never rendered because `set_anchors_and_offsets_preset()`
plus `.position` on a zero-size Label collapses the rect to negative width.
Switched to a bottom-anchored full-width strip with explicit offsets and
text alignment, brighter colour + black outline. Shows on the main menu
(bottom-right) and in-world HUD (bottom-left) as `build PC3 <commit>`.
Verified: avatar smoke 14/14 from source and against the exported pack;
game.tscn + main_menu.tscn boot headless with no script errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
assets/avatars/catalog.json referenced 14 GLB files that were never in the
repo, so the avatar system could not load a single avatar. And the loader's
assembly path had never run against a real skeleton -- it called
Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a
way that dropped the skin binding.
- scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via
GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical
rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art
swaps in as a pure content change (catalog paths unchanged).
- assets/avatars/*.glb: the generated pack (checked in).
- avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound
Skeleton3D that generate_scene() already produces, instead of rebuilding it.
Attachments now mount via BoneAttachment3D to the catalog's mount_bone and
are actually added to the avatar tree (they were parsed then dropped).
Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin,
and the Array[String] type mismatch on the accessory list.
- smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully
because the files don't exist" section with a real assembly check --
Skeleton3D present, full 24-bone rig, canonical bones resolve by name,
visible body mesh, equipped items mounted to bones. 14/14 from a cold cache.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This branch never actually had the Godot scaffold — a workspace-reuse bug
(see agp source repo) let leftover backend/**/__pycache__/*.pyc files from
an unrelated task get swept into this branch's only prior commit, which
was checkpointed under this task's title despite containing zero Godot
files. Removing that garbage and bringing over the real project from
agent/997c429bc6b4 (the original PR's branch) with the review feedback
addressed:
- Play button no longer references the nonexistent scenes/game.tscn.
- Wired PlayButton/QuitButton [connection] entries in main_menu.tscn.
- Removed the invalid uid="uid://main_menu".
- Added .gitignore for .godot/.
- Removed the non-standard [export]/[export_options] sections from
project.godot (Godot 4 uses export_presets.cfg, not this).