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:
@@ -14,6 +14,8 @@
|
||||
- `<project>` = the repo root (e.g. `/Users/ryan/Projects/stickman`, or `C:\Godot4\stickman` on Windows).
|
||||
- **Run a headless test suite** (`tests/*.gd` extend `SceneTree`, print PASS/FAIL, `quit(0/1)`):
|
||||
`<godot> --headless --path <project> --script res://tests/test_cli_scene.gd`
|
||||
- Prop Editor suites (same pattern): `tests/test_prop_editor_file.gd`, `tests/test_prop_editor_theme.gd`,
|
||||
`tests/test_prop_editor_canvas.gd`, `tests/test_prop_editor_thumb.gd`, `tests/test_prop_editor_round3.gd`.
|
||||
- **Launch the Sandbox Stage and auto-PLAY a `.scene`** (visual/runtime debugging — `print()` output streams to the launching terminal):
|
||||
`<godot> --path <project> res://scenes/sandbox_stage.tscn -- --play --scene <path>.scene`
|
||||
- Args after `--` are parsed by `scripts/cli_scene_launcher.gd` (`CliSceneLauncher.parse`) via `OS.get_cmdline_user_args()`.
|
||||
@@ -37,6 +39,11 @@ props, live stickman rigs, action queues, and events. The editor remains the con
|
||||
`run/main_scene`; the Sandbox Stage and its runtime systems are separate scenes and tools
|
||||
that form the foundation of the broader moviemaking experience.
|
||||
|
||||
The **Prop Editor** (Phase 9) is a standalone, kid-friendly vector-prop authoring tool — the
|
||||
authoring counterpart to the stickman editor — for creating, editing, and saving `.prop`
|
||||
vector prop assets. It is launched via **F6** on `res://scenes/prop_editor.tscn` and is **not**
|
||||
the configured `run/main_scene`.
|
||||
|
||||
## Required addon
|
||||
|
||||
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
|
||||
@@ -147,6 +154,228 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
|
||||
dots at alpha `0.65`. Joint dots use `GUIDE_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.
|
||||
- `scripts/prop_editor.gd` — `class_name PropEditor`, `extends Control`; the **Prop Editor**
|
||||
controller (Phase 9, **not wired into the editor**; launched via **F6** on
|
||||
`res://scenes/prop_editor.tscn`). Owns the menu bar, save/load/new flow, `.prop`
|
||||
(de)serialization, the material-preset + per-shape inspector, the mode toolbar, the status
|
||||
bar, the window title, the exit dialog, and the autosave/recovery pipeline. State:
|
||||
`_theme: PropTheme`, `_material_preset: int` (`PropBlock.MaterialPreset`), `_current_path`,
|
||||
`_dirty`, `_clipboard`, `_thumb: PropThumbnail`, `_thumb_cache: ThumbnailCache`, `_ui_theme:
|
||||
Theme` (local only — never mutates the global GUI theme), plus `_recent_props`.
|
||||
- **Menu bar:** **📁 File** = 🆕 New / 💾 Save / 📄 Save As... / 📂 Load... / separator /
|
||||
🕑 **Recent** submenu; **👁️ View** = 🔍 Reset View. There is **no Edit menu** (Round 4
|
||||
removed it — grid/snap moved to the top-right controls). Menu labels carry kid-friendly
|
||||
emoji.
|
||||
- **Recent props (Round 5/6):** `SETTINGS_PATH := "user://prop_editor_settings.json"` (schema
|
||||
`{ "version": 1, "recent_props": [...] }`), `RECENT_MAX := 5`; `_load_settings()` /
|
||||
`_save_settings()` read/write the list. `static func update_recent_list(list, path,
|
||||
max_count = RECENT_MAX) -> Array` is a pure helper (dedupe + front-insert + trim). The
|
||||
**🕑 Recent** submenu (`FileMenu/RecentProps` child `PopupMenu`) is rebuilt on
|
||||
`about_to_popup` (`_update_recent_menu`): missing files are pruned (and settings re-saved)
|
||||
and an empty list shows a disabled "(No recent props)" placeholder. `_push_recent(path)` is
|
||||
called on every successful **load AND save**; selecting a recents entry calls `_load_from()`,
|
||||
and a missing file shows the error dialog + removes the entry. `RECENT_MAX` is 5,
|
||||
most-recent-first.
|
||||
- **Top-right controls (Round 4):** built in `_build_top_right_controls()` into `%TopRightControls`
|
||||
(the right side of `%TopRow`, beside `%MenuBar`): a `Grid:` `Label` + `_grid_spin` `SpinBox`
|
||||
(1–512, initialized from `_theme.grid_size()`) + a `Snap` `CheckBox` (`_snap_check`). This
|
||||
replaces the old `Edit → Configure Grid / Snap...` dialog/menu entirely.
|
||||
- **Mode toolbar (Round 2/3):** a centered `%ModeBar` `HBoxContainer` above the canvas
|
||||
(`_build_mode_toolbar()`) holding two `toggle_mode` `Button`s sharing a `ButtonGroup`:
|
||||
🖐️ **Arrange** (`EditMode.TRANSFORM`) and ✏️ **Reshape** (`EditMode.POINTS`).
|
||||
`static func mode_button_text(mode: int) -> String` is the pure, headless-testable
|
||||
label builder (`MODE_EMOJI`/`MODE_LABELS`, clamps to the last entry). Each button applies
|
||||
`_theme.font_mode()` + `_theme.mode_button_min_height()` and sets
|
||||
`focus_mode = FOCUS_NONE` (Round 4 fix — the `focus` stylebox draws after `pressed`, which
|
||||
otherwise masked the selected/accent state until the cursor moved). `_sync_mode_buttons()`
|
||||
re-presses the right button on `mode_changed` (via `set_pressed_no_signal`) and colors the
|
||||
label with `accent_color()`.
|
||||
- **Inspector (`_build_inspector()`, built into `%InspectorList`):** a material-preset
|
||||
`OptionButton` (`PRESET_LABELS` None/Wood/Rubber/Cardboard/Metal) + read-only mass/friction/
|
||||
bounce `Label`s sourced from `PropBlock.mass_for()` / `PropBlock.physics_material()`
|
||||
(single source of truth — never themed), then per-shape fill/outline `ColorPickerButton`s +
|
||||
an outline-width `SpinBox`, a vertex-count label, and an **Erase Shape** button, then the
|
||||
**Add Shape** section (primitive `OptionButton` + per-primitive param `SpinBox`es +
|
||||
**Add a Shape** button). `PRIMITIVE_IDS`/`PRIMITIVE_LABELS` = Box/Plank/Triangle/Line/Circle
|
||||
(**Ball removed, Round 4** — circle covers it; `"ball"` stays available programmatically).
|
||||
`_refresh_inspector_from_selection()` syncs the controls from the selected shape and gates
|
||||
the **circle Radius row**: when the shape's `reshaped` flag is set the `_radius_row` is
|
||||
hidden and a muted "✏️ This shape was reshaped" note (`_reshaped_note`) is shown instead.
|
||||
`_sync_mode_controls(mode)` hides the Add-Shape section (`_add_section`) and the Erase
|
||||
button in POINTS (Reshape) mode, called from `_on_canvas_mode_changed` and `_ready()`.
|
||||
- **Window title (Round 3):** `static func build_window_title(prop_name, file_path, dirty) ->
|
||||
String` is a pure, headless-testable builder producing `[name][ — filename.prop ][ *]`
|
||||
(empty name → `UNTITLED_NAME` = "Untitled"; `file_path.get_file()` keeps the `.prop`
|
||||
extension; ` *` when dirty). `_update_window_title()` is the single re-derivation site,
|
||||
called from `_update_save_status()` (which is already invoked on every relevant transition).
|
||||
`_on_save_file_selected()` normalizes the path to `.prop` so `get_file()` always carries the
|
||||
extension.
|
||||
- **Exit dialog (Round 3):** `get_tree().auto_accept_quit = false` in `_ready()` +
|
||||
`_notification(NOTIFICATION_WM_CLOSE_REQUEST)` → `_request_close()`. Clean tree quits
|
||||
immediately; dirty shows the themed `_exit_dialog` ("⚠️ Unsaved Changes") with **💾 Save** /
|
||||
**🗑️ Discard** (`add_button`, action `&"discard"`) / **❌ Cancel**. Save with no
|
||||
`_current_path` opens the Save dialog (the pending flag stays set); otherwise
|
||||
`_save_and_finish()`. `_save_to(path, with_thumbnail)` returns success and **skips the
|
||||
thumbnail render on exit-save** (the PNG is a cache nicety — a fast, deterministic quit). A
|
||||
successful save-then-quit clears the autosave via `_save_to`. The **same dialog** is reused
|
||||
by **File → New** (`_request_new()` sets `_pending_new`, different prompt text; Save →
|
||||
`_do_new()`, Discard → `_do_new()`, Cancel → stay).
|
||||
- **Autosave / recovery (Round 3):** `AUTOSAVE_PATH := "user://prop_editor_autosave.prop"`,
|
||||
`AUTOSAVE_DEBOUNCE := 1.0`. Dirty setpoints route through `_mark_dirty()` (sets `_dirty`,
|
||||
refreshes the status bar/title, arms `_autosave_remaining`); `_process(delta)` counts down
|
||||
and calls `_write_autosave()`, which stamps `metadata.autosave = true` /
|
||||
`metadata.source_path` / `metadata.modified_unix` and writes atomically via
|
||||
`PropFile.save_safe()` (no re-trigger: it does not touch `_dirty` or emit `shape_changed`).
|
||||
`_clear_autosave()` removes the file (also resetting the countdown). `_check_autosave_recovery()`
|
||||
(called from `_ready()` after dialogs are built) prompts 🔄 **Restore** / 🗑️ **Discard** on a
|
||||
stale-checked autosave (if the saved source exists and is newer-or-equal, the autosave is
|
||||
dropped silently); Restore calls `_apply_json_data(raw, true)` (the `is_recovery` flag keeps
|
||||
the prop **dirty**) and restores `_current_path`. Clear semantics: clear on successful
|
||||
save/load/new/discard-quit; **kept after Restore**; stale autosaves are dropped at startup.
|
||||
- **Save flow:** `_save_to(path, with_thumbnail = true) -> bool` writes `PropFile.save(path,
|
||||
_build_json_data())`, updates `_current_path`/`_dirty`, clears the autosave, refreshes
|
||||
status/title, and (when `with_thumbnail`) `await`s `_save_prop_thumbnail()`. The thumbnail
|
||||
path reuses `PropThumbnailScript.render(PropFile.flatten_to_payload(shapes),
|
||||
_material_preset)` then `ThumbnailCacheScript.save_png(tex, _thumb_cache.prop_file_png(path))`
|
||||
(200×200 `user://thumbnails/props/<basename>_<mtime>.png`); a blank/headless capture
|
||||
(`null`) is skipped silently. The editor preloads `prop_file.gd`, `prop_theme.gd`,
|
||||
`prop_block.gd`, `prop_canvas.gd`, `thumbnails/prop_thumbnail.gd`, and
|
||||
`thumbnails/thumbnail_cache.gd` via `preload` consts. `_apply_json_data(json, is_recovery =
|
||||
false) -> String` returns `""` on success or an error string (dialog on failure).
|
||||
- `scripts/prop_canvas.gd` — `class_name PropCanvas`, `extends Control`; the Prop Editor's
|
||||
**multi-shape vector canvas** (Phase 9, **not used by the editor**), mirroring
|
||||
`BodyPartPanel`'s proven input model (cursor-centered zoom, pinch/pan gestures, middle-mouse
|
||||
pan, vertex drag, right-click context menu, z-order) while adding prop-specific needs
|
||||
(per-shape fill/outline/width, polygon/circle/line primitives, transform gizmos, an in-memory
|
||||
clipboard, grid-snap sanitize-on-commit). Enums `ShapeType { POLYGON, CIRCLE, LINE }`,
|
||||
`VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }`, `DragMode { NONE, VERTEX, TRANSLATE, ROTATE,
|
||||
SCALE }`, and `EditMode { TRANSFORM, POINTS }`. Signals `shape_changed(shapes)`,
|
||||
`shape_selected(idx)`, `shape_copy_requested(shape)`, `paste_from_context_requested(world_pos)`,
|
||||
`vertex_created(shape_idx, vertex_idx)`, `mode_changed(mode)`. State `shapes:
|
||||
Array[Dictionary]`, `_selected_shape_idx`, `_zoom`/`_pan_offset`, `_grid_size`/`_snap_enabled`,
|
||||
`_edit_mode` (default TRANSFORM), `_has_clipboard`, and the drag/rotate/scale state. Public
|
||||
API mirrors the Phase 9 spec: `set_shape_data`/`get_shape_data`, `clear`, `select`/`deselect`/
|
||||
`get_selected_index`, `set_grid_size`/`set_snap_enabled`/`reset_view`, `add_primitive`,
|
||||
`insert_vertex`/`remove_vertex`, `bring_forward`/`send_back`, `mirror_x`/`mirror_y`,
|
||||
`copy_shape`/`paste_shape`/`set_has_clipboard`, `translate_selected`/`rotate_selected`/
|
||||
`scale_selected`, `is_cursor_over_drawing`/`global_to_world`, `set_shape_radius`,
|
||||
`set_context_menu_font_size`, `create_point_on_edge`, `hit_test_segment`, and
|
||||
`set_edit_mode`/`get_edit_mode`.
|
||||
- **Zoom / pan:** wheel zoom is cursor-centered (mouse wheel and pinch), clamped to
|
||||
`[MIN_ZOOM 0.3, MAX_ZOOM 3.0]` with `ZOOM_STEP 1.10`; middle-mouse drag and
|
||||
`InputEventPanGesture` pan (delta × 3 for parity), matching `BodyPartPanel`/preview.
|
||||
- **Edit modes (Round 2/3):** `EditMode { TRANSFORM, POINTS }` with kid-facing labels
|
||||
("Arrange" / "Reshape") owned by the controller. `set_edit_mode(mode)` clamps unknown values
|
||||
to TRANSFORM, cancels any in-progress drag, redraws, and emits `mode_changed` **only on a
|
||||
real change**; `get_edit_mode()` reads it. The mode gates **input and drawing only** — every
|
||||
math API (`insert_vertex`, `translate_selected`, `sanitize_shape`, clipboard, mirror, z-order,
|
||||
`select`/`deselect`, `set_shape_data`/`clear`) stays **mode-agnostic**, and the mode
|
||||
**persists across clear/load** (a tool preference).
|
||||
- **Arrange (TRANSFORM) gizmos:** selection draws a bounding box, a **rotation DOT below the
|
||||
bbox bottom-center** (`ROTATION_CIRCLE_RADIUS 5.0` / `ROTATION_CIRCLE_HIT_RADIUS 12.0` /
|
||||
`ROTATION_CIRCLE_OFFSET 26.0`, mirroring `whole_stickman_preview`), and four corner **scale
|
||||
CROSSES** (`SCALE_CROSS_SIZE 5.0` / `SCALE_CROSS_HIT_RADIUS 12.0`) — the Round 1
|
||||
ring/scale-circles were replaced. Rotation uses **incremental angle accumulation**
|
||||
(`_rotate_total_delta` + `_rotate_prev_angle`, wrapping-safely) so dragging a full circle
|
||||
never jumps at ±180°; **Ctrl** snaps to **15°** (`ROTATION_SNAP_DEG`). Scale is free-form,
|
||||
**Ctrl** locks the aspect ratio (uniform). Dragging the shape body translates it.
|
||||
- **Reshape (POINTS) vertex editing (Round 2):** color-matched **3 px** vertex handles
|
||||
(`HANDLE_RADIUS 3.0`, yellow while dragging; user-created vertices are hollow rects) with a
|
||||
dark halo backing; vertex drag reshapes live. **Create Point** inserts at the **cursor's
|
||||
projection** onto the nearest segment (clamped, closed-aware) via `create_point_on_edge()`,
|
||||
which auto-selects, flags the vertex USER_CREATED, emits `vertex_created`, and **flashes the
|
||||
new vertex with a 0.6 s yellow ring** (`FLASH_DURATION`/`FLASH_RING_RADIUS`). **Remove Point**
|
||||
deletes a vertex; **< 2 vertices drops the shape**. In POINTS mode the shape body is
|
||||
**select-only** (no translate).
|
||||
- **POINTS-mode right-click matrix (Round 3):** on a vertex (selected first, then front→back)
|
||||
→ **Remove Point** only; near a **segment** (within `EDGE_HIT_THRESHOLD / _zoom`, via the
|
||||
public `hit_test_segment(world_pos) -> int` segment-proximity helper) → **Create Point** only
|
||||
(shape is selected first); the interior body of a closed shape → **select only, no menu**;
|
||||
empty canvas → **no menu** (the Add-Shape menu is disabled in POINTS mode).
|
||||
- **TRANSFORM-mode right-click menu:** on a shape → **Copy / Paste / Erase / Flip Left-Right /
|
||||
Flip Up-Down / Bring Forward / Send Back**; on empty canvas → **Add Box / Plank / Triangle /
|
||||
Line / Circle** (**Ball removed**, Round 4) (+ Paste when the clipboard is populated). Copy
|
||||
emits `shape_copy_requested` (the editor owns the clipboard slot); Paste emits
|
||||
`paste_from_context_requested`; `copy_shape(idx)` returns a deep duplicate
|
||||
(`duplicate(true)`) and `paste_shape(source, target_pos)` inserts it offset by
|
||||
`CLIPBOARD_PASTE_OFFSET` (16, 16). `set_context_menu_font_size(size)` applies the theme's
|
||||
`font_context_menu` to the RMB menu.
|
||||
- **`reshaped` flag (Round 6):** every shape dict carries `reshaped: bool` (default `false`;
|
||||
internal + export + `.prop` schema). It is set `true` by vertex insert/remove/drag,
|
||||
`create_point_on_edge` (via insert), and **non-uniform scaling of a circle** (both the gizmo
|
||||
drag and `scale_selected`); it is cleared by `set_shape_radius()` (which regenerates the
|
||||
points from the radius). Translate/rotate/mirror/uniform-scale do **not** set it (they
|
||||
preserve primitive-ness). The editor's inspector gates primitive-specific rows on it (today:
|
||||
the circle Radius row, replaced by the muted "reshaped" note).
|
||||
- **Sanitize on commit:** vertex-drag, rotate, and scale commits run the points through
|
||||
`TerrainUtils.sanitize_points` (grid-snapped, clockwise, collinear-stripped), with the
|
||||
`vertex_flags` winding reversed to match. `sanitize_shape` is also exposed statically on
|
||||
`PropFile`.
|
||||
- **Two-tone halo selection (Round 7):** every selection element — the selection polyline,
|
||||
the bounding box, the rotation dot, the four scale crosses, and the POINTS-mode vertex
|
||||
handles — is drawn as a **dark backing pass** (`SELECTION_HALO_COLOR`, black at 0.85 alpha,
|
||||
~2 px wider) + a **bright core pass** (`SELECTION_CORE_COLOR`, white, original width). The
|
||||
pair is visible on **any** background (the Round 6 fill-adaptive `contrast_color_for` was
|
||||
removed because it could not know what sits *behind* a selected shape).
|
||||
- `scripts/prop_file.gd` — `class_name PropFile`, `extends RefCounted`; the static `.prop`
|
||||
schema + converters (Phase 9, **not used by the editor**). Consts `FILE_VERSION := "1.0"`,
|
||||
`FILE_EXT := ".prop"`, `DEFAULT_DIR := "res://props"`, `SUPPORTED_VERSIONS: ["1.0"]`,
|
||||
`PRESET_STRINGS` (index == `PropBlock.MaterialPreset`: none/wood/rubber/cardboard/metal),
|
||||
`SHAPE_STRINGS` (polygon/circle/line), `DEFAULT_RADIUS`, `DEFAULT_CIRCLE_SEGMENTS`.
|
||||
- **Schema (v1.0):** top-level `version`, `prop_name`, `material_preset`, `shapes[]`, and
|
||||
write-only `metadata` (`created_at`/`modified_at`). Each shape carries `shape_type`
|
||||
(`polygon`/`circle`/`line`), `points` (`[{x,y}]`, polygon/line), `radius` (circle),
|
||||
`fill_color`/`outline_color` (hex), `outline_width`, `closed`, `vertex_flags` (0/1), and
|
||||
**`reshaped`** (Round 6, default `false`; additive/backward-compatible). `metadata` is
|
||||
ignored on load.
|
||||
- `preset_to_string` / `preset_from_string` (unknown → `MaterialPreset.NONE`),
|
||||
`shape_type_to_payload_type` (circle → CIRCLE, else POLYGON), `sanitize_shape(shape,
|
||||
grid_size)` (circles pass through; polygon/line points run through
|
||||
`TerrainUtils.sanitize_points`, keeping `vertex_flags` aligned).
|
||||
- `to_file_dict(prop_name, material_preset, shapes)` / `from_file_dict(data)` (accepts a dict
|
||||
or JSON string; returns `{}` + `push_warning` on any schema/version error; missing keys fall
|
||||
back to defaults; a non-`"1.0"` `version` is rejected).
|
||||
- `flatten_to_payload(shapes) -> Dictionary` — the deterministic single-shape payload
|
||||
`PropUtils.spawn_prop()` consumes: **frontmost closed polygon** (scan last→first) → else
|
||||
**circle** (last→first) → else the **first shape's** points as a polygon; colors come from
|
||||
the chosen shape (material tint fallback); empty `shapes` → `{}` (caller skips). Lines are
|
||||
editor-only and never become collision geometry.
|
||||
- `save(path, data)` (FileAccess WRITE + `JSON.stringify`) and `load(path)` (`{}` +
|
||||
`push_warning` on read/parse failure), plus **`save_safe(path, data)`** — an atomic
|
||||
tmp/`.bak` swap used by the autosave (and later the editor's normal save path may use it).
|
||||
- **Round 5 serialization lesson (critical):** `_serialize_shape()` must route `points`
|
||||
through the **`_coerce_points()` helper**. Assigning an export-format `Array` of `{x, y}`
|
||||
dictionaries directly to a typed `PackedVector2Array` **silently zeroes every coordinate**
|
||||
(Godot cannot construct `Vector2` from a dictionary; the count is preserved but every
|
||||
element becomes `Vector2.ZERO`). Round 1–4 files saved this way are unrecoverable. Any typed
|
||||
`Packed*Array` assignment from dictionary/JSON data must go through a Variant-coercion
|
||||
helper, and regression tests must assert point **values**, not just counts.
|
||||
- `scripts/prop_theme.gd` — `class_name PropTheme`, `extends RefCounted`; the **validated theme
|
||||
loader** for `res://prop_theme.json` (Phase 9, **not used by the editor**), modeled on
|
||||
`SandboxTheme`. Consts `SCHEMA_VERSION := "1.0"`, `DEFAULT_PATH := "res://prop_theme.json"`,
|
||||
and `DEFAULT_CONFIG` (mirrors the committed file). `load_from(path)` deep-merges the file over
|
||||
`DEFAULT_CONFIG` (missing/malformed → defaults; warn-once via `_warn_once`), so an older file
|
||||
without newer sections still loads. Accessors: `value` (dotted path), `color` (`#RRGGBB[AA]`
|
||||
validation + warn-once), `number`/`integer` (clamped, finite-guarded).
|
||||
`material_tint(preset, channel)` implements the fallback hierarchy **theme file →
|
||||
`DEFAULT_CONFIG` → `PropBlock.tint_for(preset)`** (outline's final fallback is the tint
|
||||
darkened 0.55). `outline_width_default/min/max`, `primitive_default(primitive, key)`,
|
||||
`grid_size`, `canvas_background_color`, `canvas_grid_color`.
|
||||
- **Physics is never themeable** — mass/friction/bounce come only from `PropBlock`'s static
|
||||
preset tables.
|
||||
- **`ui` tokens (Rounds 2–4):** fonts `body` **18**, `header` **24**, `menu` **22**,
|
||||
`menu_bar` **22**, `mode` **26**, `status` **18**, `prop_name` **20**, `context_menu` **22**;
|
||||
hit targets `button_min_height` **36**, `row_min_height` **36**, `mode_button_min_height`
|
||||
**56**; colors `text` `#F2F2F2`, `text_muted` `#9A9A9A`, `border` `#FFFFFF33`, `accent`
|
||||
`#4FA3FF`. Accessors `font_body/header/menu/status/mode/menu_bar/prop_name/context_menu`,
|
||||
`button_min_height`, `row_min_height`, `mode_button_min_height`, `text_color`,
|
||||
`text_muted_color`, `border_color`, `accent_color`. All are clamped with `DEFAULT_CONFIG`
|
||||
fallbacks and applied through a **local** `Theme` (no global GUI mutation).
|
||||
- `scripts/thumbnails/thumbnail_cache.gd` — see its existing section; the Phase 9 extension
|
||||
adds `prop_file_key(path)` → `"<basename>_<mtime>"` and `prop_file_png(path)` →
|
||||
`PROP_DIR + "/" + key + ".png"` for **custom `.prop`** files (mtime-keyed so a re-edited prop
|
||||
invalidates its cached PNG). Built-in template thumbnails keep `prop_png(id)` →
|
||||
`"<id>_v<PROP_VERSION>.png"`; `stickman_key`/`stickman_png` are unchanged.
|
||||
- `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 instantiated `master_rig.tscn` to a loaded `.stk`
|
||||
@@ -1066,6 +1295,13 @@ page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect
|
||||
(thumbnail + name + prop material badge) is built in code at runtime because the entry set is
|
||||
dynamic; instantiated by `sandbox_stage.gd` (`_build_ui`) for the **Stickman** / **Prop**
|
||||
palette-button selector grids.
|
||||
- `scenes/prop_editor.tscn` — **standalone Prop Editor scene** (Phase 9, not wired into the
|
||||
editor; launched via **F6**). Backed by `scripts/prop_editor.gd`, minimal shell with
|
||||
`%MenuBar` inside `%TopRow` (beside `%TopRightControls` — the grid spinbox + snap checkbox),
|
||||
`%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%CanvasArea`/`%ModeBar`/`%PropCanvas`,
|
||||
`%InspectorColumn`/`%InspectorList`, a bottom status bar with `%SaveStatus` left +
|
||||
`%CursorCoords` right, and `%SaveDialog`/`%LoadDialog`/`%ErrorDialog`. The menu, inspector,
|
||||
mode toolbar, and dialogs are built in code; the `.tscn` is only the layout skeleton.
|
||||
|
||||
### Body-part data model
|
||||
|
||||
@@ -1093,6 +1329,29 @@ rotation, scale}`.
|
||||
- The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible;
|
||||
`"1.0"`–`"1.4"` files auto-migrate on load).
|
||||
|
||||
### `.prop` file format (Phase 9)
|
||||
|
||||
- The Prop Editor's `.prop` vector-prop assets are versioned JSON (`version "1.0"`),
|
||||
defined in `README.md`, saved under `res://props/*.prop`, and converted to a runtime
|
||||
payload by `PropFile.flatten_to_payload()`.
|
||||
- Shape dictionary (per entry in `shapes[]`): `{ "shape_type": "polygon"|"circle"|"line",
|
||||
"points": Array[{x,y}] (polygon/line), "radius": float (circle), "fill_color": "#hex",
|
||||
"outline_color": "#hex", "outline_width": float, "closed": bool,
|
||||
"vertex_flags": Array[int], "reshaped": bool }`.
|
||||
- `vertex_flags`: same length as `points`; `0` = original vertex, `1` = user-created via
|
||||
"Create Point" (circles: empty). `reshaped` defaults to `false`; set by vertex
|
||||
insert/remove/drag and non-uniform circle scaling, cleared by `set_shape_radius()`.
|
||||
- **Backward compatibility rule:** unknown/missing keys fall back to defaults (material
|
||||
preset → `"none"`, `shape_type` → `"polygon"`, missing colors → material tint, missing
|
||||
`reshaped` → `false`); only **additive** keys may be introduced — never change the
|
||||
meaning of an existing key. A non-`"1.0"` `version` is rejected on load.
|
||||
- Prop Editor settings files:
|
||||
- `user://prop_editor_settings.json` — schema `{ "version": 1, "recent_props": [...] }`,
|
||||
the last up-to-5 opened/saved prop paths, most-recent-first.
|
||||
- `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, dropped when stale at startup.
|
||||
|
||||
### settings.json (Phase 6)
|
||||
|
||||
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
|
||||
|
||||
Reference in New Issue
Block a user