220 lines
39 KiB
Markdown
220 lines
39 KiB
Markdown
# Phase 13 — Comic Speech Bubbles
|
||
|
||
> **2026-10-01 behavior amendment:** Comic chain is a third Show bubbles presentation mode. It reveals bubbles cumulatively and joins each new bubble to the previous one with a broad attachment. Stack and Same spot remain placement actions. See [Comic Chain Speech Mode](speech_comic_chain_layout.md) for the current behavior and legacy payload migration.
|
||
|
||
Status: **Approved follow-up implemented; saved `talk.scene` load verified (2026-09-28)**. The base feature, first composer refinement, and approved §9 follow-up are implemented. The reported scene now loads through the Sandbox Stage CLI without a skipped-speech warning; visual GUI verification remains with the user. User deferred bundled font files; previews use available system fonts until a later font-bundling task.
|
||
Source: `docs/archive/plans/PHASE_13_SPEECH_BUBBLES.md`, the Phase 0 UI proposal, the user's Phase 1 decisions, and the subsequent composer review.
|
||
|
||
## 1. Product decisions and scope
|
||
|
||
The Speak editor is a comic composition panel beside the live Sandbox Stage. The selected stickman and an attached, temporary bubble preview remain visible while the child writes, changes frames and text, replays animations, and places the bubble. The panel supports **Talk, Shout, Think, Whisper, and Sound Effect** frames; bubble-wide font, size, and text color; emoji; and whole-word font, color, **Shake, Wave, and Bounce** choices applied by tapping one word and then one control. It also supports **All at once, Letter by letter, and Word by word** reveal and an entrance choice: **Bounce in** or **Simply appear**. Every bubble card has its own content and appearance.
|
||
|
||
An action can contain several bubbles. **Show all** is the default: bubbles appear simultaneously as a connected vertical stack, with the bottom bubble pointing to the speaker. **One at a time** shows exactly one bubble at a time. When its reading interval ends, it transitions to the next; previous bubbles disappear. These labels retain the serialized `together` and `take_turns` IDs. `speech_finished` fires once after the entire action, never between cards.
|
||
|
||
Follow-up review: the user reported inspector scroll zoom leaking to the camera, stack overlap, word-marker clipping, and speech missing from the queue after scene reload. The approved changes and acceptance checks are recorded in §9; implementation is ready for in-editor verification.
|
||
|
||
Reading time is calculated automatically. The child chooses **Normal**, **Slow**, or **Slower**; the composer does not display calculated seconds. These are display labels for the existing `just_enough`, `longer`, and `longest` saved IDs and retain their current timing formulas. A child can move the composed group on the stage. The saved position is relative to the character, so it follows movement and changes facing with the character. **Reset placement** restores automatic above-head placement. There are no placement arrow buttons.
|
||
|
||
An event-triggered Speak group temporarily takes visual priority over queued speech on that character. The queued group's time and reveal animation pause and resume when the event group ends. A later event Speak replaces the earlier event group. `stop_current` retains its existing sequential behavior: it interrupts the current action rather than using the visual overlay lane.
|
||
|
||
Scope includes direct Speak, the rule builder, queue add/edit/insert, and rule add/edit; `.scene` save/load; the shared preview/playback renderer; and Phase 12 pause/resume behavior. No text-to-speech, audio recording, collaborative dialogue timeline, arbitrary user-authored BBCode, or document-wide undo is included.
|
||
|
||
## 2. Existing seams and target hierarchy
|
||
|
||
`SandboxStage` currently opens a modal `AcceptDialog` with one `LineEdit` and hardcodes a two-second direct/rule Speak. `ActionEditor` has a separate one-line Speak field and numeric duration for queue/rule edits. `ActionRegistry` flattens and nests rule actions. `StageSceneIO` v1.1 writes only `text`, `duration`, and `stop_current`. `StickmanActionRunner` owns one primary and one overlay timer and lazily attaches one `SpeechBubble` to the rig at `(0, -640)`. The bubble draws a fallback-font string in `_draw()`. The rig mirrors its root for left facing and the runner counter-mirrors bubble content.
|
||
|
||
```text
|
||
SandboxStage (Node2D; composition context and stage input)
|
||
├── Camera2D
|
||
├── World (Node2D)
|
||
│ └── StickmanRig
|
||
│ ├── RigComponents/ActionRunner (playback state)
|
||
│ ├── SpeechBubble (runner-owned, created lazily)
|
||
│ └── SpeechPreview (stage-owned, temporary; same renderer class)
|
||
└── UI (CanvasLayer)
|
||
└── SpeechComposer (Control, side panel)
|
||
├── Numbered bubble tabs with snippets / one active TextEdit
|
||
├── Scrolling inspector: Frame / Text / Effects / Show / Timing
|
||
└── Move bubble / Reset / Replay / Save speech / Cancel
|
||
```
|
||
|
||
The stage owns the preview lifecycle. It is an unsaved child with no queue or rule side effects. If the rig already displays runtime speech during paused DIRECT, the stage suppresses only that runtime renderer while the preview is open and restores it when the composer closes; playback state is preserved.
|
||
|
||
## 3. Static contracts and signals
|
||
|
||
| Target | Contract |
|
||
| --- | --- |
|
||
| `class_name SpeechPayload extends RefCounted` (new `scripts/speech/speech_payload.gd`) | Pure, statically typed helpers: `static func normalize(raw: Dictionary) -> Dictionary`, `static func validate(raw: Variant) -> String`, `static func duration_for(raw: Dictionary) -> float`, `static func bubble_intervals(raw: Dictionary) -> PackedFloat32Array`, `static func plain_summary(raw: Dictionary) -> String`, `static func word_span_at(text: String, index: int) -> Vector2i`, and `static func rich_text_runs(text: String, word_styles: Array[Dictionary]) -> Array[Dictionary]`. `word_span_at` returns `Vector2i(-1, -1)` for non-words. No scene-tree access. Constants enumerate valid style/font/size/reveal/effect IDs and caps. Returns deep copies, never aliases mutable action arrays. The run compiler accepts only validated whole-word spans and trusted font/color/effect IDs. |
|
||
| `class_name SpeechComposer extends Control` (`scripts/editors/stage/speech_composer.gd` and `scenes/editors/stage/speech_composer.tscn`) | Retain `open_for(actor: StickmanRig, initial: Dictionary, reactive_options: bool) -> void`, `get_draft() -> Dictionary`, `draft_changed(action: Dictionary)`, `committed(action: Dictionary)`, and `cancelled()`. Show one active `TextEdit` bound to the selected numbered tab; inactive bubble text lives in the staged draft. Keep one transient whole-word target `{bubble_index: int, start: int, end: int, text: String}` and a graphical, input-transparent word cue. Own a preview state `STATIC / PLAYING / BLANK_HOLD`; expose visibility and elapsed time to the stage without touching runner playback. |
|
||
| `class_name SpeechBubble extends Node2D` (`scripts/speech/stickman_speech_bubble.gd`) | Keep the shared, input-transparent `RichTextLabel` renderer. Factor frame geometry so runtime frames and small frame-choice thumbnails use the same shape rules. Given the rig's live head geometry, draw one filled triangular tail from the nearest visible lower frame boundary to just outside the head rim; never draw a fallback connector line through the character. SFX has no line or tail. Derive each card's Bounce in entrance from that card's local elapsed time; the true bounce overshoots and settles, while Simply appear has no entrance scale animation. |
|
||
| `class_name StickmanActionRunner extends Node` | Keep primary and overlay playback records `{action: Dictionary, elapsed: float, duration: float}`. Add `speak_action(action: Dictionary) -> void`, `speak_overlay_action(action: Dictionary) -> void`, and `set_speech_preview_suppressed(value: bool) -> void`; retain `speak(text: String, duration: float)` and `speak_overlay(text: String, duration: float)` as adapters. Tick only the visible overlay while it has priority, pause the primary speech clock beneath it, and render the winning record. Emit `speech_finished` exactly once when each completed group ends. |
|
||
| `class_name StickmanRig extends Node2D` | Keep `speak_action(action: Dictionary) -> void`, `set_speech_preview_suppressed(value: bool) -> void`, and legacy `speak(text: String, duration: float)`. Expose live head center and radius from the mounted `Body/Head` circle, with a ragdoll-head or safe rig fallback when that visual is unavailable. Preview and playback use the same geometry after facing/camera transforms. |
|
||
| `class_name SandboxStage extends Node2D` | Continue routing every Speak entry point through one composer session: actor, origin context, action ID/target, draft, and return target. Manage temporary renderer, stage placement capture, actor-first capture for rule-add, and final commit through existing queue/rule methods. Preserve the recovery payload on non-explicit dismissal, validate origin identity on Restore, and clear recovery on successful Save speech or explicit Cancel. The preview never calls `rig.speak()` or changes an authored action before Save speech. |
|
||
| `class_name ActionEditor extends PopupPanel` | On selecting/editing `speak`, emit `speech_requested(initial: Dictionary)` to the stage instead of rebuilding its old Speak `LineEdit` and duration `SpinBox`. Keep all other action types and existing `committed(action: Dictionary)` routing. |
|
||
| `class_name ActionRegistry extends RefCounted` | Preserve complete normalized Speak data when converting flat queue actions to nested rule params and back. `summarize()` shows a short plain first-line preview, bubble count/mode, and total reading time; never display raw effect syntax. |
|
||
| `class_name StageSceneIO extends RefCounted` | Explicitly encode/decode the optional Speak fields in both queue and rule actions, validate type/range/enum/array bounds through `SpeechPayload`, and convert `placement.center` between `Vector2` and `{x,y}`. Preserve v1.0 migration and v1.1 loading. Malformed optional speech data falls back safely to the legacy single bubble or causes only that action to be skipped with an existing warning; it must not corrupt other actions or the scene. |
|
||
| `class_name SandboxTheme extends RefCounted` and `sandbox_theme.json` | Provide validated `speech` tokens for five frame styles, preview ghost, layout, timing, size examples, and a **speech-specific** palette of 16 named **text** colors. Preserve the existing global `widgets.swatches` API. Keep font resolution: selected configured preset → configured UI font → `ThemeDB.fallback_font`; previews use available `SystemFont` aliases for now. The user deferred bundling licensed font files with attribution to a later task, so exact typefaces may vary by platform. |
|
||
|
||
The stage forwards `SpeechComposer.committed` to the same queue/rule destination used by `ActionEditor.committed`, after rechecking that the actor, action ID, and insertion point remain valid. For rule-add with no chosen actor, capture the actor **before** opening the composer so the preview has a real stage anchor. **Save speech** commits the staged action to that destination, then clears the recovery draft. It is not a disk Save Scene command. **Cancel** discards the staged action and its recovery draft, then returns to the prior queue/rule panel or editor. The composer never mutates a queued action, rule, scene dirty flag, or runner state while its draft is being edited.
|
||
|
||
### 3.1 Recoverable draft lifecycle
|
||
|
||
`SandboxStage` maintains one speech-composer recovery record at `user://speech_composer_draft.json`. Every draft edit refreshes an atomic write after a **0.75-second debounce**. The envelope includes a schema version, modified time, normalized speech payload, selected bubble index, and origin context (direct/new/edit/insert/rule plus the scene and target action identity when available). It contains no live `Object` references. It is private editor recovery data and is never part of `.scene` JSON.
|
||
|
||
Recovery data is not an action commit: it never edits the queue, rule, runner, scene dirty flag, or `.scene` file. If the composer is dismissed without Save speech or Cancel, the recovery record stays available. On the next eligible composer open, show **Restore draft** / **Discard draft**. When the saved origin and target can be revalidated, Restore resumes that edit; when they cannot, Restore loads the speech as an unbound draft and requires the user to choose an actor and destination before Save speech. A stale runtime instance ID or queue position alone is never enough to replace an action. A malformed or unsupported recovery record is discarded with a plain status message, without affecting the scene.
|
||
|
||
Successful Save speech clears the recovery file only after the existing queue/rule commit succeeds. Explicit Cancel clears it and returns without mutation. If a new composition starts while an older recovery record exists, the user must choose Restore or Discard before the new draft can replace it. The ordinary Save Scene/autosave flow remains separate and unchanged.
|
||
|
||
## 4. Speech action data
|
||
|
||
Flat queue actions are the canonical in-memory shape; rule actions nest the same fields in `params`. JSON uses the same scalar/dictionary/array fields, except its `placement.center` is `{x,y}` instead of `Vector2`.
|
||
|
||
```gdscript
|
||
{
|
||
"type": "speak",
|
||
"text": "Look out!", # derived first-bubble summary for older consumers
|
||
"duration": 1.5, # effective whole-group duration in seconds
|
||
"stop_current": false,
|
||
"arrangement": "together", # or "take_turns"
|
||
"duration_mode": "just_enough", # or "longer" / "longest"
|
||
"placement": {"mode": "manual", "center": Vector2(40.0, -710.0)},
|
||
"bubbles": [
|
||
{
|
||
"text": "Look out!", # literal plain text, including emoji/newlines
|
||
"visual": {
|
||
"bubble_style": "shout", # talk/shout/think/whisper/sfx
|
||
"font_preset": "comic", # IDs offered by SandboxTheme
|
||
"size_preset": "huge", # small/medium/huge
|
||
"color": "#FF3333", # text color; frame colors come from theme
|
||
"reveal": "letter", # instant/letter/word
|
||
"pop_in": true,
|
||
"word_styles": [
|
||
{"start": 5, "end": 8, "effect": "shake", "font_preset": "pixel", "color": "#FFB300"}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`placement.mode = "auto"` omits `center`; the renderer centers the group above the speech anchor. Manual `center` is the **group frame center in rig-local coordinates**, so a stage click places the actual frame center under the cursor, not its tail tip. In One at a time, each single frame uses that same center; in Show all, the stack as a whole uses it. The frame center and its saved position stay fixed when text changes; geometry grows around it. The filled tail points toward the live head rim without a separate connector line. This is an authored coordinate, never a screen coordinate. The rig transform handles movement and facing; the counter-mirrored visual root keeps letters readable.
|
||
|
||
`text` and `duration` remain at the top level because current consumers and older scene files expect them. For new actions, `text` is copied from the first bubble and `duration` is recalculated on each edit. `bubbles` is authoritative when present. A legacy `{type:"speak", text, duration}` action normalizes to one Talk/medium/instant bubble and keeps its exact saved duration via an in-memory `fixed_legacy` timing mode; the writer keeps its original legacy fields until the composer edits it. Opening it in the composer selects Normal and recalculates on Save speech. Optional legacy top-level `visual` from the plan is accepted as that bubble's style. New writes may include the optional fields while keeping `.scene` file version **1.1**; no scene-wide migration is needed. Unknown optional fields are ignored; unknown enum values fall back to defaults. Set defensive implementation bounds of **eight bubbles** and **240 visible characters per bubble** to bound layout work and saved payload size; these are safety caps, not a suggested story length. Reject non-finite, negative, or out-of-range manual coordinates and durations at the IO boundary.
|
||
|
||
`visual.font_preset` and `visual.color` are defaults for the **whole bubble**. Optional `visual.word_styles` records override them for individually targeted words and may also set one effect. Each record is `{start: int, end: int, font_preset?: String, color?: String, effect?: String}` with half-open offsets over the **plain** text. Its range must cover exactly one complete word token, `0 <= start < end <= text.length()`. Records cannot overlap or repeat the same word; omitted keys inherit the bubble default, and a record with no override keys is removed. Font IDs must resolve through the validated theme presets, colors must be valid hex, and effects must be one of `shake`, `wave`, or `bounce`. These structured fields never contain user-authored BBCode or resource paths.
|
||
|
||
In the multiline editor, a child **clicks or taps one word once**. The composer maps the pointer to a plain-text offset and uses the same `word_span_at()` helper as the validator: a word is a run of letters/numbers with an internal apostrophe, while a standalone emoji is one targetable grapheme; whitespace and punctuation have no target. The composer records that entire word's bubble and range **before** a style button takes focus. A graphical, input-transparent marker outlines the whole targeted word and follows editor wrapping/scrolling; a small word chip with an effect icon remains beside the controls as a second cue. It never uses native text selection, so typing cannot overwrite the targeted word. With a word target, changing font/color or pressing Shake/Wave/Bounce updates **that word only**; the controls show the word's effective values, including inherited defaults. With no word target, font and color controls edit the whole bubble's defaults and the effect buttons are disabled. Tapping `I` → Wave, then `GHOST` → Wave creates two separate saved word records; the second tap does not alter the first. Applying the same effect again is idempotent; choosing another effect replaces the previous effect on that word. **No effect** removes only that word's effect. **Use bubble defaults** removes its font/color overrides, and if no effect remains, removes the record. Clicking punctuation or blank space clears the target. Drag highlighting, Shift-click, and multiselect/append have no styling meaning and are not part of this workflow.
|
||
|
||
The composer shows a compact style marker on each affected word. Text edits shift unaffected later records and drop a record whose word is edited; if an edit, card removal, or card reorder invalidates the transient target, it is cleared visibly before any control can apply to a stale range. `SpeechPayload.rich_text_runs()` splits escaped literal text at validated word boundaries and returns trusted segments; the renderer applies `RichTextLabel` font/color overrides and approved effect tags or custom effects to each segment. If the installed Godot build lacks a built-in bounce tag, use one small custom `RichTextEffect` for bounce; the saved `effect` value remains stable. The renderer measures wrapped content before drawing the frame. Emoji and multiline text remain literal input; no raw BBCode entry box appears in the child UI.
|
||
|
||
For a new bubble, the base reading time is `max(1.5, 1.0 + 0.2 × word_count)` seconds, counting visible words in plain text. `longer` multiplies by 1.5; `longest` by 2.0. Bound each interval to `[1.5, 12.0]` seconds and ensure at least `reveal_time + 0.8` seconds after choosing the reveal mode. Empty/whitespace-only cards cannot be committed. Together uses the **maximum** card interval; Take turns uses their **sum**. `duration_for()` is the single source for UI preview, saved total, and runner timing. Legacy actions bypass this calculation until edited in the new composer. Word-style records and emoji do not count as extra words.
|
||
|
||
## 5. Playback, geometry, placement, and pause
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> Hidden
|
||
Hidden --> Primary: queued Speak starts
|
||
Primary --> Overlay: reactive Speak starts
|
||
Overlay --> Primary: overlay group ends; resume elapsed primary time
|
||
Primary --> Hidden: primary group ends
|
||
Overlay --> Hidden: overlay ends with no primary
|
||
Primary --> Primary: next card in Take turns
|
||
Overlay --> Overlay: next card in Take turns
|
||
```
|
||
|
||
The runner stores elapsed time, not a free-running tween, and passes that time to `SpeechBubble.render_action()`. This makes Replay, pause, overlay restoration, and deterministic scene playback use the same visual clock. For Show all, all items are visible for the whole interval and reveal from time zero. For One at a time, `bubble_intervals()` locates one current card, computes local time, and transitions to the next at the boundary; the outgoing card is gone before the new one appears. **Bounce in** uses a short overshoot-and-settle entrance based on each card's local elapsed time, including every later One at a time card. **Simply appear** uses no entrance scale animation. The composer shows a small, understandable example for each choice. Letter/word reveal maps elapsed reveal time to `RichTextLabel.visible_characters`; existing `pop_in` boolean and reveal IDs stay unchanged in files.
|
||
|
||
All frames share one text/layout path and deterministic shape helpers used by the frame-choice thumbnails. Talk uses a rounded frame; Shout a starburst; Think a cloud with descending dots; Whisper a translucent dashed frame; SFX a high-contrast banner. In Show all, the renderer measures each frame, stacks them bottom-up with bounded gaps, and draws bridges between adjacent frames; only the bottom frame points to the head. In One at a time, one frame uses the common manual/auto center. SFX draws no tail or connector line. Body, tail, inset, and selected-outline details follow [Speech Bubble Visual Geometry](speech_bubble_visuals.md).
|
||
|
||
The stage converts the exact stage click through `StickmanRig.to_local(world_pos)` and saves that as manual `placement.center`; it shows a ghost frame outline while hovering. The composer suspends its controls only enough to accept the stage click. Esc/right-click cancels placement and restores the previous center; Reset placement writes auto into the **draft**. The selected tab controls the editing widgets, while Move bubble moves the whole group. There are no placement arrow buttons or line from the frame through the character. The preview updates on every edit without mutating the action queue or scene dirty flag. Actor deletion, scene load/reset, mode exit, or other non-explicit dismissal preserves only the recovery payload; on Restore, context is revalidated and stale context becomes an unbound draft that requires an explicit actor/destination. Explicit Cancel discards the recovery payload.
|
||
|
||
At render time, compute the visual group bounds in screen space after the actor/camera transforms, then translate only the **displayed** frame group inside the visible stage rectangle (excluding the side panel and chrome) with a small margin. The rig supplies the live head center and radius from `Body/Head`, or the ragdoll head/fallback when appropriate. Convert that geometry through the final bubble transform and connect applicable frames to the head rim with the single-contour balloon treatment specified in [Speech Bubble Visual Geometry](speech_bubble_visuals.md). Think uses its dot chain toward the same rim; SFX has no connection. Use the final displayed frame, camera zoom, and facing so clamping/mirroring cannot detach the tail. Display fit never changes serialized placement. At a window too small for a readable simultaneous stack, show a separate overflow cue; never silently crop text or show calculated reading seconds.
|
||
|
||
The runner's pause flag stops both speech clocks. Inline animated effects obey the selected group's visual clock. The composer controls Replay with `STATIC → PLAYING → BLANK_HOLD → STATIC`: start at a clean first frame, play each entrance/reveal/effect and every One at a time transition, hide the preview when the full action duration ends, leave the stage blank for about **1 second**, then restore the full static editing preview. Edits during Replay return to STATIC using the latest draft. The stage must honor an explicit preview-hidden state during BLANK_HOLD; rendering the action at its final elapsed time would leave the last card visible. Replay never calls the runner or changes the saved action. An overlay ending restores the primary group's saved elapsed time, exact current card, and reveal position. A canceled/replaced overlay does not emit a spurious `speech_finished`.
|
||
|
||
## 6. Composer layout and entry flows
|
||
|
||
```text
|
||
Visible stage and selected character Speech Composer side panel
|
||
┌──────────────────────────────┐ ┌─────────────────────────────┐
|
||
│ character ◯◀ filled tail │ │ [1 Hello…] [2 Help…] [+] │
|
||
│ comic frame │ │ one active multiline editor │
|
||
│ ghost frame while placing │ │ marked word + visual chip │
|
||
│ │ │ Frame: five shape thumbnails│
|
||
│ │ │ Text: font samples / sizes │
|
||
│ │ │ Color: 4×4 named text tiles │
|
||
│ │ │ Effects: Shake Wave Bounce │
|
||
│ │ │ Entrance: Bounce / Simply │
|
||
│ │ │ Show: all / one at a time │
|
||
│ │ │ Timing: Normal Slow Slower │
|
||
│ │ │ Move / Reset / Replay │
|
||
│ │ │ Save speech / Cancel │
|
||
└──────────────────────────────┘ └─────────────────────────────┘
|
||
```
|
||
|
||
The panel keeps its title and Save speech/Cancel footer fixed. A **vertical-only `ScrollContainer`** holds the inspector; it must not produce a horizontal scrollbar or clip controls at the supported minimum window width. Use `HFlowContainer` or responsive vertical groups for variable-width choice rows. Place text swatches in a fixed four-column `GridContainer`, with **16 large filled squares** and accessible names/tooltips such as “Sunset red”; show no hex values in the child UI. These squares set `visual.color` (bubble **text** default) when no word is targeted and `visual.word_styles[].color` for a targeted word. They never change the bubble frame fill. Add a strong selected outline/checkmark that remains visible on pale and dark colors.
|
||
|
||
The bubble chooser uses high-contrast numbered tabs with a short text snippet and a clear active state; tabs wrap or scroll within the panel as needed. Only the selected bubble has a live `TextEdit`. Switching tabs commits its current text into the staged draft and rebinds one editor, preserving each card's text and optional transient caret/scroll position without duplicating editors. A graphical word marker overlays the targeted full word inside this editor without using native drag selection; it follows wrapping, scrolling, and resize, ignores pointer input, and remains visible while the child uses font, color, or effect controls. A small graphic chip shows the targeted word with its active style/effect cue; there is no plain **Target Word** label. A Clear target button remains available. The target is cleared if its text/card is changed or removed.
|
||
|
||
Frame buttons show actual Talk/Shout/Think/Whisper/SFX silhouettes from the same geometry helper as the runtime renderer, plus a short label. Font buttons render a sample word in their selected font and show the selected state; until font files are bundled in a later task, the available system-font aliases provide the samples and may vary by platform. Size choices show a text-size example. The inspector has separate **Text**, **Effects**, **Show**, and **Timing** headings. Effects contains Shake, Wave, Bounce, No effect, and Use bubble defaults with the current one-word targeting behavior. Entrance presents **Bounce in** and **Simply appear** as mutually exclusive choices with a small animated/graphic example; the saved field stays `pop_in: bool`. Show presents **Show all** / **One at a time** and Timing presents **Normal** / **Slow** / **Slower**. The automatic seconds label is removed; a separate contextual warning may report actual layout overflow without a duration number. Move bubble and Reset placement remain; arrow nudges are removed. Reorder and Remove stay available for tabs, and Add explains the defensive eight-card limit only when reached.
|
||
|
||
Direct Speak and the old rule-builder Speak button open the composer with their already selected target rig. Queue/rule add/edit from `ActionEditor` hand off to the composer when type Speak is selected; existing Speak edit may open it directly. For rule-add where an actor is not selected, the stage first uses its existing stickman capture path, then opens the composer attached to that actor. The stage retains an action ID for delayed queue edits/inserts and validates it on Save, following Phase 12's pending-only edit rules. Rule action data passes through `ActionRegistry` and `StageSceneIO` without losing bubble fields. Stop-current remains visible only in reactive/rule contexts. Reopening a saved action restores every card, style, interval mode, and placement.
|
||
|
||
## 7. Refinement implementation sequence and acceptance checks
|
||
|
||
1. Refactor the composer to display one active bubble editor, high-contrast numbered snippet tabs, and a vertical-only responsive inspector. Add frame thumbnails, per-font samples, size samples, 16 named 4×4 text swatches, a graphical word marker, and the separate Effects/Show/Timing sections. Preserve existing speech serialization IDs while changing only the child-facing labels.
|
||
2. Replace the approximate/line connector with shared dynamic head geometry from `StickmanRig`; draw one elongated filled tail for applicable frames, ending just outside the live head rim. Remove the fallback connector line and all SFX lines. Make Bounce in an actual overshoot-and-settle animation and keep its clock local to each card.
|
||
3. Implement the `STATIC → PLAYING → BLANK_HOLD → STATIC` composer replay state. Replay exercises entrances, text reveal, inline word effects, One at a time transitions, then hides all bubbles for about one second before returning to the full editing preview. Keep the runner untouched.
|
||
4. Add the debounced local recovery record. Verify crash/reopen recovery, valid-context resume, stale-context fallback to an unbound draft requiring actor/destination selection, and that Restore never commits or dirties the scene. Verify successful Save speech commits exactly once and clears recovery; Cancel discards the draft and clears recovery; failed commits retain recovery.
|
||
5. Perform Phase 2 headless syntax verification per spec-pipeline, plus focused validation of recovery envelope/version, debounce/atomic write, context matching, discard/clear paths, queue/rule round-trip, old scenes, word-boundary records, timing-ID mapping, per-card replay and blank hold, head/tail geometry across facing/zoom/clamping, and Cancel leaving authored state untouched. Manually verify supported narrow-window layouts have no horizontal scroll; frame/font/size samples match the preview; all 16 text colors remain visible on light and dark swatches; and all direct, queue, insert, rule-add, and rule-edit entry points preserve their destination.
|
||
|
||
Acceptance means a child can compose and rearrange a multi-bubble comic in the one-editor inspector, tap separate words to style each with an effect, font, and text color, preview the frame and size choices, place the group with a tail that points to the character's head, replay the complete sequence, and save/reload the result. Bubble tabs remain readable and controls never require horizontal scrolling. **Show all** stacks every bubble; **One at a time** never displays two. Replay ends with a one-second blank stage and restores the static draft preview. If the composer is dismissed unexpectedly, Restore recovers the latest draft without changing the authored action until Save speech; Cancel clears the draft. Normal/Slow/Slower retain their existing timing math and serialized IDs. Old single-text Speak scenes keep their original saved duration. No malformed recovery or speech data crashes scene loading.
|
||
|
||
## 8. Architectural tradeoffs
|
||
|
||
- A dedicated stage-side composer keeps the actual character visible and lets one stage-click placement flow serve every Speak entry point. It requires a small context handoff from existing `PopupPanel`s and careful cleanup of the temporary renderer.
|
||
- A `Dictionary` payload with one pure validator fits the existing action/JSON pipeline. Per-bubble `Resource` files would add conversion and identity rules without helping this scene-local feature yet.
|
||
- Plain text plus validated single-word style records gives children direct writing and safe literal brackets. One persistent transient word target avoids relying on native TextEdit selection focus, while bubble-level font/color defaults keep unstyled words coherent. Arbitrary BBCode and external content markup are deferred.
|
||
- A runner-owned elapsed clock makes overlay restoration and Phase 12 pause deterministic. Time-derived visual animation is more predictable here than independent tweens per bubble.
|
||
- Character-relative manual centers keep placement stable as the actor moves. Screen fit is a render-only adjustment; tiny viewports may need a later responsive layout or paging pass.
|
||
- A local recoverable draft addresses accidental dismissal without treating each keystroke as an action/scene commit. It adds one bounded, versioned user-data file and context validation; stale targets restore only as an unbound draft, never as an implicit replacement.
|
||
- Dynamic head geometry avoids a guessed fixed offset and removes long lines that can cross the character. The tradeoff is a small rig API and explicit ragdoll/fallback behavior; the same renderer keeps preview and playback aligned.
|
||
- Bundled comic/pixel/scary font assets would make samples consistent across platforms, but are deferred by the user; system-font aliases remain the interim behavior.
|
||
|
||
## 9. Approved follow-up review (2026-09-27)
|
||
|
||
### Interaction and inspector
|
||
|
||
- Treat the visible composer panel as the owner of wheel and touchpad scrolling. Stage camera zoom and pan gestures must not run when their pointer is over the panel. A middle-mouse pan may begin only over the stage. Keep the inspector vertical-only.
|
||
- Hide **Remove** when only one bubble remains.
|
||
- Keep the main inspector sections open. Use vertical scrolling, readable section cards, and larger high-contrast ribbon headers. Keep the bubble selector and Save speech / Cancel controls reachable while editing; do not add collapse toggles to the core sections.
|
||
- Rename the message editor section **Message**. Present word styling in a separate **Make words move** section with the instruction **Tap a word in the bubble, then choose a style.** Keep the selected word visible as a simple word chip. Place Shake, Wave, Bounce, and No effect together in **Effects**. No effect clears that word's animation; **Done** exits word editing. No selection means the child is choosing a bubble-wide style; one tapped word means the child is choosing that word's style.
|
||
- Make font and size choices keep a clear selected fill or border until the selection changes; keyboard focus must not look like a selected value.
|
||
- When a word is selected, font, size, and color choices apply to that word. With no selected word, those same controls set the bubble defaults. **Use bubble defaults** clears per-word font, size, and color while preserving its effect.
|
||
- Label the palette **Color**. Use sixteen equal, larger square swatches in four rows. Do not print color names, hex values, or checkmarks on the squares. Use a high-contrast border for the selected color and retain color names only in tooltips.
|
||
|
||
### Word selection marker
|
||
|
||
- The graphical marker must cover the full rendered word, including the first and last glyph, with a little padding and softly rounded corners. A wrapped word gets one marker segment per visual line. Skip invalid/offscreen glyph rectangles and clip the marker to the active text editor; recompute it after caret changes, scrolling, wrapping, resize, or font changes. Keep punctuation only when it is part of the word target according to the shared word-span helper.
|
||
|
||
### Per-bubble placement and default layout
|
||
|
||
- Add optional per-bubble `placement` data with a character-local manual center. Preserve the existing top-level placement as the legacy/group placement and fallback, so old speech keeps its current saved positions. Encode/decode every vector through the scene JSON boundary.
|
||
- New automatic bubbles appear above the character with a slight alternating stagger. The default layout must leave the character's head and body unobscured. If the group will clip at the top of the visible stage, compact its spacing or move it beside the character; do not shift the stack down across the character to make it fit.
|
||
- Add **Move this bubble** and **Reset spot** for the selected bubble. Let the child drag that bubble in the preview or choose a spot with a stage click; either gesture edits only that bubble. During placement, keep other bubbles visible as faint guides. Show all displays every bubble at its saved spot; One at a time uses each bubble's saved spot on its turn. Recompute the applicable tail/connection from the final displayed position to the live head. Keep authored positions separate from render-time viewport fitting.
|
||
|
||
### Save and reload integrity
|
||
|
||
- Keep the full validated speech payload intact through queue save/load, including legacy single-text speech, bubble visuals, word styles, arrangement, timing, top-level placement, and per-bubble placement.
|
||
- A confirmed failure mode in the current save path is that `encode_queue()` can reject an invalid action while `_collect_scene_data()` discards its warnings and still writes the remaining queue. Do not report a successful scene save if any authored queue action was omitted. Surface a clear save error naming the affected action/character and leave the scene file unchanged. On load, report which action could not be restored instead of silently presenting an empty queue.
|
||
- Acceptance requires a saved multi-bubble Speak action to remain visible exactly once in the same stickman's queue after Save speech → Save Scene → close/reload, with its word styles and positions intact. Include a legacy `{text, duration}` action in the compatibility round trip.
|
||
|
||
### Acceptance checks
|
||
|
||
- Scrolling over the composer changes its scroll position without changing camera zoom; scrolling over the stage still zooms. Touchpad scroll/pinch follows the same ownership rule.
|
||
- A one-bubble draft has no Remove control. Core sections remain open and visually separated by readable cards. Selected font, size, and color are visually apparent without checkmarks or labels inside swatches.
|
||
- Tapping a word highlights every visible glyph of that word, including wrapped words, and styling remains bound to that occurrence when focus moves to a control. Font, size, and color choices can be applied independently to that word.
|
||
- Added bubbles do not cover the character by default. Each bubble can be moved and reset independently; its position follows the character and survives scene save/load.
|
||
- Valid speech survives scene save/load. Invalid speech prevents a misleading partial save and produces an actionable message.
|