83 KiB
83 KiB
AGENTS.md — stickman (Godot 4.4)
Project type
- Godot 4.4 2D/GUI project (Forward Plus renderer)
- No CLI build/test/lint commands; open in the Godot editor to run
- Main scene:
res://scenes/stickman_editor.tscn(set asrun/main_sceneinproject.godot)
Project overview
Stickman Studio is an editor tool for drawing and assembling stick figures. It is a
Control-based GUI (not physics/animation) in its current phase. Body-part vector shapes
are authored per-panel (each panel supports multiple shapes with Z-ordering) and
assembled in a "Whole Stickman" preview that supports translation, rotation, and scale.
Required addon
- Scalable Vector Shapes 2D (v2.27.7) at
addons/curved_lines_2d/- Declared dependency for the project. The current editor UI does not instantiate it directly,
but keep it present — it is required for the legacy
stick.tscnrig.
- Declared dependency for the project. The current editor UI does not instantiate it directly,
but keep it present — it is required for the legacy
Architecture
scripts/stickman_editor.gd—extends Control; the main controller. Owns the menu bar, save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels. WritesFILE_VERSION "1.5"; auto-migrates"1.0"–"1.4"files on load. Coordinates cross-panel selection so only one shape is selected at a time (shape_selected→ deselect others). Collects per-part{shapes[], position, rotation, scale, pivot, length, guide_offset}for save/load.- Phase 8 save export: writes top-level
proportions(hardcoded master-rig rest-pose constants 168/200/200/200/391.5 via thePROPORTIONSconst) and per-partpivot/lengthcomputed from the panel's local shape bounding box (_compute_part_pivot_length()):pivot= bbox center,length= bbox width for the 4 arm parts (X_AXIS_PARTS) and bbox height otherwise.pivot/length/proportionsare write-only metadata — never read back on load, recomputed on every save. - Phase 9 Round 5 guide-offset export: each part's save dict also gains
"guide_offset": {x, y}= (part bbox center in preview space) − (guide joint in preview space), computed in_collect_all_shape_data()as(pos + pivot) - _whole_preview.get_guide_joint_preview(...)— a pure master-space delta (both points are preview-world coordinates, so panel-size terms cancel). The part→joint map isconst GUIDE_JOINT_FOR_PART(head→"Neck" — the head bone's rig attachment origin, NOT the circle center — torso→"Hips", upper arms→Shoulders, lower arms→Elbows, upper legs→"Hips", lower legs→Knees). Write-only metadata likepivot/length; the load path (_apply_json_data) ignores it, so v1.0–v1.4 files load unchanged and gain the key on their next save. - Phase 6 recent colors: stores
_recent_colors: Array[String](max 8, most-recent-first), loads fromsettings.json(recent_colorskey) in_load_settings(), saves on each color selection via_save_settings(), and broadcasts to all 10 panels via_broadcast_recent_colors(). _on_color_selected(color, part_name): deduplicates, inserts the hex string at the front, trims to 8 entries, saves settings, then broadcasts to all panels._broadcast_recent_colors(): pushes_recent_colorsto every panel viaBodyPartPanel.set_recent_colors(_recent_colors).- Phase 6 status bar (cursor coords):
_process()pollsget_global_mouse_position()each frame, checks each panel viais_cursor_over_drawing(global_pos)and the preview viais_cursor_over_preview(global_pos), converts to world space viaglobal_to_world(global_pos), and writes"X: ### Y: ###"to_status_cursor_coords. - Phase 6 snap status:
_status_snap_statusdisplays"SNAP: ON"/"SNAP: OFF", set in_ready()and re-synced when snap is toggled (_on_edit_menu_id_pressed). - Phase 7 pose guide toggle: stores
_show_guide: bool(defaulttrue), persisted tosettings.jsonunder theshow_pose_guidekey (defaulttrue) via_load_settings()/_save_settings(). The View menu item (id 1) is a dynamic, text-only label (no checkmark): "Hide Pose Guide" while the guide is visible, "Show Pose Guide" while hidden, set via_guide_menu_label()._update_guide_menu_item()refreshes only the item text; it is synced on theabout_to_popupsignal (_on_view_menu_about_to_popup) and again at the end of_load_settings()(so a persistedshow_pose_guide: falseshows "Show Pose Guide" immediately at startup)._on_view_menu_id_pressed(id 1) toggles the state, saves, and broadcasts._broadcast_settings()pushes the value to the preview viaWholeStickmanPreview.set_show_guide(_show_guide)(called in_ready()after_load_settings()).
- Phase 8 save export: writes top-level
scripts/body_part_panel.gd—class_name BodyPartPanel,extends PanelContainer. Reusable per-part editor. Public API:set_shape_data(data: Variant)— import shape data (Array or single Dictionary; used on Load/Clear)get_shape_data() -> Array[Dictionary]— export array of{shape_type, points, color, closed, vertex_flags}clear_shape()— reset panel, clears all shapes (does not emitshape_changed)select()— mark the panel's topmost shape as selected (white outline highlight)deselect()— clear selection and cancel any in-progress vertex dragsignal shape_changed(shapes: Array)— emitted when any shape is created, modified, deleted, or reorderedsignal shape_selected()— emitted on left-click; the editor deselects all other panelssignal color_selected(color: Color)— emitted when the color is confirmed (OK button)set_recent_colors(colors_hex: Array)— clears existing ColorPicker presets and populates with the given hex colorsis_cursor_over_drawing(global_pos: Vector2) -> bool— true ifglobal_posis over the drawing surfaceglobal_to_world(global_pos: Vector2) -> Vector2— maps a global position to drawing ("world") space- Phase 2 vertex editing: left-click on a shape selects it; drag a vertex handle to
reshape in real time; with a shape selected, right-click near an outline edge offers
"Create Point" (inserts a vertex flagged
1at the edge midpoint). Original vertices are filled circles; user-created vertices are hollow rectangles. - Phase 4 multi-shape: panels store a
shapes[]array. Right-click context menu includes "Send Back" (id 7) and "Bring Forward" (id 8) for Z-ordering. "Delete" (id 5) removes the specific shape under the mouse._selected_shape_idxtracks which shape is active for vertex editing. - Per-panel zoom: mouse wheel multiplies
_zoomby 1.10, clamped to[0.3, 3.0]; drawing and input hit-testing both run in world space viadraw_set_transform. - Phase 6 touchpad:
_gui_inputhandlesInputEventMagnifyGesture(pinch zoom) andInputEventPanGesture(2-finger drag panning), both checked beforeInputEventMouseButton. Pan gesture delta is multiplied by 3.0 for speed parity with mouse panning. - Phase 6 cursor-centered zoom: both mouse wheel and pinch zoom adjust
_pan_offsetso the world point under the cursor stays fixed during zoom. - Selection gizmos always on top: bounding box, rotation circle, and scale
crosses for the selected part are drawn in a second pass after all parts,
via
_selected_gizmo_bounds, so they always render in front.
scripts/whole_stickman_preview.gd—class_name WholeStickmanPreview,extends Control; the assembly preview. Owns per-part position/rotation/scale, Z-order (_part_order), selection + gizmos, grid drawing, and pan/zoom. Public API includesset_body_parts(),set_show_guide(enabled: bool),reset_view(),is_cursor_over_preview(global_pos), and (Phase 9 Round 5)get_guide_joint_preview(joint_name) -> Vector2— returns the preview-space position of aGUIDE_JOINTSentry via_guide_to_preview(), guarded against unknown names (push_warning+Vector2.ZERO).- Phase 7 pose silhouette guide:
set_show_guide()stores_show_guide: bool(defaulttrue) and redraws;_draw_silhouette_guide()is called in_on_preview_draw()after the part loop and before the drag highlight/selection gizmos, so it renders above the grid and in front of user parts (ghosting over them), below the selection gizmos and drag highlight. The guide is centered at the default view and afterReset Views, computed at draw time from the live preview size via_guide_to_preview()=(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE + preview_area.size * 0.5, so it also re-centers on window resize; it remains a world-space fixture that moves with pan/zoom. Joint positions are hardcoded constants derived from themaster_rig.tscnrest pose (GUIDE_JOINTS: 13 anchors Head/Neck/Shoulders/Elbows/Wrists/Hips/Knees/Ankles;GUIDE_SCALE = 1.0,GUIDE_FIGURE_CENTER = (0, -93.75),GUIDE_HEAD_RADIUS = 100.0). Color-coded: left limbs cyan-blueColor(0.35, 0.70, 1.00), right limbs orange-redColor(1.00, 0.50, 0.20), central spine/head white, with lines at alpha0.45and joint dots at alpha0.65. Joint dots useGUIDE_JOINT_RADIUS / _zoom(6 px constant screen size). The guide is pure drawing — no hit-testing is added, so it never intercepts part dragging/selection.
- Phase 7 pose silhouette guide:
scripts/stk_rig_adapter.gd—class_name StkRigAdapter,extends RefCounted; a standalone runtime adapter (Phase 8, not referenced by the editor, extended by Phase 9).static func apply(stk_data, rig)fits an instantiatedmaster_rig.tscnto a loaded.stkdictionary, calling three private helpers in order:_fit_bones(re-fits the 8 limbBone2Dlengths + lower-bone origins, and zeroes the Head driver's local position so the chin sits on the neck joint),_recalibrate_ik(repositions theIK_Targets/Left|Right_HandandLeft|Right_Legtargets), and_mount_shapes(mounts.stkshapes onto theBody/*visual nodes — one node per shape: closed → singlePolygon2D, open → singleLine2Dwidth 2). TheRemoteTransform2Ddrivers keep their defaults (update_rotation = true), so mounted shapes follow their bones in every pose.- Phase 2 (Sandbox) single-shape support:
_mount_shapes()reads a part'sshapesarray when present (v1.2+), and otherwise — when the part dict itself carriespoints— wraps the whole dict as a single shape (shapes = [pd]), so v1.0/v1.1 single-shape.stkfiles (e.g.stickmen/test.stk) mount as visible geometry instead of being cleared to nothing. - Phase 9 extension: also fits the head bone (
Skeleton2D/Torso/Head.position.y = -proportions.torso_length, x preserved) and mounts the head as full geometry like every other part — it clears theBody/Headnode's inline@toolcircle script viaset_script(null)and mounts.stkhead shapes asLine2D/Polygon2D. Dead helpers_mount_head_circle,_compute_shapes_bbox, and_first_shape_colorwere removed. - Phase 9 Round 2 bugfix (hanging-convention mount): driver rotation neutralization was
removed — the
RemoteTransform2Ddrivers keepupdate_rotation = true, so each mounted part rotates to follow its bone in every pose (IK flexing included)._mount_shapes()no longer reads the file'spivot/lengthfields — it recomputes a per-part bounding box at mount time via_compute_part_bbox()(empty bbox → the part is skipped) and derives the mount transform via_compute_mount_transform(), which emits{anchor, scale, theta}: geometry is mounted in the rig's hanging convention (joint anchor at the local origin, far end along local+Y). Anchors: head and torso → bottom-center(cx, max_y)(chin / hip end at the origin); left limbs drawn horizontally →(max_x, cy); right limbs drawn horizontally →(min_x, cy); vertically drawn limbs → top-center(cx, min_y). Alignment rotation θ maps the far end onto local+Y: head0, torsoπ(driver π cancels it at rest), left horizontal limbs−π/2, right horizontal limbs+π/2, vertical limbs0. Scaling is anisotropic — only the auto-detected drawn long axis (width >= height) scales to the bone length (bone_length/extent, guardextent <= 0.0001→1.0); the cross axis stays 1:1 (so horizontally-drawn legs become ~200×28, not 101-px bars)._bone_length_for()maps each part to its bone length (upper/lower arm/leg, torso)._map_point()applies(P − J) ⋅ Sthenq.rotated(θ). The head driver's local position (Skeleton2D/Torso/Head/RemoteTransform2D) is zeroed in_fit_bones()so the mounted head's chin lands on the neck joint.DEFAULT_LINE_WIDTH := 2.0(was 16.0) matches the editor's 2 px outline._reset_node_transform()still resets eachBody/*container's scale to(1, 1)and rotation to0before mounting (position untouched, owned by the driver). - Phase 9 Round 3 bugfix (part preview transform + one node per shape): the mount
pipeline now composes the part's preview transform
E(P) = C + R(rot)·S·(P − C)(scale-then-rotate about the raw bbox center — the editor's exact Whole-Stickman-preview transform) before the hanging-convention mount._mount_shapes()reads the per-partrotation(degrees, default0.0) andscale({x,y}, default(1,1)) from the part dict and appliesEto the raw joint endJ_rawand far pointF_pt_raw(J' = E(J_raw),F' = E(F_pt_raw) − J'). The anchor, alignment θ, and bone-fit scalesare then computed on the transformed geometry: rotations near ±180° (|wrapf(rot)| > 0.75π) swap the attachment to the drawn far end (A = F_pt',V = −F') so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint and its hip end at the neck); other rotations keep limbs attached along their bones (a 90° forearm hangs from the elbow with its content turned, exactly as assembled). The bone-fit scales = bone_length / |V|is measured on the transformed extent so user-scaled parts are not double-fitted. The head mounts upright withθ = 0,s = 1(a bone-fit scale would double-scale the face), but still applies the part scale throughE(face ≈160 px) with the chin at the neck joint and the flip anchor rule still applying._mount_shape()mounts one node per shape: closed → singlePolygon2D(fill only, no pairedLine2Doutline); open → singleLine2D(width 2). - Phase 9 Round 4 bugfix (head chin drop): adds
const HEAD_CHIN_DROP := 28.0, derived from the editor's pose guide — the head circle (radius 100) is centered at the Head joint(0, −463.5), so its bottom is−363.5; the neck (Head bone origin) is at−391.5, so the chin drops 28 px below the neck. The mounted head points get aVector2(0.0, 28.0)rig-space translation (offsetin_compute_mount_transform()/_map_point(), applied after the part transform and the(θ = 0, s = 1)transform; flip-agnostic — only the head branch sets a non-zerooffset). Result: the head's chin lands at world ≈(0, −363.5), overlapping the torso's top (which ends at−391.5) by 28 px — matching the silhouette guide in the editor. - Phase 9 Round 5 guide-offset application:
_mount_shapes()reads each part'sguide_offset({x, y}, default absent) and, only when the key is present (old files keep the previous offset-0 behavior and the head falls back to the Round 4HEAD_CHIN_DROP), applies a node-frame translationt = (guide_offset + (A − C)).rotated(−c_node)where A = the mount anchor already computed (the transformed joint endJ', or the transformed far endF_pt'when flipped — Round 3), C = the raw bbox center, andc_node= the part's driverRemoteTransform2D.global_rotationat apply time (read via the reintroducedDRIVER_PATHSconst; null-guarded, fallback 0.0). This converts the editor's master-space guide offset into a bone-relative placement so the harness reproduces the guide placement 1:1 (and, for the head — mapped to the guide Neck joint — subsumes theHEAD_CHIN_DROPfallback).tis applied in_map_point()as the final rig-space translation, afterE/θ/scale/flip and independent of the flip logic. Re-saving a.stkfrom the editor populates the offsets. - Phase 9 Round 6 bugfix (guide-driven anchor selection): when
guide_offsetis present, the joint anchor in_compute_mount_transform()is now whichever transformed end (j_prime = E(J_raw)orf_pt_prime = E(F_pt_raw)) is nearest the part's stored guide joint (center − guide_offset): ifd_far < d_joint(strict) the far end attaches (anchor = f_pt_prime,v = −f_prime), else the family end (anchor = j_prime,v = f_prime). This replaces the per-side family choice and the 180° flip heuristic for theguide_offsetcase, fixing the lower left leg (knee now at the joint, was the ankle) and lower right arm (elbow now at the joint, was the wrist), both 180° off their bones because the user's drawn-side conventions are inconsistent per part. The nearest-end rule preserves every previously-correct case and naturally reproduces the 180° flip (a flipped part's far end lands nearest the joint — e.g. the flipped right upper arm shoulder and the flipped torso neck end), plus the head chin (nearest the guide Neck). Old files without the key keep the previous family rules + flip heuristic exactly as before.theta,s, the Round 5 offsett, and theHEAD_CHIN_DROPfallback are unchanged — they consumeanchor/vgenerically. Targetsmaster_rig.tscnnode paths; every node lookup is null-guarded (missing node →push_warning+ skip, never crash). Consumed by a future runtime pipeline.
- Phase 2 (Sandbox) single-shape support:
scripts/stickman_rig.gd—class_name StickmanRig,extends Node2D; the runtime owner of facing direction, per-joint bone bend, andBody/*z-order formaster_rig.tscn(Phase 9 Task 4). Attached to theMasterroot node ofmaster_rig.tscn. Non-@tool— node resolution, flag writes, and z-order reordering run only at runtime (_ready+ setters on a live instance). EnumsFacingProfile { LEFT, RIGHT, FORWARD }(values are the harness facing-menu ids),BendDirection { NORMAL, INVERTED }, andRigState { ANIMATED, RAGDOLL, RECOVERING }. Constants (moved from the harness):SKELETON_PATH,BODY_CONTAINER_PATH,BEND_JOINTS(["LeftArm","RightArm","LeftLeg","RightLeg"]),BEND_JOINT_BONE_PATHS(each joint → its lowerBone2DNodePath relative toSkeleton2D),PROFILE_FLAGS(per-profileflip_bend_directionsets),Z_ORDER_BY_PROFILE(per-profileBody/*draw-order tables, back-to-front). Exports:facing_profile: FacingProfile(defaultFORWARD, a preset whose setter writes the four per-joint vars, reordersBody/*, and applies a whole-rig Y-axis mirror for LEFT —Master.scale.x = -1; RIGHT/FORWARD(1,1)) and an@export_group("Bend Direction")of four@export_enum("Normal","Inverted")varsleft_arm_bend/right_arm_bend/left_leg_bend/right_leg_bend(defaults NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Recovery exports:rest_timeout(2.0 s),auto_recover(true), plus recovery constantsSTAND_POSE/IK_TARGET_PATHS/REST_LINEAR_THRESHOLD/REST_ANGULAR_THRESHOLD/STAND_UP_DURATION/STABILIZATION_DELAY/RAGDOLL_TARGET_SOFTNESS. Signalsfacing_profile_changed(profile)/bend_flag_changed(joint, flipped). Public API:set_facing_profile/get_facing_profile,set_joint_bend_flipped/get_joint_bend_flipped,get_bend_joints(), andget_bend_joint_global_position(joint)(unknown joint →push_warning+ no-op/false/Vector2.ZERO)._ready()resolvesSkeleton2D/Body/4 lowerBone2Ds/4 TwoBoneIK modifications (matched byjoint_two_bone2d_nodeNodePath, never stack index), enables the modification stack (stack.enabled = true), applies the current profile once, then sets_nodes_ready; a_nodes_readyguard makes pre-_readysetters store-only (robust against setter timing duringPackedScene.instantiate()). Null-guards +push_warningprefixed"StickmanRig: "throughout; never crashes.- Whole-rig Y-axis mirror (replaces
_apply_head_flip(), 2026 design change): Facing LEFT is applied as a whole-rig Y-axis mirror —Master.scale.x = -1(RIGHT/FORWARD →(1,1)) — so the head AND body mirror together and face the correct direction. The old_apply_head_flip()and theBody/Head.scale.xmirror are removed (the Head Pivot node's driver transform is untouched). Per-jointflip_bend_directionflags (PROFILE_FLAGS) andZ_ORDER_BY_PROFILEare kept provisionally (unchanged): the mirror reflects the whole skeleton +IK_Targets+ mountedBody/*geometry, while z-order (depth) is unaffected by an X-mirror.walk_to()always plays the canonicalwalk_rightclip (X-mirrored by the root for LEFT;walk_leftis no longer used at runtime) and sets the facing profile explicitly. Two head-related fixes under the mirror: the master_rig.tscn headRemoteTransform2D(Skeleton2D/Torso/Head/Pivot) no longer setsupdate_scale = false— it pushes the full transform like every otherBodydriver, soBody/Head.scalestays identity under the mirrored root (the old partial-channel push re-canonicalized the scale and caused per-frame Y-flips/wrap-jumps). And_apply_head_lookat_mirror_mode()(called from_apply_profile()) disables the headSkeletonModification2DLookAtwhen facing LEFT and pins the head bone rotation to the FORWARD canonical aim (π);_pin_mirrored_head_rotation()re-asserts that pin each_physics_processframe while ANIMATED/RECOVERING so recovery's stack re-arm can't let LookAt flip the bone. RIGHT/FORWARD re-enable the LookAt. Consequence: interactive head-aiming while facing LEFT is intentionally static. - Phase 10 ragdoll state system:
StickmanRigowns a reversibleANIMATED ⇄ RAGDOLLphysics mode switch plus aRECOVERINGstand-up state.enum RigState { ANIMATED, RAGDOLL, RECOVERING },var state: RigState(defaultANIMATED),signal state_changed(new_state: int), and public APIset_ragdoll(enabled: bool)/toggle_ragdoll()/is_in_ragdoll() -> bool/request_recovery()/snap_to_standing()(Phase 2 Sandbox: instantly destroys the ragdoll or cancels the recovery tween, sets the 6 IK targets directly toSTAND_POSE, re-showsBody/*, re-enables IK, and returns toANIMATEDwith no stand-up glide)._physics_process()→_track_momentum(delta)caches the rig root's linear/angular velocity from per-frameglobal_position/global_rotationdeltas, then drives_update_rest_detection()(auto-recovery trigger)._enter_ragdoll()stop(true)s theAnimationPlayer(ANIMATION_PLAYER_PATHconst, keep_state — no pose reset), builds the ragdoll from the CURRENT solved bone positions while the IK stack is still enabled (disabling it first would revert the bones to the authored rest pose, popping the figure), then hidesBody/*and disables the IK stack immediately — an instant handoff with no crossfade (the ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), setsstate = RAGDOLL+ emits._build_ragdoll()creates aNode2Dcontainer"RagdollBodyContainer"under the rig's parent (world root; fallbackget_tree().current_scene) and populates it from theRAGDOLL_BODIEStable (10RigidBody2D: torsoCapsuleShape2Dradius 12 mass 8.0, headCircleShape2Dradius 100 mass 2.0, limb capsules radius 8 masses 1.0–2.0;collision_layer/collision_mask= 1, bodies spawn fully visible) and theRAGDOLL_JOINTStable (9PinJoint2D, one per non-root body pinned at the child bone's origin,softness = RAGDOLL_TARGET_SOFTNESSat build). Angular limits via_apply_ragdoll_joint_limits():elbow_kneefolds +CW-5°..+150°,elbow_knee_ccw-150°..+5°,shoulder_hip±160°, default (neck) free. Cached momentum is applied to the torso body.apply_ragdoll_velocity_boost(velocity)applies the same velocity delta (mass-scaledapply_central_impulse) to every ragdoll body — used by the harness "Knock Up" button. All ragdoll nodes are spawned procedurally —master_rig.tscnis not modified. - Phase 11 instant handoff + recovery:
_update_rest_detection()(only whenstate == RAGDOLL) reads_ragdoll_bodies["torso"]: when its linear/angular velocity drops belowREST_LINEAR_THRESHOLD/REST_ANGULAR_THRESHOLDit accumulates_rest_timer; afterrest_timeout(and aSTABILIZATION_DELAYhold) withauto_recoveron it calls_start_recovery()._start_recovery()(alsorequest_recovery(), no-op unless in RAGDOLL) captures the 10 bodies' rig-local{pos, rot, half}into_captured_pose(half= each capsule's half-length from build-time metadata) plus a landing anchor (_captured_landing_center= the ragdoll torso's world center,_captured_ground_y=torso.center.y + RAGDOLL_TORSO_RADIUS),_reanchor_root_to_landing()s (translates the rig root so the standing figure's feet sit on the ground at the landing X —feet = (_captured_landing_center.x, _captured_ground_y),new_root = feet + FOOT_OFFSET— and re-bases the captured rig-local positions by the root shift; the figure stands up in place, on the ground, where the ragdoll landed, since the ragdoll bodies live under a world sibling and the root never moved while it fell),_destroy_ragdoll()s, setsstate = RECOVERING+ emits, then_snap_skeleton_to_pose()— a marker-driven snap writingIK_Targets/Torso.position/.rotation,IK_Targets/Head.position, and the 4 limb markers (never the slavedTorsoBone2D), re-showingBody/*, then calling_rearm_ik_stack()— a defensive re-setup (re-sets up theSkeletonModificationStack2Dwhen it reports!get_is_setup(), re-assertsenabled = trueandSkeleton2D.set_process_internal(true), and re-asserts the Torso marker'sRemoteTransform2Dupdate-position/rotation/scale flags) so the stack reliably resumes solving toward the end-effectors after a disable→enable toggle. Runtime diagnosis of the re-arm is gated behindconst DEBUG_RECOVERY := false(off by default;_recovery_dbg()traces stack enabled/setup/internal + bone-following state). Snap geometry: the ragdoll capsules span joint origin→tip along their +X, so the hip is derived astorso.pos − spine_dir·halfand the wrist/ankle targets aslower_body.pos + dir·half; the Torso marker rotation subtracts the Torso Bone2D'sbone_angle(bone world angle = marker rotation + bone_angle — copying the body rotation directly would slam the skeleton −90° and lay it flat)._play_stand_up()then tweens the 6 markers directly from their captured values toSTAND_POSEoverSTAND_UP_DURATION(2.0 s, sine ease-in-out,_tween_markers_to()); the baked"stand_up"animation is not played (a fixed first keyframe can never match an arbitrary ragdoll rest pose, so recovery starts from wherever the snap left the markers). On tween finish_on_stand_up_finished()re-enables IK, re-showsBody/*, setsstate = ANIMATED, and emits. Interruptible:set_ragdoll(true)duringRECOVERINGkills the stand-up tween and rebuilds the ragdoll;set_ragdoll(false)duringRAGDOLLroutes through_start_recovery(); repeatedset_ragdollcalls are idempotent.is_in_ragdoll()staysstate == RigState.RAGDOLL(soRECOVERINGreads as "Stickman"). - Phase 3a director functionality: adds navigation/walking, speech, an action queue, and a
queue-runner state machine. Enums
RunnerState { IDLE, EXECUTING }andActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }. ConstantsFOOT_OFFSET := (0, -385)(feet → root; matchesStageSpawner.STICKMAN_FOOT_OFFSET),NAV_AGENT_LOCAL_POS := (0, 385)(== -FOOT_OFFSET),ARRIVE_DISTANCE/NAV_PATH_DESIRED_DISTANCE/NAV_TARGET_DESIRED_DISTANCE/SPEECH_BUBBLE_OFFSET. New signalsarrived,action_started(action, index),action_finished(action, index),queue_finished(on completion, not stop),queue_changed(any queue mutation),speech_finished. Exportswalk_speed(300.0). Public API:walk_to( target, speed = -1.0)(feet/ground destination; no-op unlessstate == ANIMATED),is_walking(),speak(text, duration)(lazily creates aSpeechBubblechild atSPEECH_BUBBLE_OFFSET, auto-hides- emits
speech_finished), the queue APIqueue_action/clear_queue/get_queue/remove_action/insert_action/queue_size(all mutations emitqueue_changed), and the runner APIstart_queue/stop_queue/is_queue_running._ready()builds aNavigationAgent2Dchild atNAV_AGENT_LOCAL_POS(feet, on the ground-level nav mesh;avoidance_enabled = false,max_speed = walk_speed, shared default nav map layer 1). A_physics_processordering of_track_momentum→_update_rest_detection→_update_walking→_update_speech→_update_runnerdrives the runner state machine perActionPhase(walk_to→ wait for_walk_done;speak→ wait for!_speech_active;wait→_phase_timercountdown;ragdoll→set_ragdoll(true)then waitis_ragdoll_at_rest();recover→request_recovery()then waitstate == ANIMATED).is_ragdoll_at_rest()is a new public query exposing the rest result regardless ofauto_recover(the runner polls it; auto-recovery logic is unchanged)._enter_ragdoll()additionally calls_cancel_walking()(no stale walk/path state) and resets_ragdoll_at_rest = false. Walk fix (2026-08-29, hybrid policy):_update_walkingdefers all nav reads untilNavigationServer2D.map_get_iteration_id(...) != 0(map-sync guard), forces the path query viaget_next_path_position()before any empty-path / finished check (the read-onlyget_current_navigation_path()alone never triggers a repath), then branches onis_target_reachable(): an on-mesh target follows the nav path (_walk_mode = "nav"), while an off-mesh/unreachable target switches to direct straight-line steering toward the clicked waypoint (_walk_mode = "direct", root target = waypoint +FOOT_OFFSET) — a supported case with nopush_warning(the old "warn + finish in place" policy was replaced; the rig never stands still at a waypoint). There is no_walk_path_gracevariable (the map-sync guard + forced path query replace it) and no unreachable warning; the debug trace now carries amode=nav|directfield. Off-by-default diagnostics:DEBUG_WALK+_walk_dbg()(rig) andDEBUG_STAGE+_stage_dbg()(sandbox_stage). Walking is kinematic (global_position.move_toward); movement composes with the canonicalwalk_rightin-place limb animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set explicitly bywalk_to()/set_facing_profile();walk_leftis no longer used at runtime and the animation.:facing_profiletracks are neutralized/removed).
- emits
- Phase 4 triggers: the
arrivedsignal gained atarget: Vector2payload (emits_walk_target_feet) so the stage can match waypoints forarrived_at_waypointrules; newenqueue_reactive(actions: Array[Dictionary]) -> voidappends reactive actions to the action queue and, if the runner isIDLE, resumes at the first newly-appended action (no replay of the already-consumed queue prefix). Sequential Phase 3a queues are untouched.
- Whole-rig Y-axis mirror (replaces
scripts/stickman_factory.gd—class_name StickmanFactory,extends RefCounted; a static factory and the runtime entry point (Phase 9, not used by the editor) that turns a.stkfile into a live, riggedmaster_rig.tscninstance:static func load_stk(path: String) -> Dictionary— reads a.stkfile (FileAccess+JSON.parse_string); returns{}+push_warningon failure.static func spawn_from_data(stk_data: Dictionary) -> StickmanRig— instantiatesres://master_rig.tscn, callsStkRigAdapter.apply(stk_data, rig), returns the rig root (typed asStickmanRigsince the rig now carries theStickmanRigroot script).static func spawn(path: String) -> StickmanRig—load_stk()thenspawn_from_data(); returnsnullon empty data.
scripts/create_animations.gd—@tool extends EditorScript; a standalone editor utility (run manually withmaster_rig.tscnopen, not auto-loaded or referenced at runtime). Supersedes the deletedscripts/create_walk.gd._run()bakeswalk_left/walk_right(same keyframes as the old script, via_generate_walk_animation()) and a one-shotstand_up(via_generate_pose_animation(),STAND_UP_DURATION= 2.0,loop_mode = LOOP_NONE) into the open scene's defaultAnimationLibrary.stand_upkeys the 6IK_Targets/*:positiontracks plus aIK_Targets/Torso:rotationtrack fromPOSE_DOWN(a generic "lying on back" pose) toPOSE_STANDING(matchingmaster_rig.tscndefaults), withPOSE_PATHS/POSE_MARKERSconsts. The bakedstand_upis an authored reference only — runtime recovery does not play it (StickmanRig tweens the IK targets directly from the captured ragdoll pose).scripts/test_harness.gd— standalone staging scene (Phase 9, not wired into the editor; run via F6 onres://scenes/test_harness.tscn) for debugging bone scales, vector drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" button →FileDialog(*.stk); quick-select buttons forstickmen/break.stk,stickmen/basic.stk,stickmen/test.stk; "Show Bones" / "Show IK Handles" checkboxes; a status label showing the loaded filename. Viewport:SubViewportContainer→SubViewport→ worldNode2D+ enabledCamera2D; middle-mouse pan, mouse-wheel zoom, camera recenters on each spawn. Each load frees the previous rig and spawns a fresh one viaStickmanFactory.spawn(). Debug overlay (a world-spaceNode2D_draw()): true bone segments (a joint dot at eachBone2Dorigin + a parent→child line to eachBone2Dchild, color-coded left cyan / right orange / central white) with leaf bones drawn out to their IK targets (LeftLowerArm→Left_Hand,RightLowerArm→Right_Hand,LeftLowerLeg→Left_Leg,RightLowerLeg→Right_Leg) so wrist/ankle joints are visible (Phase 9 Round 2; previously only origin→parent-origin lines were drawn). The Head leaf is the exception (Phase 9 Round 3): its IK target is aSkeletonModification2DLookAtaim point, not a joint, so it is not inLEAF_BONE_IK_PATHSand the no-target fallback draws a ~90 px segment along the bone's own direction (Vector2(length, 0)rotated bybone_anglethenglobal_rotation) instead of a line to the aim point; colored markers atIK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}when "Show IK Handles" is on. Interactive IK: click-drag theMarker2DIK targets; the scene'sSkeletonModificationStack2DTwoBoneIK flexes limbs live (the rig self-enables its modification stack in_ready()).- Phase 9 Round 7 draggable Torso & Head handles:
IK_HANDLE_PATHSnow has 6 entries — the 4 limb targets plus"Head"(IK_Targets/Head, theSkeletonModification2DLookAtaim point) and"Torso"(IK_Targets/Torso, whose childRemoteTransform2Dmoves the hip bone). Dragging the Torso handle translates bones only (no target following) — the marker'sRemoteTransform2Dmoves the hip bone and the whole skeleton +Body/*visuals follow rigidly, while the limb/head targets stay put (dragging the figure away from them stretches the limbs toward the stationary targets, per user decision). Dragging the Head handle drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint);Body/Headfollows._handle_color()colors the head marker yellow (HANDLE_COLOR_HEAD) and the torso marker magenta (HANDLE_COLOR_TORSO); hands stay green, feet blue. The IK overlay also draws a null-guarded semi-transparent yellow aim line from the Head bone origin to the head marker (_draw_ik_handles, width1.5/zoom, alpha0.5) — a visual aid for the LookAt test. - Phase 9 Task 1 skeleton IK bone switches: adds a "Facing"
MenuButton(leftmost control in the top-barHBox) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip Bend Direction" flags. The facing profile, the per-joint bend flags, and theBody/*z-order tables now live in theStickmanRigscript (Phase 9 Task 4) — the harness drives the rig via_rig_script: StickmanRig(typed root fromStickmanFactory.spawn(); signals connected beforeadd_child) and keeps_facing_profile(defaultFORWARD) only as a UI mirror for the[√]menu prefix + respawn re-application; the rig's exportedfacing_profileis the authority. Menu item ids areStickmanRig.FacingProfile.LEFT/RIGHT/FORWARD(values used directly as menu item ids). State_context_joint,_facing_button/_facing_menu,_context_menu. The Facing popup (Left/Right/Forward) uses dynamic text-only labels with the current profile prefixed[√], refreshed onabout_to_popupand after selection (mirroring the editor's snap-menu pattern); selection calls_rig_script.set_facing_profile(id). Hit-test for the right-click toggle iterates_rig_script.get_bend_joints()with positions from_rig_script.get_bend_joint_global_position(joint). Per-joint toggle: right-click inside the viewport on an elbow/knee (the upper↔lower limb connector, withinJOINT_HIT_RADIUS_PX := 14.0screen px converted to world by_camera.zoom.x, nearest joint wins) pops a one-item context menu labeled "Normal Bend" (when the rig'sget_joint_bend_flipped(joint)is true) or "Invert Bend" (when false); selecting calls_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint)). Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/head/torso are not. Lifecycle:_free_current_rig()clears_rig_script(and_context_joint);_load_and_spawn()captures the remembered profile beforeadd_child(so the rig's_ready()facing_profile_changed(FORWARD)doesn't clobber the mirror) then re-applies_rig_script.set_facing_profile(remembered_profile)after_resolve_rig_nodes(). The rig enables its own modification stack in_ready(). No persistence to disk. - Phase 9 Task 2 body-part z-order: the rig's
_apply_profile()reorders the rig'sBody/*visual part nodes (tree order = draw order) from the Facing profile;Z_ORDER_BY_PROFILE(now owned byStickmanRig) maps eachFacingProfileto theBody/*part node names in back-to-front draw order (Godot 4Node2Ddraws siblings in tree order; all parts keepz_index = 0). FORWARD: torso → left/right upper legs → left/right lower legs → left/right upper arms → left/right lower arms → head (all limbs in front of the torso); LEFT: left arm pair then left leg pair behind the torso, right leg pair then right arm pair in front; RIGHT: mirrored. In every profile upper limbs stay behind lower limbs; far-side (behind-torso) arms draw behind the legs while near-side arms draw in front of the legs; the head is always frontmost. The rig's_apply_body_z_order()walks the profile array back-to-front andmove_child(part, count - 1)s each existing child (missing parts skipped), which yields the profile order; unknown extra children stay at the back. Safe with the adapter: shapes are children of the part nodes, so moving a part moves its whole shape group. No persistence to disk. - Phase 9 Task 3 coordinates display: adds a "Show Coords"
CheckBoxin the top-barHBox(after "Show IK Handles", before the status label), default ON, toggled via_on_show_coords_toggled(pressed)(sets_show_coords: bool = true, flips_coords_panel.visible). A code-built right-side readout (_build_coords_panel(), called from_build_ui()after the viewport container so it renders in front):_coords_panel(PanelContainer) + monospace selectable_coords_label(RichTextLabel,selection_enabled+context_menu_enabled+fit_content, autowrap off, scroll off,FOCUS_CLICK;SystemFont: Consolas/Menlo/DejaVu Sans Mono/Courier New via thenormal_font/normal_font_size(18) theme overrides), anchoredPRESET_TOP_RIGHT,offset_top = 40.0,offset_right = -8.0,offset_left = -COORDS_PANEL_WIDTH(320.0),grow_vertical = GROW_DIRECTION_END+grow_horizontal = GROW_DIRECTION_BEGIN(auto-height/width, grows left so text never runs off-screen),mouse_filter = MOUSE_FILTER_IGNOREon the panel (the label itself stays interactive for selection); StyleBoxFlat bgColor(0,0,0,0.55), borderColor(1,1,1,0.12)w1, corner radius 4, content margin 8. ConstsCOORDS_PANEL_WIDTH := 320.0andCOORD_BONE_PATHS(10 Skeleton2D-relative bone paths: Torso, Torso/Head, both upper/lower arms, both upper/lower legs). State_coord_bones: Dictionary(bone display name →Bone2D, keyed bypath.get_file()),_show_coords._resolve_rig_nodes()calls_resolve_coord_bones()(via_skeleton.get_node_or_null,push_warningon missing);_free_current_rig()clears_coord_bones(toggle persists across respawns)._process(delta)early-outs when hidden or label null, else_update_coords_display()— sections "Skeleton2D" pos + rot deg, "Bones" pos + rot deg, "IK Targets" pos only (reusingIK_HANDLE_PATHS/_ik_handles); "No rig loaded" fallback; every read guarded withis_instance_valid; the label text is only reassigned when the built string changes, so an active text selection survives idle frames. Values are world-space (global_position/global_rotation), rotation in degrees via_fmt_deg(rad)(1 decimal,°),_fmt_vec2(v)for positions. No persistence to disk. - Phase 9 Task 5 rig animation: adds top-bar controls immediately after the "Facing" menu
— an
_anim_dropdownOptionButtonpopulated per spawn fromAnimationPlayer.get_animation_list()(preferringwalk_rightviaDEFAULT_ANIMATION), a_play_buttonwhose label swaps "Play"/"Pause"/"Resume" by_playback_state, a_stop_button(Stop), and a_loop_checkCheckBoxdefault ON (harness-level, persists across respawns like_show_coords). The harness resolves the rig'sAnimationPlayerdirectly by node path via theANIMATION_PLAYER_PATHconst (_resolve_anim_player(), called at the end of_resolve_rig_nodes()) and drives it directly; theAnimationTreenode remains an untouched unconfigured placeholder (out of scope, D1). Loop is implemented by writingAnimation.loop_mode(LOOP_LINEAR/LOOP_NONE) on the selected animation before each play (_apply_loop_mode()); playback state is tracked by the enumPlaybackState {STOPPED, PLAYING, PAUSED}via the button handlers + theanimation_finishedsignal (guarded by_loop) — no polling in_process. Changing the dropdown selection stops playback;_free_current_rig()clears_anim_player, the dropdown,_selected_animation, and state. The animation.:facing_profiletracks are neutralized/removed — facing is now set explicitly viaset_facing_profile()/walk_to()(which sets LEFT/RIGHT from the walk direction and root-mirrors for LEFT), so playingwalk_rightno longer flips the rig's profile through an animation track. No persistence to disk. The "Facing" menu and all animation controls are hidden until an .stk is loaded (_set_rig_controls_visible(false)at the end of_build_ui()and in_free_current_rig(); shown on successful spawn in_load_and_spawn()).
- Phase 9 Round 7 draggable Torso & Head handles:
scripts/terrain_block.gd—class_name TerrainBlock,extends StaticBody2D; a reusable vector terrain component (Vector Terrain System, not used by the editor). Builds its three children in code:Polygon2D(interior fill,fill_color),Line2D(crisp vector border,outline_color/outline_width, auto-closed loop by appending the first vertex to the end,LINE_JOINT_ROUND+ round caps), andCollisionPolygon2D(BUILD_SOLIDSsolid decomposition — supports concave blocks). Exported properties:polygon_points: PackedVector2Array,fill_color,outline_color,outline_width; a unified setter pushes vertex changes to all three children live (no manual rebuilds).scripts/terrain_utils.gd—class_name TerrainUtils,extends RefCounted; static utility (not used by the editor):sanitize_points(points: PackedVector2Array, grid_size: float = 16.0) -> PackedVector2Array— sanitization pipeline in order: grid snap → redundancy removal via a local_simplify_polyline()(Godot 4.7 has noGeometry2D.simplify_polyline(); the local version drops consecutive duplicates, a closing duplicate whenlast == first, and collinear vertices) → clockwise enforcement viaGeometry2D.is_polygon_clockwise()(reverses if false, guaranteeing clockwise output).spawn_block(...)— factory that sanitizes raw input vectors (sanitize_points), creates aTerrainBlock, applies the cleaned points, and adds it to the target container.
scripts/prop_block.gd—class_name PropBlock,extends RigidBody2D; a reusable dynamic vector prop component (Dynamic Vector Props, not used by the editor),@tool. Builds its children in code:Polygon2D(interior fill,fill_color),Line2D(crisp vector outline,outline_color/outline_width, auto-closed loop by appending the first vertex,LINE_JOINT_ROUND+LINE_CAP_ROUND), and a collision node —CollisionPolygon2D(BUILD_SOLIDS) inPOLYGONmode, orCollisionShape2D+CircleShape2D(48-segment radial loop,CIRCLE_SEGMENTS = 48) inCIRCLEmode, toggled viashape_type. Exported properties:shape_type(@export_enum("Polygon","Circle")),polygon_points: PackedVector2Array,radius: float,fill_color,outline_color,outline_width, andmaterial_preset(@export_enum("None","Wood","Rubber","Cardboard","Metal")). Presets setmass+physics_material_overridevia staticmass_for()/physics_material()/tint_for()(Wood: mass 3.0, friction 0.6, bounce 0.1; Rubber: mass 0.5, friction 0.9, bounce 0.85; Cardboard: mass 0.4, friction 0.3, bounce 0.05; Metal: mass 8.0, friction 0.9, bounce 0.0; None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-NONE. Unified live-update setters push geometry/color changes to all children (null-guarded for@tooleditor safety);_apply_shape()enables exactly one collision node.- Phase 4 collision signal: new
signal collided(other: Node);_ready()setscontact_monitor = true,max_contacts_reported = 8, and connects the guardedbody_enteredsignal →_on_body_entered→collided.emit(body), so prop-vs-prop collisions are reported by the physics engine (the stage's geometric feet-point test covers stickman-vs-prop separately).
- Phase 4 collision signal: new
scripts/trigger_area.gd—class_name TriggerArea,extends Node2D; a placeable sensor (Phase 4, not used by the editor).@export size: Vector2(default 96×96),get_area_rect() -> Rect2(centered on the node's global position), and_draw()(translucent green fill + dashed border). No physics and no signals — it is a pure geometric region evaluated bysandbox_stage.gd's event engine (_update_area_entry) forentered_arearules.scripts/prop_utils.gd—class_name PropUtils,extends RefCounted; static factory (not used by the editor):create_box(size := Vector2(48,48))/create_ball(radius := 24.0)/create_plank(length := 160.0, thickness := 16.0)/create_triangle(base := 56.0, height := 48.0)— primitive generators returning shape-payload dictionaries with default dimensions + color themes (wood/rubber/metal/cardboard).spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity := Vector2.ZERO) -> PropBlock— instantiates aPropBlock, applies the payload (shape_type+ geometry + colors via_apply_shape_payload(), sanitizing polygon points throughTerrainUtils.sanitize_points()), setslinear_velocityafteradd_child(only when non-zero), and returns the spawned prop.
scripts/physics_test_harness.gd—class_name PhysicsTestHarness,extends Node2D; standalone staging scene root (Vector Terrain System / Dynamic Vector Props, not wired into the editor; run via F6 onres://scenes/physics_test_harness.tscn). Builds flat ground, angled ramps, and steppedTerrainBlockinstances viaTerrainUtils, instantiatesres://master_rig.tscnstanding on the flat ground, and handles camera input. A top-bar UI (_build_ui(), aCanvasLayer+PanelContainermatchingtest_harness's style) replaces the old key bindings: Spawn Crate / Spawn Ball / Spawn Plank buttons spawn dynamic props above the angled ramp viaPropUtils.spawn_prop()(PROP_SPAWN_POSITION = (300, -300): Wood Crate (create_box(),WOOD, velocity(60,0)), Bouncy Ball (create_ball(),RUBBER,(-80,0)), Heavy Plank (create_plank(),METAL,(30,-40)). Adds a best-effortStaticBody2Dcollision proxy (RigCollisionProxy,_add_rig_collision_proxy()) since the rig has no physics bodies of its own — a 240×1000 pxRectangleShape2Dcentered at(0,-500)(RIG_PROXY_SIZE/RIG_PROXY_CENTER) matching the standing figure's world bounds, so props bounce/rest against it; the proxy is a code-only stand-in, not part of the rig.- Phase 10 ragdoll trigger: the harness stores the spawned rig in
_rig: StickmanRigand shows a toggle-modeButton(_ragdoll_toggle) whose text flips "Stickman" ↔ "Ragdoll" (_update_ragdoll_toggle(),set_pressed_no_signalkeeps the label in sync without retriggering). Toggling calls_rig.set_ragdoll(pressed), then on entry calls_remove_rig_collision_proxy()(the ragdoll collides directly with the terrain) and on exit_add_rig_collision_proxy()._add_rig_collision_proxy()is idempotent — it first_find_rig_collision_proxy()(a direct child named"RigCollisionProxy") and returns early if one exists, so rapid toggling leaves no duplicate proxies. - Phase 10 external forces: a "Knock Up" button (
_knock_up()) tests impulses beyond gravity: when the rig is in RAGDOLL mode it callsStickmanRig.apply_ragdoll_velocity_boost( KNOCK_UP_VELOCITY = (0, -450))(mass-scaledapply_central_impulseon every ragdoll body, preserving internal structure), and applies the same upward velocity delta to every dynamic prop (RigidBody2Dchild of_environment) so the whole pile flies up together. - Phase 11 recovery UI: a
Label("Rest")+SpinBox(_rest_timeout_spinbox, min 0.1 / max 10.0 / step 0.1, initialized to_rig.rest_timeoutafter_build_ui) and a "Recover Now" button (_recover_now()→_rig.request_recovery()) are added after the "Knock Up" button._on_rest_timeout_changed(v)writes_rig.rest_timeout = v(runtime-only, no settings.json)._spawn_rig()connects_rig.state_changed→_on_rig_state_changed(), which removes the collision proxy onRAGDOLL, re-adds it onANIMATED/RECOVERING, and always_update_ragdoll_toggle()(proxy helpers are idempotent, so the toggle handler's own add/remove is harmless). The toggle label reads "Stickman" duringRECOVERING(sinceis_in_ragdoll()is false).
- Phase 10 ragdoll trigger: the harness stores the spawned rig in
scripts/sandbox_stage.gd—class_name SandboxStage,extends Node2D; the Sandbox Stage Builder root controller (Phase 2, not wired into the editor; run via F6 onres://scenes/sandbox_stage.tscn). Owns the EDIT/PLAY mode state machine, placement mode, camera pan/zoom, deletion, status bar, and signal fan-out; instantiatesStageSpawner/StageSelection/StageGizmos(viapreloadconsts).enum StageMode { EDIT, PLAY }(default EDIT). EDIT freezesRigidBody2Dprops withfreeze = true+freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC(script-driven gizmo dragging needs KINEMATIC, not STATIC) and keepsStickmanRigs ANIMATED (set_ragdoll(false)runs first); PLAY unfreezes props and ragdolls stickmen withauto_recover = false. Signalsmode_changed(mode),object_placed(node),object_selected(nodes),object_deselected(),object_deleted(nodes). Camera: middle-mouse pan, wheel zoom clamped to@export min_zoom/max_zoom(0.1 / 6.0); mouse→world via_camera.get_global_mouse_position()(no SubViewport)._world_children_selectable()returns directNode2Dchildren ofWorldexcludingRagdollBodyContainer. Authored-state / restart-the-sim:_authored(instance id →{node, position, rotation}) is updated at edit time on every place (_place_at→_save_object_state) and move/rotate (transform_committed→_on_transform_committed), and cleared on delete (_clear_object_state)._enter_edit_mode()stands stickmen, then freezes every prop (freeze_mode = FREEZE_MODE_KINEMATICbeforefreeze = true, so the body freezes directly as kinematic — never via the static layer, whose transform sync drops a subsequent position set), then_restore_authored_state()teleports viaglobal_position/global_rotation+ zeroed velocity. Because the freeze+teleport can take a physics frame or two to settle,_restore_authored_state()is also re-asserted over the next few_physics_processframes (_restore_frames_left) — so every Play session starts from and returns to the same authored state. Placement ghost: while a palette item is active,set_placement_mode(id)spawns a translucent (modulate.a = 0.5), non-colliding (collision_layer/mask = 0, frozen) copy of the object reparented out ofWorldinto_ghost_holder;_process()tracks it to the cursor (+ snap) via_update_ghost_position(), and_place_at()re-spawns it after each placement. A ghost stickman has itsSkeleton2Dmodification stack disabled and itsAnimationPlayerstopped so it renders as a static standing figure (its limbs don't flex/follow the cursor). Grid/snap: an optionalStageGridoverlay (drawn behindWorld) plusGrid/SnapCheckBoxtoggles and aSizeSpinBox, persisted touser://sandbox_settings.json(grid_size/snap_to_grid/show_grid); snapping rounds the placement cursor and translate drags via_snap_to_grid()/StageGizmos.snap_size. The grid is visible only in EDIT mode (_apply_grid_settings()sets_grid.visible = _show_grid and current_mode == EDIT, re-applied on mode change). Touchpad:_inputhandlesInputEventMagnifyGesture(pinch zoom ×factor) andInputEventPanGesture(two-finger pan,delta × 3 / zoom) alongside the mouse wheel/middle pan. UI built in code (CanvasLayer + PanelContainer top bar): mode toggle, six palette buttons (built from the spawner registry), Grid/Snap/Size controls, status label. The build controls (spawn palette + Grid/Snap/Size) are hidden in PLAY via_set_build_controls_visible(), called from_enter_edit_mode()/_enter_play_mode()(the mode toggle and status label stay visible).- Phase 3a director tool: adds a "Direct" palette toggle button (mutually exclusive with
placement) and a
PopupMenu(_action_popup, itemsWalk To/Speak/Wait/Ragdoll/Recover, idsACT_WALK…ACT_RECOVER) opened by clicking a stickman (_handle_direct_click); the popup is positioned at the clicked stickman's world position converted to screen (_world_to_screen(world_pos)) offset 24 px right, not at the mouse cursor (this first menu also records the session popup anchor for the Phase 4 rule-builder child menus); speak/waitAcceptDialogs appendspeak/waitactions;Walk Toenters a pending target-capture mode whose next stage click appends{"type":"walk_to","target":world_pos}and which Esc cancels (Esc priority: pending target → exit direct mode → existing placement clears). Builds a code-builtNavigationRegion2D(_build_navigation(), child of the stage notWorldso it is never hit-tested) carrying a proceduralNavigationPolygonfrom per-TerrainBlockconvex decomposition (_rebake_navigation(),Geometry2D.decompose_polygon_in_convex+ fan triangulation, world-space viablock.transform * p); a_nav_dirtyflag re-bakes once per frame (_process) on terrain place / move / rotate (transform_committed) / delete. Instantiates aStageDirectorVisualsoverlay (_build_director_visuals()). Play mode change (D3):_enter_play_mode()no longer auto-ragdolls stickmen — it now sets each rigauto_recover = falseand callsstart_queue();ragdoll/recoverare explicit queue actions; props still unfreeze._enter_edit_mode()stop_queue()s thensnap_to_standing()s stickmen and re-enables the visuals. Wiresnode.queue_changed → _director_visuals.mark_dirtywhen a stickman is placed;object_deleted→mark_dirty()._set_build_controls_visible()also hides the Direct button in PLAY. - Phase 4 triggers & event system: adds a When→Then rule system —
_event_rules: Array[Dictionary]of{id, trigger, actions}rules (trigger types:arrived_at_waypoint,action_finished,speech_finished,entered_area,collided; action types reuse the Phase 3a set:walk_to/speak/wait/ragdoll/recover). Rules persist across Play/Edit mode toggles but are not saved to disk (Phase 5). A geometric event engine runs in PLAY (_update_area_entrytests a movable's feet/position against eachTriggerArea.get_area_rect();_update_stickman_prop_collisiontests a stickman's feet point against prop AABBs and vice-versa), with edge-triggered dicts (already-fired events) reset on mode entry. A rule-builder UI state machine (RuleStepenum:IDLE,SELECT_TRIGGER,TRIGGER_TARGET,SELECT_ACTION,ACTION_TARGET,ACTION_POSITION,PARAMS) is driven from a "⚡ When..." item in the Direct action popup: trigger sub-menu popup → trigger-target click → rule-action popup → target/position → "Add another action / Done" popup. Esc has highest priority; status-bar hints + toast messages guide the flow. Rule label click → consequence-only edit (replaces the rule, sameid); ✕ deletes;_cleanup_rules_for_nodesauto-removes rules referencing deleted objects. All rule-builder context popups are session-anchored: the first popup in a session records its screen position (_popup_anchor/_popup_anchor_set; the Direct first menu = right of the clicked stickman, the rule-label edit entry = the click position), and every child popup in the session — "⚡ When…" trigger sub-menu, rule-action popup, "⬅ Back to actions", "Add another action" — reuses that recorded position via_set_popup_anchor(rect)/_popup_anchor_rect(), so cycling the menus never walks down the screen at the live cursor. The anchor is cleared on confirm (_finalize_rule), cancel (_cancel_rule_build), or Direct-mode/flow exit (_clear_director_pending), but not by_reset_rule_builder()(the Back-to-actions path intentionally reuses it). - Phase 3b asset-library selector integration: the Stickman and Prop palette buttons no
longer place directly — pressing them opens a modal selector grid (
_selector: AssetSelector, an instantiatedscenes/asset_selector.tscnPopupPanel, added to the UICanvasLayerin_build_ui()with theme/font overrides applied viaAssetSelector.apply_font), backed by a dim backdrop_selector_dim(a blackColorRectatSELECTOR_DIM_ALPHA= 0.5,mouse_filter = MOUSE_FILTER_IGNORE, on the UICanvasLayerbehind the selector, shown only while the selector is open). State_selector_open/_selector_kind,_thumbnail_queue,_thumbnail_busy._ready()builds_stickman_library/_thumbnail_cacheand the two thumbnail rendererNodes (_stickman_thumb/_prop_thumb, added as children so they canawait), plus a Browse_browse_dialogFileDialog(*.stk)._on_palette_toggledroutesstickman/proppresses to_open_selector(id)(which forces the palette button pressed, rescans entries, performs the single-item skip for a lone stickman file, and — when the selected path is absent from the scan — reselects the first entry); its un-press branch closes a matching open selector._on_asset_selected(entry)writes the selection toStageSpawner(selected_stickman_path/selected_prop_id), closes the selector, and callsset_placement_mode(kind);_close_selector()also clears_thumbnail_queue(it flips_selector_openoff before hiding the popup)._on_selector_cancelled()closes + exits placement (un-presses the palette button); the selector'spopup_hidesignal is also routed to_on_selector_cancelled()(idempotency-guarded), so an outside-click close likewise un-presses the palette button._on_browse_file_selectedbuilds an ad-hoc entry viaStickmanLibrary.make_entry(path)(toast on failure);_on_refresh_requestedrescans + re-enqueues. Lazy thumbnail drain:_load_or_enqueue_thumbnailsseeds the selector from any cached PNG and enqueues the rest;_process→_drain_thumbnail_queue()renders one per frame (awaits the renderer,save_pngs it, hands the texture back via_selector.set_thumbnailwhile the selector is open). The selector re-centers on window resize (the rootAssetSelectorre-runspopup_centered()onsize_changedwhile visible). Esc priority inserts the selector before DIRECT/placement in_unhandled_key_input;_handle_world_click/_handle_mouse_motionearly-return while_selector_open(belt-and-suspenders over the modal popup). Selection is session-only — persists across EDIT/DIRECT/PLAY toggles, resets on scene reload, no disk save (_save_settingsis untouched).
- Phase 3a director tool: adds a "Direct" palette toggle button (mutually exclusive with
placement) and a
scripts/stickman_speech_bubble.gd—class_name SpeechBubble,extends Node2D; a world-space speech bubble drawn in_draw()(Phase 3a, not used by the editor). Child of aStickmanRigatSPEECH_BUBBLE_OFFSET(above the head), so it follows the figure and scales with the camera. ConstsFONT_SIZE/PADDING/TAIL_HEIGHT/MAX_WIDTH/BG_COLOR/BORDER_COLOR/TEXT_COLOR. Measures text viaThemeDB.fallback_font.get_string_size(...), draws a rounded-rect background + a downward tail triangle centered on the rig-local origin, thendraw_string(...);visible = falseby default, no hit-testing. Public API:show_text(text)(store,visible = true,queue_redraw()),hide_bubble(). Driven byStickmanRig.speak(); the rig owns hiding + thespeech_finishedsignal.scripts/stage_director_visuals.gd—class_name StageDirectorVisuals,extends Node2D; the Edit-mode director overlay (Phase 3a, not used by the editor), mirroringStageGrid/StageGizmos. Statecamera/world/enabled/_dirty; publicset_enabled(value)/mark_dirty()._process()redraws on a dirty flag;_draw()reads each rig's queue via_collect_rigs()(directWorldchildren filteredis StickmanRig). Per action in order:walk_to→ a blue waypoint dot (WAYPOINT_RADIUS_PX / _zoom()) with white outline + order number ataction["target"]; dashed connectors (draw_dashed_line,DASH_*/_zoom()) between consecutive dots (and from the rig's current feet position to the first dot); non-walk actions (speak/wait/ragdoll/recover) → a badge (speech bubble / clock / X / up-arrow glyph + order number) anchored at the stickman's position at that point in the sequence — derived by simulating the queue (start at rig feet +FOOT_OFFSET; eachwalk_toadvances the anchor; non-walk anchors at the current position), consecutive badges stacking(0, -28)/zoom. All sizes divided by_zoom()so markers stay screen-constant; pure_draw(), no hit-testing; hidden in PLAY viaset_enabled(false).- Phase 4 rule visualization:
_draw()also renders_event_rules(set viaset_rules()) — a dashed white connector line from the trigger object to the target, a green ⚡ trigger badge, an orange → action badge, a dark label withrule_summary()text, and a ✕ delete icon (the label + icon are hit-testable viahit_test_rule(),Vector2.INFsentinel fromhit_test_waypoint()); click-label → consequence-only edit, click-✕ → delete.
- Phase 4 rule visualization:
scripts/stage_spawner.gd—class_name StageSpawner,extends RefCounted; registry-driven spawner (Phase 2). A_registry: Array[Dictionary]maps ids to terrain/prop/stickman templates; adding a type = appending an entry (no hard-coded idmatch). ReusesTerrainUtils.spawn_block,PropUtils.spawn_prop,StickmanFactory.spawn_from_dataviapreloadconsts. Terrain entries store origin-relative point templates (ground/ramp/step);_spawn_terraincenters the template bbox on its local origin then setsblock.positionto the cursor, soglobal_rotation= "rotate about center". Also exposes astatic get_world_aabb(node)helper (for a stickman it unions the mountedBody/*shape geometry via a recursive_collect_visual_points()so the box is centered head-to-feet).- Phase 4 area palette entry: a new
"area"registry entry (label "Area") →_spawn_area()instantiates aTriggerArea;get_world_aabb()gains a duck-typedget_area_rectAABB branch (if the node responds toget_area_rect(), use itsRect2as the world bounds). - Phase 3b asset library (selected-asset spawner): the registry entries are now
ground/ramp/step(terrain) +prop+stickman+area— the separatecrate/ballentries were removed and a single"prop"entry (label "Prop", kind"prop") added, soget_spawnable_ids()==["ground","ramp","step","prop","stickman","area"]. Session state:selected_stickman_path(defaultDEFAULT_STICKMAN_PATH=res://stickmen/test.stk) andselected_prop_id(default"crate"), read/written by the sandbox selector (Phase 3b) — they are session-only (no disk save)._stickman_cache: Dictionary(path → parsed data) seeds the default path in_initand lazily loads+parses any newly selected path on first spawn._spawn_stickmanspawns from the selected path (appliesSTICKMAN_FOOT_OFFSET (0, -385)so feet land at the cursor);_spawn_proplooks the selected prop id up viaPropLibrary.get_entry(id)and spawns its payload (PropUtils.spawn_prop) + material preset. New gettersget_selected_stickman_path()/get_selected_prop_id()(status + tests).
- Phase 4 area palette entry: a new
scripts/stickman_library.gd—class_name StickmanLibrary,extends RefCounted(Phase 3b, not used by the editor). Scansres://stickmen/*.stkinto entry models for the asset selector.const STICKMEN_DIR := "res://stickmen";var entries: Array[Dictionary]. Public API:scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary](viaDirAccess+ per-fileStickmanFactory.load_stk; a corrupt or missing-body_partsfile is skipped with apush_warning— never{}-as-entry; the displayname=stickman_name(stripped) if non-empty else the filename basename; results sorted by name, then path, cached onselfand returned),get_entries() -> Array[Dictionary](returns the last scan, does not rescan),find_by_path(path) -> Dictionary({}if absent), andmake_entry(path) -> Dictionary(ad-hoc single-file entry for Browse:load_stk+ name fallback;{}on load failure or missingbody_parts). Entry shape{ "path", "name", "data" }; thumbnails are not embedded in entries — resolved viaThumbnailCacheat display time by the selector.scripts/prop_library.gd—class_name PropLibrary,extends RefCounted(Phase 3b, not used by the editor). A static registry of the 4 built-in prop templates for the asset selector.static var _templates: Array[Dictionary](lazily built on first access —PropUtils.create_*()arestatic funcand cannot run in aconst). Public API:static get_entries()(builds once, returns the 4 templates),static get_ids(),static get_entry(id) -> Dictionary({}if unknown),static get_default_id() -> String("crate"). Template dicts carry{id, name, material_preset, material_label, payload}— Crate/Wood (create_box()+WOOD), Ball/Rubber (create_ball()+RUBBER), Plank/Metal (create_plank()+METAL), Triangle/Cardboard (create_triangle()+CARDBOARD) — generator color themes already match the presets.scripts/thumbnails/thumbnail_cache.gd—class_name ThumbnailCache,extends RefCounted(Phase 3b, not used by the editor). Disk PNG cache underuser://thumbnails/. ConstsSTICKMEN_DIR := "user://thumbnails/stickmen",PROP_DIR := "user://thumbnails/props",PROP_VERSION := 1. Public API:stickman_key(path)→"<basename>_<FileAccess.get_modified_time(path)>"(an mtime change yields a new key → missing PNG → regenerate);stickman_png(key)/prop_png(id)("<id>_v<PROP_VERSION>.png"— bumpPROP_VERSIONto invalidate all prop thumbnails);load_png(png_path) -> Texture2D(Image.load_from_file+ImageTexture,nullif missing/unloadable);save_png(tex, png_path) -> Error(creates the parent dir);ensure_dir;clean_stale_stickmen(valid_keys)(deletesbasename_*PNGs not in the valid key set).scripts/thumbnails/stickman_thumbnail.gd—class_name StickmanThumbnail,extends Node(Phase 3b, not used by the editor). Renders a parsed.stkinto aTexture2Dfor the asset selector by spawning the real rig.const SIZE := Vector2i(200, 200)._ready()builds one persistent offscreenSubViewport(transparent_bg,render_target_update_mode = UPDATE_ALWAYS) with an enabled in-viewportCamera2D(make_current()).render(stk_data) -> Texture2D: frees prior children,StickmanFactory.spawn_from_data(stk_data)s the rig into the viewport atVector2.ZERO, frames it viaStageSpawner.get_world_aabb(rig)(a degenerate/no-area bbox falls back to a fixed(120×500)rect),_frame_camerafits it with a 12 px margin,awaitsRenderingServer.frame_post_drawtwice (standard offscreen capture recipe), grabs the viewport texture, frees the rig, and returns anImageTexture— ornull(a placeholder) when the capture is blank/empty (the headless degrade; pixel rendering is manual/F6 verification only). Output is identical to the rig actually placed on stage, not a re-implementation of the editor preview.scripts/thumbnails/prop_thumbnail.gd—class_name PropThumbnail,extends Node(Phase 3b, not used by the editor). Renders a prop template into aTexture2D.const SIZE := Vector2i(200, 200)._ready()builds the same offscreenSubViewport+Camera2Dpattern asStickmanThumbnail.render(payload, material_preset) -> Texture2DmirrorsPropBlock's geometry into a plainNode2Dwith aPolygon2D(fill) +Line2D(closed loop, round joints/caps) — not aRigidBody2D, so nothing falls under gravity inside the viewport; circle payloads are re-expanded viaPropBlock.CIRCLE_SEGMENTS; non-NONEpresets tint fill viaPropBlock.tint_forand darken the outline; then frames + double-frame_post_drawcaptures and returns anImageTextureornull(blank → placeholder).scripts/asset_selector.gd—class_name AssetSelector,extends PopupPanel(Phase 3b, not used by the editor); root script ofscenes/asset_selector.tscn. A grid popup controller. Signalsitem_selected(entry),cancelled(),browse_requested(),refresh_requested().const COLUMNS := 4,ROWS := 3,PAGE_SIZE := 12,CELL_MIN_SIZE,THUMB_SIZE.var kind("stickman"|"prop"). Public API:open(kind, entries)(sets the title "Choose Your Stickman"/"Choose a Prop", shows Browse…/Refresh only for stickmen, thenpopup_centered()),set_entries(entries)(used by Refresh — resets page + thumbnails),set_thumbnail(entry, tex)(lazy handoff from the stage's drain),close(), andapply_font(ui_font, emoji_font)(walks the authored controls)._ready()setsexclusive = trueand wires the footer buttons;_unhandled_inputmapsKEY_ESCAPE→cancelled.emit()(the Window's built-in Esc close is not relied on). Re-centers on window resize: the root'ssize_changedsignal re-runspopup_centered()while the popup is visible. Cell build:_rebuild()frees the%GridContainerchildren, toggles the empty-state%EmptyLabel, shows/hides Prev/Next/page label by page count, and slices the current page via the static purepage_bounds(total, page, page_size) -> Dictionary {start, end, total, page_count}helper._build_cell(entry)returns aButtonwith aTextureRect(cached/placeholder texture) + a nameLabel+ (prop only) a material-badgeLabel; cells emititem_selected. No cell is pre-highlighted on open (the previous selection highlight styling was removed). The.tscnis a minimal shell (title bar, empty grid, footer as authored unique-name nodes%TitleLabel/%GridContainer/%EmptyLabel/%PageLabel/%PrevButton/%NextButton/%BrowseButton/%RefreshButton/%CloseButton); all dynamic per-cell content is built in code at runtime because the entry set is dynamic.scripts/stage_selection.gd—class_name StageSelection,extends RefCounted; hover/click/ box selection via geometric world-space AABB hit-testing (Phase 2).static get_world_aabbunions aPolygon2Dchild's world points (terrain/props) or, for a stickman rig, recursively unions its mountedBody/*Line2D/Polygon2Dgeometry (_collect_visual_points()), so the bounding box is centered on the actual figure head-to-feet (not a fixed rect)._frontmost_at= highestWorldchild index wins, smallest area breaks ties._is_selectableexcludes theRagdollBodyContainersubtree. Signalshover_changed(node)/selection_changed(nodes); public APIget_selected/get_primary/clear_selection/select_only/add_to_selection/toggle_selection/is_selected/hit_test/update_hover/box_select. (Phase 4)get_world_aabbalso handlesTriggerAreaobjects via the same duck-typedget_area_rectAABB branch, so trigger areas are selectable/hit-testable.scripts/stage_gizmos.gd—class_name StageGizmos,extends Node2D; hover highlight + selection outlines + a rotate ring (Phase 2). No translate handle — objects are dragged directly by the root;set_targets(nodes: Array[Node2D])holds the current multi-selection, andbegin_translate_drag(node, world_pos)starts a TRANSLATE drag that moves all selected nodes together (drag_todrivesglobal_positionwith optional grid snap viasnap_size, snapping the drag anchor and applying the same delta to the rest). The rotate ring is drawn and hit-tested only when exactly one node is selected (hit_test→Handle.ROTATE). The rotate drag is absolute-from-start —drag_to()setsglobal_rotationto_rotate_start_rotation + (current − start angle), with holding Ctrl snapping to 15° increments viasnappedf(new_rotation, deg_to_rad(15.0))._draw()draws a selection outline for every selected node. Pure_draw()+ distance-based hit-testing (noArea2D);enum Handle { NONE, TRANSLATE, ROTATE }.transform_committed(nodes: Array[Node2D])emitted on drag end.set_enabled(false)hides + disables in PLAY. Also draws the box-select marquee viaset_box_rect(rect).scripts/stage_grid.gd—class_name StageGrid,extends Node2D; the optional world-space grid overlay (Phase 2). Pure_draw(): grid lines pan/zoom with the camera (camera.get_screen_center_position()- viewport extent / zoom), with a heavier major line every 5 cells;
grid_size/enabledare set bySandboxStage. Added as the first child of the stage root so it renders behindWorld; no hit-testing.
- viewport extent / zoom), with a heavier major line every 5 cells;
- Scenes:
scenes/stickman_editor.tscn— main editor layout; unique-name nodes (%Prefix) used for typed@onreadyaccess:%MenuBar,%StickmanNameEdit,%LeftColumn,%CenterColumn,%WholeStickmanPreview,%SaveDialog,%LoadDialog,%ClearConfirmDialog,%ErrorDialog,%StatusBar,%CursorCoords,%SnapStatus.- Phase 6 status bar:
StatusBaris anHBoxContainerbottom-anchored at 28 px height containingCursorCoords(left) andSnapStatus(right);MainLayoutoffset_bottomis-28.0to leave room for it.
- Phase 6 status bar:
scenes/body_part_panel.tscn— instantiated 10× at runtime (5 per column). Each panel setssize_flags_vertical = SIZE_EXPAND_FILLso the panels expand to fill the column height in their parent VBoxContainer.scenes/test_harness.tscn— standalone staging scene (Phase 9, not wired into the editor; run via F6). Backed byscripts/test_harness.gd. Top UI bar with file open / quick-select, "Show Bones" / "Show IK Handles" / "Show Coords" toggles, and a loaded-filename status label;SubViewportworld with an enabledCamera2D(middle-mouse pan, wheel zoom, recenter on spawn); interactive limb IK viaSkeletonModificationStack2DTwoBoneIK.scenes/physics_test_harness.tscn— standalone staging scene (Vector Terrain System / Dynamic Vector Props, not wired into the editor; run via F6). Backed byscripts/physics_test_harness.gd. RootNode2D+ script,Camera2Dat position(0, -400)zoom0.5, empty Environment container. Wheel-zoom scales between0.25xand3.0x(resolution independence / vector outline thickness); middle-drag pans. Top-bar buttons Spawn Crate / Spawn Ball / Spawn Plank spawn dynamic props (PropUtils) above the angled ramp; a toggle button flips the rig Stickman ↔ Ragdoll (removing/restoring the best-effortStaticBody2Drig collision proxy); a Knock Up button impulses the ragdoll and all props upward.scenes/sandbox_stage.tscn— standalone staging scene (Sandbox Stage Builder, Phase 2, not wired into the editor; run via F6). Backed byscripts/sandbox_stage.gd. Minimal rootNode2D+ script,Camera2Dat position(0, -400)zoom(0.5, 0.5), and an emptyWorld(Node2D) container; theGridLayer(StageGrid),GizmoLayer(StageGizmos),PlacementGhostholder, and the CanvasLayer top-bar UI (mode toggle + six palette buttons + Grid/Snap/Size controls + status label) are built in code at_ready(). Phase 3b instantiates theAssetSelectorgrid popup (see below) into the UICanvasLayer.scenes/asset_selector.tscn— standalone asset-selector grid popup (Phase 3b, not wired into the editor). Rooted atAssetSelector(PopupPanel,scripts/asset_selector.gd); a minimal shell/layout skeleton — authored title bar, emptyGridContainer, empty-stateLabel, and a footer (Prev/ page label /Next/Browse…/Refresh/Close), all as unique-name nodes (%TitleLabel,%GridContainer,%EmptyLabel,%PageLabel,%PrevButton,%NextButton,%BrowseButton,%RefreshButton,%CloseButton). All dynamic per-cell content (thumbnail + name + prop material badge) is built in code at runtime because the entry set is dynamic; instantiated bysandbox_stage.gd(_build_ui) for the Stickman / Prop palette-button selector grids.
Body-part data model
- 10 internal part keys (ordered):
head,torso,left_upper_arm,left_lower_arm,right_upper_arm,right_lower_arm,left_upper_leg,left_lower_leg,right_upper_leg,right_lower_leg. - Shape dictionary:
{ "shape_type": String, "points": Array[{x,y}], "color": "#hex", "closed": bool, "vertex_flags": Array[int] }. shape_typevalues:"line","rectangle","circle",""(empty). Descriptive tag in Phase 2 — rendering usesclosed.closed:true= filled + closed outline,false= open outline-only.vertex_flags: same length aspoints;0= original vertex (filled circle),1= user-created via "Create Point" (hollow rectangle).- Phase 4: A panel stores a
shapes[]array of shape dictionaries. Z-order = array position (first = back, last = front). Per-part data includes{shapes[], position, rotation, scale}. - Phase 8: per-part data adds
pivot{x, y}(local bounding-box center = rotation origin) andlength(float, bbox extent along the segment axis) for format v1.4; the.stkroot also gains a top-levelproportionsobject (5 rig bone lengths). All three are write-only metadata recomputed on every save — never read back on load. - Phase 9 Round 5: per-part data adds
guide_offset{x, y}(bbox center − guide joint, preview space; pure master-space delta) for format v1.5. Write-only metadata likepivot/length; the load path ignores it, so v1.0–v1.4 files load unchanged and gain the key on their next save. - The JSON
.stkformat is defined inREADME.md(versioned"1.5", extensible;"1.0"–"1.4"files auto-migrate on load).
settings.json (Phase 6)
- Persisted editor preferences written to
settings.jsonvia_save_settings()and loaded in_load_settings(). - Keys:
version,grid_size,snap_to_grid,recent_colors,show_pose_guide. recent_colors: Array[String]— the last up-to-8 selected hex colors, most-recent-first. Populated on load and flushed on every color selection.show_pose_guide: bool— whether the pose silhouette guide is visible in the Whole Stickman preview. Defaulttrue. Loaded in_load_settings()and flushed on every toggle via_broadcast_settings().
Legacy scene (do not delete)
stick.tscn— the original rigged/animated figure usingSkeleton2D+ IK targets +RemoteTransform2D+Line2Dlimbs, plus an embedded@toolscript drawing the head circle. Animations: "walk", "walk_to", "RESET". Not the current main scene; kept for future animation/rigging phases.
Editor conventions
.godot/is gitignored; never edit it manually.- Scene files (
*.tscn) and.importfiles are text-based; use Godot's editor for complex changes. - UID references (Godot 4 native) exist in scenes; do not change them by hand.
- Use standard Godot
Controlnodes where applicable. - Use
class_namefor globally referenced scripts (BodyPartPanel,WholeStickmanPreview). - Prefer
%UniqueNameaccess over exported node paths instickman_editor.gd/body_part_panel.gd. - Keep the
.stkJSON format backward compatible — never change the meaning of an existing key.