Add headless regression tests for Prop Editor functionality
- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality. - Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management. - Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens. - Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
This commit is contained in:
@@ -883,6 +883,79 @@ Each entry in the `shapes` array:
|
||||
|
||||
> On load, parts missing from the file fall back to built-in default preview positions. v1.0 files have `closed` inferred from `shape_type` and `vertex_flags` default to all zeros. v1.0/v1.1 files have their single shape wrapped in a `shapes` array and default `rotation`/`scale` applied. Files without `part_order` (v1.0–v1.2) use the default part-key order. Note that shape mirroring (Phase 5) rewrites the `points` array in place, so no new shape fields are required — the resulting vertices load identically across all supported versions.
|
||||
|
||||
## File format (`.prop`)
|
||||
|
||||
The **Prop Editor** (Phase 9) authors vector prop assets saved as `.prop` files, stored under `res://props/*.prop`. Files are UTF-8 JSON, pretty-printed with tab indentation, versioned, and designed to remain **backward compatible** — the format stores the editor's full multi-shape stack, while a deterministic converter (`PropFile.flatten_to_payload()`) collapses it into the single-shape payload `PropUtils.spawn_prop()` consumes at runtime (frontmost closed polygon → else circle → else the first shape; lines are editor-only and never become collision geometry).
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "My Crate",
|
||||
"material_preset": "wood",
|
||||
"shapes": [
|
||||
{
|
||||
"shape_type": "polygon",
|
||||
"points": [
|
||||
{ "x": -24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": 24.0 },
|
||||
{ "x": -24.0, "y": 24.0 }
|
||||
],
|
||||
"fill_color": "#8C6133",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": [0, 0, 0, 0],
|
||||
"reshaped": false
|
||||
},
|
||||
{
|
||||
"shape_type": "circle",
|
||||
"radius": 24.0,
|
||||
"fill_color": "#E63333",
|
||||
"outline_color": "#590D0D",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": [],
|
||||
"reshaped": false
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created_at": "2026-09-18T10:00:00",
|
||||
"modified_at": "2026-09-18T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Root object
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `version` | `string` | Format version. Currently `"1.0"`. A non-`"1.0"` version is rejected on load. |
|
||||
| `prop_name` | `string` | Optional display name; an empty name falls back to the filename basename. |
|
||||
| `material_preset` | `string` | `"none"` \| `"wood"` \| `"rubber"` \| `"cardboard"` \| `"metal"`. Missing/invalid → `"none"`. Sets `mass` + `physics_material_override` (from `PropBlock`'s static preset tables). |
|
||||
| `shapes` | `array` | Array of shape objects (see below). Draw/Z-order = array order (first = back, last = front). |
|
||||
| `metadata.created_at` | `string` | Timestamp written on save. Write-only — ignored on load. |
|
||||
| `metadata.modified_at` | `string` | Timestamp written on save. Write-only — ignored on load. |
|
||||
|
||||
### Shape object
|
||||
|
||||
Each entry in the `shapes` array:
|
||||
|
||||
| Key | Type | Description |
|
||||
|---|---|---|
|
||||
| `shape_type` | `string` | `"polygon"`, `"circle"`, or `"line"`. Missing → `"polygon"`. |
|
||||
| `points` | `array` | Array of `{ "x": float, "y": float }` vertices — polygon/line only. Missing → `[]`. |
|
||||
| `radius` | `float` | Circle only; default `24.0`. |
|
||||
| `fill_color` / `outline_color` | `string` | Hex colour (`#RRGGBB` / `#RRGGBBAA`). Missing → the material tint (from `PropBlock.tint_for`). |
|
||||
| `outline_width` | `float` | Vector outline thickness. Default `2.0` (`PropBlock.DEFAULT_OUTLINE_WIDTH`). |
|
||||
| `closed` | `bool` | Polygon/circle `true` (filled); line `false` (outline-only). |
|
||||
| `vertex_flags` | `array[int]` | Same length as `points`. `0` = original vertex, `1` = user-created via "Create Point" (circles: empty). |
|
||||
| `reshaped` | `bool` | **Round 6.** `false` by default. Set `true` when the shape's vertices were edited (insert/remove/drag) or a circle was scaled non-uniformly; cleared by `set_shape_radius()`. The inspector hides primitive-specific fields (today: the circle Radius row) when set. Additive/backward-compatible — old files load as `false`. |
|
||||
|
||||
> **Backward compatibility rule:** unknown/missing keys fall back to defaults, and only **additive** keys may be introduced — never change the meaning of an existing key. The `reshaped` flag is the Round 6 addition; a `material_preset` of `"none"` and a missing `shape_type` both fall back gracefully.
|
||||
|
||||
> **Save-serialization note (Round 5):** point coordinates must be written through a Variant-coercion helper (`_coerce_points`). Assigning an `Array` of `{x,y}` dictionaries directly to a typed `PackedVector2Array` silently zeroes every coordinate in Godot — files saved by the Round 1–4 build were unrecoverable.
|
||||
|
||||
## Settings file (`user://settings.json`)
|
||||
|
||||
Phase 3 introduces a global settings file stored in Godot's **user data** directory (`user://`), separate from the `.stk` figure files. It persists the grid configuration and is versioned for future extensibility:
|
||||
@@ -911,6 +984,8 @@ Behavior:
|
||||
- **Scope** — global; all body-part panels and the Whole Stickman preview share the same grid size and snap setting.
|
||||
- **Pan offsets are NOT persisted** — they reset on load, clear, and "Reset Views".
|
||||
|
||||
> **Prop Editor settings (Phase 9, separate files):** the standalone Prop Editor uses its own user-data files — `user://prop_editor_settings.json` (schema `{ "version": 1, "recent_props": [...] }`, the last up-to-5 opened/saved `.prop` paths, most-recent-first) and `user://prop_editor_autosave.prop` (the debounced 1.0 s autosave, written atomically via `PropFile.save_safe` with `metadata.autosave`/`source_path`/`modified_unix`; cleared on save/load/new/discard, kept after Restore, and dropped when stale at startup). The Prop Editor does **not** read or write `user://settings.json`.
|
||||
|
||||
## Project structure
|
||||
|
||||
| Path | Purpose |
|
||||
@@ -929,8 +1004,13 @@ Behavior:
|
||||
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
|
||||
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (run via **F6**; not wired into the editor). A toggle-mode button flips the rig's kinematic-to-ragdoll mode via `_rig.set_ragdoll()`, plus (Phase 11) a **Rest** `SpinBox` (writes `_rig.rest_timeout`) and **"Recover Now"** button (`_rig.request_recovery()`); the rig's `state_changed` signal removes the proxy on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`. |
|
||||
| `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. |
|
||||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). |
|
||||
| `res://scripts/prop_block.gd` | **Dynamic Vector Props / Phase 4.5.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce), live-updating exports, contact monitoring, shape-cast CCD, and `collided(other, contact_normal, contact_position)` / `separated(other)` signals carrying world-space contact data. Also the single source of truth for the Prop Editor's inspector physics readouts. |
|
||||
| `res://scripts/prop_editor.gd` | **Prop Editor (Phase 9).** `class_name PropEditor`, `extends Control` — standalone vector-prop authoring controller (run via **F6** on `res://scenes/prop_editor.tscn`; **not** the main scene). Owns the 📁 File (🆕 New / 💾 Save / 📄 Save As… / 📂 Load… / 🕑 Recent) and 👁️ View (🔍 Reset View) menus, the top-right Grid spinbox + Snap checkbox, the centered Arrange/Reshape mode toolbar, the material-preset + per-shape inspector (physics readouts from `PropBlock`; hides the circle Radius row + shows a "reshaped" note when a shape's `reshaped` flag is set), the status bar, the window title builder, the exit dialog (💾 Save / 🗑️ Discard / ❌ Cancel), recent-props persistence (`user://prop_editor_settings.json`, max 5), and the debounced autosave/recovery pipeline (`user://prop_editor_autosave.prop` via `PropFile.save_safe`). Saves export a cached 200×200 thumbnail. |
|
||||
| `res://scripts/prop_canvas.gd` | **Prop Editor (Phase 9).** `class_name PropCanvas`, `extends Control` — multi-shape vector canvas: cursor-centered zoom/pan (wheel, pinch, middle-mouse), `EditMode { TRANSFORM, POINTS }` ("Arrange"/"Reshape") gating input + drawing only, rotation dot + corner scale crosses (Ctrl = 15° snap / aspect lock), color-matched vertex handles with Create/Remove Point (0.6 s flash), per-mode right-click menus, a local clipboard, mirror + z-order, the per-shape `reshaped` flag, sanitize-on-commit via `TerrainUtils`, and two-tone halo selection. |
|
||||
| `res://scripts/prop_file.gd` | **Prop Editor (Phase 9).** `class_name PropFile`, `extends RefCounted` — static `.prop` v1.0 schema + converters: `to_file_dict`/`from_file_dict` (round-trip, version rejection, missing-key defaults), `preset_to_string`/`from_string`, `sanitize_shape`, `flatten_to_payload` (frontmost closed polygon → circle → first shape), `save`/`load`, and the atomic `save_safe` (tmp/`.bak` swap). Points must be coerced via `_coerce_points` when serializing (Round 5). |
|
||||
| `res://scripts/prop_theme.gd` | **Prop Editor (Phase 9).** `class_name PropTheme`, `extends RefCounted` — validated loader for `res://prop_theme.json` (deep-merge, warn-once, clamped accessors); hierarchy theme file → `DEFAULT_CONFIG` → `PropBlock` constants. Physics is never themeable. Holds the Round 2–4 `ui` tokens (fonts, hit targets, colors). Applied through a **local** Theme only. |
|
||||
| `res://scenes/prop_editor.tscn` | **Prop Editor (Phase 9).** Standalone scene backing `scripts/prop_editor.gd` (run via **F6**; not the main scene): minimal shell (`%MenuBar` in `%TopRow` beside `%TopRightControls`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%CanvasArea`/`%ModeBar`/`%PropCanvas`, `%InspectorColumn`/`%InspectorList`, status bar, dialogs); UI built in code. |
|
||||
| `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). `PropFile.flatten_to_payload()` produces the payload it consumes. |
|
||||
| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder / Phase 4.5.** `class_name SandboxStage`, `extends Node2D` — root controller: `enum StageMode { EDIT, DIRECT, PLAY }` state machine (freezes props with `FREEZE_MODE_KINEMATIC`; runs stickman queues + rags props/areas in PLAY), placement mode + terrain drag-painting, a grid spatial dictionary, camera pan/zoom, deletion, bottom status bar, mode badge/frame/cursors, the `res://sandbox_theme.json` loader, and signal fan-out (`mode_changed(mode: int)` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). **Phase 3b** instantiates the `AssetSelector` popup + thumbnail renderers, owns the selector open/close flow and lazy per-frame thumbnail drain (§22). **Phase 3c** instantiates the queue/rule panels + editors + waypoint context, owns the unified `CaptureKind` target-capture system, the "Edit Queue…"/"Edit Rules…" + right-click context entry points, the shared confirmation dialog, and consequence-only rule editing (§23). **Phase 4.5** routes `PropBlock` physics contacts through proxy metadata, tracks per-part body IDs for pair debouncing, preserves rig→prop rule ordering, purges stale pairs on separation/deletion/ragdoll, and restores authored rig state when returning PLAY→DIRECT. Standalone staging scene run via **F6**; not wired into the editor. |
|
||||
| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin. Exposes `is_terrain_id()` / `get_template_aabb()` and tags spawned terrain with a `spawn_id`. `get_template_aabb(id)` mirrors `_spawn_terrain()`'s sanitize pass (`TerrainUtils.sanitize_points` at `TERRAIN_GRID_SIZE` 16), so the returned extent matches the real placed footprint — e.g. the 200-px-wide Ground template returns a **192-px** stride — and drives the block-unit paint stride, ghost sizing, and cell rasterization. **Phase 3b:** registry ids `ground/ramp/step/prop/stickman/area` (separate `crate`/`ball` removed); holds `selected_stickman_path` / `selected_prop_id` session state + a per-path `_stickman_cache`; `prop`/`stickman` spawn the **selected** asset (§22). |
|
||||
| `res://scripts/stickman_library.gd` | **Asset Library (Phase 3b).** `class_name StickmanLibrary`, `extends RefCounted` — scans `res://stickmen/*.stk` into `{path, name, data}` entries (corrupt/missing-`body_parts` skipped; name = `stickman_name` else filename basename); `make_entry(path)` for Browse-chosen paths. |
|
||||
@@ -1057,3 +1137,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
||||
|
||||
> **Phase 11 (Instant Handoff & Recovery):** replaces the hard ragdoll entry/exit with an **instant handoff** and adds a **stand-up recovery** path. `StickmanRig` gains `enum RigState { ANIMATED, RAGDOLL, RECOVERING }` plus exports `rest_timeout` (2.0 s) and `auto_recover` (true). On entering `RAGDOLL` the ragdoll is built from the **current solved bone positions** (the player is stopped with `keep_state`), then `Body/*` is hidden and the IK stack disabled in the same call — no crossfade, since the ragdoll spawns at exactly the same pose and a fade would only read as ghosting (an earlier `transition_duration` blend was removed on director feedback). Rest detection reads the Torso body — sleeping, or linear ≤ `REST_LINEAR_THRESHOLD` (5.0 px/s, tuned up from the plan's 0.1 because a soft-pinned ragdoll micro-jitters around ~0.5 px/s) and angular ≤ 0.1 rad/s — then after `rest_timeout` + `STABILIZATION_DELAY` (0.1 s) with `auto_recover` on, calls `_start_recovery()`. Recovery first calls `_capture_pre_ragdoll_state()` (6 IK markers, 8 limb + head bone rotations, facing profile, 4 bend flags, head LookAt state, per-leg `leg_sides`, and entry chain offsets `torso_chain_offset`/`head_chain_offset`) as the END target, then `_capture_ragdoll_pose()` records the 10 bodies' rig-local pose **and** probes the terrain via `_probe_ground_y()` (a 7-ray fan with a tolerance band, excluding the ragdoll RIDs), re-anchors the rig root so the standing figure's **feet** sit on the ground at the landing X (`_reanchor_root_to_landing()`, `new_root = feet + FOOT_OFFSET`, with a mirror-aware local shift `local_shift = to_local(shift) − to_local(Vector2.ZERO)`), destroys the ragdoll, sets `state = RECOVERING` + emits, snap-solves via `_snap_skeleton_to_pose()` — the 6 IK targets plus direct arm/head bone rotations (`rot_u = chain(u) − θ_t − bone_angle_u`, `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`, head via `head_chain_offset`), all normalized into `(−π, π]`, with the ankles clamped onto the terrain and the two leg IK mods enabled with the captured knee side — and finally `_play_stand_up()` runs a **2.0 s** sine-in-out tween of the markers → their pre-ragdoll values and the arm/head bones to the snap-computed `_recovery_bones` targets (elbows via `_natural_joint_target`), legs IK-driven. `_on_stand_up_finished()` restores the facing profile + bend flags + LookAt state, re-enables the arm mods, and returns to `ANIMATED`. The per-joint bend decisions live in three editable tables — `RAGDOLL_JOINT_BAND_LIMITS`, `RECOVERY_LEG_SIDE_FORCE`, `RECOVERY_JOINT_PATH` (Joint bend configuration (EDIT ME)); diagnostics are gated behind `DEBUG_RECOVERY := false`.
|
||||
Torso bone's `bone_angle` (copying the body rotation directly would slam the skeleton −90° and lay it flat). `request_recovery()` is public (no-op unless in `RAGDOLL`); `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll, `set_ragdoll(false)` during `RAGDOLL` routes through recovery, and calls are otherwise idempotent. `is_in_ragdoll()` stays `state == RAGDOLL` (so `RECOVERING` reads as "Stickman"). A new `res://scripts/create_animations.gd` editor script (superseding the deleted `create_walk.gd`) bakes `walk_left`/`walk_right`/the one-shot `stand_up` into `master_rig.tscn`'s `AnimationLibrary` (the baked `stand_up` is an authored reference only — recovery does not play it) — **no `.stk` format change**; `master_rig.tscn` scene nodes are unchanged (only its baked animations are added). The physics harness gains a **Rest** `SpinBox` (0.1–10 s, writes `_rig.rest_timeout`), a **"Recover Now"** button (`request_recovery()`), and a `state_changed` hook that removes the `RigCollisionProxy` on `RAGDOLL` and re-adds it (idempotently) on `ANIMATED`/`RECOVERING`.
|
||||
|
||||
> **Phase 9 Prop Editor (Rounds 1–7):** a standalone, kid-friendly vector-prop authoring tool — the authoring counterpart to the stickman editor — run via **F6** on `res://scenes/prop_editor.tscn` (not the main scene). It adds a new `.prop` v1.0 JSON format (`res://props/*.prop`, multi-shape stack + deterministic `PropFile.flatten_to_payload()` conversion), a dedicated `PropCanvas` multi-shape canvas, a validated `PropTheme` (`res://prop_theme.json`) with a Round 2–4 `ui` token section, and a save-side 200×200 thumbnail pipeline reusing `PropThumbnail` + a mtime-keyed `ThumbnailCache.prop_file_png`. Round highlights: **R2** — two explicit canvas modes ("Arrange"/"Reshape"), the stickman-style bottom rotation dot + corner scale crosses, color-matched 3 px vertex handles, and cursor-projection "Create Point" with a 0.6 s flash; **R3** — POINTS-mode right-click is point-editing only (segment proximity via `hit_test_segment`), centered mode toolbar with emoji, window title `Untitled — file.prop *`, exit dialog (💾/🗑️/❌), and a debounced autosave/recovery prompt; **R4** — selected-mode is clearly outlined (`FOCUS_NONE` + accent-border `pressed` stylebox), bigger theme-driven defaults, the Grid spinbox + Snap checkbox moved to the menu row (Edit menu removed), File → New with a save prompt, and **Ball removed** from the add menus; **R5** — File → 🕑 Recent (last 5, persisted) and a critical serialization fix (typed `PackedVector2Array` assignments from `{x,y}` dicts silently zeroed every coordinate — `_serialize_shape` now routes through `_coerce_points`); **R6** — Recent updates on save, a per-shape **`reshaped`** flag gates the circle Radius row, and fill-adaptive selection contrast; **R7** — two-tone halo selection (dark backing + bright core) so outlines/boxes/dots/crosses/handles read on any background. Several Prop Editor rows are logged in `docs/tech_debt_and_optimizations.md` (#29–#35).
|
||||
|
||||
Reference in New Issue
Block a user