Implement speech style toolbar live-edit refinements and whole speech show controls
- Added a new specification for the speech style toolbar live-edit, detailing immediate style application, scoped effect demonstration, and hover feedback. - Introduced refinements to the speech style toolbar, including improved chooser sizing, exclusive stage editing during speech composition, and removal of redundant controls. - Documented new stage controls for managing how the whole speech shows, including options for bubble arrangement, word appearance, and bubble motion. - Created a new log file for speech padding syntax to track engine versioning.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# 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: `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_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/speech_composer.gd` and `scenes/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/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.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Speech Bubble Badge Stability
|
||||
|
||||
## Diagram
|
||||
|
||||
Separate bubbles keep their own badge positions when selection changes:
|
||||
|
||||
```text
|
||||
① [Bubble 1] ② [Bubble 2]
|
||||
```
|
||||
|
||||
After selecting bubble 2, only its selection appearance changes:
|
||||
|
||||
```text
|
||||
① [Bubble 1] 🟡② [Bubble 2]
|
||||
```
|
||||
|
||||
When preferred badge positions are crowded, badges form a sorted group:
|
||||
|
||||
```text
|
||||
① ② ③
|
||||
[overlapping bubbles]
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Give each bubble a preferred badge center at its frame's upper-left corner.
|
||||
- Recalculate badge positions from bubble geometry, viewport bounds, and other badge positions. Selection state and floating dialog rectangles do not affect the badge layout.
|
||||
- Keep distant, non-overlapping bubbles independent. Arrange badges as a sorted row or column when preferred badge centers are closer than the minimum badge spacing, or when their bubble frames intersect.
|
||||
- When intersecting bubble frames form a badge cluster, place the sorted row above the largest bubble frame so smaller bubbles' badges cannot show through the larger bubble's text area. If the top row is within the viewport, allow it to use space above the ordinary toolbar-safe margin.
|
||||
- Move badge clusters deterministically when they reach viewport edges or collide with another badge.
|
||||
- Preserve selected-state feedback through the existing selected badge colors and bubble outline. Selection changes do not change badge centers.
|
||||
- Keep badges visible and clickable above floating editor panels by passing their viewport rectangles to the Show Speech and Speech Style toolbars. Place those panels in the nearest available location that avoids badges. The Show Speech panel keeps its pinned top edge while expanded; in that state, badge avoidance may adjust its horizontal position only.
|
||||
- Badge positions may change when bubble geometry, camera transform, viewport size, or a true badge-to-badge collision changes.
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `SpeechBubble.order_badge_viewport_rects()` exposes the current badge bounds for UI collision avoidance.
|
||||
- The badge solver remains independent of selection and dialog positions.
|
||||
- The stage shares badge bounds with the speech toolbars; toolbars reposition themselves rather than feeding their positions back into badge placement.
|
||||
- The existing badge hit testing and pointing-hand hover behavior use the same resolved centers as badge drawing.
|
||||
|
||||
## Acceptance checks
|
||||
|
||||
- Selecting a different bubble without moving bubbles or changing the viewport leaves every badge center unchanged.
|
||||
- Two distant bubbles retain individual badges without being grouped.
|
||||
- Crowded badge anchors produce a stable, reading-order row or column.
|
||||
- When one bubble obscures another, their badges form a stable, sorted horizontal row above the larger bubble.
|
||||
- A toolbar avoids badge bounds when a clear placement exists.
|
||||
- Camera/viewport changes keep badges inside the stage's safe bounds where possible.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Speech Bubble Layout Presets
|
||||
|
||||
Status: **Implemented; awaiting user verification** (2026-10-01).
|
||||
|
||||
## Interaction
|
||||
|
||||
Provide three pictorial, one-tap actions to the whole-speech stage panel under **Bubble layout**:
|
||||
|
||||
- **Stack** keeps Bubble 1 at its current center, then arranges each later bubble above the previous one. Use measured frame heights and the existing stack gap so large text does not overlap. Alternate a small horizontal offset to make the group read like comic panels.
|
||||
- **Same spot** places every bubble at Bubble 1's current center, so the speech can take turns from a shared location.
|
||||
These are placement actions, not toggle choices. Their buttons do not keep a selected state. Each action updates bubble placements immediately; children can then drag any bubble independently. Comic chain is a separate persistent **Show bubbles** presentation choice; details are in [Comic Chain Speech Mode](speech_comic_chain_layout.md).
|
||||
|
||||
## Data and ownership
|
||||
|
||||
`SpeechBubble` computes the requested centers from its current measured editor-preview frames, in rig-local coordinates. `SandboxStage` routes the selected preset to `SpeechComposer`, which writes each center into that bubble's existing `placement: {mode: "manual", center: Vector2}` field. Stack and Same spot only change positions. Comic chain is saved as `arrangement: "comic_chain"` and controls cumulative playback and connected tails.
|
||||
|
||||
Order badges remain fixed-size editor controls across camera zoom. When two or more badge positions are close, group the badges into one ascending-number row above the bubbles. If that row does not fit in the stage or overlaps a visible Speech Show or Speech Style toolbar, use an ascending vertical rail beside the group; use a row below only when the stage edges and toolbars leave no room above or beside it. Keep the group together, outside visible toolbar bounds, and make the layout used for drawing the same layout used for badge hit testing. The selected bubble's badge has a yellow fill and dark number; other badges keep a dark fill, yellow outline, and light number. A badge click selects that bubble and raises its frame and text above overlapping siblings for editing; it does not change speech order or saved placement.
|
||||
|
||||
Bubble 1 is the anchor for both actions. **Stack** computes the first later bubble at `previous_center.y - previous_height/2 - STACK_GAP - current_height/2`, with alternating ±22 px horizontal offsets from Bubble 1. **Same spot** copies Bubble 1's center exactly to every bubble.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. The Show Speech panel displays **Stack** and **Same spot** placement actions, while Comic chain appears as a persistent choice under **Show bubbles**.
|
||||
2. Stack leaves Bubble 1's saved center intact and moves later bubbles upward with no frame overlap, including bubbles with different font sizes.
|
||||
3. Same spot gives every bubble the exact same saved center as Bubble 1.
|
||||
4. Dragging one bubble after either action changes only that bubble. Reapplying a preset lays out the full sequence again.
|
||||
5. Layout positions survive speech save and scene save/load. Choosing Stack or Same spot does not change the selected Show bubbles mode.
|
||||
6. When two or more badge positions are close, their badges form a sorted horizontal row above the bubble group, or a sorted vertical rail beside it when the row cannot fit. The selected bubble's badge is visibly filled yellow. Every badge remains readable and clickable; clicking one brings that bubble's frame and text to the front for editing.
|
||||
7. No order badge is drawn underneath the visible speech toolbar.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Speech Bubble Visual Geometry
|
||||
|
||||
Status: **Implemented; awaiting editor verification** (2026-09-30).
|
||||
|
||||
## Balloon body and tail
|
||||
|
||||
Talk, Shout, and Whisper speech balloons use one generated, filled silhouette for the body and speaker tail. Draw its fill once and its closed outline once, so the body edge does not continue behind the tail and the tail has no visible seam. Compute the attachment where a ray from the frame center toward the speaker head meets the frame outline. The tail tapers to a point just outside the live head rim and follows the final displayed frame after placement, zoom, facing, and viewport fitting.
|
||||
|
||||
- **Talk:** rounded rectangle with a gently curved tail. Keep the tail moderate in width and let its curve follow the direction toward the speaker.
|
||||
- **Shout:** jagged burst body with the same integrated tail silhouette. Make the tail a little narrower and add one small angular kink near its base to echo the burst. Keep the rest of the tail clean and tapered.
|
||||
- **Whisper:** lightly translucent, softly rounded body with its dashed inset detail and a smooth integrated tail.
|
||||
- **Think:** cloud frame with two descending thought dots; it has no pointed tail.
|
||||
- **SFX:** high-contrast banner with no tail.
|
||||
|
||||
The selected-bubble halo follows the same body-and-tail contour as the actual balloon. Frame-choice thumbnails continue to show frame shapes without a speaker tail. Generated geometry is deterministic and shared by the stage preview and playback; no external image asset is needed.
|
||||
|
||||
## Text inset
|
||||
|
||||
Give every frame style more room between its text and the outline. Use a standard text inset of **24 px horizontally and 18 px vertically**. Use a larger **30 px horizontal and 22 px vertical** inset for Shout so burst points do not crowd the lettering. Use the selected per-word font and size when measuring the frame, then place and center the label inside the matching inset. Wrapping, frame sizing, selection markers, and preview must use the same measured layout.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Talk, Shout, and Whisper each render as one filled body-and-tail silhouette with a continuous outer outline and no line visible across the tail root.
|
||||
2. A changing speaker position or bubble placement moves the tail attachment and tip while preserving the continuous contour and keeping the tip just outside the live head rim.
|
||||
3. Shout keeps its burst body and gains a slightly narrower tail with one small angular detail near the base.
|
||||
4. Think retains its dot connector; SFX remains tailless.
|
||||
5. All speech styles use the increased text inset; Shout uses its larger inset. Large per-word lettering remains centered and does not touch the frame.
|
||||
6. Selected-bubble outlines trace the same tail shape as the visible balloon. Preview and playback render matching deterministic geometry.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Comic Chain Speech Mode
|
||||
|
||||
Status: **Implemented; awaiting editor verification** (2026-10-01).
|
||||
|
||||
## Intent
|
||||
|
||||
Comic chain is a speech presentation mode. Bubble 1 appears first. Each next bubble appears above and connects to its predecessor, while all earlier bubbles remain visible. Bubble 1's character-facing tail identifies the speaker.
|
||||
|
||||
## Show Speech UI
|
||||
|
||||
Place Comic chain beside the other choices in **Show bubbles**:
|
||||
|
||||
```text
|
||||
💬 Show bubbles
|
||||
[Show all] [One at a time] [💬 Comic chain]
|
||||
|
||||
🗨 Bubble layout
|
||||
[Stack] [Same spot]
|
||||
```
|
||||
|
||||
- The three Show bubbles choices use the existing selected-choice styling. Comic chain is a persistent presentation choice, not a one-shot placement action.
|
||||
- Choosing Show all or One at a time exits Comic chain. The selected mode should be clear from its highlighted button.
|
||||
- Stack and Same spot remain one-tap placement actions. They do not enable or disable Comic chain.
|
||||
- Entering Comic chain arranges the bubbles using the measured Stack placement. Children may drag bubbles afterward; connectors follow their positions.
|
||||
- Disable Comic chain when a speech has fewer than two bubbles. Keep the existing placement-action availability rules.
|
||||
- No explanatory blurb or extra dialog.
|
||||
|
||||
## Playback behavior
|
||||
|
||||
Comic chain is cumulative: bubble 1 appears, then bubble 2 is added while bubble 1 stays visible, then bubble 3 is added while bubbles 1 and 2 stay visible, and so on. Previous bubbles finish their entrance and text reveal before they become static. Each new bubble follows the existing per-bubble timing and reveal settings.
|
||||
|
||||
The stage editor's static preview shows the complete chain at once. Preview speech and runtime playback build the chain progressively. Standard Show all and One at a time behavior stays unchanged.
|
||||
|
||||
## Layout and connector appearance
|
||||
|
||||
- On entry to Comic chain, keep Bubble 1's current center and arrange each later bubble above its predecessor, using measured frame heights, the existing stack gap, and alternating horizontal offsets.
|
||||
- Bubble 1 connects to the character using its frame style: Talk/Whisper use a smooth tail, Shout uses an angular tail, Think uses descending dots, and SFX has no connector.
|
||||
- For each later bubble, connect it to the preceding bubble's nearest frame edge. Use the current bubble's frame style. Talk/Whisper use a broad, blunt-ended bridge, and Shout uses an angular bridge. The attachment joins the previous bubble's outline; it does not narrow into a pointed tip.
|
||||
- Draw connector bridges behind bubble frames and text so the frame outlines cover the seam and the connector does not obscure previous text. Think bubbles use descending thought dots that end at the previous bubble. SFX has no connector.
|
||||
- Recompute connectors after bubble movement, camera changes, or actor movement. Preserve readable order badges above all bubble art.
|
||||
|
||||
## Saved data and compatibility
|
||||
|
||||
Use `arrangement: "comic_chain"` as the canonical saved mode, alongside the existing `together` and `take_turns` values. Existing scenes with `bubble_layout: "comic_chain"` migrate to `arrangement: "comic_chain"` when normalized. New saves omit the obsolete `bubble_layout` key. Missing or invalid mode values default to `together`.
|
||||
|
||||
Preserve the mode through speech drafts, queue and rule actions, `.scene` save/load, and runtime playback. There is no scene file-version change.
|
||||
|
||||
## Ownership and acceptance
|
||||
|
||||
- `SpeechShowToolbar` shows the three persistent Show bubbles choices and keeps Stack/Same spot as placement actions.
|
||||
- `SandboxStage` applies measured Stack centers when Comic chain is selected.
|
||||
- `SpeechComposer` commits arrangement and bubble centers together as one draft update.
|
||||
- `SpeechPayload` validates the new arrangement and migrates the previous `bubble_layout` field.
|
||||
- `StageSceneIO` and `ActionRegistry` preserve the canonical arrangement across queues and rules.
|
||||
- `SpeechBubble` reveals the chain cumulatively and renders blunt joined bridges behind the predecessor frame.
|
||||
|
||||
Acceptance: selecting Comic chain highlights it, stacks bubbles, and previews a cumulative chain; bubble 1 points to the character; later bubbles join their predecessor with a blunt attachment; Think uses dots and SFX stays unconnected; dragging keeps connectors aligned; Show all and One at a time remain unchanged; old Comic chain saves migrate; Stack and Same spot only alter positions.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Speech Message, Emoji, Timing, and Whole-Bubble Effects
|
||||
|
||||
Status: **Approved for implementation** (2026-09-29). This addendum extends the live stage speech-style toolbar. It supersedes prior requirements that Effects need a selected token or that Message, Emoji, and Timing remain as inspector cards.
|
||||
|
||||
## Interaction
|
||||
|
||||
- The stage toolbar has two grouped rows: **Speech** (Message, Timing) and **Style** (Frame, Letters, Effects), followed by **Done styling**. Keep large pictorial buttons, a visible active chooser, responsive placement, and no horizontal scrolling.
|
||||
- **Message** is a highlighted toolbar button that opens a nearby, ribbon-titled editor panel styled like the Frame/Letters/Effects chooser. It does not dim the stage. The panel contains the current bubble's text field and a **😀 Emoji** button directly below it. The button opens an emoji grid in the same panel. Choosing an emoji inserts it at the text caret and leaves the panel open. Keep the text field fixed in place and let the panel grow downward when the grid opens. Text selection in this editor is for text editing only; style targets are selected on the stage bubble.
|
||||
- While the Message panel is open, stage/object input stays locked. Clicking Message again, choosing another toolbar category, or pressing Escape closes the panel. Closing it edits only the recoverable composer draft. **Save speech** remains the sole queue/rule commit point; composer Cancel still discards the editing session.
|
||||
- A stage tap selects a word, punctuation token, or emoji. With a token selected, Letters and Effects change that token. With no token selected, Letters and Effects change all text in the selected bubble. Frame always changes the whole selected bubble. An empty bubble has Effects disabled until it has text.
|
||||
- If a message edit changes text, preserve unaffected word-style spans and carry existing explicit style properties onto the remaining or replacement token content when an edit touches a styled span. Tokens created outside a styled span inherit explicit style properties from the preceding token; when insertion is at the beginning, inherit from the following token. Only text that is fully deleted loses its style. Do not use fuzzy text matching to move styles between repeated words.
|
||||
- Preserve the existing one-second, non-blocking effect sample. A selected-token effect previews only that token. A bubble-default effect previews all tokens in the bubble. The saved effect remains static in edit mode after the sample and animates during speech playback.
|
||||
- **Timing** uses the existing action-wide `duration_mode` choices and IDs: Normal → `just_enough`, Slow → `longer`, Slower → `longest`. It sets how long each bubble remains visible; the existing Show arrangement, Reveal, and Entrance controls stay in the inspector.
|
||||
- Remove Message, Emoji, and Timing from the inspector. Keep bubble management, Show, Replay, Save speech, and Cancel there. Omit the Inspector's Placement section: children position each bubble directly by dragging it on the stage. Continue loading and saving existing placement data so manually positioned bubbles retain their locations.
|
||||
|
||||
## Payload and ownership
|
||||
|
||||
- Add `visual.effect` as the bubble's default effect (`none`, `shake`, `wave`, or `bounce`). Missing values in old scenes normalize to `none`.
|
||||
- `visual.word_styles[].effect` overrides the bubble default. Accept `none` as an explicit per-token override so a selected token can opt out of a bubble-wide effect.
|
||||
- Choosing an effect with no token selected sets `visual.effect` and clears per-token effect overrides, ensuring the choice reaches every token. Choosing an effect on a selected token changes only that token. Choosing **No effect** on a selected token removes its override when the bubble default is `none`, or stores an explicit `none` override when the bubble default is animated.
|
||||
- Choosing **No effect** with no selected token sets the bubble default to `none` and clears all per-token effect overrides.
|
||||
- `SpeechComposer` remains the sole draft and recovery writer. `SpeechStyleToolbar` owns stage-side controls and emits Message/Timing/choice requests. `SandboxStage` opens/closes the modal, blocks world input while it is open, and restores the toolbar afterward. `SpeechBubble` renders bubble defaults plus token overrides.
|
||||
- No scene-version or action-ID change. Preserve existing saved `duration_mode` values and legacy single-text speech behavior.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Message is highlighted while its nearby, non-dimming editor panel is open. The text field stays still when Emoji opens and the panel grows downward; emoji inserts at the caret, and stage selection cannot occur while the panel is open.
|
||||
- Effects with no token selected apply and preview across every word, punctuation token, and emoji in the selected non-empty bubble. Selected-token effects affect only that token. Old scenes with no bubble default effect remain unchanged.
|
||||
- Bubble-level and token-level effects survive save, scene reload, queue editing, and playback. A token can suppress an inherited bubble effect with No effect.
|
||||
- Timing selection updates the existing action-wide duration field, with the established Normal/Slow/Slower behavior. Show controls remain available in the inspector.
|
||||
- Styling remains attached when text inside a styled word is erased or replaced. Inserted tokens outside an existing styled span inherit the preceding token's explicit style, or the following token's style at the start. Text editor selection never changes the stage style target.
|
||||
- Draft recovery and the separate Save speech/Cancel semantics remain intact.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Stage-Only Comic Speech Editor
|
||||
|
||||
Status: **Architecture specification for user sign-off** (2026-09-30). This extends `specs/speech_whole_sequence_show.md` and the approved Speech Style toolbar specs. It replaces the Comic Speech Inspector with stage controls. No implementation is included here.
|
||||
|
||||
## 1. Goals and boundaries
|
||||
|
||||
- Make the stage the primary place to select and style speech bubbles.
|
||||
- Remove the Comic Speech Inspector, including its Bubbles, placement, preview, timing, message, and save controls. Do not leave duplicate speech controls in another inspector section.
|
||||
- Preserve `SpeechComposer` as the owner of the active action draft, Message editing, validation, preview state, and recoverable-draft data. Its root stays active and visible for the full editing session; only the Inspector panel is removed.
|
||||
- Keep the current speech payload, queue/rule encoders, and 8-bubble maximum unless an implementation issue requires a separately approved schema change.
|
||||
- Keep normal Scene Save distinct from committing the speech action to the in-memory queue or rule editor.
|
||||
|
||||
## 2. Stage UI and state changes
|
||||
|
||||
There are two stage editing states. A session opens in **Show Speech** with no bubble selected. Clicking a bubble or its text selects that bubble and switches to **Speech Style**. Clicking empty stage space switches back to Show Speech. Empty-stage clicks, toolbar clicks, message-dialog clicks, and bubble editing must not select or alter other stage objects while the speech session is open.
|
||||
|
||||
```text
|
||||
No bubble selected Bubble selected
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ Show speech │ │ Speech style │
|
||||
│ Bubbles Show all | One… │ │ [Message] [Frame] │
|
||||
│ Reveal All | Letter… │ │ [Letters] [Effects] │
|
||||
│ Entrance Bounce | Appear │ │ [Earlier] [Later] │
|
||||
│ Timing Normal | Slow… │ │ [+ Add] [🗑 Remove] [Undo]│
|
||||
│ [▶ Preview speech] │ │ [✓ Done styling] │
|
||||
│ [✓ Done speech] [Cancel] │ │ [✓ Done speech] [Cancel] │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
beside group beside bubble
|
||||
```
|
||||
|
||||
The actual controls remain large, pictorial, and responsive to the available stage size. Do not put a permanent bubble selector in the Inspector. In Speech Style mode, show small order-number badges on the speech bubbles. Do not add a cramped selected-word status label; the selected token marker on the bubble is the target feedback.
|
||||
|
||||
### Show Speech
|
||||
|
||||
Place the existing speech-wide controls in `SpeechShowToolbar`:
|
||||
|
||||
- **Bubbles:** Show all / One at a time (`arrangement`).
|
||||
- **Words appear:** All at once / Letter by letter / Word by word (`reveal`).
|
||||
- **Entrance:** Bounce in / Just appear (`pop_in`).
|
||||
- **Timing:** Normal / Slow / Slower (`duration_mode`).
|
||||
- **Preview speech:** plays the complete sequence once.
|
||||
- **More:** contains **Stop current action before speaking** only for reactive speech sessions.
|
||||
- **Done speech:** ends the editing session as described below.
|
||||
- **Cancel speech:** discards the uncommitted speech draft and returns to the previous queue/rule editing context. Ask for confirmation when the draft has changed.
|
||||
|
||||
Choosing a control updates the draft and live preview immediately. Keep selected outlines visible on hover. Preview and control changes retain the current recoverable-draft debounce.
|
||||
|
||||
### Speech Style
|
||||
|
||||
Extend `SpeechStyleToolbar` with sequence controls:
|
||||
|
||||
- **Message**, **Frame**, **Letters**, and **Effects** keep their existing chooser and selection behavior. Message opens the existing non-dimming Message dialog; emoji insertion remains inside it. Tokens are selected on the stage.
|
||||
- **Earlier / Later** reorder the selected bubble in the spoken sequence. Disable a direction at the corresponding end.
|
||||
- **+ Add bubble** inserts after the selected bubble, selects it, and opens Message for the new bubble.
|
||||
- **🗑 Remove bubble** removes the selected bubble and is disabled when only one bubble remains.
|
||||
- After removal, show a clear **Undo** control for the one-step restoration described below.
|
||||
- **Done styling** returns to Show Speech. It does not commit the action or unlock unrelated stage objects.
|
||||
- **Done speech** remains prominent while in this state too.
|
||||
- **Cancel speech** remains available while in this state too.
|
||||
|
||||
Do not include the old Bubble 1/2 selector, Save speech, Preview speech, placement arrows, or duplicate Timing controls in Speech Style. Bubble dragging continues to change placement; Earlier/Later changes sequence order.
|
||||
|
||||
Both panels anchor beside the bubble/group, choosing the side with room and falling below or above as needed. Use the full stage width now available after removing the Inspector reservation. Keep controls within the usable stage bounds, avoid covering the bubble when possible, and make each panel capture pointer input. A click on a toolbar must not pass through to the bubble underneath.
|
||||
|
||||
## 3. Draft, commit, and recovery
|
||||
|
||||
All speech edits, including typing and style choices, update the live preview and the recoverable local draft. They do not replace the queue action or rule action on every keystroke. **Done speech** is the one-time validation and in-memory commit point:
|
||||
|
||||
1. Save the active Message editor state and build the normalized action draft.
|
||||
2. Validate it. If invalid (including a blank bubble), keep speech editing open, focus the offending bubble, open Message, and explain the needed correction. Do not commit or clear recovery.
|
||||
3. Commit exactly once to the existing destination path: direct queue, queue add/edit/insert, reactive rule builder, or RuleEditor action buffer. If the target action is consumed, missing, or otherwise not editable, keep the composer and recovered draft open and show the current destination error.
|
||||
4. On success, clear the recoverable draft, close speech editing, restore the previous queue/rule editing context, and mark the scene dirty as appropriate. Rule changes remain in the RuleEditor buffer until the RuleEditor performs its existing commit. Queue/rule in-memory commit does not itself write the `.scene` file.
|
||||
|
||||
The ordinary Scene Save writes the scene to disk after speech has been committed with Done speech. If Scene Save is invoked while a speech session is still open, defer the save and explain that the child should choose **Done speech** first; offer **Keep editing** to dismiss the prompt. Do not implicitly commit on Scene Save and do not serialize the stale pre-edit action. If Done speech validation or destination commit fails, leave the editor and recovery draft open. This preserves an explicit speech commit boundary and prevents a saved scene from silently omitting visible speech edits.
|
||||
|
||||
Keep the existing recoverable draft document and its destination/session metadata. Restoring a draft restores the active speech session, preview, current bubble, and pending edits; it must not create a queue/rule action until Done speech succeeds. A successful Done clears recovery. A session interruption before Done remains recoverable through the current Restore/Discard flow. No new full speech undo stack is introduced.
|
||||
|
||||
**Cancel speech** discards the uncommitted draft only after the child confirms when edits have been made. It clears that session's recovery file and restores the prior queue/rule editor state. It does not save the scene. Escape dismisses the current chooser/dialog; it does not silently cancel the whole speech session.
|
||||
|
||||
## 4. Bubble add, remove, ordering, and placement
|
||||
|
||||
### Add
|
||||
|
||||
- Retain the 8-bubble hard maximum. At the limit, disable Add and show a short child-facing explanation.
|
||||
- At five bubbles, show a gentle one-time-in-session hint that **One at a time** can make a long sequence easier to follow; do not block additional bubbles.
|
||||
- New bubble text starts empty. Copy the selected bubble's frame, whole-bubble lettering defaults, and whole-bubble effect. Do not copy its per-word styles. Action-wide Show, Timing, and reactive settings carry over automatically.
|
||||
- Give the new bubble a distinct nearby character-relative placement using the existing stagger/above-character placement rules. Do not reuse the exact selected position if that would overlap, and do not obscure the character.
|
||||
- Select the new bubble, update its order badge, and open Message. The user can immediately type, then tap the bubble text to select words for styling.
|
||||
|
||||
### Remove and Undo
|
||||
|
||||
- A speech must retain at least one bubble. Hide/disable Remove at one bubble.
|
||||
- Removing captures the complete bubble, its sequence index, its character-relative placement, per-word styles, and its caret/scroll editor state.
|
||||
- Undo restores that bubble at its prior sequence index and state, selects it, restores its number badge, and clears the undo snapshot.
|
||||
- Keep only one removal snapshot. Clear/disable Undo after an add, another structural edit, reorder, or leaving the speech session. Do not add a modal confirmation for Remove.
|
||||
|
||||
### Earlier / Later
|
||||
|
||||
Move the bubble object and paired editor state together in `bubbles[]`. Preserve its text, visual defaults, word-style spans, placement, and identity as the selected bubble. Reordering affects playback sequence only; it does not move the bubble on stage. Clear any stale token target after reorder and refresh labels/badges/preview.
|
||||
|
||||
## 5. Ownership and implementation scope
|
||||
|
||||
- `scripts/speech_composer.gd`: keep draft/session APIs, message dialog, selection context, validation, preview, signals, recovery inputs, and editor state. Remove construction and behavior for the Inspector `ComposerPanel`, Bubble selector/actions, its footer Cancel/Preview/Save controls, and inspector-only layout/scroll handling. Add composer APIs/signals for sequence controls, Done speech validation/commit, confirmed Cancel speech, session state, and invalid-bubble focus. Keep the composer root visible while editing so current input ownership, preview, and recovery gates remain valid.
|
||||
- `scripts/speech_style_toolbar.gd`: add Earlier/Later/Add/Remove/Undo UI and events; preserve Message/Frame/Letters/Effects chooser behavior, selected bubble/token feedback, and Done styling.
|
||||
- `scripts/speech_show_toolbar.gd`: add Timing, Preview speech, More/reactive option, and Done speech presentation alongside the current speech-wide controls.
|
||||
- `scripts/sandbox_stage.gd`: own state switching, bubble/stage pointer routing, session input lock, toolbar anchors and event consumption, one-time commit into all existing queue/rule destinations, Scene Save integration, and teardown/recovery. Keep toolbars/message UI interactive while suppressing unrelated object selection/transforms.
|
||||
- `scripts/stickman_speech_bubble.gd`: provide the group/bubble viewport bounds used to anchor the panels; remove the former side-Inspector width subtraction from editor display fitting while retaining character-relative saved placement.
|
||||
- `scripts/sandbox_theme.gd` and `sandbox_theme.json`: add only stage-control sizing/colors needed to make the expanded controls readable and consistent with the existing ribbon/selection theme.
|
||||
- `scripts/speech_payload.gd`, `scripts/stage_scene_io.gd`, and `scripts/action_registry.gd`: preserve and validate the existing speech payload fields. No schema change is required by this UI change; only adjust these files if the new bubble insertion path reveals a missing existing default.
|
||||
- `scenes/speech_composer.tscn`: retain the root controller scene; it must no longer expose an Inspector panel as a visible child.
|
||||
|
||||
## 6. Acceptance checks
|
||||
|
||||
1. Open speech editing from a direct queue action, queue add/edit/insert, and a reactive rule action. The Comic Speech Inspector and duplicate speech controls are absent; Show Speech appears beside the group and Speech Style appears after a bubble click.
|
||||
2. Click a bubble, a word, punctuation, and empty stage space. Bubble/token selection changes panels as specified; empty stage returns to Show Speech. No click selects or edits a character, prop, floor, or other stage object until Done speech.
|
||||
3. Change every whole-speech option and Timing. Preview uses the current settings and plays once. Reactive More appears only for reactive speech. Message/Emoji remains anchored and usable in Speech Style.
|
||||
4. Add after the selected bubble: verify empty Message opens, style defaults are copied without per-word styles, placement is distinct and above/clear of the character, and order badge/playback order are correct. Verify the fifth-bubble hint, the eighth-bubble cap, and Add feedback at the cap.
|
||||
5. Remove a middle bubble and Undo it: the original order, text, styles, placement, selected bubble, and editor state return. Remove cannot leave a zero-bubble action. Verify Undo invalidation after add/reorder and after session exit.
|
||||
6. Earlier/Later changes playback order but leaves stage position unchanged. Drag changes stage position but leaves order unchanged.
|
||||
7. Type and style speech, interrupt and restore the session: draft and preview survive but no queue/rule action was committed before Done speech. Done commits exactly once; a second activation cannot duplicate the action. Cancel asks before discarding edits, then restores the prior action/editor state and clears recovery.
|
||||
8. Exercise blank-text validation and stale/consumed queue targets: editing stays open, identifies the affected bubble/destination, and keeps recovery. Rule edits update the RuleEditor buffer and remain subject to its normal commit.
|
||||
9. Invoke Scene Save while a speech draft is open: defer the save and prompt the user to choose Done speech first or keep editing; do not commit implicitly or write the stale action. After a successful Done speech, normal Scene Save writes the new action. With invalid text or a stale target, Done remains open and no scene save occurs.
|
||||
10. Save/reload a scene and a reactive rule containing multiple bubbles. Message, Show settings, Timing, ordering, styles, placements, and stop-current settings round-trip with the existing schema.
|
||||
11. Pan/zoom/resize the stage and open Message, Frame, Letters, Effects, Show Speech, and More. Panels remain within stage bounds when space permits, the full stage remains usable, and panel clicks never pass through.
|
||||
|
||||
## 7. Tradeoffs
|
||||
|
||||
- Edits are recoverable immediately but do not enter the queue/rule buffer until Done speech. This avoids persisting temporarily empty bubbles and preserves the current atomic action commit paths. Scene Save waits for the explicit Done speech commit to prevent stale disk writes.
|
||||
- Existing queue and rule ownership stays intact: Done speech updates the direct/action-editor destination once; a rule's enclosing RuleEditor still owns its final commit.
|
||||
- One-step Remove Undo is intentionally narrower than document-wide undo. It is cheap to explain and restores all state needed to make removal forgiving; add/reorder invalidate it to keep the snapshot unambiguous.
|
||||
- Removing the Inspector frees horizontal stage space, but the combined stage controls can still be large. Adaptive placement and vertical wrapping are required; responsive paging is deferred until real editor use shows a need.
|
||||
- The action retains the current 8-bubble cap; the five-bubble prompt is advisory only.
|
||||
|
||||
## 8. Decisions recorded
|
||||
|
||||
- Keep `SpeechComposer` as a hidden-from-layout session controller; remove its Inspector panel, not the session root.
|
||||
- Draft/recovery updates live; Done speech commits once to the in-memory destination; normal Scene Save writes disk.
|
||||
- Cancel speech is a separate discard path, with confirmation when the draft changed; Escape does not discard the whole session.
|
||||
- New bubbles copy selected bubble defaults, clear word-level overrides, open Message, and receive a distinct nearby placement.
|
||||
- Remove is disabled at one bubble; Undo restores only the latest removal and clears after structural changes/session exit.
|
||||
- Keep the existing 8-bubble cap and show a soft One at a time hint at five.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Speech styling toolbar — stage-first composer follow-up
|
||||
|
||||
Status: **Implemented; pending user verification** (2026-10-02). This extends the implemented Phase 13 comic speech composer in `specs/phase_13_speech_bubbles.md`. The user approved a nearby line-token picker for cramped text, transactional chooser previews with **Done** and **Cancel**, and deferring **Undo**.
|
||||
|
||||
> **Current main-toolbar layout:** see the 2026-10-02 amendment at the end of this document. It supersedes older Speech Style footer and responsive-layout descriptions below.
|
||||
|
||||
## 1. Interaction and scope
|
||||
|
||||
When the speech composer is open, the selected bubble has a compact toolbar near its frame on the stage. The toolbar groups **Message** and **Timing** under Speech, then **Frame**, **Letters**, and **Effects** under Style, with one chooser surface at a time. **Frame** changes the selected bubble's frame. **Letters** offers the existing font, size, and 16 text-color choices. Without a text target these change the whole bubble; with one selected token they change only that token. **Effects** offers Shake, Wave, Bounce, and No effect. With no token selected, Effects applies to the whole non-empty bubble; with a token selected, it applies only to that token.
|
||||
|
||||
Clicking a bubble selects it. Message editing is in a modal opened from the stage toolbar; words are selected on the stage, never by selecting text in the Message dialog. Clicking rendered text selects one whole token with a clear stage highlight. There is no drag or Shift selection. Repeated identical punctuation such as `...`, `!!!`, and `???` forms one target. A Unicode ellipsis `…` forms one target. Mixed punctuation such as `?!` forms two targets. A word with an internal apostrophe remains one target; emoji keep their existing grapheme behavior. For crowded text, a tap in the rendered text line opens a temporary picker of that line's tokens. Each picker choice has a minimum 44 × 44 px hit area.
|
||||
|
||||
The inspector retains bubble add/remove/reorder, Show, Placement, Replay, **Save speech**, and Cancel. Message, its emoji picker, and Timing live in the stage toolbar flow and are removed from the inspector. The selected bubble's existing bright outline stays visible. Existing placement, drag threshold, overlap bubble picker, recovery draft, and queue/rule commit flows remain intact.
|
||||
|
||||
## 2. Godot hierarchy and ownership
|
||||
|
||||
```text
|
||||
SandboxStage (Node2D; input, positioning, popup coordination)
|
||||
├── World/StickmanRig
|
||||
│ └── SpeechPreview: SpeechBubble (Node2D; frame, RichTextLabel, hit data)
|
||||
└── UI: CanvasLayer
|
||||
├── SpeechComposer: SpeechComposer (Control; authoritative draft + Message dialog)
|
||||
└── SpeechStyleToolbar: SpeechStyleToolbar (Control; transient view)
|
||||
└── StyleChooser: PopupPanel (one reusable chooser)
|
||||
└── MarginContainer/ScrollContainer/VBoxContainer
|
||||
├── Frame choices OR Letters choices OR Effects choices
|
||||
└── Cancel / Done
|
||||
|
||||
Temporary line-token picker: PopupPanel under UI, owned by SandboxStage.
|
||||
```
|
||||
|
||||
`SpeechStyleToolbar` lives in `scripts/speech_style_toolbar.gd` (`class_name SpeechStyleToolbar`, `extends Control`) with a small `scenes/speech_style_toolbar.tscn` or code-built children matching the existing composer convention. It owns visual selection and chooser widget state only. `SpeechComposer` remains the single writer for speech dictionaries. `SandboxStage` positions the toolbar from the renderer, routes signals, and keeps popup input away from camera pan/zoom and bubble dragging. `SpeechBubble` remains the shared renderer for stage preview and playback; token interactions are enabled only for a static editor preview, never for PLAY or runtime speech.
|
||||
|
||||
Use a local `SandboxTheme.control_theme()` and add validated `speech.toolbar` tokens in `sandbox_theme.json` / `scripts/sandbox_theme.gd` for surface, border, selection halo, gap, maximum width, and minimum hit height. Do not alter the global theme or serialize toolbar settings into `.scene`.
|
||||
|
||||
## 3. Static contracts and signal flow
|
||||
|
||||
| Class | Added or revised public contract |
|
||||
| --- | --- |
|
||||
| `class_name SpeechPayload extends RefCounted` | `static func token_spans(text: String) -> Array[Vector2i]` and `static func token_span_at(text: String, index: int) -> Vector2i`. Return half-open plain-text offsets; invalid hit returns `Vector2i(-1, -1)`. Keep `word_span_at` as a compatibility wrapper if other callers still use it, but move style validation/normalization and run segmentation to the token rule. |
|
||||
| `class_name SpeechBubble extends Node2D` | `signal stage_token_selected(bubble_index: int, start: int, end: int)`; `func selected_frame_viewport_rect(index: int) -> Rect2`; `func text_line_tokens_at_viewport(index: int, viewport_position: Vector2) -> Array[Vector2i]`; `func set_selected_token(index: int, start: int, end: int) -> void`. Existing `hit_test_bubbles(world_position)` and drag APIs remain. |
|
||||
| `class_name SpeechComposer extends Control` | `func style_context() -> Dictionary`; `func begin_style_preview(kind: StringName) -> void`; `func preview_style_choice(kind: StringName, value: String) -> void`; `func finish_style_preview(keep: bool) -> void`; `func get_preview_action() -> Dictionary`. `get_draft()` continues to return committed draft only. `select_bubble_token_from_stage(index,start,end)` replaces the word-only entry point and validates against `token_span_at`. Existing text-click targeting uses the same token helper. |
|
||||
| `class_name SpeechStyleToolbar extends Control` | `signal chooser_open_requested(kind: StringName)`; `signal choice_preview_requested(kind: StringName, value: String)`; `signal chooser_finished(keep: bool)`; `signal clear_target_requested()`; `signal replay_requested()`. `func set_context(context: Dictionary) -> void`; `func set_anchor_rect(frame_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void`; `func close_chooser() -> void`. UI strings remain in toolbar; model values are stable IDs. |
|
||||
| `class_name SandboxStage extends Node2D` | Instantiates toolbar under `UI`, connects it to composer/preview, calls `get_preview_action()` for editor rendering and effect replay, repositions toolbar after layout changes, and frees/hides it on composer close. It keeps popup exclusion in `_speech_composer_owns_event` or a renamed speech-UI hit guard. |
|
||||
|
||||
`style_context()` is transient UI data, not a saved action. Its shape is:
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"bubble_index": 1,
|
||||
"bubble_count": 3,
|
||||
"target": {"start": 5, "end": 8, "text": "!!!"}, # {} for whole bubble
|
||||
"bubble_style": "shout",
|
||||
"font_preset": "comic", # effective value for the target, else bubble default
|
||||
"size_preset": "medium",
|
||||
"color": "#202020",
|
||||
"effect": "wave" # "none" when no target or override
|
||||
}
|
||||
```
|
||||
|
||||
Use `StringName` categories `&"frame"`, `&"font"`, `&"size"`, `&"color"`, and `&"effect"` at the signal boundary; values use the existing `SpeechPayload` allowlists. The toolbar must not mutate this context. Composer selection changes cause one context refresh to toolbar and renderer. Opening another bubble or token cancels an open chooser before switching, so pending changes cannot apply to a stale offset.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Click[Stage bubble or token tap] --> Hit[SpeechBubble hit or token metadata]
|
||||
Hit --> Stage[SandboxStage routing]
|
||||
Stage --> Composer[SpeechComposer selection and style context]
|
||||
Composer --> Bar[SpeechStyleToolbar]
|
||||
Bar -->|preview choice| Composer
|
||||
Composer -->|get_preview_action| Stage
|
||||
Stage -->|render + highlight| Hit
|
||||
Bar -->|Done| Draft[Committed composer draft]
|
||||
Bar -->|Cancel| Discard[Discard preview visual]
|
||||
Draft -->|Save speech| Queue[Existing queue/rule commit]
|
||||
```
|
||||
|
||||
## 4. Token model and rendering
|
||||
|
||||
Token offsets remain integers over the literal bubble text. The existing `visual.word_styles` array is retained; `start` and `end` may now name a whole punctuation token as well as a word or emoji. The visual schema adds a backward-compatible bubble-level effect default; word-level effects override it, including an explicit `none` override. No `.scene` version change is needed. JSON numeric offset coercion stays in the existing validation path. Old word-style records remain valid because word boundaries are unchanged.
|
||||
|
||||
The tokenizer scans left to right in this order: regex word (`SpeechPayload.WORD_REGEX`, including an internal apostrophe); emoji/symbol grapheme; ASCII repeated run of the *same* `.`, `!`, or `?`; one Unicode ellipsis `…`; any other single punctuation grapheme; whitespace yields no token. It must never merge different punctuation (`?!`) or absorb punctuation into the preceding word (`Hello!`). `token_span_at` and `token_spans` share the same scanner so validator, editor click target, stage click target, save/load, and renderer agree. Keep `word_count()` semantics unchanged for reading time.
|
||||
|
||||
Update `SpeechPayload.validate`, `_normalize_visual`, `rich_text_runs`, and `SpeechComposer._adjust_word_styles` to accept only exact token spans. `rich_text_runs` must add every token boundary before applying overrides; currently unstyled punctuation can be part of a larger run with spaces, so it cannot be wrapped in its own metadata without this split. Preserve all existing style values: font, size, color, and effect. Example:
|
||||
|
||||
```gdscript
|
||||
"I am a GHOST!!!"
|
||||
# I [0, 1) word
|
||||
# am [2, 4) word
|
||||
# a [5, 6) word
|
||||
# GHOST [7, 12) word
|
||||
# !!! [12, 15) one punctuation token
|
||||
{"start": 12, "end": 15, "effect": "shake"}
|
||||
```
|
||||
|
||||
`SpeechBubble._render_label_text` wraps each token run with `RichTextLabel.push_meta({"bubble_index": i, "start": s, "end": e}, META_UNDERLINE_NEVER)`, including punctuation. A `RichTextLabel` using `MOUSE_FILTER_IGNORE` cannot receive `meta_clicked`; set label `mouse_filter = MOUSE_FILTER_PASS` only while static editor preview is active, and IGNORE in playback. The stage's existing deferred release handling should let `meta_clicked` arrive before it resolves the bubble click. The implementer must verify that press-time selection/re-render does not swallow the metadata click; if it does, defer selection until release or route all token hits through the line picker. `SpeechBubble` paints the selected token with an input-transparent highlight inside the label, while `SpeechWordMarker` keeps its rounded TextEdit highlight. Store renderer selection as transient `(bubble_index,start,end)` and clear it on text edits, bubble reorder/removal, or leaving composition.
|
||||
|
||||
`RichTextLabel` gives wrapped line ranges and line offsets but no dependable per-glyph rectangle API for the mixed-font content used here. Do not approximate every token rectangle from `Font.get_string_size()` because wrapping, glyph shaping, and per-token sizes can disagree. Exact metadata taps select directly. A tap in a rendered text line that misses metadata, or a tap on a cramped token, opens a local line-token picker. Its buttons show the complete token and have a minimum 44 × 44 px hit box; the picker includes visible words and punctuation in that line, never whitespace. Use the label's line range/offset after drawing to limit choices. If a line range is unavailable during the first frame, postpone until the next draw or show tokens for that bubble as a safe fallback. No always-visible token list occupies the inspector.
|
||||
|
||||
## 5. Toolbar and chooser layout
|
||||
|
||||
Anchor the compact toolbar to the selected bubble's *displayed* frame, including runtime fit offset, camera transform, and rig mirroring. `selected_frame_viewport_rect()` transforms all four corners of `_layout_frame + _display_offset` with `get_global_transform_with_canvas()` and returns their axis-aligned viewport rect. Do not use saved `placement.center` to position UI; it lacks viewport fitting. Recompute anchor after preview render, viewport resize, camera pan/zoom, bubble drag, bubble text change, and font/size preview. Hide the toolbar during an active bubble drag and while replay/blank hold runs; restore it beside the static preview afterward.
|
||||
|
||||
Preferred position is centered above the frame with an 8–12 px gap. Try below when there is no room, then clamp within the usable stage rectangle bounded by top chrome, bottom status bar, and the actual composer panel edge. Group Message/Timing on a Speech row and Frame/Letters/Effects on a Style row; reflow within available width without horizontal scrolling. The Message dialog blocks stage input and contains the text editor plus an Emoji button and picker. Choosers use a `ScrollContainer` with a maximum viewport-clamped height. The 16 color swatches remain equal-sized and visibly outlined when selected. Font and size samples keep their selected state and show their appearance using the existing theme/font presets. Frame samples use `SpeechBubble.draw_style_shape` so the choice looks like its actual frame. With no target and non-empty text, the Effects chooser applies to the whole bubble; with no text it is disabled with a short prompt. Use one chooser instance and one active category.
|
||||
|
||||
The toolbar itself does not save speech. The inspector's **Save speech** stays the only queue/rule commit action. Frame always edits the selected bubble even when a token is targeted. Letters applies bubble defaults if `target == {}` and token overrides otherwise. Effects sets the bubble default and clears token effect overrides if `target == {}`; otherwise it edits only the selected token. Timing updates the action-wide `duration_mode`. Switching tokens keeps prior token styles intact. Selected values refresh after every choice.
|
||||
|
||||
## 6. Transactional preview and replay
|
||||
|
||||
Opening a chooser captures `{bubble_index, target range, category, opening visual}` and creates a *provisional* copy of that bubble's visual style. Each choice updates only this provisional copy and emits a preview refresh. `SpeechComposer.get_preview_action()` overlays the provisional visual on a duplicate of `get_draft()`; it never writes to `_draft` or the recovery file. **Done** validates the still-current bubble/target, applies the provisional visual to `_draft` once, emits `draft_changed`, and closes. **Cancel**, Escape, outside click, bubble/token switch, or composer close discards the provisional visual. If text edits or bubble reorder invalidate a token while a chooser is open, discard it before applying those edits. Selecting **Save speech** while a chooser is open first completes the chooser as Done, then runs the existing save validation/commit flow; it cannot silently save an uncommitted preview.
|
||||
|
||||
For Effects, a provisional Shake/Wave/Bounce choice starts one existing composer replay cycle using `get_preview_action()` so the chosen motion can be seen. The Replay control restarts one cycle on demand. Preserve the existing one-second blank hold and static return; do not run effects continuously in static editing. `SpeechWordEffect` remains clock-driven by the speech group and unchanged in saved data. During replay, the toolbar can remain visible but disabled, or hide until static return; whichever is implemented must prevent chooser clicks from mutating an in-flight replay.
|
||||
|
||||
Undo is **deferred**. No Undo button or partial undo stack is added. The chooser's Cancel restores its opening style, and the existing Save speech point still controls queue/rule commits. A future history system should cover text, bubble order/placement, and styles together; an isolated style stack would give a misleading partial Undo.
|
||||
|
||||
## 7. Integration order and acceptance
|
||||
|
||||
1. Add pure token span helpers and migrate validation, normalization, rich-text segmentation, and editor target maintenance. Keep old word-style scenes valid.
|
||||
2. Enable static-preview token metadata/highlight in `SpeechBubble` and stage selection routing. Confirm punctuation, emoji, mixed marks, and the 44 px line picker.
|
||||
3. Add composer provisional style APIs and switch editor preview rendering to `get_preview_action()` while preserving `get_draft()` for recovery and Save speech.
|
||||
4. Add themed toolbar/chooser and responsive frame anchoring. Connect Frame/Letters/Effects, selected values, Cancel/Done, and one-cycle effect Replay.
|
||||
5. Remove duplicate inspector styling controls only after the toolbar covers all current choices. Preserve Message, placement, timing, replay, and Save speech.
|
||||
|
||||
Acceptance in Godot: clicking a bubble updates Message and toolbar; clicking `I`, styling it, then clicking `GHOST` styles only that next token; `!!!` styles as one token and `?!` as two; font/size/color still work for a whole bubble after clearing target. Tiny punctuation can be chosen through a nearby picker with 44 px choices. Cancel returns the opening appearance and leaves `get_draft()`/recovery unchanged; Done survives Save speech, scene save, reload, and queue edit. Effect preview plays once and Replay repeats it. Toolbar and picker remain reachable during zoom, bubble drag, small windows, and camera pan without covering the inspector. Playback speech does not intercept scene input.
|
||||
|
||||
## 8. Trade-offs and deferred work
|
||||
|
||||
The separate UI overlay keeps speech data ownership in the composer and uses one renderer layout for stage hit tests. It adds one more view to position and synchronize, so the stage must be the single placement coordinator. RichTextLabel metadata gives precise glyph selection but small punctuation remains physically small; the local line-token picker provides a reliable 44 px path without replacing the renderer. The picker and transient provisional visual are session-only and add no action fields. General Undo and font bundling remain deferred. The existing capped eight-bubble/240-character bounds keep token scans and popup lists modest; cache token spans per text revision only if profiling shows a cost.
|
||||
|
||||
## 9. Main Speech Style dialog layout amendment (2026-10-02)
|
||||
|
||||
The stage-side Speech Style panel follows this structure:
|
||||
|
||||
1. A ribbon title, then a **Bubble N of M** label with the existing Earlier/Later reorder actions for orientation.
|
||||
2. A two-column grid: **Message**, **Frame**, **Letters**, and **Effects**.
|
||||
3. The **Add bubble**, **Remove bubble**, and **Undo** controls together.
|
||||
4. One centered **Done styling** button. **Done speech** and **Cancel speech** appear only in Show speech.
|
||||
|
||||
Anchor the panel beside the selected bubble when there is room; otherwise place it below or above and keep it inside the stage. Cap width so the stage remains visible. On narrow or short stages, the body scrolls while the title and Done styling action remain visible. Style buttons reflow into two columns; do not require horizontal scrolling.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Speech style toolbar live-edit refinement
|
||||
|
||||
Status: **Approved; implementation complete, editor verification pending** (2026-09-29). This follow-up extends `specs/speech_style_toolbar_refinements.md` with immediate style application, a short non-blocking token effect demo, a dismissible styling toolbar, persistent hover selection feedback, and a lighter token-selection highlight.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
- Let children try style choices without confirming every chooser.
|
||||
- Preview an effect on its selected token, or all tokens in the selected bubble when no token is selected, without replaying the whole speech or blocking input.
|
||||
- Keep **Done styling** distinct from **Save speech**.
|
||||
- Preserve the selected choice outline while hovering it.
|
||||
- Keep selected word colors easy to see while making the word target obvious.
|
||||
|
||||
## 2. Immediate chooser editing
|
||||
|
||||
- Remove **Cancel** and **Done** from the Frame, Letters, and Effects choosers.
|
||||
- Apply each selected choice immediately to the composer draft. Frame affects the selected bubble. Font, size, and color affect the selected token when one is targeted, otherwise the bubble defaults; a bubble-default choice clears that property from per-word overrides so the new default reaches every word. Effects affect the selected token when targeted, otherwise all text in the selected non-empty bubble.
|
||||
- Switching to another category or clicking outside the chooser closes the current chooser and keeps all choices already made. Escape dismisses the chooser and keeps those choices too. Opening a different category opens its chooser without rolling back the previous selection.
|
||||
- Stage clicks retain their existing selection behavior: clicking a token selects it; clicking elsewhere in the usable stage clears the token target while retaining the bubble selection. Those clicks also dismiss the open chooser without undoing choices.
|
||||
- Every choice updates the composer draft and its recoverable local draft. It does **not** commit the action to the scene queue or rule. **Save speech** remains the scene commit point; the composer's existing **Cancel** still discards the speech edit session.
|
||||
- Remove chooser transaction state that exists only to support Cancel/Done rollback. Keep choice selected-state feedback and the existing saved payload format.
|
||||
|
||||
## 3. Scoped effect demonstration
|
||||
|
||||
- Selecting Shake, Wave, Bounce, or No effect applies it immediately to the current token, or to the selected bubble's default effect when no token is targeted.
|
||||
- For Shake, Wave, or Bounce, show a one-second preview on the same scope as the choice: the selected token or all tokens in the selected bubble. Other bubbles remain still. **No effect** stops the active preview immediately.
|
||||
- The preview accepts input throughout. It does not enter the full-speech PLAYING or blank-hold states, disable the toolbar, change action duration, or block stage bubble/token selection.
|
||||
- Keep the bubble frame, stack placement, and stage toolbar anchor stable throughout the one-second sample and when it ends. Refresh the animated token in place while retaining the current measured layout; typography, text, or placement edits may rebuild the layout immediately.
|
||||
- If another effect is selected before the current preview ends, stop the current animation and start the new effect from time zero on the same scope. Changing the selected token or closing the composer stops the current preview. Switching chooser categories alone does not cancel the remaining preview time.
|
||||
- When the one-second demonstration ends, the text returns to its static editor appearance; its chosen effect remains saved in the draft and plays during **Preview speech** as part of the full sequence.
|
||||
- Keep full-sequence **Preview speech** behavior unchanged, including configured transitions and the one-second blank hold.
|
||||
|
||||
### Ownership
|
||||
|
||||
`SpeechComposer` remains the source of truth for draft style choices, Message editing, and recovery data. The Message panel is a non-dimming, toolbar-anchored chooser with a highlighted Message button. It remains open while editing; clicking Message again, choosing another category, or pressing Escape closes it. The text field stays fixed as the emoji grid opens and the panel expands downward. The composer owns its text and per-token style-span maintenance. Keep the one-second effect demo in a separate transient editor-preview state, independent of the full-sequence replay clock and serialized payload. `SpeechBubble` renders that transient motion only for the selected token or selected bubble scope while remaining in static-editor interaction mode. `SandboxStage` continues to own toolbar visibility, event routing, and preview updates.
|
||||
|
||||
## 4. Done styling and re-entry
|
||||
|
||||
- Add a prominent **✓ Done styling** button to the main Speech Style toolbar, at least 56 px high.
|
||||
- It closes the style chooser and hides the Speech Style toolbar only. It does not save speech to the scene, cancel the composer, or unlock unrelated stage editing. The message editor and **Save speech** / **Cancel** controls remain available.
|
||||
- Clicking a speech bubble or its text while the composer remains open reopens the Speech Style toolbar and selects that bubble/token. Empty-stage clicks continue to clear the token target without reopening the toolbar.
|
||||
- Keep the current selected bubble and token highlight state when the toolbar is hidden. A later bubble click updates that state as usual.
|
||||
|
||||
## 5. Choice hover and token highlight
|
||||
|
||||
- A selected Frame, Font, Size, Color, or Effect choice keeps its bright selection border in normal, hover, pressed, and focus states. Hover may lighten/darken the fill but must not replace or reduce the selected border.
|
||||
- Replace the strong filled word marker with a subtle rounded target: a low-opacity fill (about 15–20%) behind the token plus a clear bright outline and a thin dark contrast edge. The text glyphs and their chosen colors stay fully visible.
|
||||
- Keep the marker input-transparent and aligned with the rendered token, including wrapped tokens and punctuation. Selection and hit-testing semantics remain unchanged.
|
||||
- Center the text horizontally and vertically within each speech frame so font and size changes do not leave the text stranded against an edge.
|
||||
|
||||
## 6. Integration scope
|
||||
|
||||
1. `scripts/speech_composer.gd`: apply selected choices directly to `_draft`, update recovery data, and manage an interruptible one-second token-effect timer separate from replay.
|
||||
2. `scripts/stickman_speech_bubble.gd` and `scripts/speech_word_effect.gd`: render the transient selected-token effect without disabling static-editor hit testing; draw the translucent rounded token marker.
|
||||
3. `scripts/speech_style_toolbar.gd`: remove chooser Cancel/Done, preserve selected borders across hover states, and add Done styling.
|
||||
4. `scripts/sandbox_stage.gd`: dismiss chooser without rollback, hide/reopen the toolbar as specified, and route transient effect-preview updates without blocking stage input.
|
||||
5. Reuse/add local `sandbox_theme.json` values as needed. Do not change global theme, speech payload schema, action IDs, queue/rule serialization, or full-sequence replay semantics.
|
||||
|
||||
## 7. Acceptance checks
|
||||
|
||||
- A chosen frame/font/size/color/effect remains applied when switching categories, dismissing the chooser, or reopening it.
|
||||
- Draft recovery includes each immediate style choice; only **Save speech** commits it to the scene, and composer **Cancel** still discards the edit session.
|
||||
- An effect choice previews only its targeted token, or all tokens in the selected bubble with no target, for about one second while text editing, category changes, and stage selection remain responsive.
|
||||
- The effect sample does not make the bubble or its styling toolbar jump as the animated glyph moves or when the sample ends.
|
||||
- With no token selected, changing a bubble's font, size, or color updates all its text, including tokens that previously had a per-word override for that property.
|
||||
- Text remains centered after changing font or size on a token or bubble.
|
||||
- Choosing another effect during that second visibly replaces the active demo without overlapping or replaying the whole speech. **No effect**, target change, and composer close stop the transient demo.
|
||||
- Full-sequence **Preview speech** still runs once with its blank hold.
|
||||
- Done styling hides the stage toolbar but leaves the composer open and stage interaction locked to speech composition. Clicking a speech bubble/token reopens the toolbar; clicking empty stage does not.
|
||||
- Hovering a selected choice never hides its selection border.
|
||||
- Selected token text remains legible in all 16 colors under a rounded, translucent marker; punctuation and wrapped tokens remain selectable.
|
||||
- Saved scene data uses the existing speech payload and reloads with selected styles intact.
|
||||
- The Message panel contains the emoji picker, blocks stage/object input while open, and leaves stage token selection as the only way to choose a styling target. It does not dim the stage or move the text field when the emoji grid opens.
|
||||
- Timing is selected from the stage toolbar but keeps the existing action-wide `duration_mode` IDs and Normal/Slow/Slower behavior; Show arrangement, Reveal, and Entrance remain in the inspector.
|
||||
|
||||
## 8. Risks and boundaries
|
||||
|
||||
The most important distinction is between saving a choice into the recoverable composer draft and committing the complete speech action into the scene. The one-second effect sample must remain independent from full speech replay, and the renderer must keep editor hit-testing enabled while a sample animates. Verify the marker with bright, dark, and similarly colored text, plus punctuation and wrapped words, in the Godot editor.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Speech style toolbar refinements
|
||||
|
||||
Status: **Approved; implementation complete, editor verification pending** (2026-09-28). This follow-up refines `specs/speech_style_toolbar.md` based on editor review. It supersedes that spec's toolbar bubble/target status, Whole bubble and Replay controls, chooser sizing, and stage input-routing details. Speech data, provisional style commits, token selection, and Save speech semantics remain as previously specified.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
- Keep the selected bubble and character visible while choosing a style.
|
||||
- Make the active style category and chooser heading easy to read.
|
||||
- Let the chooser use only the space its content needs, while remaining scrollable on small viewports.
|
||||
- Treat speech composition as an exclusive stage-editing task: bubble interactions remain available, but other scene objects cannot be selected or changed until the composer is saved or canceled.
|
||||
- Use a blank-stage click to return to whole-bubble styling without a separate button or target-status label.
|
||||
- Present replay as a preview of the whole speech sequence.
|
||||
|
||||
## 2. Recommended architecture
|
||||
|
||||
Keep the code-built `SpeechStyleToolbar` under the stage's existing `UI` CanvasLayer. It remains a transient view, and `SpeechComposer` remains the authority for draft text, style targets, provisional edits, replay, and Save speech. `SandboxStage` remains the single owner of pointer routing and toolbar placement.
|
||||
|
||||
Do not introduce a second popup implementation or change the speech payload. The current toolbar already owns one reusable chooser and the composer already owns full-sequence replay. This change refines those controls and makes stage input exclusive while the composer is open.
|
||||
|
||||
## 3. Toolbar and chooser appearance
|
||||
|
||||
### 3.1 Main toolbar
|
||||
|
||||
- Add a light-blue ribbon heading **Speech Style** above or integrated with the category row, using the same visual treatment as the inspector's section ribbons.
|
||||
- Keep **Frame**, **Letters**, and **Effects** as the primary category buttons. Use readable text (about 20–22 px) and large hit targets (at least 56 px high).
|
||||
- While a chooser is open, its category button is visibly selected with a high-contrast fill and bright outline. Only one category can be active at a time. Clear the active state when the chooser closes.
|
||||
- Remove the **Bubble N of N · “word”** status, the **Whole bubble** button, and **Replay** from this toolbar. Do not replace them with any target-status text.
|
||||
- The selected bubble's stage outline and the selected token's stage highlight remain the selection feedback. With no token highlight, Letters applies to the whole bubble.
|
||||
|
||||
### 3.2 Chooser sizing, heading, and footer
|
||||
|
||||
- Keep one chooser surface open at a time and position it beside the selected bubble when space permits. Otherwise place it above or below the bubble, keeping the bubble frame visible and staying within the usable stage bounds.
|
||||
- Size the chooser to its content. In particular, the **Frame** chooser should end shortly after its five frame cards and footer; it must not stretch down to the stage or screen bottom and leave a large empty area.
|
||||
- Give the chooser's category-specific heading (for example, **Choose a frame**) a light ribbon treatment matching the inspector's section headings.
|
||||
- Do not give the `ScrollContainer` a minimum height based on all remaining stage space. Compute the natural chooser height, then cap it to the available viewport height. Scroll only when the chooser's content exceeds that cap; the chooser heading and footer stay visible while content scrolls.
|
||||
- Make **Cancel** and **Done** at least 60 px high, center them horizontally in the footer, and leave a clear gap between them. Preserve their existing behavior: Cancel discards provisional style changes; Done applies them to the composer draft.
|
||||
- Preserve the current larger frame cards, sample previews, selected-choice borders, font/size previews, and color swatches. This refinement does not change choice values or token-style semantics.
|
||||
|
||||
## 4. Whole-bubble targeting by stage click
|
||||
|
||||
- Remove the toolbar's explicit **Whole bubble** control. A primary click/tap anywhere within the usable stage that hits no speech bubble clears the current token target and returns Letters to whole-bubble scope.
|
||||
- Keep the currently selected bubble selected and outlined when this happens. Clear its token highlight. If no token was selected, the click does not change the selected bubble.
|
||||
- A click on another speech bubble selects that bubble and clears the old token target, as it does now. A click on rendered text continues to select the tapped word or punctuation token. Overlapping speech bubbles continue to use the existing temporary bubble picker.
|
||||
- Apply the outside-click behavior to the stage area only. Clicks on the composer, toolbar, chooser, or token picker continue to belong to those controls and must not clear the target.
|
||||
- Resolve a blank-stage click on release, after checking the existing drag threshold. A drag or camera-navigation gesture must not be mistaken for a target-clearing click.
|
||||
|
||||
## 5. Exclusive stage editing while speech is open
|
||||
|
||||
While `SpeechComposer.visible` is true, speech composition owns edits on the usable stage. The stage input gate must run before normal scene selection, gizmo, prop/terrain placement, director, and context-menu handling.
|
||||
|
||||
- **Allowed speech edits:** select a speech bubble, select one of its text tokens, move a speech bubble using the existing bubble drag gesture, use the overlapping-bubble picker, and use the existing explicit speech-placement mode to place the speech group or selected speech bubble.
|
||||
- **Outside-bubble stage click:** clear the word target, keep the selected speech bubble, and consume the event. This applies even when the pointer is visually over a character or prop; do not select, move, edit, place, or open a context menu for that other object.
|
||||
- **Other stage interactions:** block them until Save speech or Cancel closes the composer. This includes character/prop selection and dragging, terrain editing, generic placement, and director/rule actions.
|
||||
- **View navigation:** retain camera-only navigation (middle-button pan, wheel zoom, pinch zoom, and trackpad pan). These gestures must not select or edit scene objects or clear a token target as if they were clicks.
|
||||
- **Composer exit:** Save speech commits through the existing queue/rule path and restores normal stage input. Cancel discards the composer draft through the existing cancellation path and restores normal stage input.
|
||||
- **Chooser dismissal:** a click outside the chooser still cancels its provisional visual preview, except tapping Save speech completes the chooser as Done before the normal save flow. If the same click lands on the stage, then apply the stage bubble/outside-bubble routing above.
|
||||
|
||||
The gate must be limited to the usable stage rectangle and must not block input to the composer or overlay controls. Speech bubble dragging and speech placement remain available because they edit the current speech draft, not unrelated scene objects.
|
||||
|
||||
## 6. Full-sequence preview
|
||||
|
||||
- Remove the toolbar's Replay button, `replay_requested` signal, and replay hit-test exception.
|
||||
- Use the existing composer replay control as the single replay entry point. Rename it **Preview speech** and place it beside **Save speech** in the composer footer/action area, with responsive wrapping at narrow widths.
|
||||
- Keep the existing `SpeechComposer.replay_preview_once()` behavior: play the complete speech sequence once, including configured bubble transitions, effects, and the one-second blank hold. Do not add a per-bubble replay behavior or alter saved duration/sequence data.
|
||||
|
||||
## 7. Implementation ownership and order
|
||||
|
||||
1. **`scripts/speech_style_toolbar.gd`** — build the Speech Style ribbon, category active states, category chooser ribbon, content-sized/max-capped chooser layout, centered large footer buttons; remove target status, Whole bubble button, and Replay UI/signal.
|
||||
2. **`scripts/speech_composer.gd`** — rename and relocate the existing full-sequence replay control next to Save speech; retain its signal and replay implementation.
|
||||
3. **`scripts/sandbox_stage.gd`** — add speech-composer stage input gating and blank-stage click resolution. Preserve bubble/token hit routing, overlap picking, bubble drag, explicit speech placement, and view-only camera navigation; prevent all normal non-speech object interactions until composer close.
|
||||
4. **`sandbox_theme.json` / theme accessors as needed** — add or reuse local theme values for ribbon colors, category selection, heading sizes, and button sizing. Do not change the global theme or serialize UI state.
|
||||
|
||||
No new `.scene` fields, speech payload fields, or action IDs are introduced.
|
||||
|
||||
## 8. Acceptance checks
|
||||
|
||||
- The Frame chooser ends after its cards and footer without a screen-height blank area. Letters and Effects also size to content; only overflowing content scrolls, with heading and footer kept visible.
|
||||
- The main toolbar and chooser headings have distinct, readable light ribbons. Frame/Letters/Effects clearly show which chooser is open. Text does not show a bubble count or token/whole-bubble status.
|
||||
- Cancel and Done are large, horizontally centered, and visibly separated. Cancel/Done preserve provisional-preview semantics.
|
||||
- Selecting a word and then clicking empty stage, the character, or a prop clears the word highlight and returns to whole-bubble scope while retaining the bubble outline. The character/prop does not become selected or move.
|
||||
- Clicking another speech bubble selects it and clears the token target; clicking its text selects a token; overlapping clicks keep the bubble picker. When a drag starts inside overlapping frames, move the smallest bubble under the pointer; if frames are the same size, move the topmost one.
|
||||
- During speech composition, generic stage object selection/editing, placement, and context actions do not run. Save speech and Cancel both restore ordinary stage interaction. Speech-placement mode and camera-only pan/zoom remain available.
|
||||
- Exactly one **Preview speech** control remains in the composer beside Save speech. It previews the full sequence once and preserves the one-second blank hold.
|
||||
- Existing save/load payloads, draft recovery, and queue/rule commit behavior are unchanged.
|
||||
|
||||
## 9. Risks and boundaries
|
||||
|
||||
The main risk is input routing order: RichTextLabel metadata clicks and the stage's deferred bubble click resolver must still select tokens before the exclusive stage gate consumes the release. The gate must distinguish click from drag and camera navigation, and must explicitly exempt speech placement. Chooser height must be measured from its content before applying the viewport cap so a scrolling child does not force the panel to fill the stage. These behaviors should be verified in the Godot editor at normal and narrow window sizes before the refinement is considered complete.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Stage Controls for How the Whole Speech Shows
|
||||
|
||||
Status: **Implemented; pending user verification** (2026-10-02). This documents the stage controls that extend the approved comic speech composer and replace the Inspector **Show** section. Bubble layout presets are specified in [Speech Bubble Layout Presets](speech_bubble_layout_presets.md).
|
||||
|
||||
> **Current dialog layout:** see the 2026-10-02 amendment at the end of this document. It supersedes older control grouping, Comic chain placement, and footer descriptions below.
|
||||
|
||||
## 1. User interaction
|
||||
|
||||
When speech editing opens, no bubble has stage focus. A compact panel beside the bubble group shows the three choices that affect the entire speech immediately. There is no **Whole Speech** button or additional mode switch.
|
||||
|
||||
| Label | Choices | Stored values |
|
||||
| --- | --- | --- |
|
||||
| 💬 **Show bubbles** | **Show all** / **One at a time** / **Comic chain** | `arrangement: "together"` / `"take_turns"` / `"comic_chain"` |
|
||||
| 🔤 **Words appear** | **All at once** / **Letter by letter** / **Word by word** | action `reveal: "instant"` / `"letter"` / `"word"` |
|
||||
| ✨ **Bubble motion** | **Bounce in** / **Just appear** | action `pop_in: true` / `false` |
|
||||
|
||||
Use large buttons with a persistent selected outline that remains visible on hover, a ribbon title such as **Show speech**, and optional small pictorial samples. A choice updates the recoverable speech draft and selection feedback immediately. Comic chain is cumulative: each bubble appears in order while earlier bubbles remain visible, and joined connectors are shown. Entering it applies the Stack positions; the separate Stack and Same spot actions only position bubbles. Choosing Show all or One at a time leaves Comic chain. **Preview speech** plays the full sequence with the chosen arrangement, reveal, and motion. The separate **Save speech** action remains the queue/rule commit point; Cancel discards the session.
|
||||
|
||||
Clicking a bubble or one of its tokens focuses that bubble and swaps the group panel for its existing **Speech Style** toolbar. The token is also selected when one was tapped. **Done styling** and a click on empty stage space clear bubble focus and restore the group panel. Empty stage clicks do not select, move, or edit characters or props beneath them. Adding a bubble still selects the new bubble, as previously approved; after **Done styling**, the group panel returns. A bubble drag focuses that bubble once the drag threshold is met. Clicking overlapping bubbles retains the existing temporary picker.
|
||||
|
||||
The group panel sits beside the visible bubble group and follows it after bubble movement, camera changes, resize, and text changes. Prefer a side with enough room; otherwise try the other side, then below or above. Clamp the panel within the usable stage rectangle and keep it off the bubbles whenever space permits. Use the existing stage theme, ribbon, 44 px minimum controls, and vertical wrapping; do not introduce horizontal stage scrolling. During a full replay or message dialog, hide or disable the group panel consistently with the current Speech Style toolbar. No group highlight overlay is required; the group title and three choice rows communicate scope.
|
||||
|
||||
```text
|
||||
Speech editor opened / Done styling / empty stage click
|
||||
┌──────────────────────┐ ┌────────────────────────────┐
|
||||
│ Bubble group │ │ Show speech │
|
||||
│ [Hi!] [Everyone!] │ │ 💬 Show bubbles │
|
||||
│ │ │ 🔤 Words appear │
|
||||
│ │ │ ✨ Bubble motion │
|
||||
└──────────────────────┘ └────────────────────────────┘
|
||||
│ click a bubble or token
|
||||
▼
|
||||
┌──────────────────────┐ ┌────────────────────────────┐
|
||||
│ Selected bubble │ │ Speech Style │
|
||||
│ [Hi!] │ │ Message · Timing │
|
||||
│ │ │ Frame · Letters · Effects │
|
||||
│ │ │ Done styling │
|
||||
└──────────────────────┘ └────────────────────────────┘
|
||||
```
|
||||
|
||||
The existing **Timing** control remains in Speech Style for this change. It already sets the action-wide `duration_mode`; this specification changes only the three former Show controls.
|
||||
|
||||
## 2. Ownership, nodes, and typed interfaces
|
||||
|
||||
Keep `SpeechComposer` (`class_name SpeechComposer extends Control`) as the owner of the draft and text editing. Its `_selected_bubble: int` remains a valid index for the Message editor and draft recovery. Add a separate transient `_stage_focus_bubble: int = -1`; `-1` means the group panel is shown. Focus is UI state and is not serialized. Add typed methods such as:
|
||||
|
||||
```gdscript
|
||||
signal stage_focus_changed(bubble_index: int) # -1 means the whole speech
|
||||
|
||||
func stage_focus_bubble_index() -> int
|
||||
func clear_stage_focus() -> void
|
||||
func choose_speech_show(key: StringName, value: Variant) -> void
|
||||
```
|
||||
|
||||
`choose_speech_show` accepts only `&"arrangement"`, `&"reveal"`, and `&"pop_in"`; it validates each value against `SpeechPayload` constants, updates one action-level field, synchronizes the per-bubble render fields, recalculates duration through the existing `_emit_draft()`, and emits one `draft_changed(action: Dictionary)`. Selecting Comic chain applies measured Stack centers and the arrangement together in one draft update. It must not accidentally overwrite the Message TextEdit content; preserve `_save_active_editor_state()` ordering. Existing `select_bubble_from_stage`, `select_bubble_token_from_stage`, and `_add_bubble` set stage focus. `open_for` and `set_draft` start with no stage focus; recovered `_selected_bubble` can still restore editor position independently.
|
||||
|
||||
Add a lightweight `SpeechShowToolbar` (`class_name SpeechShowToolbar extends Control`) as a sibling of `SpeechStyleToolbar` under the Sandbox Stage UI control. Its full-rect root ignores mouse input while its visible `PanelContainer` stops mouse input. Suggested public contract:
|
||||
|
||||
```gdscript
|
||||
signal choice_selected(key: StringName, value: Variant)
|
||||
|
||||
func configure(theme_data: SandboxTheme) -> void
|
||||
func set_context(action: Dictionary) -> void
|
||||
func set_anchor_rect(group_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void
|
||||
func set_busy(busy: bool) -> void
|
||||
func contains_viewport_point(point: Vector2) -> bool
|
||||
```
|
||||
|
||||
The toolbar only emits choices; it does not mutate a draft or save a scene. Its hierarchy is `SpeechShowToolbar / PanelContainer / VBoxContainer / ribbon Label + three labelled HFlowContainer choice rows`. Put its `PanelContainer` in the same stage UI layer as Speech Style. Reuse `SandboxTheme` colours, button sizes, and selected/hover border treatment.
|
||||
|
||||
`SpeechBubble` (`class_name SpeechBubble extends Node2D`) should expose `func group_frames_viewport_rect() -> Rect2`, the union of its rendered frame rectangles in viewport coordinates, using the same `_layout_frame` and canvas transform already used by `selected_frame_viewport_rect(index)`. Return an empty rect when no static editor frames exist. This keeps screen geometry in the renderer rather than recomputing it from text or actor positions in the toolbar.
|
||||
|
||||
`SandboxStage` owns focus presentation and pointer routing. It connects `SpeechShowToolbar.choice_selected` to the composer's `choose_speech_show`, and `SpeechComposer.stage_focus_changed` to a single refresh path that shows exactly one of the group panel and Speech Style toolbar. `_speech_composer_owns_event` must include the group panel's visible rect so a click on a choice cannot pass through to stage bubble selection. The existing `_resolve_speech_bubble_pointer_click` empty-hit path calls `clear_stage_focus()` instead of only clearing the token. The **Done styling** handler also calls `clear_stage_focus()` rather than merely hiding Speech Style. Both panels use the current busy/preview gate, and stage cleanup hides both. The current `_speech_style_toolbar_dismissed` flag should be retired or derived from focus, because an independent hidden state can disagree with the new panel swap.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P[Stage pointer] --> S[SandboxStage]
|
||||
S -->|bubble/token click or empty click| C[SpeechComposer focus]
|
||||
C -->|stage_focus_changed| S
|
||||
S -->|focus = -1| G[SpeechShowToolbar]
|
||||
S -->|focus >= 0| B[SpeechStyleToolbar]
|
||||
G -->|choice_selected| S
|
||||
S -->|choose_speech_show| C
|
||||
C -->|draft_changed| S
|
||||
S --> R[SpeechBubble preview]
|
||||
```
|
||||
|
||||
## 3. Action payload and migration
|
||||
|
||||
Use action-level fields as the source of truth for all three Show choices. Keep the existing `arrangement` field and add `reveal` and `pop_in` at the action level. The bubble `visual.reveal` and `visual.pop_in` keys remain synchronized render copies for the existing `SpeechBubble` and `SpeechPayload.duration_for()` paths; they are never independent editing controls after this change.
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"type": "speak",
|
||||
"arrangement": "take_turns", # or "together" / "comic_chain"
|
||||
"reveal": "letter",
|
||||
"pop_in": true,
|
||||
"duration_mode": "just_enough",
|
||||
"bubbles": [
|
||||
{"text": "Hi!", "visual": {"bubble_style": "talk", "reveal": "letter", "pop_in": true}},
|
||||
{"text": "Everyone!", "visual": {"bubble_style": "shout", "reveal": "letter", "pop_in": true}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`SpeechPayload.normalize(raw: Dictionary) -> Dictionary` determines shared `reveal` and `pop_in` in this order: valid action-level values; otherwise the first bubble's visual values; otherwise the legacy single-text `visual`; otherwise `"instant"` and `false`. It writes the chosen values to the action and every normalized bubble visual. The same first-bubble rule deliberately resolves older multi-bubble actions whose per-bubble values differ. Loading such an action into editing and saving it makes all bubbles use the first bubble's reveal and motion. Preserve the first bubble's other visual properties and all bubble placements. `arrangement` accepts `together`, `take_turns`, and `comic_chain`, defaulting to `together`. Older `bubble_layout: "comic_chain"` values migrate to the canonical arrangement.
|
||||
|
||||
`SpeechPayload.validate` accepts old actions without the new top-level keys, and validates the keys when present (`reveal` in `REVEAL_MODES`, `pop_in` a bool). The scene decoder currently validates before it normalizes, so this backward compatibility is required there. `StageSceneIO._encode_speech` includes the new action-level fields in its explicit speech allowlist and writes a normalized speech action with synchronized bubble visuals. `_decode_speech` continues to accept pre-change scenes and returns the normalized action. `ActionRegistry.to_rule_action` and `from_rule_action` add `reveal` and `pop_in` to their explicit speak field lists; this prevents event-rule speech from silently losing the choices. Draft JSON uses the full action dictionary and therefore carries the fields naturally. No `.scene` file version bump is needed because all new keys are optional to the decoder.
|
||||
|
||||
`SpeechComposer._new_bubble` copies action-level `reveal` and `pop_in` into its visual; `SpeechPayload.normalize` enforces the same contract for actions created elsewhere. `SpeechPayload.duration_for()` should use the action-level reveal value when present and the first bubble fallback for legacy actions, or consume its synchronized normalized bubble copies. `SpeechBubble.render_action()` already normalizes before building labels and entrance scales, so runtime and editor preview share the same choices. Avoid calling `normalize` once per frame inside each individual bubble render; normalize once at the action boundary as it does today.
|
||||
|
||||
## 4. Lifecycle and acceptance
|
||||
|
||||
1. Open a new speech or a saved speech: the group panel appears beside the group; no bubble is dimmed or highlighted as selected. Click a bubble or token: group panel hides and Speech Style appears. **Done styling** or empty stage click returns to the group panel, without changing the draft's text or positions.
|
||||
2. Choose each Bubbles, Words appear, and Bubble motion option: the selected border persists on hover; all existing bubbles use that choice on **Preview speech**. Add another bubble and preview again: the new bubble uses the same reveal and motion, regardless of which bubble was previously edited.
|
||||
3. Save speech, save the scene, reload it, and reopen queue editing: the three choices are selected correctly and playback matches. The same fields survive a reactive rule round trip and recoverable draft restore.
|
||||
4. Load a pre-change scene with mixed per-bubble reveal or motion values: the group panel reflects the first bubble, and every bubble follows that choice when edited or replayed. Existing legacy single-text actions still load and play; old scenes are not rejected for missing action-level fields.
|
||||
5. On a narrow stage, drag a bubble, pan/zoom the camera, and resize the window: the panel remains adjacent when space allows, stays within stage bounds, and does not obscure its choice labels. Its buttons do not trigger a bubble click or an object interaction behind it.
|
||||
6. During a full replay and while Message is open, group choices cannot be changed accidentally. Save speech and Cancel retain their current commit/discard semantics, and the inspector no longer displays the Show card.
|
||||
|
||||
## 5. Tradeoffs
|
||||
|
||||
- Keeping synchronized `visual.reveal` and `visual.pop_in` copies avoids a broad renderer rewrite but duplicates two values in saved JSON. `SpeechPayload.normalize()` must remain the single synchronization boundary; future speech writers should use it before serialization.
|
||||
- Migrating mixed legacy bubbles from the first bubble makes the new group panel truthful and deterministic. It intentionally removes later bubbles' distinct reveal/motion choices when they are normalized into this speech-wide model.
|
||||
- A dedicated stage panel makes the group scope visible without adding a button or mode, at the cost of one more transient Control node and adaptive placement logic. A separate focus index preserves existing Message and draft recovery behavior.
|
||||
|
||||
## 6. Dialog layout amendment (2026-10-02)
|
||||
|
||||
Keep the whole-speech controls visually distinct from per-bubble styling:
|
||||
|
||||
- **Show bubbles** has only **Show all** and **One at a time**.
|
||||
- **Bubble layout** has **Stack** and **Same spot**.
|
||||
- **Connect bubbles** is a separate on/off switch. Turning it on selects Stack if needed; choosing Same spot turns it off. It is available only when there are at least two bubbles.
|
||||
- With One at a time and Connect bubbles on, reveal one new bubble per step while keeping previous bubbles visible as part of the chain. With Show all, all connected bubbles are visible together.
|
||||
- **More speech settings** starts collapsed and contains Words appear, Bubble motion, Timing, and the reactive-only Stop current action option.
|
||||
- **Preview speech**, **Done speech**, and **Cancel speech** stay in a footer outside the scrolling content. Done speech commits the speech action; Cancel discards the editing session.
|
||||
- The panel stays beside the bubble group when space allows, otherwise moves below or above it. Cap its width to preserve stage visibility; scroll the settings content on short stages while keeping the footer reachable.
|
||||
|
||||
```text
|
||||
┌──────────────────────────────┐
|
||||
│ Show speech │
|
||||
│ Show bubbles │
|
||||
│ [Show all] [One at a time] │
|
||||
│ Bubble layout │
|
||||
│ [Stack] [Same spot] │
|
||||
│ 🔗 Connect bubbles [Off/On] │
|
||||
│ ▸ More speech settings │
|
||||
│ │
|
||||
│ [▶ Preview] [Done] [Cancel] │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
Reference in New Issue
Block a user