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:
2026-10-03 14:49:22 -04:00
parent 6a1359f244
commit 6666173c4f
42 changed files with 9163 additions and 165 deletions
+59
View File
@@ -0,0 +1,59 @@
name = "uiux"
description = "You are the UI/UX Lead for a kid-friendly Godot 2D sandbox editor. Your goal is to eliminate cognitive load, prevent user frustration, and make every interaction feel like a toy. You prioritize visual feedback over written text, big touchable controls, vibrant colors, and forgiving workflows."
model = "gpt-6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
## 1. Kid-Friendly UI Guardrails (10-Year-Old Persona)
* **Touch & Click Target Rules:**
* Minimum pointer hit-target: **44×44 logical pixels** (never build tiny 16px/24px buttons).
* Control heights (buttons, dropdowns, rows) must obey `button_min_height` tokens from the theme.
* **Plain-Language Copy Guidelines:**
* Never use technical, developer, or geometry jargon.
* Use active, friendly plain language:
* ❌ "Add Entity" ➔ ✅ "Add a Shape"
* ❌ "Delete" / "Remove" ➔ ✅ "Erase"
* ❌ "Mirror X" / "Flip Horizontal" ➔ ✅ "Flip Left-Right"
* ❌ "Interrupt Locomotion" ➔ ✅ "Stop what he's doing?"
* **Cursor-First & On-Canvas Feedback:**
* **Eyes stay on the mouse:** Avoid putting crucial action instructions in top headers or bottom status bars where kids won't read them.
* **Floating Tooltip Pills:** Attach high-contrast instruction badges directly to the mouse pointer (e.g., `🚩 Click to set walk target`).
* **Trajectory & Ghost Guides:** When placing objects or setting paths, always render continuous rubber-band lines (green = valid, red = blocked) and semi-transparent ghost previews under the cursor.
* **Context Cursors:** Swap the default mouse arrow to dynamic action reticles, flags, or wand cursors when awaiting clicks.
* **Visual Mode Clarity:**
* Use high-contrast color frames and mode pills so users never have to guess what state they are in:
* ✏️ **EDIT Mode:** Cyan / Blue accents, active grid visible.
* 🎬 **DIRECT Mode:** Amber / Gold accents, camera frame overlay, gizmos tint gold.
* ▶️ **PLAY Mode:** Green accents, grid hidden, clean viewport preview.
## 2. Godot 4.7.1 Architectural Requirements
* **Theme System Compliance:**
* UI layout must consume `SandboxTheme` / `PropTheme` JSON contracts rather than hardcoded UI colors or font constants.
* Dynamic layouts must use `FlowContainer` for button wraps or `VBoxContainer`/`HBoxContainer` with proper minimum heights to avoid clipping when font sizes are raised.
* Dialogs and popups must explicitly receive `theme.control_theme()` to respect window borders.
* **Accessible Inputs:**
* Prefer simple explicit buttons (e.g., ⬆️ / ⬇️ Move Up/Down) over drag-and-drop reordering inside popups for accessibility and ease of testing.
* Always provide prominent, clickable `[X Cancel]` buttons on active workflows so users don't need to know the `Esc` key.
## 3. Pipeline Output Protocol
When invoked during Phase 0a (UI/UX Proposal & Alternatives):
1. Analyze the initial proposal for usability hurdles, tiny click targets, or hidden menus.
2. Pitch 1 to 3 alternate visual/interaction approaches (e.g., compare floating cursor badges vs. top notification bars).
3. Mark your recommended choice with [Recommended] and ask up to 3 simple clarification questions.
4. STOP and wait for user feedback.
When invoked during Phase 0b (UI Spec Generation):
Output a structured spec covering:
* Control Layout & Node Structure (Containers, sizing rules, button groups)
* Theme & Color Tokens (Active state colors, badge accents)
* Cursor & Canvas Feedback (Floating labels, ghost markers, trajectory lines)
* Kid-Friendly Copy Map (Exact string replacements for all visual labels)
"""
+22 -12
View File
@@ -1,28 +1,38 @@
---
name: spec-pipeline
description: "Lean implementation loop: Architect (Clarify -> Spec) -> Developer -> User Verification."
description: "Lean implementation loop: UI/UX (Ideas -> Spec) -> Architect (Clarify -> Spec) -> Developer -> User Verification."
---
## Execution Logic
1. **Phase 1a: Architecture Proposal & Clarification**
- `@architect` analyzes existing patterns, pitches 1–3 Godot best-practice ideas, and asks up to 3 clarification questions with `[Recommended]` options.
1. **Phase 0a: UI/UX Proposal & Alternative Ideas (User-Facing Features Only)**
- `@uiux` analyzes the user's initial idea for cognitive friction, accessibility, and kid-friendliness.
- `@uiux` pitches **1–3 alternative interaction approaches** (e.g., comparing floating tooltips vs. top-center banners, custom cursor reticles, or streamlined dialogs).
- `@uiux` highlights the most intuitive option with `[Recommended]` and asks up to 3 clarification questions.
- `@uiux` **STOP:** Waits for user feedback/selection before writing the UI spec.
- _Bypass Rule:_ If the feature is strictly internal engine/backend logic (e.g., physics, serialization, scanning), skip directly to Phase 1a.
2. **Phase 0b: UI Spec Generation**
- User approves an option or provides feedback.
- `@uiux` outputs the UI/UX specification (control layouts, widget behaviors, visual feedback states, and cursor logic).
3. **Phase 1a: Architecture Proposal & Clarification**
- `@architect` analyzes existing patterns and the Phase 0b UI/UX specification.
- `@architect` pitches 1–3 Godot best-practice implementation options and asks up to 3 clarification questions with `[Recommended]` choices.
- `@architect` stops without writing the spec file yet.
2. **Phase 1b: User Feedback & Spec Generation**
4. **Phase 1b: User Feedback & Spec Generation**
- User approves, modifies, or selects choices from `@architect`'s proposal.
- `@architect` writes the finalized specification to `specs/[feature-name].md` and logs decisions in `docs/tech_debt_and_optimizations.md`.
- **STOP:** Wait for final user sign-off on the spec before invoking developer.
3. **Phase 2: Implementation & Engine Validation**
- `@developer` updates code and scenes using targeted diffs/snippets based on `specs/[feature-name].md`.
- `@developer` runs headless syntax verification:
`& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://path/to/script.gd" --path "C:\Godot4\project"`
5. **Phase 2: Implementation & Engine Validation**
- `@developer` updates code and scenes using targeted diffs based on `specs/[feature-name].md`.
- `@developer` runs headless syntax verification.
- If static check fails, `@developer` fixes the code immediately.
4. **Phase 3: User Hand-off (Stop)**
6. **Phase 3: User Hand-off (Stop)**
- Present the changes to the user to test inside the Godot Editor.
- Do NOT update READMEs or documentation yet.
5. **Phase 4: Documentation (Triggered manually by user)**
- Only call `@writer` after the user confirms: _"Feature verified and working."_
7. **Phase 4: Documentation (Triggered manually by user)**
- Only call `@writer` after the user confirms: "Feature verified and working."
+63
View File
@@ -0,0 +1,63 @@
# Speech System — As Built
**Updated:** 2026-10-03
This is the current implementation overview for speech authoring and playback. It records the reviewed UX and the behavior present in the stage scripts. The detailed specifications linked below provide payload and layout specifics; older plans and superseded proposals are historical context, not the current UI contract.
## Editor flow
`SandboxStage` opens a speech editing session anchored to the character and keeps a live bubble preview on the stage. `SpeechComposer` owns the editable draft, selected bubble, Message editor, recovery data, and preview clock. Stage-side controls are split by scope:
| Control | Scope | Current behavior |
| --- | --- | --- |
| **Speech Style** | Selected bubble | Message, Frame, Letters, and Effects; selecting a stage word targets that word, otherwise text/font/size/color/effect choices apply to the bubble. |
| **Show speech** | Whole sequence | Show all or One at a time, Stack or Same spot placement actions, and a separate Connect bubbles toggle. More speech settings contains reveal, entrance motion, timing, and Keep acting while talking. |
| **Preview speech** | Whole sequence | Plays the current draft on the stage without committing it. |
| **Done styling** | Style controls | Hides Speech Style while leaving the speech session open. |
| **Done speech / Cancel speech** | Editing session | Done commits the validated draft to its queue/rule destination. Cancel discards the session, with confirmation for changed drafts. |
Draft changes are recoverable while editing. They do not mutate the queued action until Done speech. Scene saving remains a separate operation after the in-memory speech commit.
## Bubble editing and presentation
- Each bubble has its own message, frame, font, size, text color, effects, and stage position. Numbered stage badges select bubbles; Earlier/Later changes their reading order without changing placement.
- Add bubble inserts after the selected bubble and carries over its bubble-level appearance defaults. The current cap is eight bubbles, with a hint at five. Remove is unavailable for the last bubble; Undo appears only after a removal and restores that bubble.
- Stack arranges the sequence upward from Bubble 1. Same spot aligns bubbles to Bubble 1's center. These are placement actions; Connect bubbles is a separate on/off presentation setting. With connections on, the sequence builds cumulatively: Bubble 1 points to the character and later balloons attach to the prior bubble. Think uses thought dots; SFX has no connector.
- Show all and One at a time remain the sequence visibility choices. Reveal supports all at once, letter by letter, and word by word. Entrance motion is Bounce in or Just appear; timing is Normal, Slow, or Slower. Reactive speech can use Keep acting while talking.
- The Message sub-dialog is attached to Speech Style and does not dim the stage. Its text field stays in place as the emoji grid opens and the dialog grows downward. Opening Message puts the caret at the end; emoji inserts at the caret.
- Text edits preserve valid word styling: edits within styled words retain styling on remaining/replacement content; inserted text inherits the preceding token's style, or the following token's style at the start. Bubble-level controls change only the selected bubble unless a stage word is targeted.
- Talk, Shout, and Whisper render a continuous filled body-and-tail contour. Think uses dots; SFX is tailless. Text is measured against the selected font and size with added inner padding, including larger Shout padding. Preview and runtime use the same generated bubble geometry; no external bubble SVG is required.
- Speech order badges remain screen-sized across camera zoom and do not move when selection changes. Crowded badges are sorted into a row/rail; intersecting bubble frames form a sorted row above the largest frame when space allows. Speech panels avoid the resolved badge bounds.
- Bubble dragging hit-tests the smallest bubble under the pointer, breaking equal-size ties in favor of the topmost bubble. A click where bubbles overlap still opens the bubble picker; clicking a badge selects that numbered bubble and brings it forward for editing.
## Data and rendering flow
Speech is stored as a normalized action `Dictionary` with action-wide sequence settings and an ordered `bubbles[]` array. Each bubble stores plain text, visual defaults, optional whole-word style spans, and placement. `SpeechPayload` validates and normalizes data before the stage preview, queue/rule routing, scene serialization, or runtime playback. Legacy single-text speech remains loadable; legacy comic-chain payloads are normalized to the current connection setting.
The editor preview and runtime playback share `SpeechBubble`. `StickmanActionRunner` owns runtime elapsed time and speech completion; `StageSceneIO` and `ActionRegistry` preserve speech fields in saved scenes and rule actions. The editor preview is separate from runner playback and does not advance or replace runtime speech state.
## Main implementation files
| File | Responsibility |
| --- | --- |
| `scripts/speech_composer.gd`, `scenes/speech_composer.tscn` | Draft editing, bubble management, Message/Emoji UI, recovery, style context, and preview state. |
| `scripts/speech_style_toolbar.gd` | Bubble-scoped stage controls and attached choosers. |
| `scripts/speech_show_toolbar.gd` | Whole-sequence controls, layout presets, preview/done/cancel, and More settings. |
| `scripts/speech_payload.gd` | Payload defaults, validation, migration, word spans, and duration calculation. |
| `scripts/stickman_speech_bubble.gd` | Shared preview/playback renderer, bubble measurement and geometry, word targeting, order badges, and bubble hit testing. |
| `scripts/sandbox_stage.gd` | Session lifecycle, stage input routing, toolbar placement, badge avoidance, and queue/rule commit. |
| `scripts/stickman_action_runner.gd` | Runtime speech clocks and bubble ownership. |
| `scripts/stage_scene_io.gd`, `scripts/action_registry.gd` | Scene and rule serialization adapters. |
| `scripts/sandbox_theme.gd`, `sandbox_theme.json`, `resources/fonts/` | Theme-backed speech font presets and per-font base sizes; Comic, Pixel, and Scary font assets are included. |
## Detailed behavior specifications
- [Whole-sequence Show controls](../specs/speech_whole_sequence_show.md)
- [Speech Style toolbar](../specs/speech_style_toolbar.md), [live editing](../specs/speech_style_toolbar_live_edit.md), and [Message/Emoji/Timing](../specs/speech_message_emoji_timing.md)
- [Bubble layout presets](../specs/speech_bubble_layout_presets.md) and [bubble visuals](../specs/speech_bubble_visuals.md)
- [Badge stability, grouping, and toolbar avoidance](../specs/speech_badge_stability.md)
- [Phase 13 payload and playback details](../specs/phase_13_speech_bubbles.md)
## Validation status
The user reviewed the recent badge clustering and overlapping-bubble drag behavior and reported that the speech editor looked good. Focused Godot syntax checks and `git diff --check` passed for those latest fixes. The saved `game_scenes/talk.scene` had also loaded through the Sandbox Stage CLI without a skipped-speech warning during the earlier Phase 13 verification (2026-09-28). This does not claim a complete automated or GUI regression pass for every speech control and runtime mode.
+25
View File
@@ -62,6 +62,13 @@ This document tracks known technical debt, optimization opportunities, and minor
| 50 | **Phase 12 paused-edit autosave stores authored state, not the frozen snapshot** — queue/rule edits occur at `t > 0` while stage bodies are at temporary physical transforms. | Medium | ✅ Resolved | A 1.0 s debounce writes the existing `.scene` schema using authored object transforms plus current queues/rules. The timer can finish after Resume; runtime pose, velocities, reactive consequences, and action history are session-only. Paused edits therefore autosave without turning the live pose into the authored starting layout. (2026-09-24) |
| 51 | **Phase 11 throw distance and speed are shared across prop types** — the landing-target solver does not yet tune throwability by material or weight. | Low | Open | Deliberately deferred from the approved landing-target amendment. If playtesting needs it, add an explicit gameplay throwability factor based on prop weight/material; do not rely on rigid-body mass to change gravity or mutate physical mass for throw balance. Current shared limits are a 3,600 px target radius and 2,100 px/s speed cap. (2026-09-25) |
| 52 | **Throw target preview ignores earlier queued pickups** — the live preview requires `_held_prop`, so editing a pending throw after a pending pickup at `t = 0` cannot show the actual planned throw arc. | Medium | ✅ Resolved | The preview now infers a virtual held-prop state from the authored queue prefix and provides a pure, non-mutating planning trajectory using the prop/grip and predicted pickup stand-off. When a prop cannot be resolved, it shows a neutral explanation; runtime still recomputes from actual state. User verified the result. (2026-09-25) |
| 53 | **Phase 13 speech stacks may exceed very small stage viewports** — long simultaneous stacks compete with the side composer and stage chrome. | Low | Planned | The spec sets defensive eight-card and per-card text bounds, keeps frames near a stable screen size, fits the displayed group within the visible stage area without changing saved character-relative placement, and requires a visible overflow cue when a stack cannot fit legibly. Responsive paging for unusually small windows is deferred until observed in editor playtesting. See `specs/phase_13_speech_bubbles.md`. (2026-09-26) |
| 54 | **Phase 13 font samples vary by platform until fonts are bundled** — OS `SystemFont` aliases can resolve Comic/Pixel/Scary choices to different families or generic fallbacks. | Low | Deferred | User explicitly deferred font bundling. Keep system-font previews now; a later task can add visually distinct, redistributable fonts with license/attribution files and point the speech presets to them. See `specs/phase_13_speech_bubbles.md`. (2026-09-27) |
| 55 | **Speech styling has no general Undo history** — style choices update the recoverable draft immediately, but committed text, bubble order/placement, and styling share no undo stack. | Medium | Deferred | User chose immediate style application and deferred Undo for this release. Save speech commits the complete draft to the scene; composer Cancel discards the whole edit session. A future undo command boundary should cover the whole speech draft; token offsets must remain coherent after text edits. See `specs/speech_style_toolbar_live_edit.md`. (2026-09-29) |
| 56 | **RichTextLabel has no dependable per-token 44 px hit rectangles for mixed-font speech** — exact metadata clicks can target narrow punctuation only at its rendered glyph. | Low | Planned | Use a temporary picker of tokens on the tapped rendered line, each with a 44 × 44 px hit area, while retaining exact metadata taps for comfortable words. Avoid estimating glyph boxes from raw string widths because shaping and wrapping can disagree. Revisit a dedicated token renderer only if editor playtesting shows the picker is insufficient. See `specs/speech_style_toolbar.md`. (2026-09-28) |
| 57 | **Speech composition must temporarily own stage input** — normal scene controls can otherwise select or alter characters and props while the user is editing speech. | Medium | Planned | Gate stage object interactions while the composer is open; keep speech bubble selection/drag, explicit speech placement, and camera-only navigation available. A non-bubble stage click clears the token target but must not activate the object underneath. Verify deferred token clicks and drag thresholds. See `specs/speech_style_toolbar_refinements.md`. (2026-09-28) |
| 58 | **Speech-wide Show settings retain per-bubble render copies** — action-level reveal and motion settings are mirrored into every bubble visual for the current renderer and duration calculations. | Low | Planned | `SpeechPayload.normalize()` is the synchronization boundary; scene and rule encoders must retain the new action-level fields. Older mixed bubble settings intentionally migrate from the first bubble when edited, so later distinct motion/reveal choices are no longer expressible in the group model. If a future renderer reads action-level settings directly, the duplicate visual keys can be removed in a versioned payload migration. See `specs/speech_whole_sequence_show.md`. (2026-09-30) |
| 59 | **Stage-only speech edits remain drafts until session completion** — a recoverable draft can differ from its queue/rule target while the child is styling. | Medium | Planned | Done speech is the single validated in-memory commit boundary. A Scene Save request during composition is deferred until the user explicitly completes speech; it never commits implicitly or writes the stale queue/rule target. Cancel speech confirms before discarding an edited draft. This avoids premature persistence of blank bubbles and stale scene data. Recovery remains draft-only. See `specs/speech_stage_only_editor.md`. (2026-09-30) |
---
@@ -118,7 +125,25 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-25 | Phase 11 throw UX amendment approved (`specs/phase_11_prop_interactions.md` §1.1): treat the saved cursor point as aim plus power (distance maps smoothly to a 300–1,250 px/s capped speed), preview the collision-limited ballistic impact, and replace the short windup with a reach-clamped overhand arc that releases during its forward sweep. The existing `{type, target_position}` queue payload remains unchanged. |
| 2026-09-25 | Phase 11 throw landing-target amendment approved (`specs/phase_11_prop_interactions.md` §1.2): clamp the cursor's effective target to 800 px from the stickman root, solve the fastest valid descending trajectory under a 1,250 px/s cap, distinguish the raw cursor/effective target/early collision markers, and preserve the fixed world-space queue payload. Open-air targets remain valid; prop weight/material throwability is deferred under #51. |
| 2026-09-25 | Phase 11 throw-range tuning updated after user feedback: target radius 3,600 px and speed cap 2,100 px/s. The queue-aware, non-mutating target-preview amendment (`specs/phase_11_prop_interactions.md` §1.3) was approved, implemented, and user verified; row #52 is resolved. Throw limits remain shared across prop types under open row #51. |
| 2026-09-26 | Phase 13 comic speech specification drafted for user sign-off (`specs/phase_13_speech_bubbles.md`). Approved choices: a side composer with a live character-attached preview; simultaneous stacks by default or one-at-a-time transitions; automatic reading time with optional longer choices; character-relative placement; and reactive speech that temporarily takes visual priority, then resumes queued speech at its saved time. One tap targets one whole word; effect, font, and color controls apply to that word, while font/color controls edit bubble defaults with no target. The target remains visible while controls take focus; separate tapped words retain separate safe, validated style records. Drag highlighting, Shift-click, and multiselect/append are outside the interaction. The architecture uses one normalized Dictionary payload across queue/rule JSON, a shared RichTextLabel renderer, and runner-owned elapsed clocks for pause-safe replay. It preserves legacy `{text,duration}` actions. Logged #53 for tiny-viewport stack fit; arbitrary BBCode and per-bubble Resource files are deliberate scope exclusions, not open defects. |
| 2026-09-27 | Phase 13 composer refinement written for final user sign-off (`specs/phase_13_speech_bubbles.md`): one active text editor with high-contrast snippet tabs and a vertical-only responsive inspector; example frame/font/size controls; 16 named text-color swatches; graphical whole-word target; separate Effects/Show/Timing groups; Show all/One at a time and Normal/Slow/Slower labels mapped to existing serialized IDs; clarified Bounce in/Simply appear; no placement arrows or auto-seconds label; live-head triangular tails with no line crossing the character; and a full replay followed by a one-second blank stage. User chose recoverable local drafts while retaining **Save speech** as the commit point. Draft recovery never mutates queues/rules/scene state; Save commits and clears, Cancel discards and clears, and stale targets recover only as an unbound draft pending an explicit destination. |
| 2026-09-27 | User approved the Phase 13 composer refinement for implementation. Font binaries are explicitly deferred; keep OS system-font aliases for the sample buttons and retain #54 as deferred. |
| 2026-09-27 | User approved the Phase 13 follow-up (`specs/phase_13_speech_bubbles.md` §9): keep core inspector cards open with larger ribbon headers; clarify word targeting; fix full-word markers and selected-control states; give bubbles independent character-local placement and an above-character stagger that never fits downward across the actor; route scrolling to the composer under the pointer; and preserve valid speech through queue save/load while rejecting partial scene saves. Code is implemented, syntax checks passed, and in-editor verification is pending. |
| 2026-09-27 | Phase 13 verification follow-up: strengthened rounded word-marker coverage/clipping, added per-word size styling alongside font and color, and made speech commits confirm that the action actually entered the character queue before closing the composer. Syntax checks passed; verify save/reload and marker behavior in-editor. |
| 2026-09-28 | Fixed Phase 13 scene-load rejection for saved per-word styles: JSON parsing yields whole-number offsets as floats, while speech validation required `int`. Validation now accepts finite integral numeric offsets and normalization stores them as integers. `game_scenes/talk.scene` loaded through the Sandbox Stage CLI without a skipped-speech warning; the scene file was not modified. |
| 2026-09-28 | Speech styling toolbar architecture spec written for final user sign-off (`specs/speech_style_toolbar.md`). The stage-anchored Frame/Letters/Effects toolbar uses one theme-backed chooser and a provisional visual copy: Done commits a style to the composer draft; Cancel discards it without affecting recovery. A shared token scanner extends saved half-open style offsets to punctuation, and a temporary rendered-line token picker provides 44 px choices when glyphs are cramped. The inspector keeps Message and Save speech. User deferred general Undo; logged #55 and #56. No implementation code was changed in this phase. |
| 2026-09-28 | Speech style toolbar refinements approved and implemented (`specs/speech_style_toolbar_refinements.md`). The chooser now sizes to content with overflow scrolling, ribbon headings, active categories, and centered large buttons; stage clicks clear the token target while preserving bubble selection, and speech composition blocks unrelated scene edits while retaining bubble controls and camera navigation. The full-sequence control is **Preview speech** beside Save speech. Godot headless syntax checks pass; in-editor verification is pending. |
| 2026-09-29 | Speech style live-edit refinement approved and implemented (`specs/speech_style_toolbar_live_edit.md`). Choices now update the recoverable draft directly; token effects demo for one second without replaying the sequence; **Done styling** hides the toolbar while keeping speech editing open; selected choice borders persist on hover; and the token target uses a translucent rounded marker. Godot headless syntax checks pass; editor verification is pending. |
| 2026-09-29 | Speech style follow-up fixes: hold bubble geometry steady during the one-second token effect demo; center speech text after font/size changes; and make whole-bubble font/size/color defaults clear matching per-word overrides. Godot headless syntax checks pass; editor verification is pending. |
---
| 2026-09-29 | Speech Message/Emoji/Timing follow-up approved for implementation (specs/speech_message_emoji_timing.md): move Message plus its emoji picker and action-wide Timing to the stage toolbar; use two Speech/Style rows; make unselected Effects apply to the whole bubble via a backward-compatible bubble default with token overrides; keep text editing separate from stage token targeting and preserve unaffected style spans. |
| 2026-09-30 | Stage speech-wide Show architecture specification written for user sign-off (`specs/speech_whole_sequence_show.md`). No bubble focus presents Bubbles, Words appear, and Bubble motion directly beside the group; a bubble/token click swaps to Speech Style, while Done styling or an empty stage click returns to group controls. The action owns reveal and motion values, all existing/future bubbles inherit them, and older mixed settings migrate from the first bubble. Logged #58 for synchronized per-bubble render copies. No implementation code changed. |
| 2026-09-30 | Stage-only speech editor architecture specification drafted for user sign-off (`specs/speech_stage_only_editor.md`). Removes the Comic Speech Inspector, moves Show/Timing/Preview/More to Show Speech and Message/Frame/Letters/Effects plus sequence controls to Speech Style, and keeps persistent Done speech and Cancel speech controls. Draft recovery remains immediate; Done commits once to the queue/rule buffer, while Scene Save waits for the explicit Done commit. New bubble defaults, placement, one-step removal Undo, order controls, input ownership, and the existing eight-bubble cap are specified. Logged #59. No implementation code changed. |
| 2026-10-01 | Comic Chain speech behavior revised after preview feedback (`specs/speech_comic_chain_layout.md`): Comic chain is the third persistent **Show bubbles** choice and builds cumulatively, while Stack/Same spot remain placement actions. Entering the mode applies measured Stack positions. Later balloon connectors use blunt joins drawn behind predecessor frames; Think uses dots. Older `bubble_layout: comic_chain` payloads migrate to `arrangement: comic_chain`. |
| 2026-10-03 | Speech bubble order badges now use selection-independent positions (`specs/speech_badge_stability.md`). Badge clustering responds to actual proximity; Show Speech and Speech Style panels avoid the stable badge bounds instead of moving badges when selection changes. |
| 2026-10-03 | Speech badge clusters now also form when bubble frames intersect. For overlapping bubbles, the sorted badge row anchors above the largest frame, preventing a smaller bubble's badge from appearing through the larger bubble's text. |
_End of Log_
+140
View File
@@ -0,0 +1,140 @@
{
"version": "1.1",
"scene_name": "Untitled",
"camera": {
"position": {
"x": -496.058837890625,
"y": -1357.21496582031
},
"zoom": 0.454545497894287
},
"objects": [
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -480.0,
"y": -685.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "speak",
"text": "Hello Everyone",
"duration": 1.5,
"stop_current": false,
"arrangement": "together",
"duration_mode": "just_enough",
"bubbles": [
{
"text": "Hello Everyone",
"visual": {
"bubble_style": "shout",
"font_preset": "comic",
"size_preset": "huge",
"color": "#202020",
"reveal": "letter",
"pop_in": true,
"word_styles": [
{
"start": 6,
"end": 14,
"font_preset": "scary",
"color": "#0077b6",
"effect": "bounce"
}
]
}
}
],
"placement": {
"mode": "manual",
"center": {
"x": 664.741455078125,
"y": 271.705322265625
}
}
}
]
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -768.0,
"y": -96.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -576.0,
"y": -96.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -384.0,
"y": -96.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -192.0,
"y": -96.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": 0.0,
"y": -96.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1790483201,
"modified_unix": 1790517359.218,
"autosave": false,
"source_path": ""
}
}
+297
View File
@@ -0,0 +1,297 @@
{
"version": "1.1",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 0.0,
"y": -400.0
},
"zoom": 0.5
},
"objects": [
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -132.0,
"y": -673.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "speak",
"text": "What is new?",
"duration": 12.0,
"stop_current": false,
"arrangement": "take_turns",
"bubble_layout": "stack",
"connect_bubbles": true,
"reveal": "letter",
"pop_in": true,
"duration_mode": "longest",
"bubbles": [
{
"text": "What is new?",
"visual": {
"bubble_style": "talk",
"font_preset": "comic",
"size_preset": "medium",
"color": "#202020",
"effect": "none",
"reveal": "letter",
"pop_in": true,
"word_styles": []
},
"placement": {
"mode": "manual",
"center": {
"x": 97.1448669433594,
"y": -701.999938964844
}
}
},
{
"text": "What's going on today?\nI'm planning to go to the park.\nWe will play an have fun. 👍",
"visual": {
"bubble_style": "shout",
"font_preset": "comic",
"size_preset": "medium",
"color": "#202020",
"effect": "none",
"reveal": "letter",
"pop_in": true,
"word_styles": [
{
"start": 49,
"end": 53,
"effect": "shake",
"color": "#0077b6"
}
]
},
"placement": {
"mode": "manual",
"center": {
"x": 105.144866943359,
"y": -942.999938964844
}
}
}
]
},
{
"type": "walk_to",
"target": {
"x": 850.0,
"y": -350.0
}
}
]
},
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -768.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -576.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -384.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": 192.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": 384.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 576.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 768.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 960.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 1152.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 1344.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": 1536.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": 1728.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": -192.0,
"y": -256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": 0.0,
"y": -256.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1790567936,
"modified_unix": 1790975418.635,
"autosave": false,
"source_path": ""
}
}
+157
View File
@@ -0,0 +1,157 @@
## Summary of Speech & Speech Bubble Features
The goal is to expand the existing single-bubble `SpeechBubble` system into a comic-book movie engine designed for young directors.
### 1. Data Contract & Theme Integration
- **Expanded Action Payload**: Extend the `speak` action payload to support explicit visual parameters:
```json
{
"type": "speak",
"text": "Look out for the [shake]MONSTER![/shake]",
"duration": 2.5,
"stop_current": false,
"visual": {
"bubble_style": "yell",
"font_preset": "comic",
"size_preset": "huge",
"color": "#FF3333",
"anim_mode": "typewriter"
}
}
```
- **Theme Fallbacks**: Route missing keys through `sandbox_theme.json` defaults $\rightarrow$ Global Theme $\rightarrow$ Godot `ThemeDB.fallback_font`.
### 2. Rich Text & Visual Styles
- **`RichTextLabel` Migration**: Shift text rendering from raw `draw_string()` calls to an internal `RichTextLabel` child node to enable full BBCode support (`[wave]`, `[shake]`, bolding, inline colors).
- **Shape Presets (`BubbleStyle`)**:
- **Standard**: Classic rounded rectangle + downward tail triangle.
- **Yell / Shout**: Multi-pointed starburst polygon.
- **Thought**: Arc/circle cluster cloud boundary + descending bubble chain dots.
- **Whisper**: Semi-transparent fill with dashed border lines (`draw_dashed_line()`).
- **Comic SFX**: Borderless, high-contrast banner box for sound effect popups ("BOOM!", "POW!").
### 3. Dynamic Text & Bubble Animations
- **Typewriter & Word Reveals**: Animate `visible_characters` via Godot `Tween` for letter-by-letter or word-by-word reveals.
- **Elastic Entrances & Transitions**: Scale bubble from $0 \rightarrow 1$ using `TRANS_ELASTIC` on pop-in; morph bounding sizes with smooth squish-and-stretch tweens when swapping text lines back-to-back.
- **Word-Specific Shake & Wave**: Support inline animated text effects wrapped in BBCode tags.
### 4. Advanced Layout & Stagger Stacking
- **Comic Stagger Stacking**: Allow multiple speech lines to chain into vertical bubble stacks. The bottom bubble anchors to the stickman's head via the main tail, while higher bubbles stack upward connected by bridge geometry (`draw_colored_polygon()`).
- **Mirroring & Viewport Clamping**: Keep X-scale counter-mirroring active (`_sync_speech_bubble_mirror`). Clamp bubble bounds inside `get_viewport_rect()` so bubbles flip tail anchors or offset position to remain on-screen.
### 5. Director UI Upgrades
- **Kid-Friendly Controls**: Color swatch buttons, size toggles (**Small**, **Medium**, **HUGE**), font style pickers, and an emoji grid picker inside the `Speak` parameter dialog.
- **Text Highlight Formatting Bar**: Selecting text in the dialogue input shows quick-action buttons (🫨 **Shake**, 🌊 **Wave**, 🔥 **Bounce**) that automatically inject BBCode tags.
- **Auto-Duration Calculator**: Default `duration` automatically calculates based on word count ($1.0\text{s base} + 0.2\text{s/word}$).
---
## Detailed Implementation Plan
### Phase 1: Engine Migration & Data Foundation
**Goal:** Upgrade `SpeechBubble` to use `RichTextLabel` and expand data contracts without breaking existing scene functionality.
1. **Update Action Data Pipeline**:
- Update `ActionRegistry` and `StageSceneIO` to preserve the optional `visual` dictionary payload on `speak` actions.
- Ensure `StickmanRig.speak()` parses both flat strings and full visual dictionaries.
2. **Refactor `SpeechBubble` Node Structure (`scripts/stickman_speech_bubble.gd`)**:
- Add a child `RichTextLabel` node to `SpeechBubble`.
- Set `RichTextLabel.bbcode_enabled = true` and configure autowrap.
- Modify `_draw()`: Calculate background rectangle bounds dynamically using `RichTextLabel.get_content_height()` and text size metrics, then draw the background polygon/rect and tail under the text.
### Phase 2: Animated Reveals & Line Transitions
**Goal:** Implement typewriter effects, word reveals, and smooth line-swapping transitions.
1. **Typewriter & Word Reveal Engine**:
- Add `_play_typewriter(text: String, duration: float, mode: String)` to `SpeechBubble`.
- Use a Godot `Tween` targeting `RichTextLabel.visible_characters` from `0` to total character length over the target duration.
- Implement word-by-word step incrementing by parsing space/punctuation indices in the input string.
2. **Line Swap & Elastic Transitions**:
- Update `show_text()`: If a bubble is already visible when a new `speak` command arrives:
- Scale container down ($1.0 \rightarrow 0.8$) via `EASE_IN` tween.
- Swap payload and recalculate bounds.
- Scale container back out ($0.8 \rightarrow 1.0$) using `TRANS_ELASTIC` / `EASE_OUT`.
- Add squish-and-stretch size morphing when text length changes between consecutive lines.
### Phase 3: Polygon Shape Styles & Comic Geometry
**Goal:** Generate dynamic background shapes for distinct bubble styles.
1. **Shape Draw Functions**:
- **`_draw_standard_bubble(rect)`**: Existing rounded rect + downward tail triangle.
- **`_draw_yell_bubble(rect)`**: Generate an outer jagged polygon by iterating around `rect` perimeter and alternating vertex radii with random/sine noise offsets.
- **`_draw_thought_bubble(rect)`**: Draw overlapping circles along the bounding box edges using `draw_circle()`, followed by 2–3 smaller descending circles pointing to origin.
- **`_draw_whisper_bubble(rect)`**: Draw semi-transparent background fill; outline using `draw_dashed_line()` around the perimeter.
2. **Viewport Boundary Protection**:
- Calculate global screen position of bubble rect in `_process()`.
- If top or side edges exit `get_viewport_rect()`, offset the bubble box downward/inward and flip tail geometry anchor accordingly.
### Phase 4: Staggered Multi-Bubble Stacking
**Goal:** Support chained comic bubbles stacking upward from a single character.
1. **Stack Container Logic**:
- Extend `SpeechBubble` to manage an array of child `BubbleItem` records.
- When a multi-line or stacked speech payload is received, instantiate child bubble slots.
2. **Stack Geometry & Bridge Rendering**:
- Anchor the bottom-most bubble to `SPEECH_BUBBLE_OFFSET`.
- Position preceding bubbles vertically above it with vertical spacing gaps.
- In `_draw()`, calculate bridge vertices between `Bubble N` bottom-center and `Bubble N+1` top-center, drawing connecting polygons using `draw_colored_polygon()`.
- Restrict the downward head tail exclusively to the bottom bubble in the stack.
### Phase 5: Director UI & Formatting Controls
**Goal:** Expose all visual styling tools to kids in the editor UI.
1. **Integrate Theme Controls into `Speak` Parameter Dialog**:
- Add `ColorSwatchGrid` popover hooked into theme colors.
- Add `SizeToggle` segmented control (**Small**, **Medium**, **HUGE**) mapping to font scale metrics.
- Add `StylePicker` icon buttons (**Standard**, **Yell**, **Thought**, **Whisper**).
- Embed `EmojiPicker` grid widget.
2. **Implement Selection Highlight Formatting Bar**:
- Attach a floating mini-toolbar above the dialogue `LineEdit` / `TextEdit` field in the `Speak` dialog.
- Detect text selection (`get_selected_text()`). When text is highlighted, show mini-buttons:
- 🫨 **Shake** $\rightarrow$ wraps selection in `[shake rate=20.0 level=10.0]...[/shake]`
- 🌊 **Wave** $\rightarrow$ wraps selection in `[wave amp=50.0 freq=5.0]...[/wave]`
- 🔥 **Bounce** $\rightarrow$ wraps selection in `[bounce]...[/bounce]`
3. **Auto-Duration & Stagger Helpers**:
- Add auto-duration calculation checkbox/toggle in dialog.
- Add **"+ Add Stacked Bubble"** button in the speech dialog to split longer dialogue into chained stack items easily.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+195 -8
View File
@@ -1,11 +1,198 @@
{
"version": "2.0",
"global": { "font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8 },
"fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": { "ui": {"label":"Standard","path":""}, "comic":{"label":"Comic","path":""}, "pixel":{"label":"Pixel","path":""}, "scary":{"label":"Scary","path":""} }, "sizes": { "action_popup":24, "action_popup_emoji":22, "assignment_badge":20, "rule_label":18, "status_pill":18, "tooltip":18, "file_menu":22, "file_menu_popup":26, "mode_switcher":24, "timestamp_badge":22, "queue_panel":22, "rule_panel":22, "action_editor":22, "rule_editor":22, "panel_row":20, "panel_title":24, "asset_label":18, "asset_badge":14 }, "flags": { "panel_title_bold":true, "rule_label_bold":false, "badge_bold":true, "action_popup_bold":false, "action_popup_italic":false } },
"mode_colors": { "edit":"#22C6FF", "direct":"#FFB300", "play":"#33DD77", "guide":"#22C6FF" }, "grid": { "snap_size":15.0 },
"chrome": { "style_defaults": {"bg_color":"#171B24F2","corner_radius":10}, "top_bar":{"height":64}, "status_bar":{"height":38}, "mode_switcher":{"segment_min_width":124}, "mode_frame":{"border_width":3}, "mode_badge":{"corner_radius":12,"padding":10}, "timestamp_badge":{"corner_radius":10,"padding":8,"bg_color":"#171B24F2"}, "tooltip":{"bg_color":"#101010E6","border_width":1}, "dialogs":{"queue_size":[680,520],"rule_size":[720,560],"action_size":[560,390],"confirm_size":[620,300],"font_size":22,"message_font_size":26} },
"popups": { "style_defaults":{"bg_color":"#202633FA","border_color":"#FFFFFF33","corner_radius":12,"padding":12}, "action_menu":{"font_size":24}, "dialog":{}, "asset_selector":{"backdrop_alpha":0.55,"cell_size":[190,220],"thumbnail_size":[156,156]} },
"palette": { "style_defaults":{"bg_color":"#252B38FA","corner_radius":10,"padding":6}, "dock_width":116, "card_size":[96,94], "icon_size":30, "drag_threshold":8, "selected_border_width":3 },
"overlays": { "style_defaults":{"line_width":2,"dash_length":8,"dash_gap":5}, "terrain_guide":{"color":"#22C6FF"}, "completed_path_alpha":0.20, "completed_path_color":"#959EAD", "valid":{"color":"#4DE66680"}, "same_type":{"color":"#FFFFFF26"}, "invalid":{"color":"#FF4D4D80"} },
"widgets": { "swatches":["#FFFFFF","#111111","#22C6FF","#FFB300","#33DD77","#FF4D4D"], "size_presets":[{"id":"small","label":"Small","value":20},{"id":"medium","label":"Medium","value":28},{"id":"huge","label":"HUGE","value":40}], "emoji":["😀","😂","😍","😮","😢","😡","👍","❤️"] }
"global": {
"font_preset": "ui",
"font_size": 18,
"text_color": "#F5F7FA",
"muted_text_color": "#AAB2C0",
"bg_color": "#171B24F2",
"surface_color": "#252B38FA",
"border_color": "#FFFFFF33",
"border_width": 1,
"corner_radius": 10,
"padding": 8,
"spacing": 8
},
"fonts": {
"ui_font": "",
"emoji_font": "",
"ui_font_bold": "",
"ui_font_italic": "",
"presets": {
"ui": { "label": "Standard", "path": "" },
"comic": {
"label": "Comic",
"path": "res://resources/fonts/comic.ttf",
"speech_base_size": 28
},
"pixel": {
"label": "Pixel",
"path": "res://resources/fonts/pixel.ttf",
"speech_base_size": 46
},
"scary": {
"label": "Scary",
"path": "res://resources/fonts/scary.ttf",
"speech_base_size": 28
}
},
"sizes": {
"action_popup": 24,
"action_popup_emoji": 22,
"assignment_badge": 20,
"rule_label": 18,
"status_pill": 18,
"tooltip": 18,
"file_menu": 22,
"file_menu_popup": 26,
"mode_switcher": 24,
"timestamp_badge": 22,
"queue_panel": 22,
"rule_panel": 22,
"action_editor": 22,
"rule_editor": 22,
"panel_row": 20,
"panel_title": 24,
"asset_label": 18,
"asset_badge": 14
},
"flags": {
"panel_title_bold": true,
"rule_label_bold": false,
"badge_bold": true,
"action_popup_bold": false,
"action_popup_italic": false
}
},
"mode_colors": {
"edit": "#22C6FF",
"direct": "#FFB300",
"play": "#33DD77",
"guide": "#22C6FF"
},
"grid": { "snap_size": 15.0 },
"chrome": {
"style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
"top_bar": { "height": 64 },
"status_bar": { "height": 38 },
"mode_switcher": { "segment_min_width": 124 },
"mode_frame": { "border_width": 3 },
"mode_badge": { "corner_radius": 12, "padding": 10 },
"timestamp_badge": {
"corner_radius": 10,
"padding": 8,
"bg_color": "#171B24F2"
},
"tooltip": { "bg_color": "#101010E6", "border_width": 1 },
"dialogs": {
"queue_size": [680, 520],
"rule_size": [720, 560],
"action_size": [560, 390],
"confirm_size": [620, 300],
"font_size": 22,
"message_font_size": 26
}
},
"popups": {
"style_defaults": {
"bg_color": "#202633FA",
"border_color": "#FFFFFF33",
"corner_radius": 12,
"padding": 12
},
"action_menu": { "font_size": 24 },
"dialog": {},
"asset_selector": {
"backdrop_alpha": 0.55,
"cell_size": [190, 220],
"thumbnail_size": [156, 156]
}
},
"palette": {
"style_defaults": {
"bg_color": "#252B38FA",
"corner_radius": 10,
"padding": 6
},
"dock_width": 116,
"card_size": [96, 94],
"icon_size": 30,
"drag_threshold": 8,
"selected_border_width": 3
},
"overlays": {
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
"terrain_guide": { "color": "#22C6FF" },
"completed_path_alpha": 0.2,
"completed_path_color": "#959EAD",
"valid": { "color": "#4DE66680" },
"same_type": { "color": "#FFFFFF26" },
"invalid": { "color": "#FF4D4D80" }
},
"widgets": {
"swatches": [
"#FFFFFF",
"#111111",
"#22C6FF",
"#FFB300",
"#33DD77",
"#FF4D4D"
],
"size_presets": [
{ "id": "small", "label": "Small", "value": 20 },
{ "id": "medium", "label": "Medium", "value": 28 },
{ "id": "huge", "label": "HUGE", "value": 40 }
],
"emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]
},
"speech": {
"toolbar": {
"surface": "#1C2431F7",
"border": "#9EADBF",
"selection_halo": "#FFD84D",
"ribbon": "#2385A0",
"gap": 10,
"max_width": 560,
"min_hit_height": 56
},
"ui": { "panel_width": 440, "button_min_height": 44, "heading_size": 24 },
"layout": {
"max_width": 420,
"stack_gap": 12,
"screen_margin": 18,
"bubble_padding": 18
},
"animation": {
"letter_interval": 0.045,
"word_interval": 0.2,
"pop_duration": 0.24
},
"styles": {
"talk": { "fill": "#FFFDF6", "outline": "#202020", "tail": "talk" },
"shout": { "fill": "#FFD642", "outline": "#D92F26", "tail": "shout" },
"think": { "fill": "#E9F5FF", "outline": "#37698B", "tail": "think" },
"whisper": { "fill": "#F2F2F2", "outline": "#6D7885", "tail": "whisper" },
"sfx": { "fill": "#FFDD39", "outline": "#D92F26", "tail": "none" }
},
"text_colors": [
{ "name": "Ink", "color": "#202020" },
{ "name": "Midnight", "color": "#111827" },
{ "name": "Slate", "color": "#475569" },
{ "name": "Snow", "color": "#FFFFFF" },
{ "name": "Cloud", "color": "#CBD5E1" },
{ "name": "Sky", "color": "#2196F3" },
{ "name": "Ocean", "color": "#0077B6" },
{ "name": "Teal", "color": "#00897B" },
{ "name": "Leaf", "color": "#2E7D32" },
{ "name": "Lime", "color": "#8BC34A" },
{ "name": "Sunshine", "color": "#FFC107" },
{ "name": "Orange", "color": "#F57C00" },
{ "name": "Sunset red", "color": "#E53935" },
{ "name": "Rose", "color": "#D81B60" },
{ "name": "Violet", "color": "#7E57C2" },
{ "name": "Brown", "color": "#795548" }
],
"preview_ghost": "#FFFFFF99",
"connector": "#FFFFFFB0"
}
}
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/speech_composer.gd" id="1_composer"]
[node name="SpeechComposer" type="Control"]
script = ExtResource("1_composer")
+12 -21
View File
@@ -13,6 +13,7 @@ const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
signal committed(action: Dictionary)
signal cancelled()
signal target_requested()
signal speech_requested(initial: Dictionary)
var _mode: String = "new"
var _initial: Dictionary = {}
@@ -74,6 +75,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
_mode = "edit"
_initial = action.duplicate(true)
var type := String(action.get("type", "walk_to"))
if type == "speak":
speech_requested.emit(_initial.duplicate(true))
return
_has_target = action.has("target") and action["target"] is Vector2
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
_prop_target_id = String(action.get("target_prop_id", ""))
@@ -202,6 +206,9 @@ func _current_type() -> String:
func _on_type_selected(_index: int) -> void:
if _current_type() == "speak":
speech_requested.emit(_initial.duplicate(true))
return
_rebuild_params(_current_type())
@@ -264,23 +271,9 @@ func _rebuild_params(type: String) -> void:
throw_row.add_child(_set_target_btn)
_update_throw_target_label()
"speak":
var tlabel := Label.new()
tlabel.text = "Text:"
_params_box.add_child(tlabel)
_text_edit = LineEdit.new()
_text_edit.placeholder_text = "Say something…"
_text_edit.text = String(_initial.get("text", ""))
_params_box.add_child(_text_edit)
var dlabel := Label.new()
dlabel.text = "Duration (s):"
_params_box.add_child(dlabel)
_duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0)))
_params_box.add_child(_duration_spin)
if _show_reactive_options and ACTION_REGISTRY.is_overlay_eligible(type):
_stop_current_check = CheckBox.new()
_stop_current_check.text = "Stop what he's doing?"
_stop_current_check.button_pressed = bool(_initial.get("stop_current", false))
_params_box.add_child(_stop_current_check)
var speech_label := Label.new()
speech_label.text = "Opening the comic speech composer…"
_params_box.add_child(speech_label)
"wait":
var wlabel := Label.new()
wlabel.text = "Duration (s):"
@@ -349,10 +342,8 @@ func _on_ok_pressed() -> void:
return
action["target_position"] = _throw_target_pos
"speak":
action["text"] = _text_edit.text if _text_edit != null else ""
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
if _show_reactive_options and _stop_current_check != null:
action["stop_current"] = _stop_current_check.button_pressed
speech_requested.emit(_initial.duplicate(true))
return
"wait":
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
committed.emit(action)
+18 -14
View File
@@ -106,8 +106,11 @@ static func reactive_role(action: Dictionary) -> ReactiveRole:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
var role: ReactiveRole = int(tpl.get("reactive_role", ReactiveRole.SEQUENTIAL))
var params: Dictionary = action.get("params", {})
if type == "speak" and bool(params.get("stop_current", action.get("stop_current", false))):
return ReactiveRole.SEQUENTIAL
if type == "speak":
if params.has("keep_acting") or action.has("keep_acting"):
return ReactiveRole.OVERLAY if bool(params.get("keep_acting", action.get("keep_acting", false))) else ReactiveRole.SEQUENTIAL
if bool(params.get("stop_current", action.get("stop_current", false))):
return ReactiveRole.SEQUENTIAL
return role
@@ -119,10 +122,9 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
"walk_to":
params["target"] = action.get("target", Vector2.ZERO)
"speak":
params["text"] = String(action.get("text", ""))
params["duration"] = float(action.get("duration", 2.0))
if bool(action.get("stop_current", false)):
params["stop_current"] = true
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
if action.has(key):
params[key] = action[key].duplicate(true) if action[key] is Dictionary or action[key] is Array else action[key]
"wait":
params["duration"] = float(action.get("duration", 0.0))
"pick_up_prop":
@@ -144,13 +146,12 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
"walk_to":
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
"speak":
var speak: Dictionary = {
"type": "speak",
"text": String(params.get("text", "")),
"duration": float(params.get("duration", 2.0)),
}
if bool(params.get("stop_current", false)):
speak["stop_current"] = true
var speak: Dictionary = {"type": "speak"}
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
if params.has(key):
speak[key] = params[key].duplicate(true) if params[key] is Dictionary or params[key] is Array else params[key]
if not speak.has("keep_acting"):
speak["keep_acting"] = not bool(params.get("stop_current", false))
return speak
"wait":
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
@@ -180,7 +181,10 @@ static func summarize(action: Dictionary) -> String:
"speak":
var text := String(action.get("text", params.get("text", "")))
var dur := float(action.get("duration", params.get("duration", 2.0)))
var suffix: String = " — stops current" if bool(params.get("stop_current", action.get("stop_current", false))) else ""
var has_keep_acting: bool = params.has("keep_acting") or action.has("keep_acting")
var keep_acting: bool = bool(params.get("keep_acting", action.get("keep_acting", false)))
var legacy_stop_current: bool = bool(params.get("stop_current", action.get("stop_current", false)))
var suffix: String = " — keeps acting" if has_keep_acting and keep_acting else (" — stops current" if not has_keep_acting and legacy_stop_current else "")
return "Speak \"%s\" (%ss)%s" % [text, _fmt_duration(dur), suffix]
"wait":
var wd := float(action.get("duration", params.get("duration", 0.0)))
+1743 -40
View File
File diff suppressed because it is too large Load Diff
+66 -3
View File
@@ -8,14 +8,15 @@ const DEFAULT_PATH: String = "res://sandbox_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
@@ -81,7 +82,18 @@ func font(component_path: String = "", preset_id: String = "ui") -> Font:
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
if path.is_empty(): path = String(value("fonts.ui_font", ""))
if path.is_empty(): return ThemeDB.fallback_font
if path.is_empty():
if preset_id == "ui": return ThemeDB.fallback_font
var system_key: String = "system:" + preset_id
if _font_cache.has(system_key): return _font_cache[system_key]
var system_font := SystemFont.new()
match preset_id:
"comic": system_font.font_names = PackedStringArray(["Comic Sans MS", "Comic Neue", "Chalkboard", "Comic Sans", "sans-serif"])
"pixel": system_font.font_names = PackedStringArray(["Consolas", "Courier New", "Liberation Mono", "monospace"])
"scary": system_font.font_names = PackedStringArray(["Impact", "Chiller", "Papyrus", "fantasy", "sans-serif"])
_: system_font.font_names = PackedStringArray(["Arial", "Liberation Sans", "sans-serif"])
_font_cache[system_key] = system_font
return system_font
if _font_cache.has(path): return _font_cache[path]
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
if resource is Font:
@@ -90,6 +102,18 @@ func font(component_path: String = "", preset_id: String = "ui") -> Font:
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
return ThemeDB.fallback_font
func speech_font(preset_id: String = "ui") -> Font:
return font("", preset_id)
func speech_font_size(preset_id: String, size_preset: String) -> int:
var base_size: int = integer("fonts.presets.%s.speech_base_size" % preset_id, 28, 8, 96)
var scale: float = 1.0
match size_preset:
"small": scale = 0.72
"huge": scale = 1.43
return clampi(roundi(float(base_size) * scale), 8, 128)
func emoji_font() -> Font:
var path: String = String(value("fonts.emoji_font", ""))
if path.is_empty(): return font()
@@ -152,6 +176,45 @@ func emoji_items() -> Array[String]:
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
return result
func speech_color(frame: String, channel: String, fallback: Color) -> Color:
if not ["talk", "shout", "think", "whisper", "sfx"].has(frame) or not ["fill", "outline"].has(channel):
return fallback
return color("speech.styles.%s.%s" % [frame, channel], fallback)
func speech_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
if not ["ui.panel_width", "ui.button_min_height", "ui.heading_size", "layout.max_width", "layout.stack_gap", "layout.screen_margin", "layout.bubble_padding", "animation.letter_interval", "animation.word_interval", "animation.pop_duration"].has(token):
return fallback
return number("speech." + token, fallback, minimum, maximum)
func speech_toolbar_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
if not ["gap", "max_width", "min_hit_height"].has(token):
return fallback
return number("speech.toolbar." + token, fallback, minimum, maximum)
func speech_text_colors() -> Array[Dictionary]:
var result: Array[Dictionary] = []
var raw_colors: Variant = value("speech.text_colors", [])
if not raw_colors is Array:
return result
for raw: Variant in raw_colors as Array:
if not raw is Dictionary:
continue
var item: Dictionary = raw as Dictionary
var name: String = String(item.get("name", "")).strip_edges()
var color_text: String = String(item.get("color", ""))
if not name.is_empty() and _is_valid_hex_color(color_text):
result.append({"name": name, "color": Color.from_string(color_text, Color.BLACK)})
return result
func _is_valid_hex_color(text: String) -> bool:
if text.length() != 7 and text.length() != 9 or not text.begins_with("#"):
return false
for index: int in range(1, text.length()):
if not "0123456789abcdefABCDEF".contains(text.substr(index, 1)):
return false
return true
func invalidate_caches() -> void:
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://dlee4y2wnkupm
+521
View File
@@ -0,0 +1,521 @@
class_name SpeechPayload
extends RefCounted
## Validated, scene-independent helpers for comic speech actions.
const MAX_BUBBLES: int = 8
const MAX_VISIBLE_CHARACTERS: int = 240
const MIN_INTERVAL: float = 1.5
const MAX_INTERVAL: float = 12.0
const BASE_FONT_PRESET: String = "comic"
const DEFAULT_TEXT_COLOR: String = "#202020"
const ARRANGEMENTS: Array[String] = ["together", "take_turns"]
const BUBBLE_LAYOUT_MODES: Array[String] = ["stack", "same_spot"]
const DURATION_MODES: Array[String] = ["just_enough", "longer", "longest"]
const BUBBLE_STYLES: Array[String] = ["talk", "shout", "think", "whisper", "sfx"]
const FONT_PRESETS: Array[String] = ["ui", "comic", "pixel", "scary"]
const SIZE_PRESETS: Array[String] = ["small", "medium", "huge"]
const REVEAL_MODES: Array[String] = ["instant", "letter", "word"]
const EFFECTS: Array[String] = ["shake", "wave", "bounce"]
const WORD_REGEX: String = "[\\p{L}\\p{N}]+(?:['’][\\p{L}\\p{N}]+)*"
static func normalize(raw: Dictionary) -> Dictionary:
var source: Dictionary = raw.duplicate(true)
var legacy: bool = not source.get("bubbles", null) is Array
var out: Dictionary = {
"type": "speak",
"text": String(source.get("text", "")),
"duration": _number_or(source.get("duration", 2.0), 2.0),
"stop_current": bool(source.get("stop_current", false)),
"keep_acting": bool(source.get("keep_acting", false)),
"arrangement": "together",
"bubble_layout": "stack",
"connect_bubbles": false,
"reveal": "instant",
"pop_in": false,
"duration_mode": "just_enough",
"placement": {"mode": "auto"},
"bubbles": [],
}
var source_arrangement: String = String(source.get("arrangement", "together"))
var legacy_comic_chain: bool = source_arrangement == "comic_chain" or String(source.get("bubble_layout", "")) == "comic_chain"
var migrated_arrangement: String = "take_turns" if source_arrangement == "comic_chain" else _validated_choice(source_arrangement, ARRANGEMENTS, "together")
var source_layout: String = String(source.get("bubble_layout", "stack"))
var migrated_layout: String = "same_spot" if not legacy_comic_chain and source_layout == "same_spot" else "stack"
var migrated_connect: bool = legacy_comic_chain or bool(source.get("connect_bubbles", false))
if migrated_connect and migrated_layout == "same_spot":
migrated_layout = "stack"
if legacy:
var visual: Dictionary = source.get("visual", {}) if source.get("visual", {}) is Dictionary else {}
var legacy_text: String = String(source.get("text", ""))
out["bubbles"] = [{"text": legacy_text, "visual": _normalize_visual(visual, legacy_text)}]
out["legacy_fixed"] = true
out["arrangement"] = migrated_arrangement
out["bubble_layout"] = migrated_layout
out["connect_bubbles"] = migrated_connect
out["duration_mode"] = _validated_choice(source.get("duration_mode", "just_enough"), DURATION_MODES, "just_enough")
if source.has("placement"):
out["placement"] = _normalize_placement(source.get("placement"))
else:
out["arrangement"] = migrated_arrangement
out["bubble_layout"] = migrated_layout
out["connect_bubbles"] = migrated_connect
out["duration_mode"] = String(source.get("duration_mode", "just_enough"))
if not DURATION_MODES.has(out["duration_mode"]):
out["duration_mode"] = "just_enough"
var raw_placement: Variant = source.get("placement", {"mode": "auto"})
if raw_placement is Dictionary:
var placement: Dictionary = (raw_placement as Dictionary).duplicate(true)
if String(placement.get("mode", "auto")) == "manual":
var center: Variant = placement.get("center", Vector2.ZERO)
if center is Dictionary:
center = Vector2(float((center as Dictionary).get("x", 0.0)), float((center as Dictionary).get("y", 0.0)))
if center is Vector2 and (center as Vector2).is_finite():
placement["center"] = center
placement["mode"] = "manual"
else:
placement = {"mode": "auto"}
else:
placement = {"mode": "auto"}
out["placement"] = placement
var raw_bubbles: Array = source.get("bubbles", [])
var bubbles: Array[Dictionary] = []
for item: Variant in raw_bubbles.slice(0, MAX_BUBBLES):
if item is Dictionary:
var bubble: Dictionary = item as Dictionary
var bubble_text: String = String(bubble.get("text", ""))
if bubbles.is_empty() and bubble_text.strip_edges().is_empty():
var root_text: String = String(source.get("text", ""))
if not root_text.strip_edges().is_empty():
bubble_text = root_text
var normalized_bubble: Dictionary = {"text": bubble_text, "visual": _normalize_visual(bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {}, bubble_text)}
if bubble.has("placement"):
normalized_bubble["placement"] = _normalize_placement(bubble.get("placement"))
bubbles.append(normalized_bubble)
if bubbles.is_empty():
var root_fallback_text: String = String(source.get("text", ""))
if not root_fallback_text.strip_edges().is_empty():
var root_visual: Dictionary = source.get("visual", {}) if source.get("visual", {}) is Dictionary else {}
bubbles.append({"text": root_fallback_text, "visual": _normalize_visual(root_visual, root_fallback_text)})
out["bubbles"] = bubbles
if not bubbles.is_empty():
out["text"] = String(bubbles[0].get("text", ""))
# Whole-speech choices are canonical. Old multi-bubble actions may disagree;
# adopting the first bubble gives those scenes a deterministic shared setting.
var first_visual: Dictionary = {}
var normalized_bubbles: Array = out.get("bubbles", [])
if not normalized_bubbles.is_empty() and normalized_bubbles[0] is Dictionary:
var first_bubble: Dictionary = normalized_bubbles[0] as Dictionary
first_visual = first_bubble.get("visual", {}) if first_bubble.get("visual", {}) is Dictionary else {}
var fallback_visual: Dictionary = source.get("visual", {}) if source.get("visual", {}) is Dictionary else {}
var reveal_fallback: String = String(first_visual.get("reveal", fallback_visual.get("reveal", "instant")))
var reveal_value: Variant = source.get("reveal", reveal_fallback)
var reveal: String = String(reveal_value) if reveal_value is String and REVEAL_MODES.has(String(reveal_value)) else (reveal_fallback if REVEAL_MODES.has(reveal_fallback) else "instant")
var pop_fallback: bool = bool(first_visual.get("pop_in", fallback_visual.get("pop_in", false)))
var pop_value: Variant = source.get("pop_in", pop_fallback)
var pop_in: bool = bool(pop_value) if pop_value is bool else pop_fallback
out["reveal"] = reveal
out["pop_in"] = pop_in
for index: int in normalized_bubbles.size():
if not normalized_bubbles[index] is Dictionary:
continue
var bubble: Dictionary = normalized_bubbles[index] as Dictionary
var visual: Dictionary = bubble.get("visual", {}).duplicate(true)
visual["reveal"] = reveal
visual["pop_in"] = pop_in
bubble["visual"] = visual
normalized_bubbles[index] = bubble
out["bubbles"] = normalized_bubbles
if not normalized_bubbles.is_empty():
out["duration"] = duration_for(out)
return out
static func _validated_choice(value: Variant, choices: Array[String], fallback: String) -> String:
var choice: String = String(value)
return choice if choices.has(choice) else fallback
static func validate(raw: Variant) -> String:
if not raw is Dictionary:
return "Speech must be a dictionary."
var data: Dictionary = raw as Dictionary
if String(data.get("type", "speak")) != "speak":
return "Speech action type is invalid."
var duration_value: Variant = data.get("duration", 2.0)
if not _finite_number(duration_value) or float(duration_value) < 0.0 or float(duration_value) > 3600.0:
return "Speech duration is out of range."
if data.has("reveal") and not REVEAL_MODES.has(String(data.get("reveal", ""))):
return "Speech reveal is invalid."
if data.has("pop_in") and not data.get("pop_in") is bool:
return "Speech bubble motion is invalid."
if data.has("keep_acting") and not data.get("keep_acting") is bool:
return "Speech action setting is invalid."
var bubbles_value: Variant = data.get("bubbles", null)
if bubbles_value == null:
return ""
if not bubbles_value is Array or (bubbles_value as Array).is_empty() or (bubbles_value as Array).size() > MAX_BUBBLES:
return "Speech needs between one and eight bubbles."
if not ARRANGEMENTS.has(String(data.get("arrangement", "together"))) and String(data.get("arrangement", "")) != "comic_chain":
return "Speech arrangement is invalid."
if data.has("bubble_layout") and not BUBBLE_LAYOUT_MODES.has(String(data.get("bubble_layout", ""))) and not ["standard", "comic_chain"].has(String(data.get("bubble_layout", ""))):
return "Speech bubble layout is invalid."
if data.has("connect_bubbles") and not data.get("connect_bubbles") is bool:
return "Speech bubble connection setting is invalid."
if not DURATION_MODES.has(String(data.get("duration_mode", "just_enough"))):
return "Speech reading time is invalid."
var placement: Variant = data.get("placement", {"mode": "auto"})
if not placement is Dictionary:
return "Speech placement is invalid."
if String((placement as Dictionary).get("mode", "auto")) == "manual":
var center: Variant = (placement as Dictionary).get("center", null)
if center is Dictionary:
if not _finite_number((center as Dictionary).get("x")) or not _finite_number((center as Dictionary).get("y")):
return "Speech placement is invalid."
center = Vector2(float((center as Dictionary)["x"]), float((center as Dictionary)["y"]))
if not center is Vector2 or not (center as Vector2).is_finite() or absf((center as Vector2).x) > 100000.0 or absf((center as Vector2).y) > 100000.0:
return "Speech placement is out of range."
var authored_bubbles: Array = bubbles_value as Array
for bubble_index: int in authored_bubbles.size():
var raw_bubble: Variant = authored_bubbles[bubble_index]
if not raw_bubble is Dictionary:
return "Speech bubble is malformed."
var bubble: Dictionary = raw_bubble as Dictionary
var text: String = String(bubble.get("text", ""))
if text.strip_edges().is_empty() or text.length() > MAX_VISIBLE_CHARACTERS:
return "Each bubble needs text and must fit within 240 characters."
if bubble.has("placement"):
var bubble_placement_error: String = _validate_placement(bubble.get("placement"))
if not bubble_placement_error.is_empty():
return "Bubble %d %s" % [bubble_index + 1, bubble_placement_error]
var visual: Variant = bubble.get("visual", {})
if not visual is Dictionary:
return "Bubble appearance is malformed."
var v: Dictionary = visual as Dictionary
if not BUBBLE_STYLES.has(String(v.get("bubble_style", "talk"))) or not FONT_PRESETS.has(String(v.get("font_preset", BASE_FONT_PRESET))) or not SIZE_PRESETS.has(String(v.get("size_preset", "medium"))) or not REVEAL_MODES.has(String(v.get("reveal", "instant"))):
return "Bubble appearance contains an unknown choice."
if String(v.get("effect", "none")) != "none" and not EFFECTS.has(String(v.get("effect", "none"))):
return "Bubble effect is invalid."
if not _valid_color(String(v.get("color", DEFAULT_TEXT_COLOR))):
return "Bubble text color is invalid."
var word_styles: Variant = v.get("word_styles", [])
if not word_styles is Array:
return "Word styles are malformed."
var seen: Dictionary = {}
for raw_style: Variant in word_styles as Array:
if not raw_style is Dictionary:
return "Word style is malformed."
var style: Dictionary = raw_style as Dictionary
if not _integer_number(style.get("start")) or not _integer_number(style.get("end")):
return "Word style range is invalid."
var start: int = int(style["start"])
var end: int = int(style["end"])
if start < 0 or end <= start or end > text.length() or token_span_at(text, start) != Vector2i(start, end):
return "Text style must target one complete word or punctuation mark."
if seen.has(start):
return "Word styles cannot repeat or overlap."
seen[start] = true
for key: String in ["font_preset"]:
if style.has(key) and not FONT_PRESETS.has(String(style[key])):
return "Word style font is invalid."
if style.has("size_preset") and not SIZE_PRESETS.has(String(style["size_preset"])):
return "Word style size is invalid."
if style.has("color") and not _valid_color(String(style["color"])):
return "Word style color is invalid."
if style.has("effect") and String(style["effect"]) != "none" and not EFFECTS.has(String(style["effect"])):
return "Word style effect is invalid."
return ""
static func _normalize_placement(raw: Variant) -> Dictionary:
if not raw is Dictionary:
return {"mode": "auto"}
var placement: Dictionary = (raw as Dictionary).duplicate(true)
if String(placement.get("mode", "auto")) != "manual":
return {"mode": "auto"}
var center: Variant = placement.get("center", null)
if center is Dictionary:
if not _finite_number((center as Dictionary).get("x")) or not _finite_number((center as Dictionary).get("y")):
return {"mode": "auto"}
center = Vector2(float((center as Dictionary)["x"]), float((center as Dictionary)["y"]))
if not center is Vector2 or not (center as Vector2).is_finite():
return {"mode": "auto"}
placement["mode"] = "manual"
placement["center"] = center
return placement
static func _validate_placement(raw: Variant) -> String:
if not raw is Dictionary:
return "placement is invalid."
var placement: Dictionary = raw as Dictionary
var mode: String = String(placement.get("mode", "auto"))
if mode == "auto":
return ""
if mode != "manual":
return "placement mode is invalid."
var center: Variant = placement.get("center", null)
if center is Dictionary:
if not _finite_number((center as Dictionary).get("x")) or not _finite_number((center as Dictionary).get("y")):
return "placement is invalid."
center = Vector2(float((center as Dictionary)["x"]), float((center as Dictionary)["y"]))
if not center is Vector2 or not (center as Vector2).is_finite():
return "placement is out of range."
if absf((center as Vector2).x) > 100000.0 or absf((center as Vector2).y) > 100000.0:
return "placement is out of range."
return ""
static func duration_for(raw: Dictionary) -> float:
if bool(raw.get("legacy_fixed", false)):
return maxf(0.0, _number_or(raw.get("duration", 2.0), 2.0))
var bubbles: Array = raw.get("bubbles", []) if raw.get("bubbles", []) is Array else []
if bubbles.is_empty():
return maxf(0.0, _number_or(raw.get("duration", 2.0), 2.0))
var arrangement: String = String(raw.get("arrangement", "together"))
var mode: String = String(raw.get("duration_mode", "just_enough"))
var first_visual: Dictionary = (bubbles[0] as Dictionary).get("visual", {}) if bubbles[0] is Dictionary and (bubbles[0] as Dictionary).get("visual", {}) is Dictionary else {}
var shared_reveal: String = String(raw.get("reveal", first_visual.get("reveal", "instant")))
var total: float = 0.0
for item: Variant in bubbles:
if not item is Dictionary:
continue
var bubble: Dictionary = item as Dictionary
var words: int = _word_count(String(bubble.get("text", "")))
var interval: float = maxf(MIN_INTERVAL, 1.0 + 0.2 * float(words))
match mode:
"longer": interval *= 1.5
"longest": interval *= 2.0
var visual: Dictionary = bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {}
var reveal: String = shared_reveal
var characters: int = String(bubble.get("text", "")).length()
var reveal_time: float = 0.0
if reveal == "letter":
reveal_time = minf(0.045 * float(characters), interval * 0.55)
elif reveal == "word":
reveal_time = minf(0.20 * float(words), interval * 0.55)
interval = clampf(maxf(interval, reveal_time + 0.8), MIN_INTERVAL, MAX_INTERVAL)
if arrangement == "take_turns" or arrangement == "comic_chain":
total += interval
else:
total = maxf(total, interval)
return total
static func bubble_intervals(raw: Dictionary) -> PackedFloat32Array:
var result := PackedFloat32Array()
var bubbles: Array = raw.get("bubbles", []) if raw.get("bubbles", []) is Array else []
for item: Variant in bubbles:
if item is Dictionary:
var partial: Dictionary = raw.duplicate(true)
partial["bubbles"] = [item]
partial["arrangement"] = "together"
partial["duration_mode"] = raw.get("duration_mode", "just_enough")
result.append(duration_for(partial))
return result
static func plain_summary(raw: Dictionary) -> String:
var bubbles: Array = raw.get("bubbles", []) if raw.get("bubbles", []) is Array else []
var first: String = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else String(raw.get("text", ""))
first = first.replace("\n", " ").strip_edges()
if first.length() > 42:
first = first.substr(0, 39) + "…"
return first
static func word_span_at(text: String, index: int) -> Vector2i:
return token_span_at(text, index)
static func token_span_at(text: String, index: int) -> Vector2i:
if index < 0 or index >= text.length():
return Vector2i(-1, -1)
for span: Vector2i in token_spans(text):
if index >= span.x and index < span.y:
return span
return Vector2i(-1, -1)
static func token_spans(text: String) -> Array[Vector2i]:
var result: Array[Vector2i] = []
var word_ends: Dictionary = {}
var regex := RegEx.new()
if regex.compile(WORD_REGEX) == OK:
for match_result: RegExMatch in regex.search_all(text):
word_ends[match_result.get_start()] = match_result.get_end()
var index: int = 0
while index < text.length():
if word_ends.has(index):
var word_end: int = int(word_ends[index])
result.append(Vector2i(index, word_end))
index = word_end
continue
var character: String = text.substr(index, 1)
if character.strip_edges().is_empty():
index += 1
continue
var code: int = text.unicode_at(index)
if code == 0x2E or code == 0x21 or code == 0x3F:
var run_end: int = index + 1
while run_end < text.length() and text.unicode_at(run_end) == code:
run_end += 1
result.append(Vector2i(index, run_end))
index = run_end
continue
var grapheme: Vector2i = _symbol_grapheme_span(text, index)
if grapheme.x < 0 or grapheme.y <= index:
grapheme = Vector2i(index, index + 1)
result.append(grapheme)
index = grapheme.y
return result
static func _symbol_grapheme_span(text: String, index: int) -> Vector2i:
var start: int = index
while start > 0:
var current_code: int = text.unicode_at(start)
var previous_code: int = text.unicode_at(start - 1)
if _is_grapheme_extender(current_code) or previous_code == 0x200D:
start -= 1 if _is_grapheme_extender(current_code) else 2
continue
if _is_regional_indicator(current_code) and _is_regional_indicator(previous_code):
var regional_count: int = 0
var scan: int = start - 1
while scan >= 0 and _is_regional_indicator(text.unicode_at(scan)):
regional_count += 1
scan -= 1
if regional_count % 2 == 1:
start -= 1
continue
break
var end: int = start + 1
while end < text.length():
var next_code: int = text.unicode_at(end)
if _is_grapheme_extender(next_code):
end += 1
continue
if next_code == 0x200D and end + 1 < text.length():
end += 2
continue
if end == start + 1 and _is_regional_indicator(text.unicode_at(start)) and _is_regional_indicator(next_code):
end += 1
continue
break
return Vector2i(start, end)
static func _is_grapheme_extender(code: int) -> bool:
return (code >= 0xFE00 and code <= 0xFE0F) \
or (code >= 0xE0100 and code <= 0xE01EF) \
or (code >= 0x1F3FB and code <= 0x1F3FF) \
or (code >= 0xE0020 and code <= 0xE007F) \
or code == 0x20E3 or code == 0x200D
static func _is_regional_indicator(code: int) -> bool:
return code >= 0x1F1E6 and code <= 0x1F1FF
static func rich_text_runs(text: String, word_styles: Array[Dictionary]) -> Array[Dictionary]:
var boundaries: Array[int] = [0, text.length()]
for span: Vector2i in token_spans(text):
boundaries.append(span.x)
boundaries.append(span.y)
for style: Dictionary in word_styles:
var start: int = int(style.get("start", -1))
var end: int = int(style.get("end", -1))
if start >= 0 and end > start and end <= text.length() and token_span_at(text, start) == Vector2i(start, end):
boundaries.append(start)
boundaries.append(end)
boundaries.sort()
var unique: Array[int] = []
for boundary: int in boundaries:
if unique.is_empty() or unique.back() != boundary:
unique.append(boundary)
var runs: Array[Dictionary] = []
for i: int in range(unique.size() - 1):
var start: int = unique[i]
var end: int = unique[i + 1]
if end <= start:
continue
var run: Dictionary = {"start": start, "end": end, "text": text.substr(start, end - start)}
for style: Dictionary in word_styles:
if int(style.get("start", -1)) == start and int(style.get("end", -1)) == end:
run.merge(style, true)
break
runs.append(run)
return runs
static func _normalize_visual(source: Dictionary, text: String = "") -> Dictionary:
var visual: Dictionary = {
"bubble_style": String(source.get("bubble_style", "talk")),
"font_preset": String(source.get("font_preset", BASE_FONT_PRESET)),
"size_preset": String(source.get("size_preset", "medium")),
"color": String(source.get("color", DEFAULT_TEXT_COLOR)),
"effect": String(source.get("effect", "none")),
"reveal": String(source.get("reveal", "instant")),
"pop_in": bool(source.get("pop_in", false)),
"word_styles": [],
}
if not BUBBLE_STYLES.has(visual["bubble_style"]): visual["bubble_style"] = "talk"
if not FONT_PRESETS.has(visual["font_preset"]): visual["font_preset"] = BASE_FONT_PRESET
if not SIZE_PRESETS.has(visual["size_preset"]): visual["size_preset"] = "medium"
if visual["effect"] != "none" and not EFFECTS.has(visual["effect"]): visual["effect"] = "none"
if not REVEAL_MODES.has(visual["reveal"]): visual["reveal"] = "instant"
if not _valid_color(visual["color"]): visual["color"] = DEFAULT_TEXT_COLOR
var styles: Array[Dictionary] = []
for item: Variant in source.get("word_styles", []):
if item is Dictionary:
var style: Dictionary = (item as Dictionary).duplicate(true)
if not _integer_number(style.get("start")) or not _integer_number(style.get("end")):
continue
var start: int = int(style.get("start", -1))
var end: int = int(style.get("end", -1))
if start < 0 or end <= start or end > text.length() or token_span_at(text, start) != Vector2i(start, end):
continue
style["start"] = start
style["end"] = end
if style.has("font_preset") and not FONT_PRESETS.has(String(style["font_preset"])): style.erase("font_preset")
if style.has("size_preset") and not SIZE_PRESETS.has(String(style["size_preset"])): style.erase("size_preset")
if style.has("color") and not _valid_color(String(style["color"])): style.erase("color")
if style.has("effect") and String(style["effect"]) != "none" and not EFFECTS.has(String(style["effect"])): style.erase("effect")
for previous: Dictionary in styles:
if int(previous.get("start", -1)) == start:
style.clear()
break
if style.size() > 2:
styles.append(style)
visual["word_styles"] = styles
return visual
static func _word_count(text: String) -> int:
var regex := RegEx.new()
if regex.compile(WORD_REGEX) != OK:
return 0
return regex.search_all(text).size()
static func _valid_color(value: String) -> bool:
if value.length() != 7 and value.length() != 9:
return false
if not value.begins_with("#"):
return false
for i: int in range(1, value.length()):
if not "0123456789abcdefABCDEF".contains(value.substr(i, 1)):
return false
return true
static func _finite_number(value: Variant) -> bool:
return (value is int or value is float) and is_finite(float(value))
static func _integer_number(value: Variant) -> bool:
return _finite_number(value) and is_equal_approx(float(value), roundf(float(value)))
static func _number_or(value: Variant, fallback: float) -> float:
return float(value) if _finite_number(value) else fallback
+1
View File
@@ -0,0 +1 @@
uid://djyp1epb6jai
+626
View File
@@ -0,0 +1,626 @@
class_name SpeechShowToolbar
extends Control
## Stage-side controls that apply presentation choices to an entire speech.
signal choice_selected(key: StringName, value: Variant)
signal layout_preset_requested(preset: StringName)
signal preview_speech_requested()
signal done_speech_requested()
signal cancel_speech_requested()
var _theme: SandboxTheme
var _action: Dictionary = {}
var _group_rect: Rect2 = Rect2()
var _stage_rect: Rect2 = Rect2()
var _badge_avoidance_rects: Array[Rect2] = []
var _busy: bool = false
var _pin_top_while_more_open: bool = false
var _preserve_top_on_layout: bool = false
var _pinned_panel_top: float = 0.0
var _panel: PanelContainer
var _content: VBoxContainer
var _body_scroll: ScrollContainer
var _body_content: VBoxContainer
var _title_ribbon: PanelContainer
var _buttons_by_key: Dictionary = {}
var _button_values_by_key: Dictionary = {}
var _more_button: Button
var _more_content: VBoxContainer
var _keep_acting_button: Button
var _preview_button: Button
var _done_button: Button
var _cancel_button: Button
var _layout_preset_buttons: Array[Button] = []
var _layout_preset_values: Array[StringName] = []
var _connect_bubbles_button: Button
class BubbleLayoutSample extends Control:
var same_spot: bool = false
func _ready() -> void:
custom_minimum_size = Vector2(58.0, 42.0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
var boxes: Array[Rect2] = []
if same_spot:
boxes = [Rect2(4.0, 5.0, 34.0, 22.0), Rect2(10.0, 9.0, 34.0, 22.0), Rect2(16.0, 13.0, 34.0, 22.0)]
else:
boxes = [Rect2(2.0, 20.0, 32.0, 20.0), Rect2(13.0, 10.0, 32.0, 20.0), Rect2(24.0, 0.0, 32.0, 20.0)]
for index: int in boxes.size():
var bubble_style: StyleBoxFlat = StyleBoxFlat.new()
bubble_style.bg_color = Color("#FFFDF6") if index == boxes.size() - 1 else Color("#DDE7F0")
bubble_style.border_color = Color("#202733")
bubble_style.set_border_width_all(2)
bubble_style.set_corner_radius_all(5)
draw_style_box(bubble_style, boxes[index])
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 79
_build_ui()
visible = false
func configure(theme_data: SandboxTheme) -> void:
_theme = theme_data
if is_inside_tree() and _panel != null:
_apply_theme()
func set_context(action: Dictionary, _reactive_options: bool = false) -> void:
_action = action.duplicate(true)
if _more_button != null:
_more_button.visible = true
_more_button.text = "▾ More speech settings" if _more_content != null and _more_content.visible else "▸ More speech settings"
if _keep_acting_button != null:
_keep_acting_button.set_pressed_no_signal(bool(_action.get("keep_acting", false)))
_refresh_choices()
_refresh_layout_buttons()
_layout_panel()
func set_anchor_rect(group_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void:
var anchor_changed: bool = _rect_changed(_group_rect, group_viewport_rect) or _rect_changed(_stage_rect, stage_viewport_rect)
_group_rect = group_viewport_rect
_stage_rect = stage_viewport_rect
if anchor_changed and _more_content != null and _more_content.visible:
_pin_top_while_more_open = false
visible = not _busy and _group_rect.size.x > 0.0 and _group_rect.size.y > 0.0 and _stage_rect.size.x > 0.0 and _stage_rect.size.y > 0.0
if visible:
_layout_panel()
func set_badge_avoidance_rects(rects: Array[Rect2]) -> void:
if _badge_avoidance_rects == rects:
return
_badge_avoidance_rects = rects.duplicate()
if visible:
_layout_panel()
func set_busy(busy: bool) -> void:
_busy = busy
for key: Variant in _buttons_by_key.keys():
for button: Button in _buttons_by_key[key]:
button.disabled = busy
for button: Button in [_more_button, _preview_button, _done_button, _cancel_button, _keep_acting_button]:
if button != null:
button.disabled = busy
_refresh_layout_buttons()
_refresh_choices()
visible = not busy and _group_rect.size.x > 0.0 and _group_rect.size.y > 0.0 and _stage_rect.size.x > 0.0 and _stage_rect.size.y > 0.0
if visible:
_layout_panel()
func contains_viewport_point(point: Vector2) -> bool:
return visible and _panel != null and _panel.visible and _panel.get_global_rect().has_point(point)
func panel_viewport_rect() -> Rect2:
return _panel.get_global_rect() if _panel != null and is_instance_valid(_panel) and _panel.visible else Rect2()
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.name = "SpeechShowPanel"
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_panel)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 9)
margin.add_theme_constant_override("margin_bottom", 9)
_panel.add_child(margin)
_content = VBoxContainer.new()
_content.add_theme_constant_override("separation", 8)
margin.add_child(_content)
_body_scroll = ScrollContainer.new()
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_body_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_content.add_child(_body_scroll)
_body_content = VBoxContainer.new()
_body_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_content.add_theme_constant_override("separation", 8)
_body_scroll.add_child(_body_content)
_title_ribbon = PanelContainer.new()
_title_ribbon.name = "SpeechShowRibbon"
_body_content.add_child(_title_ribbon)
var title: Label = Label.new()
title.text = "Show speech"
title.add_theme_font_size_override("font_size", 22)
title.add_theme_color_override("font_color", Color.WHITE)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title_ribbon.add_child(title)
_add_choice_row("💬 Show bubbles", &"arrangement", [["Show all", "together"], ["One at a time", "take_turns"]])
_add_layout_preset_row()
_add_connect_bubbles_row()
_more_button = Button.new()
_more_button.text = "▸ More speech settings"
_more_button.toggle_mode = true
_more_button.custom_minimum_size = Vector2(220.0, _hit_height())
_more_button.focus_mode = Control.FOCUS_NONE
_more_button.toggled.connect(_on_more_toggled)
_body_content.add_child(_more_button)
_more_content = VBoxContainer.new()
_more_content.add_theme_constant_override("separation", 4)
_more_content.hide()
_body_content.add_child(_more_content)
_add_choice_row("🔤 Words appear", &"reveal", [["All at once", "instant"], ["Letter by letter", "letter"], ["Word by word", "word"]], _more_content)
_add_choice_row("✨ Bubble motion", &"pop_in", [["Bounce in", true], ["Just appear", false]], _more_content)
_add_choice_row("⏱ Timing", &"duration_mode", [["Normal", "just_enough"], ["Slow", "longer"], ["Slower", "longest"]], _more_content)
var keep_acting_row := HBoxContainer.new()
keep_acting_row.add_theme_constant_override("separation", 10)
_more_content.add_child(keep_acting_row)
var keep_acting_label := Label.new()
keep_acting_label.text = "🎭 Keep acting while talking"
keep_acting_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
keep_acting_label.add_theme_font_size_override("font_size", 19)
keep_acting_label.add_theme_color_override("font_color", Color("#F2F4F8"))
keep_acting_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
keep_acting_row.add_child(keep_acting_label)
_keep_acting_button = Button.new()
_keep_acting_button.toggle_mode = true
_keep_acting_button.custom_minimum_size = Vector2(88.0, _hit_height())
_keep_acting_button.focus_mode = Control.FOCUS_NONE
_keep_acting_button.add_theme_font_size_override("font_size", 17)
_keep_acting_button.toggled.connect(_on_keep_acting_toggled)
keep_acting_row.add_child(_keep_acting_button)
var footer: HFlowContainer = HFlowContainer.new()
footer.add_theme_constant_override("h_separation", 8)
footer.add_theme_constant_override("v_separation", 8)
_content.add_child(footer)
_preview_button = _action_button("▶ Preview speech")
_preview_button.pressed.connect(func() -> void: preview_speech_requested.emit())
footer.add_child(_preview_button)
_done_button = _action_button("✓ Done speech")
_done_button.pressed.connect(func() -> void: done_speech_requested.emit())
footer.add_child(_done_button)
_cancel_button = _action_button("Cancel speech")
_cancel_button.pressed.connect(func() -> void: cancel_speech_requested.emit())
footer.add_child(_cancel_button)
_apply_theme()
func _add_choice_row(label_text: String, key: StringName, choices: Array, parent: Control = null) -> void:
var row: VBoxContainer = VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
var row_parent: Control = parent if parent != null else _body_content
row_parent.add_child(row)
var heading: Label = Label.new()
heading.text = label_text
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Color("#F2F4F8"))
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(heading)
var flow: HFlowContainer = HFlowContainer.new()
flow.add_theme_constant_override("h_separation", 7)
flow.add_theme_constant_override("v_separation", 7)
row.add_child(flow)
var buttons: Array[Button] = []
var values: Array[Variant] = []
for pair: Array in choices:
var choice_value: Variant = pair[1]
var button: Button = Button.new()
button.text = String(pair[0])
button.toggle_mode = true
button.custom_minimum_size = Vector2(136.0, _hit_height())
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 17)
button.pressed.connect(_on_choice_pressed.bind(key, choice_value))
flow.add_child(button)
buttons.append(button)
values.append(choice_value)
_buttons_by_key[key] = buttons
_button_values_by_key[key] = values
func _add_layout_preset_row() -> void:
var row: VBoxContainer = VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
_body_content.add_child(row)
var heading: Label = Label.new()
heading.text = "🗨️ Bubble layout"
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Color("#F2F4F8"))
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(heading)
var flow: HFlowContainer = HFlowContainer.new()
flow.add_theme_constant_override("h_separation", 7)
flow.add_theme_constant_override("v_separation", 7)
row.add_child(flow)
_add_layout_preset_button(flow, "Stack", &"stack", false, "Arrange the bubbles upward from Bubble 1.")
_add_layout_preset_button(flow, "Same spot", &"same_spot", true, "Put every bubble at Bubble 1's position.")
func _add_connect_bubbles_row() -> void:
var row: HBoxContainer = HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
_body_content.add_child(row)
var label: Label = Label.new()
label.text = "🔗 Connect bubbles"
label.add_theme_font_size_override("font_size", 19)
label.add_theme_color_override("font_color", Color("#F2F4F8"))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(label)
_connect_bubbles_button = Button.new()
_connect_bubbles_button.toggle_mode = true
_connect_bubbles_button.custom_minimum_size = Vector2(112.0, _hit_height())
_connect_bubbles_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_connect_bubbles_button.focus_mode = Control.FOCUS_NONE
_connect_bubbles_button.add_theme_font_size_override("font_size", 17)
_connect_bubbles_button.pressed.connect(_on_connect_bubbles_pressed)
row.add_child(_connect_bubbles_button)
func _add_layout_preset_button(parent: HFlowContainer, caption: String, preset: StringName, same_spot: bool, hint: String) -> void:
var button: Button = Button.new()
var button_width: float = 184.0 if same_spot else 150.0
button.custom_minimum_size = Vector2(button_width, maxf(64.0, _hit_height()))
button.toggle_mode = true
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.focus_mode = Control.FOCUS_NONE
button.tooltip_text = hint
button.pressed.connect(func() -> void: layout_preset_requested.emit(preset))
var margin: MarginContainer = MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 5)
margin.add_theme_constant_override("margin_right", 5)
margin.add_theme_constant_override("margin_top", 4)
margin.add_theme_constant_override("margin_bottom", 4)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(margin)
var contents: HBoxContainer = HBoxContainer.new()
contents.add_theme_constant_override("separation", 2)
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(contents)
var sample: BubbleLayoutSample = BubbleLayoutSample.new()
sample.same_spot = same_spot
contents.add_child(sample)
var label: Label = Label.new()
label.text = caption
label.add_theme_font_size_override("font_size", 16)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
contents.add_child(label)
parent.add_child(button)
_layout_preset_buttons.append(button)
_layout_preset_values.append(preset)
func _on_choice_pressed(key: StringName, value: Variant) -> void:
if _busy:
return
_action[String(key)] = value
_refresh_choices()
choice_selected.emit(key, value)
func _on_connect_bubbles_pressed() -> void:
if _busy or _connect_bubbles_button == null:
return
var enabled: bool = not bool(_action.get("connect_bubbles", false))
_action["connect_bubbles"] = enabled
_refresh_connect_bubbles_button()
choice_selected.emit(&"connect_bubbles", enabled)
func _on_keep_acting_toggled(enabled: bool) -> void:
if _busy or _keep_acting_button == null:
return
_action["keep_acting"] = enabled
_refresh_keep_acting_toggle()
choice_selected.emit(&"keep_acting", enabled)
func _on_more_toggled(opened: bool) -> void:
if _busy:
return
_more_content.visible = opened
_more_button.text = "▾ More speech settings" if opened else "▸ More speech settings"
if opened:
_pinned_panel_top = _panel.position.y
_pin_top_while_more_open = true
else:
_pin_top_while_more_open = false
_preserve_top_on_layout = true
_layout_panel()
func _refresh_choices() -> void:
for raw_key: Variant in _buttons_by_key.keys():
var key: StringName = StringName(raw_key)
var buttons: Array = _buttons_by_key[key]
var values: Array = _button_values_by_key[key]
var current: Variant = _action.get(String(key), _default_value(key))
for index: int in buttons.size():
var button: Button = buttons[index] as Button
var selected: bool = current == values[index]
button.set_pressed_no_signal(selected)
_apply_choice_style(button, selected)
button.disabled = _busy
_refresh_connect_bubbles_button()
_refresh_keep_acting_toggle()
func _refresh_layout_buttons() -> void:
var bubbles: Array = _action.get("bubbles", [])
var disabled: bool = _busy or bubbles.size() <= 1
var selected_layout: String = String(_action.get("bubble_layout", "stack"))
for index: int in _layout_preset_buttons.size():
var button: Button = _layout_preset_buttons[index]
var selected: bool = index < _layout_preset_values.size() and String(_layout_preset_values[index]) == selected_layout
button.disabled = disabled
button.set_pressed_no_signal(selected)
_apply_choice_style(button, selected)
if _connect_bubbles_button != null:
_connect_bubbles_button.disabled = _busy or bubbles.size() < 2
_connect_bubbles_button.set_pressed_no_signal(bool(_action.get("connect_bubbles", false)))
_connect_bubbles_button.text = "On" if bool(_action.get("connect_bubbles", false)) else "Off"
_apply_choice_style(_connect_bubbles_button, bool(_action.get("connect_bubbles", false)))
func _refresh_connect_bubbles_button() -> void:
if _connect_bubbles_button == null:
return
var bubbles: Array = _action.get("bubbles", []) if _action.get("bubbles", []) is Array else []
var enabled: bool = bool(_action.get("connect_bubbles", false))
_connect_bubbles_button.disabled = _busy or bubbles.size() < 2
_connect_bubbles_button.set_pressed_no_signal(enabled)
_connect_bubbles_button.text = "On" if enabled else "Off"
_apply_choice_style(_connect_bubbles_button, enabled)
func _refresh_keep_acting_toggle() -> void:
if _keep_acting_button == null:
return
var enabled: bool = bool(_action.get("keep_acting", false))
_keep_acting_button.disabled = _busy
_keep_acting_button.set_pressed_no_signal(enabled)
_keep_acting_button.text = "ON" if enabled else "OFF"
_apply_choice_style(_keep_acting_button, enabled)
func _default_value(key: StringName) -> Variant:
match key:
&"arrangement": return "together"
&"reveal": return "instant"
&"pop_in": return false
&"duration_mode": return "just_enough"
return null
func _layout_panel() -> void:
if _panel == null or _stage_rect.size.x <= 0.0 or _stage_rect.size.y <= 0.0:
return
var gap: float = _gap()
var max_width: float = minf(_max_width(), _stage_rect.size.x)
var preserve_top: bool = _preserve_top_on_layout or (_pin_top_while_more_open and _more_content != null and _more_content.visible)
var pinned_top: float = _pinned_panel_top if _pin_top_while_more_open and _more_content != null and _more_content.visible else _panel.position.y
pinned_top = clampf(pinned_top, _stage_rect.position.y, _stage_rect.end.y)
var available_height: float = _stage_rect.size.y
if preserve_top:
available_height = maxf(1.0, _stage_rect.end.y - pinned_top)
var left_space: float = maxf(0.0, _group_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _group_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var panel_width: float = max_width
var panel_height: float
var panel_position: Vector2
if side_space >= minf(max_width, 300.0):
panel_width = minf(max_width, side_space)
panel_height = _fit_panel_height(panel_width, available_height)
var use_right: bool = right_space >= left_space
panel_position.x = _group_rect.end.x + gap if use_right else _group_rect.position.x - panel_width - gap
panel_position.y = clampf(_group_rect.get_center().y - panel_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - panel_height))
else:
panel_width = max_width
panel_height = _fit_panel_height(panel_width, available_height)
var below_space: float = _stage_rect.end.y - _group_rect.end.y - gap
var above_space: float = _group_rect.position.y - _stage_rect.position.y - gap
var place_below: bool = below_space >= panel_height or below_space >= above_space
panel_position.x = clampf(_group_rect.get_center().x - panel_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - panel_width))
panel_position.y = _group_rect.end.y + gap if place_below else _group_rect.position.y - panel_height - gap
panel_position.x = clampf(panel_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - panel_width))
if preserve_top:
panel_position.y = pinned_top
else:
panel_position.y = clampf(panel_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - panel_height))
panel_position = _position_avoiding_badges(panel_position, Vector2(panel_width, panel_height), _group_rect, preserve_top, pinned_top)
_panel.position = panel_position
_panel.size = Vector2(panel_width, panel_height)
_preserve_top_on_layout = false
func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, lock_y: bool, locked_y: float) -> Vector2:
var candidates: Array[Vector2] = [preferred]
var gap: float = _gap()
var center: Vector2 = anchor.get_center()
if lock_y:
candidates.append(Vector2(anchor.position.x - size.x - gap, locked_y))
candidates.append(Vector2(anchor.end.x + gap, locked_y))
candidates.append(Vector2(_stage_rect.position.x, locked_y))
candidates.append(Vector2(_stage_rect.end.x - size.x, locked_y))
else:
candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5))
candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y))
candidates.append(_stage_rect.get_center() - size * 0.5)
var best: Vector2 = _clamp_panel_position(preferred, size, lock_y, locked_y)
var best_collisions: int = _badge_collision_count(best, size)
var best_distance: float = best.distance_squared_to(preferred)
for candidate: Vector2 in candidates:
var clamped: Vector2 = _clamp_panel_position(candidate, size, lock_y, locked_y)
var collisions: int = _badge_collision_count(clamped, size)
var distance: float = clamped.distance_squared_to(preferred)
if collisions < best_collisions or (collisions == best_collisions and distance < best_distance):
best = clamped
best_collisions = collisions
best_distance = distance
return best
func _clamp_panel_position(candidate: Vector2, size: Vector2, lock_y: bool, locked_y: float) -> Vector2:
var y: float = locked_y if lock_y else clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y))
return Vector2(clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)), y)
func _badge_collision_count(position: Vector2, size: Vector2) -> int:
var panel_rect: Rect2 = Rect2(position, size)
var count: int = 0
for badge_rect: Rect2 in _badge_avoidance_rects:
if panel_rect.intersects(badge_rect):
count += 1
return count
func _rect_changed(previous: Rect2, next: Rect2, epsilon: float = 2.0) -> bool:
if previous.size == Vector2.ZERO:
return true
return previous.position.distance_to(next.position) > epsilon or previous.size.distance_to(next.size) > epsilon
func _fit_panel_height(panel_width: float, available_height: float) -> float:
_panel.custom_minimum_size = Vector2(panel_width, 0.0)
_body_scroll.custom_minimum_size.y = 0.0
_panel.reset_size()
var fixed_height: float = _panel.get_combined_minimum_size().y
var body_height: float = _body_content.get_combined_minimum_size().y
var max_height: float = maxf(1.0, available_height)
var body_room: float = maxf(48.0, max_height - fixed_height)
_body_scroll.custom_minimum_size.y = minf(body_height, body_room)
_panel.reset_size()
return minf(maxf(1.0, _panel.get_combined_minimum_size().y), max_height)
func _apply_theme() -> void:
if _panel == null:
return
_panel.add_theme_stylebox_override("panel", _panel_style())
if _title_ribbon != null:
_title_ribbon.add_theme_stylebox_override("panel", _ribbon_style())
_refresh_choices()
_apply_action_button_styles()
func _apply_choice_style(button: Button, selected: bool) -> void:
button.add_theme_stylebox_override("normal", _choice_style(selected, false))
button.add_theme_stylebox_override("hover", _choice_style(selected, true))
button.add_theme_stylebox_override("pressed", _choice_style(selected, true))
button.add_theme_stylebox_override("focus", _choice_style(selected, true))
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
func _action_button(caption: String) -> Button:
var button: Button = Button.new()
button.text = caption
button.custom_minimum_size = Vector2(136.0, _hit_height())
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 17)
return button
func _apply_action_button_styles() -> void:
for button: Button in [_preview_button, _done_button, _cancel_button, _more_button]:
if button == null:
continue
button.add_theme_stylebox_override("normal", _choice_style(false, false))
button.add_theme_stylebox_override("hover", _choice_style(false, true))
button.add_theme_stylebox_override("pressed", _choice_style(false, true))
button.add_theme_color_override("font_color", Color.WHITE)
for button: Button in _layout_preset_buttons:
button.add_theme_stylebox_override("normal", _choice_style(false, false))
button.add_theme_stylebox_override("hover", _choice_style(false, true))
button.add_theme_stylebox_override("pressed", _choice_style(false, true))
button.add_theme_color_override("font_color", Color.WHITE)
if _connect_bubbles_button != null:
_connect_bubbles_button.add_theme_stylebox_override("normal", _choice_style(false, false))
_connect_bubbles_button.add_theme_stylebox_override("hover", _choice_style(bool(_action.get("connect_bubbles", false)), true))
_connect_bubbles_button.add_theme_stylebox_override("pressed", _choice_style(bool(_action.get("connect_bubbles", false)), true))
_connect_bubbles_button.add_theme_color_override("font_color", Color.WHITE)
_refresh_layout_buttons()
func _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7")
style.border_color = _border_color()
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.set_content_margin_all(8)
return style
func _ribbon_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0")
style.set_corner_radius_all(7)
style.set_content_margin(SIDE_LEFT, 9.0)
style.set_content_margin(SIDE_TOP, 3.0)
style.set_content_margin(SIDE_RIGHT, 9.0)
style.set_content_margin(SIDE_BOTTOM, 3.0)
return style
func _choice_style(selected: bool, hovered: bool) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color("#176B83") if selected else (Color("#344154") if hovered else Color("#202733"))
style.border_color = _selection_color() if selected else (Color("#D8E0EA") if hovered else _border_color())
style.set_border_width_all(4 if selected else 2)
style.set_corner_radius_all(8)
style.set_content_margin_all(6)
return style
func _hit_height() -> float:
return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0
func _max_width() -> float:
return _theme.speech_toolbar_number("max_width", 440.0, 260.0, 620.0) if _theme != null else 440.0
func _gap() -> float:
return _theme.speech_toolbar_number("gap", 12.0, 6.0, 24.0) if _theme != null else 12.0
func _border_color() -> Color:
return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF")
func _selection_color() -> Color:
return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D")
+1
View File
@@ -0,0 +1 @@
uid://cc3gg7du2suwg
+849
View File
@@ -0,0 +1,849 @@
class_name SpeechStyleToolbar
extends Control
## Transient stage-side speech styling controls. The composer owns every edit.
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
class FramePreview extends Control:
const RENDERER := preload("res://scripts/stickman_speech_bubble.gd")
var frame_id: String = "talk"
var fill: Color = Color.WHITE
var outline: Color = Color.BLACK
func _draw() -> void:
var frame: Rect2 = Rect2(5.0, 6.0, maxf(30.0, size.x - 10.0), maxf(28.0, size.y - 12.0))
RENDERER.draw_style_shape(self, frame_id, frame, fill, outline, false, 3)
class FrameSample extends Button:
var frame_id: String = "talk"
var fill: Color = Color.WHITE
var outline: Color = Color.BLACK
var compact: bool = false
func configure(style_id: String, fill_color: Color, outline_color: Color, use_compact_layout: bool, card_width: float) -> void:
frame_id = style_id
fill = fill_color
outline = outline_color
compact = use_compact_layout
custom_minimum_size = Vector2(card_width, 92.0) if compact else Vector2(118.0, 108.0)
text = ""
var card_layout: VBoxContainer = VBoxContainer.new()
card_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
card_layout.offset_left = 6.0
card_layout.offset_top = 6.0
card_layout.offset_right = -6.0
card_layout.offset_bottom = -6.0
card_layout.add_theme_constant_override("separation", 2)
card_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(card_layout)
var preview: FramePreview = FramePreview.new()
preview.frame_id = frame_id
preview.fill = fill
preview.outline = outline
preview.custom_minimum_size = Vector2(0.0, 56.0 if compact else 68.0)
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_layout.add_child(preview)
var title: Label = Label.new()
title.text = style_id.capitalize()
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 16 if compact else 18)
title.add_theme_color_override("font_color", Color("#F8FAFC"))
title.custom_minimum_size.y = 24.0 if compact else 28.0
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_layout.add_child(title)
signal chooser_open_requested(kind: StringName)
signal choice_selected(kind: StringName, value: String)
signal sequence_action_requested(action: StringName)
signal done_styling_requested()
signal message_edit_requested(opened: bool)
var _theme: SandboxTheme
var _context: Dictionary = {}
var _stage_rect: Rect2 = Rect2()
var _frame_rect: Rect2 = Rect2()
var _badge_avoidance_rects: Array[Rect2] = []
var _bar: PanelContainer
var _bar_layout: VBoxContainer
var _bar_scroll: ScrollContainer
var _bar_content: VBoxContainer
var _speech_flow: GridContainer
var _bar_title: Label
var _bar_ribbon: PanelContainer
var _bubble_position_label: Label
var _message_button: Button
var _done_styling_button: Button
var _earlier_button: Button
var _later_button: Button
var _add_bubble_button: Button
var _remove_bubble_button: Button
var _undo_remove_button: Button
var _sequence_hint_label: Label
var _chooser: PanelContainer
var _chooser_layout: VBoxContainer
var _chooser_ribbon: PanelContainer
var _chooser_scroll: ScrollContainer
var _chooser_content: VBoxContainer
var _chooser_title: Label
var _category_buttons: Array[Button] = []
var _category_group: ButtonGroup
var _category_kinds: Array[StringName] = [&"frame", &"letters", &"effect"]
var _choice_buttons: Dictionary = {}
var _active_kind: StringName = &""
var _busy: bool = false
var _message_dialog_open: bool = false
var _compact_choices: bool = false
var _choice_width: float = 440.0
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 80
_build_ui()
visible = false
func configure(theme_data: SandboxTheme) -> void:
_theme = theme_data
if is_inside_tree() and _bar != null:
_apply_theme_styles()
func set_context(context: Dictionary) -> void:
var changed: bool = _context != context
_context = context.duplicate(true)
var choice_layout_changed: bool = _prepare_choice_layout()
_sync_category_disabled()
if _message_button != null:
_message_button.disabled = _busy
_sync_sequence_controls()
if not _active_kind.is_empty():
if choice_layout_changed:
_build_choices(_active_kind, false)
elif changed:
_refresh_choice_states()
_sync_category_buttons()
_layout_panels()
func set_anchor_rect(frame_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void:
_frame_rect = frame_viewport_rect
_stage_rect = stage_viewport_rect
visible = frame_viewport_rect.size.x > 0.0 and frame_viewport_rect.size.y > 0.0
var choice_layout_changed: bool = _prepare_choice_layout()
if choice_layout_changed and not _active_kind.is_empty():
_build_choices(_active_kind, false)
_layout_panels()
func set_badge_avoidance_rects(rects: Array[Rect2]) -> void:
if _badge_avoidance_rects == rects:
return
_badge_avoidance_rects = rects.duplicate()
_layout_panels()
func present_chooser(kind: StringName) -> void:
if _busy or not [&"frame", &"letters", &"effect"].has(kind):
return
_active_kind = kind
_prepare_choice_layout()
_build_choices(kind)
_chooser.visible = true
_sync_category_buttons()
_layout_panels()
func close_chooser() -> void:
if not is_chooser_open():
return
_active_kind = &""
_chooser.hide()
_sync_category_buttons()
func dismiss_styling() -> void:
close_chooser()
hide()
done_styling_requested.emit()
func is_chooser_open() -> bool:
return _chooser != null and _chooser.visible
func contains_viewport_point(point: Vector2) -> bool:
if not visible:
return false
if _bar != null and _bar.visible and _bar.get_global_rect().has_point(point):
return true
return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point)
func chooser_contains_viewport_point(point: Vector2) -> bool:
return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point)
func toolbar_viewport_rect() -> Rect2:
return _bar.get_global_rect() if _bar != null and is_instance_valid(_bar) else Rect2()
func set_message_dialog_open(opened: bool) -> void:
_message_dialog_open = opened
if _message_button != null:
_message_button.set_pressed_no_signal(opened)
_apply_category_button_style(_message_button, opened)
func set_busy(busy: bool) -> void:
_busy = busy
_sync_category_disabled()
if _message_button != null:
_message_button.disabled = busy
if _done_styling_button != null:
_done_styling_button.disabled = busy
_sync_sequence_controls()
if _chooser != null:
_chooser.mouse_filter = Control.MOUSE_FILTER_STOP if busy else Control.MOUSE_FILTER_PASS
_refresh_choice_states()
func _sync_category_disabled() -> void:
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
for index: int in range(_category_buttons.size()):
var is_effect_button: bool = index < _category_kinds.size() and _category_kinds[index] == &"effect"
_category_buttons[index].disabled = _busy or (is_effect_button and not has_message)
func _sync_sequence_controls() -> void:
if _context.is_empty():
return
var count: int = int(_context.get("bubble_count", 1))
var limit: int = int(_context.get("max_bubbles", 8))
if _bubble_position_label != null:
var selected_index: int = clampi(int(_context.get("bubble_index", 0)), 0, maxi(0, count - 1))
_bubble_position_label.text = "Bubble %d of %d" % [selected_index + 1, count]
if _earlier_button != null:
_earlier_button.disabled = _busy or not bool(_context.get("can_move_earlier", false))
if _later_button != null:
_later_button.disabled = _busy or not bool(_context.get("can_move_later", false))
if _add_bubble_button != null:
_add_bubble_button.disabled = _busy or count >= limit
_add_bubble_button.tooltip_text = "Eight bubbles is the limit." if count >= limit else "Add a bubble after this one"
if _remove_bubble_button != null:
_remove_bubble_button.disabled = _busy or count <= 1
if _undo_remove_button != null:
var can_undo_removed_bubble: bool = bool(_context.get("can_undo_remove", false))
_undo_remove_button.visible = can_undo_removed_bubble
_undo_remove_button.disabled = _busy or not can_undo_removed_bubble
if _sequence_hint_label != null:
var hint: String = String(_context.get("sequence_hint", ""))
if count >= limit and hint.is_empty():
hint = "You can add up to eight bubbles."
_sequence_hint_label.text = hint
_sequence_hint_label.visible = not hint.is_empty()
func _build_ui() -> void:
_bar = PanelContainer.new()
_bar.name = "SpeechStyleBar"
_bar.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_bar)
_bar_layout = VBoxContainer.new()
_bar_layout.add_theme_constant_override("separation", 8)
_bar.add_child(_bar_layout)
_bar_ribbon = PanelContainer.new()
_bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_bar_layout.add_child(_bar_ribbon)
_bar_title = Label.new()
_bar_title.text = "Speech Style"
_bar_title.add_theme_font_size_override("font_size", 24)
_bar_title.add_theme_color_override("font_color", Color("#FFFFFF"))
_bar_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bar_ribbon.add_child(_bar_title)
_bar_scroll = ScrollContainer.new()
_bar_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_bar_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_bar_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_bar_layout.add_child(_bar_scroll)
_bar_content = VBoxContainer.new()
_bar_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_bar_content.add_theme_constant_override("separation", 8)
_bar_scroll.add_child(_bar_content)
var sequence_flow: HFlowContainer = _new_button_flow()
_bar_content.add_child(sequence_flow)
_bubble_position_label = _group_label("Bubble 1 of 1")
_bubble_position_label.add_theme_font_size_override("font_size", 20)
_bubble_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sequence_flow.add_child(_bubble_position_label)
_earlier_button = _make_button("↑ Earlier")
_earlier_button.tooltip_text = "Move this bubble earlier in the speech"
_earlier_button.pressed.connect(_on_sequence_action_pressed.bind(&"earlier"))
sequence_flow.add_child(_earlier_button)
_later_button = _make_button("↓ Later")
_later_button.tooltip_text = "Move this bubble later in the speech"
_later_button.pressed.connect(_on_sequence_action_pressed.bind(&"later"))
sequence_flow.add_child(_later_button)
_category_group = ButtonGroup.new()
_category_group.allow_unpress = false
_speech_flow = GridContainer.new()
_speech_flow.columns = 2
_speech_flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_speech_flow.add_theme_constant_override("h_separation", 8)
_speech_flow.add_theme_constant_override("v_separation", 8)
_bar_content.add_child(_speech_flow)
_message_button = _make_button("✏ Message")
_message_button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height()))
_message_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_message_button.toggle_mode = true
_message_button.toggled.connect(_on_message_toggled)
_speech_flow.add_child(_message_button)
_apply_category_button_style(_message_button, false)
_add_category_button("🗨 Frame", &"frame", _speech_flow)
_add_category_button("🔤 Letters", &"letters", _speech_flow)
_add_category_button("✨ Effects", &"effect", _speech_flow)
var bubble_flow: HFlowContainer = _new_button_flow()
_bar_content.add_child(bubble_flow)
_add_bubble_button = _make_button("➕ Add bubble")
_add_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"add"))
bubble_flow.add_child(_add_bubble_button)
_remove_bubble_button = _make_button("🗑 Remove bubble")
_remove_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"remove"))
bubble_flow.add_child(_remove_bubble_button)
_undo_remove_button = _make_button("↶ Undo")
_undo_remove_button.visible = false
_undo_remove_button.pressed.connect(_on_sequence_action_pressed.bind(&"undo_remove"))
bubble_flow.add_child(_undo_remove_button)
_sequence_hint_label = _group_label("")
_sequence_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_sequence_hint_label.visible = false
_bar_content.add_child(_sequence_hint_label)
var done_margin: MarginContainer = MarginContainer.new()
done_margin.add_theme_constant_override("margin_top", 8)
_bar_layout.add_child(done_margin)
var footer: CenterContainer = CenterContainer.new()
done_margin.add_child(footer)
_done_styling_button = _make_button("✓ Done styling")
_done_styling_button.custom_minimum_size = Vector2(220.0, maxf(56.0, _hit_height()))
_done_styling_button.add_theme_font_size_override("font_size", 19)
_done_styling_button.pressed.connect(dismiss_styling)
footer.add_child(_done_styling_button)
_chooser = PanelContainer.new()
_chooser.name = "SpeechStyleChooser"
_chooser.mouse_filter = Control.MOUSE_FILTER_STOP
_chooser.hide()
add_child(_chooser)
_chooser_layout = VBoxContainer.new()
_chooser_layout.add_theme_constant_override("separation", 8)
_chooser.add_child(_chooser_layout)
_chooser_ribbon = PanelContainer.new()
_chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_chooser_layout.add_child(_chooser_ribbon)
_chooser_title = Label.new()
_chooser_title.add_theme_font_size_override("font_size", 22)
_chooser_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_chooser_title.add_theme_color_override("font_color", Color("#FFFFFF"))
_chooser_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chooser_ribbon.add_child(_chooser_title)
_chooser_scroll = ScrollContainer.new()
_chooser_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_chooser_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_chooser_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_chooser_layout.add_child(_chooser_scroll)
_chooser_content = VBoxContainer.new()
_chooser_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_chooser_content.add_theme_constant_override("separation", 8)
_chooser_scroll.add_child(_chooser_content)
_apply_theme_styles()
func _new_button_flow() -> HFlowContainer:
var flow: HFlowContainer = HFlowContainer.new()
flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
flow.add_theme_constant_override("h_separation", 8)
flow.add_theme_constant_override("v_separation", 8)
return flow
func _group_label(caption: String) -> Label:
var label: Label = Label.new()
label.text = caption
label.add_theme_font_size_override("font_size", 17)
label.add_theme_color_override("font_color", Color("#D6E1EF"))
return label
func _add_category_button(caption: String, kind: StringName, parent: Control) -> void:
var button: Button = _make_button(caption)
button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height()))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.toggle_mode = true
button.button_group = _category_group
button.add_theme_font_size_override("font_size", 21)
button.pressed.connect(_on_category_pressed.bind(kind))
_category_buttons.append(button)
parent.add_child(button)
_apply_category_button_style(button, false)
func _on_message_toggled(opened: bool) -> void:
if _busy:
_message_button.set_pressed_no_signal(_message_dialog_open)
return
set_message_dialog_open(opened)
if opened:
close_chooser()
message_edit_requested.emit(opened)
func _on_category_pressed(kind: StringName) -> void:
if _busy:
return
if _message_dialog_open:
set_message_dialog_open(false)
message_edit_requested.emit(false)
if is_chooser_open() and _active_kind == kind:
_sync_category_buttons()
return
chooser_open_requested.emit(kind)
func _on_sequence_action_pressed(action: StringName) -> void:
if _busy:
return
sequence_action_requested.emit(action)
func _sync_category_buttons() -> void:
for index: int in range(_category_buttons.size()):
var button: Button = _category_buttons[index]
var active: bool = is_chooser_open() and index < _category_kinds.size() and _category_kinds[index] == _active_kind
button.set_pressed_no_signal(active)
_apply_category_button_style(button, active)
func _apply_category_button_style(button: Button, active: bool) -> void:
var normal: StyleBoxFlat = StyleBoxFlat.new()
normal.bg_color = Color("#176B83") if active else Color("#344255")
normal.border_color = _selection_color() if active else Color("#C6D1DE")
normal.set_border_width_all(4 if active else 2)
normal.set_corner_radius_all(9)
normal.set_content_margin_all(8)
var hover: StyleBoxFlat = normal.duplicate() as StyleBoxFlat
if not active:
hover.bg_color = Color("#40546D")
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_color_override("font_color", Color("#FFFFFF"))
func _build_choices(kind: StringName, relayout: bool = true) -> void:
for child: Node in _chooser_content.get_children():
_chooser_content.remove_child(child)
child.free()
_chooser_scroll.custom_minimum_size.y = 0.0
_choice_buttons.clear()
var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {}
var target_text: String = String(target.get("text", ""))
_chooser_title.text = _chooser_title_for(kind, target_text)
if kind == &"frame":
var frames: GridContainer = GridContainer.new()
frames.columns = 2 if _compact_choices else 3
frames.add_theme_constant_override("h_separation", 6)
frames.add_theme_constant_override("v_separation", 8)
_chooser_content.add_child(frames)
for frame_id: String in SPEECH_PAYLOAD.BUBBLE_STYLES:
var sample: FrameSample = FrameSample.new()
var card_width: float = minf(86.0, maxf(60.0, (_choice_width - 48.0) * 0.5)) if _compact_choices else 118.0
sample.configure(frame_id, _frame_color(frame_id, "fill", Color("#FFFDF6")), _frame_color(frame_id, "outline", Color("#202020")), _compact_choices, card_width)
_configure_choice(sample, &"frame", frame_id)
frames.add_child(sample)
elif kind == &"letters":
_add_subheading("Font")
var fonts: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(fonts)
for font_id: String in SPEECH_PAYLOAD.FONT_PRESETS:
var font_choice: Button = _make_button("Aa\n" + font_id.capitalize())
font_choice.custom_minimum_size = Vector2(76.0, 86.0) if _compact_choices else Vector2(120.0, 92.0)
font_choice.add_theme_font_override("font", _theme.speech_font(font_id) if _theme != null else ThemeDB.fallback_font)
var font_preview_size: int = 24
if _theme != null:
font_preview_size = clampi(roundi(float(_theme.speech_font_size(font_id, "medium")) * 0.7), 24, 28)
font_choice.add_theme_font_size_override("font_size", font_preview_size)
_configure_choice(font_choice, &"font", font_id)
fonts.add_child(font_choice)
_add_subheading("Size")
var sizes: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(sizes)
for size_id: String in SPEECH_PAYLOAD.SIZE_PRESETS:
var size_choice: Button = _make_button("Aa\n" + size_id.capitalize())
size_choice.custom_minimum_size = Vector2(76.0, 76.0) if _compact_choices else Vector2(120.0, 84.0)
size_choice.add_theme_font_size_override("font_size", _size_font_size(size_id))
_configure_choice(size_choice, &"size", size_id)
sizes.add_child(size_choice)
_add_subheading("Color")
var colors: GridContainer = GridContainer.new()
colors.columns = clampi(floori((_choice_width - 24.0) / 72.0), 1, 4)
colors.add_theme_constant_override("h_separation", 8)
colors.add_theme_constant_override("v_separation", 8)
_chooser_content.add_child(colors)
if _theme != null:
for entry: Dictionary in _theme.speech_text_colors():
var color: Color = entry.get("color", Color.BLACK)
var color_id: String = "#" + color.to_html(false)
var swatch: Button = _make_button("")
swatch.custom_minimum_size = Vector2(64.0, 64.0)
swatch.tooltip_text = String(entry.get("name", "Color"))
_configure_choice(swatch, &"color", color_id)
colors.add_child(swatch)
else:
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
if not has_message:
var hint: Label = Label.new()
hint.text = "Add a message before choosing an effect."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_chooser_content.add_child(hint)
var effects: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(effects)
var effect_labels: Dictionary = {"shake": "〰 Shake", "wave": "🌊 Wave", "bounce": "⬆ Bounce", "none": "○ No effect"}
for effect_id: String in ["shake", "wave", "bounce", "none"]:
var effect_choice: Button = _make_button(String(effect_labels[effect_id]))
effect_choice.custom_minimum_size = Vector2(136.0, 60.0)
effect_choice.add_theme_font_size_override("font_size", 20)
effect_choice.disabled = not has_message
_configure_choice(effect_choice, &"effect", effect_id)
effects.add_child(effect_choice)
if relayout:
_layout_panels()
func _configure_choice(button: Button, kind: StringName, value: String) -> void:
button.focus_mode = Control.FOCUS_NONE
button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, _hit_height())
button.pressed.connect(_on_choice_pressed.bind(kind, value))
_choice_buttons[String(kind) + ":" + value] = {"button": button, "kind": kind, "value": value}
_refresh_choice_state(button, kind, value)
func _on_choice_pressed(kind: StringName, value: String) -> void:
if _busy:
return
if kind == &"effect" and String(_context.get("text", "")).strip_edges().is_empty():
return
choice_selected.emit(kind, value)
func _refresh_choice_state(button: Button, kind: StringName, value: String) -> void:
var selected: String = ""
match kind:
&"frame": selected = String(_context.get("bubble_style", "talk"))
&"font": selected = String(_context.get("font_preset", "comic"))
&"size": selected = String(_context.get("size_preset", "medium"))
&"color": selected = String(_context.get("color", "#202020")).to_lower()
&"effect": selected = String(_context.get("effect", "none"))
var chosen: bool = selected.to_lower() == value.to_lower()
var base_color: String = value if kind == &"color" else ("#176B83" if chosen else "#344255")
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
button.disabled = _busy or (kind == &"effect" and not has_message)
button.add_theme_stylebox_override("normal", _choice_button_style(chosen, base_color, false))
button.add_theme_stylebox_override("hover", _choice_button_style(chosen, base_color, true))
button.add_theme_stylebox_override("pressed", _choice_button_style(chosen, base_color, true))
button.add_theme_stylebox_override("focus", _choice_button_style(chosen, base_color, false))
button.add_theme_color_override("font_color", Color("#FFFFFF"))
func _refresh_choice_states() -> void:
var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {}
_chooser_title.text = _chooser_title_for(_active_kind, String(target.get("text", "")))
for entry_value: Variant in _choice_buttons.values():
if not entry_value is Dictionary:
continue
var entry: Dictionary = entry_value as Dictionary
var button: Button = entry.get("button") as Button
if button == null or not is_instance_valid(button):
continue
_refresh_choice_state(button, StringName(entry.get("kind", "")), String(entry.get("value", "")))
func _chooser_title_for(kind: StringName, target_text: String) -> String:
match kind:
&"frame": return "Choose a frame"
&"letters": return "Choose how the letters look"
&"effect": return "Choose an effect for %s" % target_text if not target_text.is_empty() else "Choose an effect for the whole bubble"
return "Choose a style"
func _choice_button_style(chosen: bool, base_color: String, hovered: bool) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(base_color)
if hovered:
style.bg_color = style.bg_color.lightened(0.12)
style.border_color = _selection_color() if chosen else Color("#F8FAFC")
style.set_border_width_all(5 if chosen else 2)
style.set_corner_radius_all(8)
style.set_content_margin_all(6)
return style
func _add_subheading(text: String) -> void:
var label: Label = Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 20)
label.add_theme_color_override("font_color", _selection_color())
_chooser_content.add_child(label)
func _layout_panels() -> void:
if _bar == null or _stage_rect.size.x <= 0.0:
return
var choice_layout_changed: bool = _prepare_choice_layout()
if choice_layout_changed and _chooser != null and _chooser.visible and not _active_kind.is_empty():
_build_choices(_active_kind, false)
var max_width: float = _max_width()
var gap: float = _gap()
var stage_width: float = _stage_rect.size.x
var stage_height: float = _stage_rect.size.y
var bar_width: float = minf(max_width, stage_width)
var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var toolbar_side_layout: bool = side_space >= minf(max_width, 300.0)
if toolbar_side_layout:
bar_width = minf(max_width, side_space)
var bar_height: float = _fit_bar_height(bar_width, stage_height)
var bar_position: Vector2
if toolbar_side_layout:
var toolbar_on_right: bool = right_space >= left_space
bar_position.x = _frame_rect.end.x + gap if toolbar_on_right else _frame_rect.position.x - bar_width - gap
bar_position.y = clampf(_frame_rect.get_center().y - bar_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height))
else:
var above_space: float = _frame_rect.position.y - _stage_rect.position.y - gap
var below_space: float = _stage_rect.end.y - _frame_rect.end.y - gap
var place_above: bool = above_space >= bar_height or above_space >= below_space
bar_position.x = clampf(_frame_rect.get_center().x - bar_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width))
bar_position.y = _frame_rect.position.y - bar_height - gap if place_above else _frame_rect.end.y + gap
bar_position.x = clampf(bar_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width))
bar_position.y = clampf(bar_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height))
bar_position = _position_avoiding_badges(bar_position, Vector2(bar_width, bar_height), _frame_rect)
_bar.position = bar_position
_bar.size = Vector2(bar_width, bar_height)
if _chooser == null or not _chooser.visible:
return
var below_toolbar: float = maxf(_stage_rect.position.y, _bar.position.y + _bar.size.y + gap)
var use_right: bool = right_space >= left_space
var chooser_side_space: float = right_space if use_right else left_space
var side_layout: bool = chooser_side_space >= 180.0 and _stage_rect.end.y - below_toolbar >= 150.0
var chooser_width: float = minf(440.0, chooser_side_space) if side_layout else minf(440.0, _stage_rect.size.x)
_chooser.custom_minimum_size = Vector2(chooser_width, 0.0)
var content_height: float = _chooser_content.get_combined_minimum_size().y
# ScrollContainer doesn't include its child's vertical minimum in the panel's
# natural size. Seed it here so short choosers fit their content and long ones
# can be capped below without collapsing the choice area to zero height.
_chooser_scroll.custom_minimum_size.y = content_height
_chooser.reset_size()
_chooser.size.x = chooser_width
var natural_height: float = maxf(1.0, _chooser.get_combined_minimum_size().y)
var fixed_height: float = maxf(0.0, natural_height - content_height)
var max_height: float = maxf(150.0, _stage_rect.size.y * 0.82)
var chooser_y: float
if side_layout:
chooser_y = below_toolbar
max_height = minf(max_height, maxf(1.0, _stage_rect.end.y - chooser_y))
else:
var below_y: float = _frame_rect.end.y + gap
var below_room: float = _stage_rect.end.y - below_y
var above_y: float = _stage_rect.position.y
var above_room: float = _bar.position.y - gap - above_y
if below_room >= above_room:
chooser_y = below_y
max_height = minf(max_height, maxf(1.0, below_room))
else:
chooser_y = above_y
max_height = minf(max_height, maxf(1.0, above_room))
var chooser_height: float = minf(natural_height, max_height)
if natural_height > max_height:
_chooser_scroll.custom_minimum_size.y = maxf(64.0, max_height - fixed_height)
_chooser.reset_size()
_chooser.custom_minimum_size = Vector2(chooser_width, chooser_height)
_chooser.size = Vector2(chooser_width, chooser_height)
var chooser_x: float
if side_layout:
chooser_x = _frame_rect.end.x + gap if use_right else _frame_rect.position.x - gap - chooser_width
else:
chooser_x = clampf(_frame_rect.get_center().x - chooser_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - chooser_width))
chooser_y = clampf(chooser_y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - chooser_height))
var chooser_position: Vector2 = Vector2(chooser_x, chooser_y)
var toolbar_obstruction: Array[Rect2] = [Rect2(_bar.position, _bar.size)]
_chooser.position = _position_avoiding_badges(chooser_position, Vector2(chooser_width, chooser_height), _frame_rect, toolbar_obstruction)
func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, extra_obstructions: Array[Rect2] = []) -> Vector2:
var candidates: Array[Vector2] = [preferred]
var gap: float = _gap()
var center: Vector2 = anchor.get_center()
candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5))
candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y))
candidates.append(_stage_rect.get_center() - size * 0.5)
var best: Vector2 = _clamp_panel_position(preferred, size)
var best_collisions: int = _badge_collision_count(best, size, extra_obstructions)
var best_distance: float = best.distance_squared_to(preferred)
for candidate: Vector2 in candidates:
var clamped: Vector2 = _clamp_panel_position(candidate, size)
var collisions: int = _badge_collision_count(clamped, size, extra_obstructions)
var distance: float = clamped.distance_squared_to(preferred)
if collisions < best_collisions or (collisions == best_collisions and distance < best_distance):
best = clamped
best_collisions = collisions
best_distance = distance
if best_collisions == 0 and is_zero_approx(best_distance):
return best
return best
func _clamp_panel_position(candidate: Vector2, size: Vector2) -> Vector2:
return Vector2(
clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)),
clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y))
)
func _badge_collision_count(position: Vector2, size: Vector2, extra_obstructions: Array[Rect2]) -> int:
var panel_rect: Rect2 = Rect2(position, size)
var count: int = 0
for badge_rect: Rect2 in _badge_avoidance_rects:
if panel_rect.intersects(badge_rect):
count += 1
for obstruction: Rect2 in extra_obstructions:
if panel_rect.intersects(obstruction):
count += 1
return count
func _fit_bar_height(bar_width: float, available_height: float) -> float:
_bar.custom_minimum_size = Vector2(bar_width, 0.0)
_bar_scroll.custom_minimum_size.y = 0.0
_bar.reset_size()
var fixed_height: float = _bar.get_combined_minimum_size().y
var content_height: float = _bar_content.get_combined_minimum_size().y
var max_height: float = maxf(1.0, available_height)
var body_room: float = maxf(48.0, max_height - fixed_height)
_bar_scroll.custom_minimum_size.y = minf(content_height, body_room)
_bar.reset_size()
return minf(maxf(54.0, _bar.get_combined_minimum_size().y), max_height)
func _prepare_choice_layout() -> bool:
var previous_compact: bool = _compact_choices
var previous_width: float = _choice_width
var gap: float = _gap()
var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var can_place_to_side: bool = side_space >= 180.0
_choice_width = minf(440.0, side_space) if can_place_to_side else minf(440.0, _stage_rect.size.x)
_compact_choices = can_place_to_side and _choice_width < 320.0
return previous_compact != _compact_choices or not is_equal_approx(previous_width, _choice_width)
func _apply_theme_styles() -> void:
if _bar == null or _chooser == null:
return
_bar.add_theme_stylebox_override("panel", _panel_style())
_chooser.add_theme_stylebox_override("panel", _panel_style())
_bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_sync_category_buttons()
var action_normal: StyleBoxFlat = _choice_button_style(false, "#344255", false)
var action_hover: StyleBoxFlat = _choice_button_style(false, "#40546D", true)
for action_button: Button in [_done_styling_button]:
if action_button == null:
continue
action_button.add_theme_stylebox_override("normal", action_normal)
action_button.add_theme_stylebox_override("hover", action_hover)
action_button.add_theme_stylebox_override("pressed", action_normal)
action_button.add_theme_stylebox_override("focus", action_normal)
action_button.add_theme_color_override("font_color", Color("#FFFFFF"))
_apply_category_button_style(_message_button, _message_dialog_open)
if _theme != null:
var gap: int = int(_theme.speech_toolbar_number("gap", 8.0, 4.0, 20.0))
_speech_flow.add_theme_constant_override("h_separation", gap)
_speech_flow.add_theme_constant_override("v_separation", gap)
_sync_sequence_controls()
func _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7")
style.border_color = _border_color()
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.set_content_margin_all(8)
return style
func _ribbon_style(color: Color) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = color
style.set_corner_radius_all(7)
style.set_content_margin(SIDE_LEFT, 10.0)
style.set_content_margin(SIDE_TOP, 4.0)
style.set_content_margin(SIDE_RIGHT, 10.0)
style.set_content_margin(SIDE_BOTTOM, 4.0)
return style
func _ribbon_color() -> Color:
return _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0")
func _make_button(caption: String) -> Button:
var button: Button = Button.new()
button.text = caption
button.custom_minimum_size.y = _hit_height()
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 18)
return button
func _hit_height() -> float:
return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0
func _max_width() -> float:
return _theme.speech_toolbar_number("max_width", 560.0, 300.0, 720.0) if _theme != null else 560.0
func _gap() -> float:
return _theme.speech_toolbar_number("gap", 10.0, 4.0, 24.0) if _theme != null else 10.0
func _border_color() -> Color:
return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF")
func _selection_color() -> Color:
return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D")
func _frame_color(frame_id: String, channel: String, fallback: Color) -> Color:
return _theme.speech_color(frame_id, channel, fallback) if _theme != null else fallback
func _size_font_size(size_id: String) -> int:
match size_id:
"small": return 20
"huge": return 40
_: return 28
+1
View File
@@ -0,0 +1 @@
uid://0d2x5ns8go3v
+18
View File
@@ -0,0 +1,18 @@
extends RichTextEffect
## Per-word motions driven entirely by the owning speech group's saved clock.
var bbcode: String = "speechmotion"
func _process_custom_fx(char_fx: CharFXTransform) -> bool:
var env: Dictionary = char_fx.env
var kind: String = String(env.get("kind", ""))
var clock: float = float(env.get("clock", 0.0))
var index: float = float(char_fx.relative_index)
match kind:
"shake":
char_fx.offset += Vector2(sin(clock * 34.0 + index * 11.0) * 2.8, cos(clock * 41.0 + index * 7.0) * 1.8)
"wave":
char_fx.offset.y += sin(clock * 11.0 - index * 0.72) * 6.0
"bounce":
char_fx.offset.y -= absf(sin(clock * 7.0 - index * 0.42)) * 8.0
return true
+1
View File
@@ -0,0 +1 @@
uid://djm7bdf1q5ao0
+114 -10
View File
@@ -6,6 +6,7 @@ extends RefCounted
const FILE_VERSION: String = "1.1"
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
@@ -25,10 +26,11 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for action: Dictionary in actions:
for action_index: int in actions.size():
var action: Dictionary = actions[action_index]
var encoded := _encode_flat_action(action)
if encoded.is_empty():
warnings.append("Skipped invalid queue action.")
warnings.append("Skipped invalid queue action %d (%s)." % [action_index + 1, String(action.get("type", "unknown"))])
else:
out.append(encoded)
return {"actions": out, "warnings": warnings}
@@ -36,13 +38,14 @@ static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
static func decode_queue(entries: Array) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for entry: Variant in entries:
for entry_index: int in entries.size():
var entry: Variant = entries[entry_index]
if not entry is Dictionary:
warnings.append("Skipped malformed queue action.")
warnings.append("Skipped malformed queue action %d (unknown type)." % (entry_index + 1))
continue
var decoded := _decode_flat_action(entry as Dictionary)
if decoded.is_empty():
warnings.append("Skipped invalid queue action.")
warnings.append("Skipped invalid queue action %d (%s)." % [entry_index + 1, String((entry as Dictionary).get("type", "unknown"))])
else:
out.append(decoded)
return {"actions": out, "warnings": warnings}
@@ -199,7 +202,7 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
"walk_to":
if not action.get("target") is Vector2: return {}
return {"type": type, "target": vector_to_data(action["target"])}
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
"speak": return _encode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
@@ -222,10 +225,7 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
if not is_vector_data(action.get("target")): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
"speak":
if not _finite_number(action.get("duration", 2.0)): return {}
var out := {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0))}
if bool(action.get("stop_current", false)): out["stop_current"] = true
return out
return _decode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
@@ -247,6 +247,110 @@ static func _unmap_ref(value: Variant, map: Dictionary, unavailable: Dictionary)
if not value is String or unavailable.has(value) or not map.has(value): return -2
return int(map[value])
static func _encode_speech(action: Dictionary) -> Dictionary:
if not _finite_number(action.get("duration", 2.0)):
return {}
var out: Dictionary = {"type": "speak", "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false)), "keep_acting": bool(action.get("keep_acting", false))}
if not action.has("bubbles"):
if action.get("visual", null) is Dictionary:
out["visual"] = (action["visual"] as Dictionary).duplicate(true)
for key: String in ["reveal", "pop_in"]:
if action.has(key):
out[key] = action[key]
var legacy_speech: Dictionary = SPEECH_PAYLOAD.normalize(action)
for key: String in ["arrangement", "bubble_layout", "connect_bubbles"]:
out[key] = legacy_speech.get(key)
if action.get("placement", null) is Dictionary:
var legacy_placement: Dictionary = (action["placement"] as Dictionary).duplicate(true)
if String(legacy_placement.get("mode", "auto")) == "manual" and legacy_placement.get("center", null) is Vector2:
legacy_placement["center"] = vector_to_data(legacy_placement["center"])
out["placement"] = legacy_placement
return out
var checked: Dictionary = action.duplicate(true)
checked["type"] = "speak"
var error: String = SPEECH_PAYLOAD.validate(checked)
if not error.is_empty():
return {}
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(checked)
for key: String in ["arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "keep_acting"]:
if normalized.has(key):
out[key] = normalized[key]
var encoded_bubbles: Array[Dictionary] = []
for raw_bubble: Variant in normalized.get("bubbles", []):
if not raw_bubble is Dictionary:
return {}
var bubble: Dictionary = (raw_bubble as Dictionary).duplicate(true)
var bubble_placement_value: Variant = bubble.get("placement", null)
if bubble_placement_value is Dictionary:
var bubble_placement: Dictionary = bubble_placement_value
if String(bubble_placement.get("mode", "auto")) == "manual":
var bubble_center: Variant = bubble_placement.get("center", null)
if bubble_center is Vector2:
bubble_placement["center"] = vector_to_data(bubble_center)
bubble["placement"] = bubble_placement
encoded_bubbles.append(bubble)
out["bubbles"] = encoded_bubbles
var placement: Dictionary = normalized.get("placement", {"mode": "auto"}).duplicate(true)
if String(placement.get("mode", "auto")) == "manual":
var center: Variant = placement.get("center", Vector2.ZERO)
if center is Vector2:
placement["center"] = vector_to_data(center)
out["placement"] = placement
return out
static func _decode_speech(action: Dictionary) -> Dictionary:
if not _finite_number(action.get("duration", 2.0)):
return {}
var raw: Dictionary = action.duplicate(true)
if raw.has("placement") and raw["placement"] is Dictionary:
var placement: Dictionary = raw["placement"]
if String(placement.get("mode", "auto")) == "manual":
if not is_vector_data(placement.get("center")):
return {}
placement["center"] = data_to_vector(placement["center"], Vector2.ZERO)
if raw.get("bubbles", null) is Array:
var decoded_bubbles: Array = []
for raw_bubble: Variant in raw["bubbles"] as Array:
if not raw_bubble is Dictionary:
return {}
var bubble: Dictionary = (raw_bubble as Dictionary).duplicate(true)
if bubble.has("placement"):
if not bubble["placement"] is Dictionary:
return {}
var bubble_placement: Dictionary = bubble["placement"]
if String(bubble_placement.get("mode", "auto")) == "manual":
if not is_vector_data(bubble_placement.get("center")):
return {}
bubble_placement["center"] = data_to_vector(bubble_placement["center"], Vector2.ZERO)
decoded_bubbles.append(bubble)
raw["bubbles"] = decoded_bubbles
if not raw.has("bubbles"):
var legacy: Dictionary = {"type": "speak", "text": String(raw.get("text", "")), "duration": float(raw.get("duration", 2.0))}
if raw.get("visual", null) is Dictionary:
legacy["visual"] = (raw["visual"] as Dictionary).duplicate(true)
if raw.get("placement", null) is Dictionary:
legacy["placement"] = (raw["placement"] as Dictionary).duplicate(true)
if bool(raw.get("stop_current", false)):
legacy["stop_current"] = true
if raw.has("keep_acting"):
legacy["keep_acting"] = bool(raw.get("keep_acting", false))
for key: String in ["reveal", "pop_in"]:
if raw.has(key):
legacy[key] = raw[key]
var legacy_speech: Dictionary = SPEECH_PAYLOAD.normalize(raw)
for key: String in ["arrangement", "bubble_layout", "connect_bubbles"]:
legacy[key] = legacy_speech.get(key)
return legacy
if raw.has("bubbles"):
var error: String = SPEECH_PAYLOAD.validate(raw)
if not error.is_empty():
return {}
var decoded: Dictionary = SPEECH_PAYLOAD.normalize(raw)
decoded.erase("legacy_fixed")
return decoded
static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary:
var type := String(trigger.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
+42 -20
View File
@@ -6,7 +6,7 @@ extends Node
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
@@ -40,6 +40,7 @@ var _phase_timer: float = 0.0
var _primary_speech: Dictionary = {}
var _overlay_speech: Dictionary = {}
var _speech_bubble: Node2D = null
var _speech_preview_suppressed: bool = false
var _action_ids: Array[int] = []
var _next_action_id: int = 1
var _current_action_id: int = -1
@@ -90,15 +91,33 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
func speak(text: String, duration: float) -> void:
_primary_speech = {"text": text, "time_left": maxf(duration, 0.0)}
speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
func speak_action(action: Dictionary) -> void:
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action)
var duration: float = SPEECH_PAYLOAD.duration_for(normalized)
_primary_speech = {"action": normalized, "elapsed": 0.0, "duration": duration}
_show_visible_speech()
func speak_overlay(text: String, duration: float) -> void:
_overlay_speech = {"text": text, "time_left": maxf(duration, 0.0)}
speak_overlay_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
func speak_overlay_action(action: Dictionary) -> void:
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action)
var duration: float = SPEECH_PAYLOAD.duration_for(normalized)
_overlay_speech = {"action": normalized, "elapsed": 0.0, "duration": duration}
_show_visible_speech()
func set_speech_preview_suppressed(value: bool) -> void:
_speech_preview_suppressed = value
if _speech_bubble != null and is_instance_valid(_speech_bubble):
(_speech_bubble as SpeechBubble).set_suppressed(value)
func hide_speech() -> void:
_primary_speech.clear()
_overlay_speech.clear()
@@ -108,7 +127,7 @@ func hide_speech() -> void:
func sync_facing(profile: int) -> void:
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE
(_speech_bubble as SpeechBubble).set_mirrored(profile == StickmanRig.FacingProfile.LEFT)
func set_authored_queue(actions: Array[Dictionary]) -> bool:
@@ -266,7 +285,7 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
for action: Dictionary in actions:
var role: int = ACTION_REGISTRY.reactive_role(action)
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
speak_overlay_action(action)
continue
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
if not takeover_seen:
@@ -394,8 +413,12 @@ func _begin_action(action: Dictionary) -> void:
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
_finish_action()
"speak":
_action_phase = ActionPhase.SPEAKING
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
if bool(action.get("keep_acting", false)):
speak_overlay_action(action)
_finish_action()
else:
_action_phase = ActionPhase.SPEAKING
speak_action(action)
"wait":
_phase_timer = float(action.get("duration", 0.0))
_action_phase = ActionPhase.WAITING
@@ -523,22 +546,20 @@ func _is_blocked() -> bool:
func _update_speech(delta: float) -> void:
var visible_owner_changed: bool = false
if not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
if float(_primary_speech["time_left"]) <= 0.0:
_primary_speech.clear()
speech_finished.emit()
if not _overlay_speech.is_empty():
_overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta
if float(_overlay_speech["time_left"]) <= 0.0:
_overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta
if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)):
_overlay_speech.clear()
visible_owner_changed = true
speech_finished.emit()
elif not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta
if float(_primary_speech.get("elapsed", 0.0)) >= float(_primary_speech.get("duration", 0.0)):
_primary_speech.clear()
speech_finished.emit()
if _overlay_speech.is_empty() and _primary_speech.is_empty():
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
elif visible_owner_changed and _overlay_speech.is_empty():
else:
_show_visible_speech()
@@ -546,11 +567,12 @@ func _show_visible_speech() -> void:
if _rig == null:
return
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as Node2D
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as SpeechBubble
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
_rig.add_child(_speech_bubble)
sync_facing(_rig.facing_profile)
(_speech_bubble as SpeechBubble).set_suppressed(_speech_preview_suppressed)
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
if not visible_speech.is_empty():
_speech_bubble.show_text(String(visible_speech.get("text", "")))
var action: Dictionary = visible_speech.get("action", {})
(_speech_bubble as SpeechBubble).render_action(action, float(visible_speech.get("elapsed", 0.0)), false)
+33
View File
@@ -410,6 +410,29 @@ func get_facing_profile() -> int:
return int(facing_profile)
func speech_head_geometry() -> Dictionary:
var head_visual: Node2D = _body_container.get_node_or_null(NodePath("Head")) as Node2D if _body_container != null and is_instance_valid(_body_container) else null
if state == RigState.RAGDOLL and _ragdoll_component != null and is_instance_valid(_ragdoll_component):
var body_nodes: Variant = _ragdoll_component.call("get_body_nodes")
if body_nodes is Array:
for raw_body: Variant in body_nodes as Array:
if not raw_body is RigidBody2D:
continue
var body: RigidBody2D = raw_body as RigidBody2D
if String(body.get_meta("part_name", "")) == "head":
var body_radius: float = float(body.get_meta("radius", 100.0))
var body_scale: Vector2 = body.global_scale.abs()
return {"center": to_local(body.global_position), "radius": body_radius * maxf(body_scale.x, body_scale.y)}
if head_visual != null and is_instance_valid(head_visual):
var raw_radius: Variant = head_visual.get("radius")
var radius: float = float(raw_radius) if raw_radius is int or raw_radius is float else 100.0
var head_scale: Vector2 = head_visual.global_scale.abs()
return {"center": to_local(head_visual.global_position), "radius": radius * maxf(head_scale.x, head_scale.y)}
if _head_bone != null and is_instance_valid(_head_bone):
return {"center": to_local(_head_bone.global_position), "radius": 100.0 * maxf(absf(_head_bone.global_scale.x), absf(_head_bone.global_scale.y))}
return {"center": Vector2(0.0, -310.0), "radius": 100.0}
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
match joint:
"LeftArm":
@@ -785,6 +808,16 @@ func speak(text: String, duration: float) -> void:
_action_runner_component.speak(text, duration)
func speak_action(action: Dictionary) -> void:
if _action_runner_component != null:
_action_runner_component.speak_action(action)
func set_speech_preview_suppressed(value: bool) -> void:
if _action_runner_component != null:
_action_runner_component.set_speech_preview_suppressed(value)
func _speak_overlay(text: String, duration: float) -> void:
if _action_runner_component != null:
_action_runner_component.speak_overlay(text, duration)
File diff suppressed because it is too large Load Diff
+219
View File
@@ -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.
+49
View File
@@ -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.
+29
View File
@@ -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.
+28
View File
@@ -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.
+57
View File
@@ -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.
+32
View File
@@ -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.
+141
View File
@@ -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.
+138
View File
@@ -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.
+77
View File
@@ -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.
+88
View File
@@ -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.
+151
View File
@@ -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] │
└──────────────────────────────┘
```
+2
View File
@@ -0,0 +1,2 @@
Godot Engine v4.7.1.stable.official.a13da4feb - https://godotengine.org