diff --git a/.codex/agents/uiux.toml b/.codex/agents/uiux.toml new file mode 100644 index 0000000..121909c --- /dev/null +++ b/.codex/agents/uiux.toml @@ -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) +""" \ No newline at end of file diff --git a/.codex/skills/spec-pipeline/SKILL.md b/.codex/skills/spec-pipeline/SKILL.md index 11a1c03..0ad4c74 100644 --- a/.codex/skills/spec-pipeline/SKILL.md +++ b/.codex/skills/spec-pipeline/SKILL.md @@ -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." diff --git a/docs/speech_system_as_built.md b/docs/speech_system_as_built.md new file mode 100644 index 0000000..19b57ae --- /dev/null +++ b/docs/speech_system_as_built.md @@ -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. diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index add481d..d6f6b77 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -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_ diff --git a/game_scenes/speech.scene b/game_scenes/speech.scene new file mode 100644 index 0000000..10290bc --- /dev/null +++ b/game_scenes/speech.scene @@ -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": "" + } +} \ No newline at end of file diff --git a/game_scenes/talk.scene b/game_scenes/talk.scene new file mode 100644 index 0000000..4c924fb --- /dev/null +++ b/game_scenes/talk.scene @@ -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": "" + } +} \ No newline at end of file diff --git a/plans/PHASE_13_SPEECH_BUBBLES.md b/plans/PHASE_13_SPEECH_BUBBLES.md new file mode 100644 index 0000000..a2dacfe --- /dev/null +++ b/plans/PHASE_13_SPEECH_BUBBLES.md @@ -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. diff --git a/resources/fonts/comic.ttf b/resources/fonts/comic.ttf new file mode 100644 index 0000000..abc42a6 Binary files /dev/null and b/resources/fonts/comic.ttf differ diff --git a/resources/fonts/pixel.ttf b/resources/fonts/pixel.ttf new file mode 100644 index 0000000..4cacfa8 Binary files /dev/null and b/resources/fonts/pixel.ttf differ diff --git a/resources/fonts/scary.ttf b/resources/fonts/scary.ttf new file mode 100644 index 0000000..c8cbd9d Binary files /dev/null and b/resources/fonts/scary.ttf differ diff --git a/sandbox_theme.json b/sandbox_theme.json index faa8efc..765b556 100644 --- a/sandbox_theme.json +++ b/sandbox_theme.json @@ -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" + } } diff --git a/scenes/speech_composer.tscn b/scenes/speech_composer.tscn new file mode 100644 index 0000000..424d075 --- /dev/null +++ b/scenes/speech_composer.tscn @@ -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") diff --git a/scripts/action_editor.gd b/scripts/action_editor.gd index a0ffa26..bc0db58 100644 --- a/scripts/action_editor.gd +++ b/scripts/action_editor.gd @@ -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) diff --git a/scripts/action_registry.gd b/scripts/action_registry.gd index 423628e..6fd4d68 100644 --- a/scripts/action_registry.gd +++ b/scripts/action_registry.gd @@ -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))) diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index 9948a7f..b4a41cc 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -35,6 +35,10 @@ const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd") const QUEUE_PANEL := preload("res://scenes/queue_panel.tscn") const RULE_PANEL := preload("res://scenes/rule_panel.tscn") const ACTION_EDITOR := preload("res://scenes/action_editor.tscn") +const SPEECH_COMPOSER := preload("res://scenes/speech_composer.tscn") +const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd") +const SPEECH_STYLE_TOOLBAR := preload("res://scripts/speech_style_toolbar.gd") +const SPEECH_SHOW_TOOLBAR := preload("res://scripts/speech_show_toolbar.gd") const RULE_EDITOR := preload("res://scenes/rule_editor.tscn") const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd") const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd") @@ -85,6 +89,10 @@ const DEFAULT_GRID_SIZE := 15.0 const MIN_GRID_SIZE := 1.0 const MAX_GRID_SIZE := 100.0 const AUTOSAVE_PATH: String = "user://sandbox_autosave.scene" +const SPEECH_DRAFT_PATH: String = "user://speech_composer_draft.json" +const SPEECH_DRAFT_DEBOUNCE: float = 0.75 +const SPEECH_DRAFT_SCHEMA: int = 1 +const SPEECH_BUBBLE_DRAG_THRESHOLD: float = 8.0 const RECENT_SCENE_LIMIT: int = 5 const DEFAULT_SCENE_NAME: String = "Untitled" const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0) @@ -381,6 +389,45 @@ var _queue_panel: QueuePanel = null var _rule_panel: RulePanel = null var _action_editor: ActionEditor = null var _rule_editor: RuleEditor = null +var _speech_composer: Control = null +var _speech_style_toolbar: Control = null +var _speech_show_toolbar: Control = null +var _speech_preview: SpeechBubble = null +var _speech_actor: StickmanRig = null +var _speech_session_kind: String = "" +var _speech_placing: bool = false +var _speech_placement_bubble_index: int = 0 +var _speech_bubble_dragging: bool = false +var _speech_bubble_pointer_pending: bool = false +var _speech_bubble_pointer_moved: bool = false +var _speech_bubble_pointer_on_badge: bool = false +var _speech_bubble_pointer_start_screen: Vector2 = Vector2.ZERO +var _speech_bubble_pointer_start_world: Vector2 = Vector2.ZERO +var _speech_bubble_pointer_hits: Array[int] = [] +var _speech_bubble_pointer_index: int = -1 +var _speech_bubble_pointer_sequence: int = 0 +var _speech_bubble_word_clicked: bool = false +var _speech_badge_cursor_active: bool = false +var _speech_bubble_picker: PopupPanel = null +var _speech_token_picker: PopupPanel = null +var _pending_speech_initial: Dictionary = {} +var _speech_origin: Dictionary = {} +var _speech_draft_dirty: bool = false +var _speech_draft_timer: float = -1.0 +var _speech_recovery_dialog: ConfirmationDialog = null +var _speech_destination_dialog: ConfirmationDialog = null +var _speech_save_gate_dialog: ConfirmationDialog = null +var _speech_pending_scene_save_path: String = "" +var _speech_pending_scene_save_as: bool = false +var _speech_scene_save_pending: bool = false +var _rule_edit_uncommitted: bool = false +var _speech_recovery_in_progress: bool = false +var _speech_pending_open: Dictionary = {} +var _speech_restored_unbound: bool = false +var _speech_restored_payload: Dictionary = {} +var _speech_restored_origin: Dictionary = {} +var _speech_restored_selected_bubble: int = 0 +var _speech_pending_commit_action: Dictionary = {} var _waypoint_context: WaypointContext = null var _rig_context_popup: PopupMenu = null var _confirm_dialog: ConfirmationDialog = null @@ -473,9 +520,16 @@ func _ready() -> void: _refresh_status() _update_scene_chrome() _handle_cli_startup(OS.get_cmdline_user_args()) + _check_speech_recovery() func _process(delta: float) -> void: + _update_speech_composition() + if _speech_draft_timer >= 0.0: + _speech_draft_timer -= delta + if _speech_draft_timer <= 0.0: + _speech_draft_timer = -1.0 + _persist_speech_draft_now() _drain_thumbnail_queue() if _nav_dirty: _nav_dirty = false @@ -488,6 +542,7 @@ func _process(delta: float) -> void: _update_cursor_coords() _update_action_overlay() _sync_awaiting_cursor() + _update_speech_badge_cursor() if _toast_timer > 0.0: _toast_timer -= delta if _toast_timer <= 0.0: @@ -536,9 +591,48 @@ func _update_throw_preview_cache() -> void: # --------------------------------------------------------------------------- func _input(event: InputEvent) -> void: + if _speech_composer_is_open() and bool(_speech_composer.call("is_message_dialog_open")): + if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE: + _speech_composer.call("close_message_dialog") + get_viewport().set_input_as_handled() + return + if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE and _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): + _speech_style_toolbar.call("close_chooser") + get_viewport().set_input_as_handled() + return if event is InputEventKey and (event as InputEventKey).keycode == KEY_SPACE: _handle_space_key_event(event as InputEventKey) return + if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")) and _is_speech_pointer_press(event): + var chooser_point: Vector2 = _speech_event_position(event) + if not bool(_speech_style_toolbar.call("chooser_contains_viewport_point", chooser_point)): + _speech_style_toolbar.call("close_chooser") + if _speech_composer_owns_event(event): + _panning = false + if _speech_bubble_pointer_pending: + _cancel_speech_bubble_pointer() + return + if _route_speech_composer_stage_input(event): + return + if _speech_bubble_pointer_pending: + if event is InputEventMouseMotion: + _update_speech_bubble_pointer((event as InputEventMouseMotion).position) + return + if event is InputEventScreenDrag: + _update_speech_bubble_pointer((event as InputEventScreenDrag).position) + return + var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed + var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed + if mouse_release or touch_release: + if _speech_bubble_dragging: + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.end_bubble_drag() + _speech_bubble_dragging = false + call_deferred("_clear_speech_bubble_pointer", _speech_bubble_pointer_sequence) + else: + var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position + call_deferred("_resolve_speech_bubble_pointer_click", _speech_bubble_pointer_sequence, release_position) + return if not _palette_drag_payload.is_empty(): if event is InputEventMouseMotion: _update_palette_drag((event as InputEventMouseMotion).position) @@ -563,20 +657,668 @@ func _input(event: InputEvent) -> void: _handle_mouse_motion(event) +func _speech_composer_owns_event(event: InputEvent) -> bool: + if _speech_composer == null or not _speech_composer.visible or not _speech_composer.has_method("contains_viewport_point"): + return false + var point: Vector2 = Vector2(-1.0, -1.0) + if event is InputEventMouse: + point = (event as InputEventMouse).position + elif event is InputEventGesture: + point = (event as InputEventGesture).position + elif event is InputEventScreenTouch: + point = (event as InputEventScreenTouch).position + elif event is InputEventScreenDrag: + point = (event as InputEventScreenDrag).position + if point.x < 0.0 or point.y < 0.0: + return false + if bool(_speech_composer.call("contains_viewport_point", point)): + return true + if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("contains_viewport_point", point)): + return true + if _speech_show_toolbar != null and bool(_speech_show_toolbar.call("contains_viewport_point", point)): + return true + if _speech_token_picker != null and is_instance_valid(_speech_token_picker) and _speech_token_picker.visible: + return Rect2(_speech_token_picker.position, Vector2(_speech_token_picker.size)).has_point(point) + return false + + +func _is_speech_pointer_press(event: InputEvent) -> bool: + return event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and (event as InputEventMouseButton).pressed or event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed + + +func _speech_event_position(event: InputEvent) -> Vector2: + if event is InputEventMouse: + return (event as InputEventMouse).position + if event is InputEventScreenTouch: + return (event as InputEventScreenTouch).position + if event is InputEventGesture: + return (event as InputEventGesture).position + return Vector2(-1.0, -1.0) + + +func _speech_composer_is_open() -> bool: + return _speech_composer != null and is_instance_valid(_speech_composer) and _speech_composer.visible + + +func _speech_stage_viewport_rect() -> Rect2: + var viewport_size: Vector2 = get_viewport_rect().size + return Rect2(Vector2(12.0, 72.0), Vector2(maxf(1.0, viewport_size.x - 24.0), maxf(1.0, viewport_size.y - 120.0))) + + +func _route_speech_composer_stage_input(event: InputEvent) -> bool: + if not _speech_composer_is_open(): + return false + if _speech_bubble_pointer_pending: + if event is InputEventMouseMotion: + _update_speech_bubble_pointer((event as InputEventMouseMotion).position) + return true + if event is InputEventScreenDrag: + _update_speech_bubble_pointer((event as InputEventScreenDrag).position) + return true + var mouse_release: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT and not (event as InputEventMouseButton).pressed + var touch_release: bool = event is InputEventScreenTouch and not (event as InputEventScreenTouch).pressed + if mouse_release or touch_release: + if _speech_bubble_dragging: + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.end_bubble_drag() + _speech_bubble_dragging = false + call_deferred("_clear_speech_bubble_pointer", _speech_bubble_pointer_sequence) + else: + var release_position: Vector2 = (event as InputEventMouseButton).position if mouse_release else (event as InputEventScreenTouch).position + call_deferred("_resolve_speech_bubble_pointer_click", _speech_bubble_pointer_sequence, release_position) + return true + var point: Vector2 = _speech_event_position(event) + if point.x < 0.0 or point.y < 0.0 or not _speech_stage_viewport_rect().has_point(point): + return false + if event is InputEventMagnifyGesture: + _set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor) + get_viewport().set_input_as_handled() + return true + if event is InputEventPanGesture: + _camera.position -= (event as InputEventPanGesture).delta * 3.0 / _camera.zoom.x + get_viewport().set_input_as_handled() + return true + if event is InputEventMouseButton: + var mouse_button: InputEventMouseButton = event as InputEventMouseButton + if mouse_button.button_index == MOUSE_BUTTON_MIDDLE or mouse_button.button_index == MOUSE_BUTTON_WHEEL_UP or mouse_button.button_index == MOUSE_BUTTON_WHEEL_DOWN: + _handle_mouse_button(mouse_button) + get_viewport().set_input_as_handled() + return true + if mouse_button.button_index == MOUSE_BUTTON_LEFT: + if mouse_button.pressed: + if _speech_placing: + return false + _begin_speech_bubble_pointer(mouse_button.position) + if not _speech_bubble_pointer_pending: + get_viewport().set_input_as_handled() + return true + get_viewport().set_input_as_handled() + return true + if mouse_button.button_index == MOUSE_BUTTON_RIGHT and mouse_button.pressed and _speech_placing: + return false + get_viewport().set_input_as_handled() + return true + if event is InputEventMouseMotion: + if _panning: + _handle_mouse_motion(event as InputEventMouseMotion) + get_viewport().set_input_as_handled() + return true + if event is InputEventScreenTouch: + var touch: InputEventScreenTouch = event as InputEventScreenTouch + if touch.pressed: + if _speech_placing: + return false + _begin_speech_bubble_pointer(touch.position) + if not _speech_bubble_pointer_pending: + get_viewport().set_input_as_handled() + return true + get_viewport().set_input_as_handled() + return true + if event is InputEventScreenDrag: + get_viewport().set_input_as_handled() + return true + return false + + +func _begin_speech_bubble_pointer(screen_position: Vector2) -> void: + if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null or not _speech_composer.visible: + return + if not _speech_stage_viewport_rect().has_point(screen_position): + return + var pointer_world: Vector2 = _viewport_to_world(screen_position) + var badge_index: int = _speech_preview.hit_test_order_badge(pointer_world) + var hits: Array[int] = [] + if badge_index >= 0: + hits.append(badge_index) + else: + hits = _speech_preview.hit_test_bubbles(pointer_world) + _speech_bubble_pointer_sequence += 1 + _speech_bubble_pointer_pending = true + _speech_bubble_pointer_moved = false + _speech_bubble_pointer_on_badge = badge_index >= 0 + _speech_bubble_pointer_start_screen = screen_position + _speech_bubble_pointer_start_world = _viewport_to_world(screen_position) + _speech_bubble_pointer_hits = hits + _speech_bubble_pointer_index = hits[0] if not hits.is_empty() else -1 + _speech_bubble_word_clicked = false + _speech_bubble_dragging = false + + +func _update_speech_badge_cursor() -> void: + var should_point: bool = false + if _speech_composer_is_open() and not _speech_placing and not _is_awaiting_click() and not _is_mouse_over_ui(): + var mouse_position: Vector2 = get_viewport().get_mouse_position() + if _speech_stage_viewport_rect().has_point(mouse_position) and _speech_preview != null and is_instance_valid(_speech_preview): + should_point = _speech_preview.hit_test_order_badge(_viewport_to_world(mouse_position)) >= 0 + if should_point == _speech_badge_cursor_active: + return + _speech_badge_cursor_active = should_point + if _speech_badge_cursor_active: + Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND) + else: + _apply_cursor() + + +func _update_speech_bubble_pointer(screen_position: Vector2) -> void: + if _speech_preview == null or not is_instance_valid(_speech_preview): + _cancel_speech_bubble_pointer() + return + if not _speech_bubble_dragging: + if screen_position.distance_to(_speech_bubble_pointer_start_screen) < SPEECH_BUBBLE_DRAG_THRESHOLD: + return + _speech_bubble_pointer_moved = true + if _speech_bubble_pointer_on_badge: + return + if _speech_bubble_pointer_hits.is_empty(): + return + var drag_bubble_index: int = _speech_preview.hit_test_bubble_for_drag(_speech_bubble_pointer_start_world) + if drag_bubble_index < 0: + drag_bubble_index = _speech_bubble_pointer_index + _select_speech_bubble_from_stage(drag_bubble_index) + _speech_bubble_dragging = _speech_preview.begin_bubble_drag(_speech_bubble_pointer_start_world, drag_bubble_index) + if not _speech_bubble_dragging: + return + _speech_preview.update_bubble_drag(_viewport_to_world(screen_position)) + + +func _resolve_speech_bubble_pointer_click(sequence: int, screen_position: Vector2) -> void: + if not _speech_bubble_pointer_pending or sequence != _speech_bubble_pointer_sequence: + return + if not _speech_bubble_pointer_moved and not _speech_bubble_word_clicked: + if _speech_bubble_pointer_hits.is_empty(): + if _speech_composer != null and _speech_composer.visible: + _speech_composer.call("clear_stage_focus") + _clear_speech_bubble_pointer(sequence) + return + _select_speech_bubble_from_stage(_speech_bubble_pointer_index) + if _speech_bubble_pointer_hits.size() > 1: + _show_speech_bubble_picker(_speech_bubble_pointer_hits, screen_position) + _clear_speech_bubble_pointer(sequence) + + +func _clear_speech_bubble_pointer(sequence: int) -> void: + if sequence != _speech_bubble_pointer_sequence: + return + _speech_bubble_pointer_pending = false + _speech_bubble_pointer_moved = false + _speech_bubble_pointer_on_badge = false + _speech_bubble_pointer_hits.clear() + _speech_bubble_pointer_index = -1 + _speech_bubble_word_clicked = false + + +func _cancel_speech_bubble_pointer() -> void: + if _speech_bubble_dragging and _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.end_bubble_drag() + _speech_bubble_dragging = false + _speech_bubble_pointer_pending = false + _speech_bubble_pointer_moved = false + _speech_bubble_pointer_on_badge = false + _speech_bubble_pointer_hits.clear() + _speech_bubble_pointer_index = -1 + _speech_bubble_word_clicked = false + _speech_bubble_pointer_sequence += 1 + + +func _select_speech_bubble_from_stage(index: int) -> void: + if _speech_composer == null or not _speech_composer.visible: + return + if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): + _speech_style_toolbar.call("close_chooser") + _speech_composer.call("select_bubble_from_stage", index) + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.set_selected_bubble_index(index) + _speech_preview.set_selected_token(-1, -1, -1) + _show_speech_style_toolbar() + + +func _on_speech_preview_word_selected(bubble_index: int, start: int, end: int) -> void: + _on_speech_preview_token_selected(bubble_index, start, end) + + +func _on_speech_preview_token_selected(bubble_index: int, start: int, end: int) -> void: + if _speech_bubble_pointer_pending: + _speech_bubble_word_clicked = true + if _speech_composer == null or not _speech_composer.visible: + return + if _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): + _speech_style_toolbar.call("close_chooser") + _speech_composer.call("select_bubble_token_from_stage", bubble_index, start, end) + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.set_selected_bubble_index(bubble_index) + _speech_preview.set_selected_token(bubble_index, start, end) + _show_speech_style_toolbar() + + +func _show_speech_bubble_picker(hits: Array[int], screen_position: Vector2) -> void: + if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker): + _speech_bubble_picker.hide() + var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer + if ui_layer == null or _speech_composer == null: + return + var picker := PopupPanel.new() + picker.name = "SpeechBubblePicker" + picker.exclusive = false + picker.transient = true + picker.unfocusable = false + ui_layer.add_child(picker) + var margin := 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", 8) + margin.add_theme_constant_override("margin_bottom", 8) + picker.add_child(margin) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 6) + margin.add_child(column) + var title := Label.new() + title.text = "Choose a bubble" + title.add_theme_font_size_override("font_size", 18) + column.add_child(title) + for index: int in hits: + var bubble_label: String = _speech_bubble_picker_label(index) + var choice := Button.new() + choice.text = bubble_label + choice.custom_minimum_size = Vector2(240.0, 44.0) + choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL + choice.pressed.connect(_on_speech_bubble_picker_selected.bind(index)) + column.add_child(choice) + var viewport_size: Vector2 = get_viewport_rect().size + var popup_width: int = mini(280, maxi(220, int(viewport_size.x) - 16)) + var popup_height: int = mini(80 + hits.size() * 50, maxi(100, int(viewport_size.y) - 16)) + picker.size = Vector2i(popup_width, popup_height) + var popup_position := Vector2i(screen_position + Vector2(12.0, 12.0)) + popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8)) + popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8)) + _speech_bubble_picker = picker + picker.popup_hide.connect(_on_speech_bubble_picker_closed.bind(picker)) + picker.position = popup_position + picker.popup() + + +func _speech_bubble_picker_label(index: int) -> String: + var bubbles: Array = [] + if _speech_composer != null and _speech_composer.has_method("get_draft"): + var draft: Dictionary = _speech_composer.call("get_draft") + bubbles = draft.get("bubbles", []) + if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: + return "Bubble %d · Empty" % (index + 1) + var text: String = String((bubbles[index] as Dictionary).get("text", "")).replace("\n", " ").strip_edges() + if text.is_empty(): + text = "Empty" + elif text.length() > 20: + text = text.substr(0, 19) + "…" + return "Bubble %d · %s" % [index + 1, text] + + +func _on_speech_bubble_picker_selected(index: int) -> void: + _select_speech_bubble_from_stage(index) + if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker): + _speech_bubble_picker.hide() + + +func _on_speech_bubble_picker_closed(picker: PopupPanel) -> void: + if _speech_bubble_picker == picker: + _speech_bubble_picker = null + if is_instance_valid(picker): + picker.queue_free() + + +func _on_speech_preview_line_tokens_requested(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: + if _speech_bubble_pointer_pending: + _speech_bubble_word_clicked = true + _show_speech_token_picker(bubble_index, spans, viewport_position) + + +func _on_speech_preview_text_tap_detected(_bubble_index: int) -> void: + if _speech_bubble_pointer_pending: + _speech_bubble_word_clicked = true + + +func _show_speech_token_picker(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: + if spans.is_empty() or _speech_composer == null: + return + if _speech_token_picker != null and is_instance_valid(_speech_token_picker): + _speech_token_picker.hide() + var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer + if ui_layer == null: + return + var picker: PopupPanel = PopupPanel.new() + picker.name = "SpeechTokenPicker" + picker.exclusive = false + picker.transient = true + picker.unfocusable = false + ui_layer.add_child(picker) + 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", 8) + margin.add_theme_constant_override("margin_bottom", 8) + picker.add_child(margin) + var column: VBoxContainer = VBoxContainer.new() + column.add_theme_constant_override("separation", 6) + margin.add_child(column) + var title: Label = Label.new() + title.text = "Choose a word or mark" + title.add_theme_font_size_override("font_size", 18) + column.add_child(title) + var scroll: ScrollContainer = ScrollContainer.new() + scroll.custom_minimum_size = Vector2(300.0, minf(320.0, float(spans.size()) * 54.0)) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + column.add_child(scroll) + var choices: HFlowContainer = HFlowContainer.new() + choices.add_theme_constant_override("h_separation", 5) + choices.add_theme_constant_override("v_separation", 5) + scroll.add_child(choices) + var draft: Dictionary = _speech_composer.call("get_draft") + var bubbles: Array = draft.get("bubbles", []) + var text: String = String((bubbles[bubble_index] as Dictionary).get("text", "")) if bubble_index >= 0 and bubble_index < bubbles.size() and bubbles[bubble_index] is Dictionary else "" + for span: Vector2i in spans: + if span.x < 0 or span.y <= span.x or span.y > text.length(): + continue + var token: String = text.substr(span.x, span.y - span.x) + var choice: Button = Button.new() + choice.text = token + choice.custom_minimum_size = Vector2(maxf(52.0, minf(160.0, 22.0 + float(token.length()) * 14.0)), 48.0) + choice.pressed.connect(_on_speech_token_picker_selected.bind(bubble_index, span.x, span.y)) + choices.add_child(choice) + var viewport_size: Vector2 = get_viewport_rect().size + var popup_width: int = mini(360, maxi(220, int(viewport_size.x) - 16)) + var popup_height: int = mini(420, maxi(130, int(viewport_size.y) - 16)) + picker.size = Vector2i(popup_width, popup_height) + var popup_position: Vector2i = Vector2i(viewport_position + Vector2(12.0, 12.0)) + popup_position.x = clampi(popup_position.x, 8, maxi(8, int(viewport_size.x) - popup_width - 8)) + popup_position.y = clampi(popup_position.y, 8, maxi(8, int(viewport_size.y) - popup_height - 8)) + _speech_token_picker = picker + picker.popup_hide.connect(_on_speech_token_picker_closed.bind(picker)) + picker.position = popup_position + picker.popup() + + +func _on_speech_token_picker_selected(bubble_index: int, start: int, end: int) -> void: + _on_speech_preview_token_selected(bubble_index, start, end) + if _speech_token_picker != null and is_instance_valid(_speech_token_picker): + _speech_token_picker.hide() + + +func _on_speech_token_picker_closed(picker: PopupPanel) -> void: + if _speech_token_picker == picker: + _speech_token_picker = null + if is_instance_valid(picker): + picker.queue_free() + + +func _on_speech_style_chooser_open_requested(kind: StringName) -> void: + if _speech_composer == null or _speech_style_toolbar == null: + return + if bool(_speech_composer.call("is_message_dialog_open")): + _speech_composer.call("close_message_dialog") + _speech_composer.call("set_style_category", kind) + _speech_style_toolbar.call("set_context", _speech_composer.call("style_context")) + _speech_style_toolbar.call("present_chooser", kind) + + +func _on_speech_style_choice_selected(kind: StringName, value: String) -> void: + if _speech_composer == null: + return + _speech_composer.call("apply_style_choice", kind, value) + _speech_style_toolbar.call("set_context", _speech_composer.call("style_context")) + + +func _on_speech_style_sequence_action_requested(action: StringName) -> void: + if _speech_composer == null or not _speech_composer.visible: + return + match action: + &"earlier": _speech_composer.call("move_selected_bubble", -1) + &"later": _speech_composer.call("move_selected_bubble", 1) + &"add": + _speech_composer.call("add_bubble_after_selected") + call_deferred("_resolve_added_speech_bubble_collision") + &"remove": _speech_composer.call("remove_selected_bubble") + &"undo_remove": _speech_composer.call("undo_remove_bubble") + _refresh_speech_style_toolbar() + + +func _resolve_added_speech_bubble_collision() -> void: + if not _speech_composer_is_open() or _speech_preview == null or not is_instance_valid(_speech_preview): + return + var bubble_index: int = int(_speech_composer.call("selected_bubble_index")) + var suggestion: Dictionary = _speech_preview.suggest_added_bubble_center(bubble_index) + if not bool(suggestion.get("collided", false)): + return + var center: Variant = suggestion.get("center", null) + if center is Vector2: + _speech_composer.call("set_bubble_placement_preview", bubble_index, center) + + +func _on_speech_done_requested() -> void: + if _speech_composer != null and _speech_composer.visible: + _speech_composer.call("request_done_speech") + + +func _on_speech_cancel_requested() -> void: + if _speech_composer != null and _speech_composer.visible: + _speech_composer.call("request_cancel_speech") + + +func _on_speech_validation_failed(message: String, bubble_index: int) -> void: + _show_toast(message) + if bubble_index >= 0 and _speech_composer != null and _speech_composer.visible: + _speech_composer.call("open_message_dialog") + _refresh_speech_style_toolbar() + + +func _on_speech_show_choice_selected(key: StringName, value: Variant) -> void: + if _speech_composer == null or not _speech_composer.visible: + return + var action: Dictionary = _speech_composer.call("get_preview_action") + if key == &"connect_bubbles" and bool(value) and String(action.get("bubble_layout", "stack")) == "same_spot": + if _speech_preview == null or not is_instance_valid(_speech_preview): + return + var bubbles: Array = action.get("bubbles", []) + var centers: Array = _speech_preview.bubble_layout_centers(&"stack") + if bubbles.size() < 2 or centers.size() != bubbles.size(): + return + _speech_composer.call("apply_bubble_layout", centers, "stack") + _speech_composer.call("choose_speech_show", key, value) + _speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled")) + _refresh_speech_style_toolbar() + + +func _on_speech_layout_preset_requested(preset: StringName) -> void: + if _speech_composer == null or not _speech_composer.visible or _speech_preview == null or not is_instance_valid(_speech_preview): + return + var centers: Array = _speech_preview.bubble_layout_centers(preset) + var action: Dictionary = _speech_composer.call("get_preview_action") + var bubbles: Array = action.get("bubbles", []) + if centers.is_empty() or centers.size() != bubbles.size(): + return + _speech_composer.call("apply_bubble_layout", centers, String(preset)) + _speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled")) + _refresh_speech_style_toolbar() + + +func _on_speech_stage_focus_changed(bubble_index: int) -> void: + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.set_selected_bubble_index(bubble_index) + if bubble_index < 0: + _speech_preview.set_selected_token(-1, -1, -1) + _refresh_speech_style_toolbar() + + +func _on_speech_style_message_requested(opened: bool) -> void: + if _speech_composer == null or _speech_style_toolbar == null: + return + if opened: + _speech_style_toolbar.call("close_chooser") + _speech_composer.call("open_message_dialog") + else: + _speech_composer.call("close_message_dialog") + _refresh_speech_style_toolbar() + + +func _on_speech_message_dialog_closed() -> void: + if _speech_style_toolbar != null: + _speech_style_toolbar.call("set_message_dialog_open", false) + _show_speech_style_toolbar() + + +func _on_speech_style_done_requested() -> void: + if _speech_composer != null and bool(_speech_composer.call("is_message_dialog_open")): + _speech_composer.call("close_message_dialog") + if _speech_composer != null: + _speech_composer.call("clear_stage_focus") + + +func _show_speech_style_toolbar() -> void: + _refresh_speech_style_toolbar() + + +func _on_speech_style_context_changed(context: Dictionary) -> void: + if _speech_style_toolbar != null: + _speech_style_toolbar.call("set_context", context) + if _speech_preview != null and is_instance_valid(_speech_preview): + var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} + _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) + var focus_index: int = int(_speech_composer.call("stage_focus_bubble_index")) + _speech_preview.set_selected_bubble_index(focus_index) + _speech_preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1))) + _refresh_speech_style_toolbar() + + +func _refresh_speech_style_toolbar() -> void: + if _speech_style_toolbar == null or _speech_show_toolbar == null or _speech_composer == null or _speech_preview == null or not is_instance_valid(_speech_preview) or not _speech_composer.visible: + if _speech_style_toolbar != null: + _speech_style_toolbar.hide() + if _speech_show_toolbar != null: + _speech_show_toolbar.hide() + _sync_speech_badge_avoidance() + return + var preview_state: String = String(_speech_composer.call("preview_state_name")) + var unavailable: bool = _speech_placing or _speech_bubble_dragging or preview_state != "static" or not _speech_preview.visible + var stage_focus_index: int = int(_speech_composer.call("stage_focus_bubble_index")) + if stage_focus_index < 0: + _speech_style_toolbar.hide() + _speech_show_toolbar.call("set_context", _speech_composer.call("get_preview_action"), _speech_composer.call("reactive_options_enabled")) + _speech_show_toolbar.call("set_busy", unavailable) + if unavailable: + _speech_show_toolbar.hide() + _sync_speech_badge_avoidance() + return + var group_rect: Rect2 = _speech_preview.group_frames_viewport_rect() + _sync_speech_badge_avoidance() + _speech_show_toolbar.call("set_anchor_rect", group_rect, _speech_stage_viewport_rect()) + return + _speech_show_toolbar.hide() + _speech_style_toolbar.call("set_busy", unavailable) + if unavailable: + _speech_style_toolbar.hide() + _sync_speech_badge_avoidance() + return + var context: Dictionary = _speech_composer.call("style_context") + _speech_style_toolbar.call("set_context", context) + var bubble_index: int = int(context.get("bubble_index", -1)) + var frame_rect: Rect2 = _speech_preview.selected_frame_viewport_rect(bubble_index) + _sync_speech_badge_avoidance() + _speech_style_toolbar.call("set_anchor_rect", frame_rect, _speech_stage_viewport_rect()) + _speech_composer.call("set_message_dialog_anchor", frame_rect, _speech_stage_viewport_rect(), _speech_style_toolbar.call("toolbar_viewport_rect")) + + +func _sync_speech_badge_avoidance() -> void: + var badge_rects: Array[Rect2] = [] + if _speech_preview != null and is_instance_valid(_speech_preview): + badge_rects = _speech_preview.order_badge_viewport_rects() + if _speech_show_toolbar != null and is_instance_valid(_speech_show_toolbar): + _speech_show_toolbar.call("set_badge_avoidance_rects", badge_rects) + if _speech_style_toolbar != null and is_instance_valid(_speech_style_toolbar): + _speech_style_toolbar.call("set_badge_avoidance_rects", badge_rects) + + func _unhandled_input(event: InputEvent) -> void: + if _speech_composer_is_open() and bool(_speech_composer.call("is_message_dialog_open")): + get_viewport().set_input_as_handled() + return + if _speech_bubble_pointer_pending: + var bubble_mouse_event: bool = event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT + var bubble_touch_event: bool = event is InputEventScreenTouch + if bubble_mouse_event or bubble_touch_event: + get_viewport().set_input_as_handled() + return + if _speech_composer_is_open(): + var speech_point: Vector2 = _speech_event_position(event) + var in_speech_stage: bool = speech_point.x >= 0.0 and speech_point.y >= 0.0 and _speech_stage_viewport_rect().has_point(speech_point) + var placement_press: bool = false + if _speech_placing and event is InputEventMouseButton: + var mouse_button: InputEventMouseButton = event as InputEventMouseButton + placement_press = mouse_button.pressed and (mouse_button.button_index == MOUSE_BUTTON_LEFT or mouse_button.button_index == MOUSE_BUTTON_RIGHT) + elif _speech_placing and event is InputEventScreenTouch: + placement_press = (event as InputEventScreenTouch).pressed + if in_speech_stage and not placement_press and (event is InputEventMouseButton or event is InputEventMouseMotion or event is InputEventScreenTouch or event is InputEventScreenDrag or event is InputEventGesture): + get_viewport().set_input_as_handled() + return if event is InputEventMouseButton: _handle_world_click(event) + elif event is InputEventScreenTouch and (event as InputEventScreenTouch).pressed and _speech_placing: + _place_speech_bubble_at((event as InputEventScreenTouch).position) + + +func _place_speech_bubble_at(viewport_position: Vector2) -> void: + if not _speech_placing or _speech_actor == null or not is_instance_valid(_speech_actor) or _speech_composer == null: + return + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.set_placement_ghost(false) + var local_center: Vector2 = _speech_actor.to_local(_viewport_to_world(viewport_position)) + if _speech_placement_bubble_index < 0: + _speech_composer.call("set_placement_preview", local_center) + else: + _speech_composer.call("set_bubble_placement_preview", _speech_placement_bubble_index, local_center) + _speech_placing = false + _speech_composer.call("set_placement_pending", false) + _speech_placement_bubble_index = 0 func _unhandled_key_input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.pressed or event.echo: return + if _speech_composer_is_open(): + if event.keycode == KEY_ESCAPE: + if bool(_speech_composer.call("is_message_dialog_open")): + _speech_composer.call("close_message_dialog") + elif _speech_style_toolbar != null and bool(_speech_style_toolbar.call("is_chooser_open")): + _speech_style_toolbar.call("close_chooser") + get_viewport().set_input_as_handled() + return match event.keycode: KEY_DELETE, KEY_BACKSPACE: if current_mode == StageMode.EDIT: delete_selected() KEY_ESCAPE: - if not _palette_drag_payload.is_empty(): + if _speech_placing: + _speech_placing = false + if _speech_composer != null: + _speech_composer.call("set_placement_pending", false) + _refresh_status() + elif not _palette_drag_payload.is_empty(): _cancel_palette_drag() elif _capture_kind != CaptureKind.NONE: _cancel_capture() @@ -634,10 +1376,12 @@ func _handle_space_key_event(event: InputEventKey) -> void: func _space_should_pass_to_gui() -> bool: + if _speech_composer_is_open(): + return true var focus: Control = get_viewport().gui_get_focus_owner() if focus is LineEdit or focus is TextEdit or focus is SpinBox: return true - for window: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]: + for window: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _speech_save_gate_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]: if window != null and window.visible: return true if _selector_open or (_queue_panel != null and _queue_panel.visible) or (_rule_panel != null and _rule_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible): @@ -664,6 +1408,16 @@ func _handle_mouse_button(mb: InputEventMouseButton) -> void: func _handle_world_click(mb: InputEventMouseButton) -> void: if _selector_open: return + if _speech_placing: + if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed: + _speech_placing = false + if _speech_composer != null: + _speech_composer.call("set_placement_pending", false) + return + if mb.button_index == MOUSE_BUTTON_LEFT and mb.pressed: + if _speech_actor != null and is_instance_valid(_speech_actor) and _speech_composer != null: + _place_speech_bubble_at(mb.position) + return if current_mode == StageMode.PLAY: return if mb.button_index == MOUSE_BUTTON_RIGHT: @@ -761,6 +1515,14 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2: func set_mode(mode: StageMode) -> void: if mode == current_mode: return + if _rule_edit_uncommitted: + _show_toast("Finish or cancel the rule before switching modes.") + _sync_mode_buttons() + return + if _speech_composer_is_open(): + _show_toast("Finish or cancel speech editing first.") + _sync_mode_buttons() + return var previous_mode: StageMode = current_mode if mode == StageMode.PLAY and _run_state == RunState.STOPPED and not _cli_startup: if not _save_scene_to_path(AUTOSAVE_PATH, true): @@ -803,6 +1565,8 @@ func _enter_edit_mode(_previous_mode: StageMode) -> void: ## (a pending walk target or a half-built When->Then rule must not survive into ## another mode where its click routing would mis-fire). func _clear_director_pending() -> void: + if _speech_composer != null and _speech_composer.visible: + _finish_speech_composition(false) _clear_popup_anchor() _pending_walk_target = false _context_rig = null @@ -1017,7 +1781,7 @@ func _pause_toggle_blocked() -> bool: var focus: Control = get_viewport().gui_get_focus_owner() if focus is LineEdit or focus is TextEdit or focus is SpinBox: return true - for dialog: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]: + for dialog: Window in [_browse_dialog, _speak_dialog, _wait_dialog, _scene_open_dialog, _scene_save_dialog, _speech_save_gate_dialog, _rename_scene_dialog, _unsaved_changes_dialog, _missing_assets_dialog, _cli_error_dialog, _confirm_dialog]: if dialog != null and dialog.visible: return true return (_queue_panel != null and _queue_panel.visible) or (_action_editor != null and _action_editor.visible) or (_rule_editor != null and _rule_editor.visible) @@ -1651,6 +2415,25 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary: func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool: + if _speech_composer_is_open(): + if is_autosave: + return false + _speech_pending_scene_save_path = path + _speech_pending_scene_save_as = false + _speech_scene_save_pending = true + if _speech_save_gate_dialog != null and not _speech_save_gate_dialog.visible: + _speech_save_gate_dialog.popup_centered() + return false + if _rule_edit_uncommitted: + if not is_autosave: + _show_toast("Finish the rule before saving the scene.") + return false + var queue_error: String = _validate_queues_for_save() + if not queue_error.is_empty(): + _show_toast(queue_error) + if _pending_file_action != PendingFileAction.NONE: + _pending_file_action = PendingFileAction.NONE + return false var result: Dictionary = STAGE_SCENE_IO.write_file_safe(path, _collect_scene_data(is_autosave)) if not bool(result.get("ok", false)): _show_toast(String(result.get("error", "Could not save scene."))) @@ -1662,9 +2445,32 @@ func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool: if _scene_created_unix <= 0: _scene_created_unix = Time.get_unix_time_from_system() _set_scene_dirty(false); _add_recent_scene(path) if FileAccess.file_exists(AUTOSAVE_PATH): DirAccess.remove_absolute(AUTOSAVE_PATH) + if (_speech_composer == null or not _speech_composer.visible) and not _current_scene_path.is_empty(): + _speech_recovery_matches_loaded_scene() return true +func _validate_queues_for_save() -> String: + for child: Node in _world.get_children(): + if not child is StickmanRig: + continue + var rig: StickmanRig = child as StickmanRig + var actions: Array[Dictionary] = rig.get_queue() + var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(actions) + if (encoded.get("actions", []) as Array).size() == actions.size(): + continue + for action_index: int in actions.size(): + var one_action: Array[Dictionary] = [actions[action_index]] + var one_encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action) + if (one_encoded.get("actions", []) as Array).size() == 1: + continue + var character_name: String = rig.source_stk_path.get_file().trim_suffix(".stk") + if character_name.is_empty(): + character_name = String(rig.name) + return "Can't save: %s action %d (%s) can't be saved. Edit or remove it from the queue." % [character_name, action_index + 1, String(actions[action_index].get("type", "unknown"))] + return "" + + func _reset_scene_content() -> void: set_mode(StageMode.EDIT) _free_ghost(); _free_terrain_ghosts(); _end_terrain_drag() @@ -1750,11 +2556,14 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) if raw_queue is Array: var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array) for warning: String in queue.get("warnings", []): - warnings.append(warning) + var character_name: String = String(entry.get("stk_path", "")).get_file() + if character_name.is_empty(): + character_name = file_id + warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning]) for action: Dictionary in queue["actions"]: (node as StickmanRig).queue_action(action) else: - warnings.append("Invalid stickman action queue") + warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id) _finalize_placed_node(node); _save_object_state(node); file_to_runtime[file_id] = node.get_instance_id() var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable) _event_rules.assign(decoded_rules["rules"]); _next_rule_id = 0 @@ -1772,6 +2581,8 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) _rebuild_grid_cells(); _nav_dirty = true; _suppress_dirty = was_suppressed; _set_scene_dirty(is_recovery) if not warnings.is_empty() or int(decoded_rules.get("skipped", 0)) > 0 or missing_stickmen > 0: _show_toast("Loaded with %d warning(s), %d missing stickman, %d skipped rule(s)" % [warnings.size(), missing_stickmen, int(decoded_rules.get("skipped", 0))]) + if not warnings.is_empty(): + _show_scene_load_warnings(warnings) if not missing_paths.is_empty(): _show_missing_assets_dialog(missing_paths) if not is_recovery: @@ -1780,6 +2591,21 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool) return true +func _show_scene_load_warnings(warnings: Array[String]) -> void: + var dialog: AcceptDialog = AcceptDialog.new() + dialog.title = "Some saved actions need a look" + var lines: PackedStringArray = PackedStringArray() + for index: int in mini(warnings.size(), 12): + lines.append("• " + warnings[index]) + if warnings.size() > 12: + lines.append("• And %d more warning(s)." % (warnings.size() - 12)) + dialog.dialog_text = "These items could not be restored:\n\n%s" % "\n".join(lines) + add_child(dialog) + dialog.confirmed.connect(dialog.queue_free) + dialog.close_requested.connect(dialog.queue_free) + dialog.popup_centered(Vector2i(620, 420)) + + func _show_missing_assets_dialog(paths: Array[String]) -> void: if _missing_assets_dialog == null: return @@ -1982,6 +2808,9 @@ func _apply_cursor() -> void: _apply_action_cursor() return Input.set_custom_mouse_cursor(null) + if _speech_badge_cursor_active: + Input.set_default_cursor_shape(Input.CURSOR_POINTING_HAND) + return match current_mode: StageMode.PLAY: Input.set_default_cursor_shape(Input.CURSOR_ARROW) @@ -2521,6 +3350,16 @@ func _build_ui() -> void: _scene_save_dialog.file_selected.connect(_on_scene_save_path_selected) _scene_save_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE) ui.add_child(_scene_save_dialog) + _speech_save_gate_dialog = ConfirmationDialog.new() + _speech_save_gate_dialog.name = "FinishSpeechBeforeSaveDialog" + _speech_save_gate_dialog.title = "Finish speech before saving" + _speech_save_gate_dialog.dialog_text = "Choose Done speech to finish editing before the scene is saved." + _speech_save_gate_dialog.ok_button_text = "Done speech" + _speech_save_gate_dialog.get_cancel_button().text = "Keep editing" + _speech_save_gate_dialog.confirmed.connect(_on_speech_save_gate_confirmed) + _speech_save_gate_dialog.canceled.connect(_on_speech_save_gate_cancelled) + ui.add_child(_speech_save_gate_dialog) + _apply_dialog_theme(_speech_save_gate_dialog) _rename_scene_dialog = ConfirmationDialog.new() _rename_scene_dialog.name = "RenameSceneDialog" @@ -2604,6 +3443,7 @@ func _build_ui() -> void: _action_editor.committed.connect(_on_action_editor_committed) _action_editor.cancelled.connect(_on_action_editor_cancelled) _action_editor.target_requested.connect(_on_action_editor_target_requested) + _action_editor.speech_requested.connect(_on_action_editor_speech_requested) ui.add_child(_action_editor) _action_editor.apply_font(_ui_font, _emoji_font, _font_sizes) _action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390)) @@ -2618,6 +3458,55 @@ func _build_ui() -> void: _rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes) _rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560)) + _speech_composer = SPEECH_COMPOSER.instantiate() as Control + _speech_composer.connect("draft_changed", _on_speech_draft_changed) + _speech_composer.connect("placement_requested", _on_speech_placement_requested) + _speech_composer.connect("placement_reset_requested", _on_speech_placement_reset) + _speech_composer.connect("bubble_placement_requested", _on_speech_bubble_placement_requested) + _speech_composer.connect("bubble_placement_reset_requested", _on_speech_bubble_placement_reset) + _speech_composer.connect("replay_requested", _on_speech_replay_requested) + _speech_composer.connect("preview_state_changed", _on_speech_preview_state_changed) + _speech_composer.connect("style_context_changed", _on_speech_style_context_changed) + _speech_composer.connect("stage_focus_changed", _on_speech_stage_focus_changed) + _speech_composer.connect("message_dialog_closed", _on_speech_message_dialog_closed) + _speech_composer.connect("committed", _on_speech_committed) + _speech_composer.connect("cancelled", _on_speech_cancelled) + _speech_composer.connect("validation_failed", _on_speech_validation_failed) + ui.add_child(_speech_composer) + _speech_style_toolbar = SPEECH_STYLE_TOOLBAR.new() as Control + _speech_style_toolbar.name = "SpeechStyleToolbar" + _speech_style_toolbar.call("configure", _sandbox_theme) + _speech_style_toolbar.connect("chooser_open_requested", _on_speech_style_chooser_open_requested) + _speech_style_toolbar.connect("choice_selected", _on_speech_style_choice_selected) + _speech_style_toolbar.connect("done_styling_requested", _on_speech_style_done_requested) + _speech_style_toolbar.connect("message_edit_requested", _on_speech_style_message_requested) + _speech_style_toolbar.connect("sequence_action_requested", _on_speech_style_sequence_action_requested) + ui.add_child(_speech_style_toolbar) + _speech_show_toolbar = SPEECH_SHOW_TOOLBAR.new() as Control + _speech_show_toolbar.name = "SpeechShowToolbar" + _speech_show_toolbar.call("configure", _sandbox_theme) + _speech_show_toolbar.connect("choice_selected", _on_speech_show_choice_selected) + _speech_show_toolbar.connect("layout_preset_requested", _on_speech_layout_preset_requested) + _speech_show_toolbar.connect("preview_speech_requested", _on_speech_toolbar_preview_requested) + _speech_show_toolbar.connect("done_speech_requested", _on_speech_done_requested) + _speech_show_toolbar.connect("cancel_speech_requested", _on_speech_cancel_requested) + ui.add_child(_speech_show_toolbar) + _speech_recovery_dialog = ConfirmationDialog.new() + _speech_recovery_dialog.title = "Speech draft found" + _speech_recovery_dialog.dialog_text = "Restore your unsaved speech draft?" + _speech_recovery_dialog.get_ok_button().text = "Restore" + _speech_recovery_dialog.get_cancel_button().text = "Discard" + _speech_recovery_dialog.confirmed.connect(_on_speech_recovery_restore) + _speech_recovery_dialog.canceled.connect(_on_speech_recovery_discard) + ui.add_child(_speech_recovery_dialog) + _speech_destination_dialog = ConfirmationDialog.new() + _speech_destination_dialog.title = "Save recovered speech" + _speech_destination_dialog.dialog_text = "Add this speech to the chosen character’s action queue?" + _speech_destination_dialog.get_ok_button().text = "Add to queue" + _speech_destination_dialog.get_cancel_button().text = "Keep editing" + _speech_destination_dialog.confirmed.connect(_commit_unbound_recovered_speech) + ui.add_child(_speech_destination_dialog) + func _build_gizmo_layer() -> void: _gizmos = STAGE_GIZMOS.new() @@ -2990,25 +3879,91 @@ func _on_transform_committed(nodes: Array[Node2D]) -> void: func _on_file_menu_id_pressed(id: int) -> void: + if id in [0, 1, 4] and _speech_composer_is_open(): + _show_toast("Finish or cancel speech editing first.") + return + if id in [0, 1, 4] and _rule_edit_uncommitted: + _show_toast("Finish or cancel the rule before changing scenes.") + return match id: 0: _request_pending_file_action(PendingFileAction.NEW_SCENE) 1: _request_pending_file_action(PendingFileAction.OPEN_DIALOG) - 2: - if _current_scene_path.is_empty(): _scene_save_dialog.popup_centered_ratio(0.7) - else: _save_scene_to_path(_current_scene_path) - 3: _scene_save_dialog.popup_centered_ratio(0.7) + 2: _request_scene_save() + 3: _request_scene_save("", true) 4: _rename_scene_prompt() func _on_scene_save_path_selected(path: String) -> void: var final_path := path if final_path.get_extension().to_lower() != "scene": final_path = final_path.get_basename() + ".scene" - if _save_scene_to_path(final_path) and _pending_file_action != PendingFileAction.NONE: + _request_scene_save(final_path) + + +func _request_scene_save(path: String = "", force_save_as: bool = false) -> void: + if _speech_composer_is_open(): + _speech_pending_scene_save_path = path + _speech_pending_scene_save_as = force_save_as + _speech_scene_save_pending = true + if _speech_save_gate_dialog != null and not _speech_save_gate_dialog.visible: + _speech_save_gate_dialog.popup_centered() + return + if _rule_edit_uncommitted: + _show_toast("Finish the rule before saving the scene.") + return + if force_save_as or (path.is_empty() and _current_scene_path.is_empty()): + _scene_save_dialog.popup_centered_ratio(0.7) + return + var target_path: String = path if not path.is_empty() else _current_scene_path + if not target_path.is_empty() and _save_scene_to_path(target_path) and _pending_file_action != PendingFileAction.NONE: _continue_pending_file_action() +func _on_speech_save_gate_confirmed() -> void: + if _speech_composer != null and _speech_composer.visible: + _speech_composer.call("request_done_speech") + + +func _on_speech_save_gate_cancelled() -> void: + _speech_pending_scene_save_path = "" + _speech_pending_scene_save_as = false + _speech_scene_save_pending = false + + +func _continue_deferred_speech_scene_save() -> void: + if not _speech_scene_save_pending or _rule_edit_uncommitted: + return + var requested_path: String = _speech_pending_scene_save_path + var force_save_as: bool = _speech_pending_scene_save_as + _speech_pending_scene_save_path = "" + _speech_pending_scene_save_as = false + _speech_scene_save_pending = false + if force_save_as or (requested_path.is_empty() and _current_scene_path.is_empty()): + _scene_save_dialog.popup_centered_ratio(0.7) + return + var target_path: String = requested_path if not requested_path.is_empty() else _current_scene_path + if not target_path.is_empty() and _save_scene_to_path(target_path) and _pending_file_action != PendingFileAction.NONE: + _continue_pending_file_action() + + +func _finish_rule_commit_deferred_speech_save() -> void: + _rule_edit_uncommitted = false + _continue_deferred_speech_scene_save() + + +func _cancel_deferred_speech_scene_save(reason: String) -> void: + if not _speech_scene_save_pending and not _rule_edit_uncommitted: + return + var had_pending_save: bool = _speech_scene_save_pending + _speech_pending_scene_save_path = "" + _speech_pending_scene_save_as = false + _speech_scene_save_pending = false + _rule_edit_uncommitted = false + if had_pending_save: + _show_toast(reason) + + func _rename_scene_prompt() -> void: _scene_name_edit.text = _scene_name _rename_scene_dialog.popup_centered() @@ -3023,6 +3978,12 @@ func _focus_scene_name_edit() -> void: func _request_pending_file_action(action: PendingFileAction, path: String = "") -> void: + if _rule_edit_uncommitted: + _show_toast("Finish or cancel the rule before changing scenes.") + return + if _speech_composer_is_open(): + _show_toast("Finish or cancel speech editing first.") + return _pending_file_action = action; _pending_open_path = path if _dirty: _unsaved_changes_dialog.popup_centered() @@ -3048,10 +4009,7 @@ func _continue_pending_file_action() -> void: func _on_unsaved_save() -> void: _unsaved_changes_dialog.hide() - if _current_scene_path.is_empty(): - _scene_save_dialog.popup_centered_ratio(0.7) - return - if _save_scene_to_path(_current_scene_path): _continue_pending_file_action() + _request_scene_save() func _on_unsaved_discard() -> void: @@ -3175,11 +4133,7 @@ func _on_action_popup_id_pressed(id: int) -> void: _apply_cursor() _refresh_status() ACT_SPEAK: - _speak_edit.text = "" - _speak_stop_current_check.visible = false - _speak_stop_current_check.button_pressed = false - _speak_dialog.popup_centered() - _speak_edit.grab_focus() + _open_speech_composer(_context_rig, {"type": "speak", "text": "", "duration": 2.0}, false, "direct") ACT_WAIT: _wait_spin.value = 1.0 _wait_dialog.popup_centered() @@ -3422,6 +4376,7 @@ func _popup_anchor_rect() -> Rect2i: ## "any stickman"). Used by both the Phase 4 "When…" action and the Phase 3c ## Rule Panel's "Add Rule" button. func _begin_rule_build(source_id: int, from_panel: bool = false) -> void: + _rule_edit_uncommitted = true _rule_build_from_panel = from_panel _rule_builder = { "trigger": { "source": source_id, "target": -1, "params": {} }, @@ -3456,6 +4411,7 @@ func _open_rule_more_popup() -> void: func _on_trigger_popup_id_pressed(id: int) -> void: if id == TRIG_BACK: + _cancel_deferred_speech_scene_save("Scene save canceled because the rule was not committed. Finish the rule and save again.") _reset_rule_builder() if _context_rig != null and is_instance_valid(_context_rig): _action_popup.popup(_popup_anchor_rect()) @@ -3634,11 +4590,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void: _refresh_status() "speak": _rule_step = RuleStep.PARAMS - _speak_edit.text = "" - _speak_stop_current_check.visible = true - _speak_stop_current_check.button_pressed = false - _speak_dialog.popup_centered() - _speak_edit.grab_focus() + _open_speech_composer(hit as StickmanRig, {"type": "speak", "text": "", "duration": 2.0}, true, "rule_builder") "wait": _rule_step = RuleStep.PARAMS _wait_spin.value = 1.0 @@ -3746,9 +4698,11 @@ func _finalize_rule() -> void: _reset_rule_builder() _restore_rule_build_panel() _refresh_status() + _finish_rule_commit_deferred_speech_save() func _cancel_rule_build() -> void: + _cancel_deferred_speech_scene_save("Scene save canceled because the rule was not committed. Finish the rule and save again.") _clear_popup_anchor() _reset_rule_builder() if _trigger_popup != null: @@ -3983,9 +4937,7 @@ func _action_for_rig(a: Dictionary) -> Dictionary: "walk_to": out["target"] = params.get("target", Vector2.ZERO) "speak": - out["text"] = String(params.get("text", "")) - out["duration"] = float(params.get("duration", 2.0)) - out["stop_current"] = bool(params.get("stop_current", false)) + return ACTION_REGISTRY.from_rule_action(a) "wait": out["duration"] = float(params.get("duration", 0.0)) "pick_up_prop": @@ -4515,6 +5467,7 @@ func _restore_rule_build_panel() -> void: # --------------------------------------------------------------------------- func _open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void: + _rule_edit_uncommitted = true _rule_editor_from_panel = from_panel if from_panel and _rule_panel != null: _rule_panel.hide() @@ -4522,6 +5475,7 @@ func _open_rule_editor_full(rule: Dictionary, from_panel: bool) -> void: func _open_rule_editor_consequence(rule: Dictionary) -> void: + _rule_edit_uncommitted = true _rule_editor_from_panel = false _rule_editor.open_consequence(rule) @@ -4551,9 +5505,11 @@ func _on_rule_editor_committed(rule: Dictionary) -> void: _director_visuals.set_rules(_event_rules) _mark_scene_dirty() _restore_rule_panel() + _finish_rule_commit_deferred_speech_save() func _on_rule_editor_cancelled() -> void: + _cancel_deferred_speech_scene_save("Scene save canceled because the rule was not committed. Finish the rule and save again.") _restore_rule_panel() @@ -4639,19 +5595,740 @@ func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: Stickm _action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit") -func _on_action_editor_committed(flat: Dictionary) -> void: - _action_editor.hide() +func _on_action_editor_speech_requested(initial: Dictionary) -> void: + if _action_editor != null: + _action_editor.hide() + var actor: StickmanRig = _action_editor_preview_rig() + if (actor == null or not is_instance_valid(actor)) and _action_editor_kind == "rule_add": + _pending_speech_initial = initial.duplicate(true) + _begin_capture(CaptureKind.STICKMAN, "🎯 Click the character who will speak (Esc or right-click to cancel)", _cb_speech_actor_capture, _cb_speech_actor_capture_cancel) + return + if actor == null or not is_instance_valid(actor): + _show_toast("Choose a character before composing speech") + _on_action_editor_cancelled() + return + _open_speech_composer(actor, initial, _action_editor_kind == "rule_add" or _action_editor_kind == "rule_edit", "action_editor") + + +func _cb_speech_actor_capture(value: Node2D) -> void: + if not value is StickmanRig: + _cb_speech_actor_capture_cancel() + return + _action_editor_actor_id = value.get_instance_id() + _open_speech_composer(value as StickmanRig, _pending_speech_initial, true, "action_editor") + _pending_speech_initial.clear() + + +func _cb_speech_actor_capture_cancel() -> void: + _pending_speech_initial.clear() + if _rule_editor != null: + _rule_editor.popup_centered() + + +func _open_speech_composer(actor: StickmanRig, initial: Dictionary, reactive_options: bool, context: String) -> void: + if actor == null or not is_instance_valid(actor) or _speech_composer == null: + return + if _speech_composer.visible: + _finish_speech_composition(false) + var speech_initial: Dictionary = initial.duplicate(true) + if not _speech_recovery_in_progress and context == "action_editor" and _action_editor_kind == "queue_edit" and _action_editor_rig == actor and _action_editor_action_id >= 0: + var queue_index: int = actor.index_for_action_id(_action_editor_action_id) + var queue: Array[Dictionary] = actor.get_queue() + if queue_index >= 0 and queue_index < queue.size() and String(queue[queue_index].get("type", "")) == "speak": + speech_initial = queue[queue_index].duplicate(true) + if _speech_recovery_matches_loaded_scene() and not _speech_recovery_dialog.visible and not _speech_recovery_in_progress: + _speech_pending_open = {"actor_id": actor.get_instance_id(), "initial": speech_initial, "reactive": reactive_options, "context": context} + _speech_recovery_dialog.popup_centered() + return + _speech_actor = actor + _speech_session_kind = context + _speech_origin = _capture_speech_origin(actor, context) + _speech_restored_unbound = context == "recovered_unbound" + _speech_placing = false + _speech_placement_bubble_index = 0 + _cancel_speech_bubble_pointer() + _speech_actor.set_speech_preview_suppressed(true) + _speech_preview = SpeechBubble.new() + _speech_preview.name = "SpeechPreview" + _speech_preview.bubble_position_changed.connect(_on_speech_bubble_position_changed) + _speech_preview.stage_token_selected.connect(_on_speech_preview_token_selected) + _speech_preview.stage_line_tokens_requested.connect(_on_speech_preview_line_tokens_requested) + _speech_preview.stage_text_tap_detected.connect(_on_speech_preview_text_tap_detected) + _speech_actor.add_child(_speech_preview) + _speech_preview.set_mirrored(_speech_actor.facing_profile == StickmanRig.FacingProfile.LEFT) + _speech_composer.call("open_for", actor, speech_initial, reactive_options) + _speech_draft_dirty = true + _speech_draft_timer = SPEECH_DRAFT_DEBOUNCE + + +func _on_speech_draft_changed(action: Dictionary) -> void: + _speech_draft_dirty = true + _speech_draft_timer = SPEECH_DRAFT_DEBOUNCE + if _speech_preview != null and is_instance_valid(_speech_preview) and _speech_composer != null: + var preview_state: String = String(_speech_composer.call("preview_state_name")) + var preview_action: Dictionary = _speech_composer.call("get_preview_action") + _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) + _speech_preview.render_action(preview_action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") + var context: Dictionary = _speech_composer.call("style_context") + var focus_index: int = int(_speech_composer.call("stage_focus_bubble_index")) + _speech_preview.set_selected_bubble_index(focus_index) + var target: Dictionary = context.get("target", {}) if context.get("target", {}) is Dictionary else {} + _speech_preview.set_selected_token(focus_index, int(target.get("start", -1)), int(target.get("end", -1))) + _sync_speech_preview_overflow(action) + _speech_style_toolbar.call("set_context", context) + _refresh_speech_style_toolbar() + + +func _update_speech_composition() -> void: + if _speech_composer == null or not _speech_composer.visible: + return + if _speech_actor == null or not is_instance_valid(_speech_actor): + _show_toast("The character is gone. Your speech draft was saved for recovery.") + _finish_speech_composition(false) + return + if _speech_preview == null or not is_instance_valid(_speech_preview): + return + _speech_preview.set_mirrored(_speech_actor.facing_profile == StickmanRig.FacingProfile.LEFT) + _speech_preview.set_selected_bubble_index(int(_speech_composer.call("stage_focus_bubble_index"))) + if not bool(_speech_composer.call("preview_visible")): + _speech_preview.visible = false + return + _speech_preview.visible = true + var action: Dictionary = _speech_composer.call("get_preview_action") + var preview_state: String = String(_speech_composer.call("preview_state_name")) + _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) + if _speech_placing: + var world_pos: Vector2 = _camera.get_global_mouse_position() + var hover_action: Dictionary = action.duplicate(true) + var local_center: Vector2 = _speech_actor.to_local(world_pos) + if _speech_placement_bubble_index < 0: + hover_action["placement"] = {"mode": "manual", "center": local_center} + else: + var bubbles: Array = hover_action.get("bubbles", []) + if _speech_placement_bubble_index < bubbles.size() and bubbles[_speech_placement_bubble_index] is Dictionary: + var bubble: Dictionary = bubbles[_speech_placement_bubble_index] + bubble["placement"] = {"mode": "manual", "center": local_center} + bubbles[_speech_placement_bubble_index] = bubble + hover_action["bubbles"] = bubbles + _speech_preview.set_placement_ghost(false) + _speech_preview.render_action(hover_action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") + else: + _speech_preview.set_placement_ghost(false) + _speech_preview.render_action(action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") + _sync_speech_preview_overflow(action) + var context: Dictionary = _speech_composer.call("style_context") + _speech_preview.set_selected_token(int(context.get("bubble_index", -1)), int((context.get("target", {}) as Dictionary).get("start", -1)), int((context.get("target", {}) as Dictionary).get("end", -1))) + _refresh_speech_style_toolbar() + + +func _on_speech_placement_requested() -> void: + _speech_placement_bubble_index = -1 + _speech_placing = true + _speech_composer.call("set_placement_pending", true, -1) + _show_toast("Click the stage to place the bubble. Esc cancels.") + + +func _on_speech_placement_reset() -> void: + _speech_placing = false + if _speech_composer != null: + _speech_composer.call("set_placement_pending", false) + + +func _on_speech_bubble_placement_requested(bubble_index: int) -> void: + _speech_placement_bubble_index = bubble_index + _speech_placing = true + _speech_composer.call("set_placement_pending", true, bubble_index) + _show_toast("Move this bubble: click the stage. Esc cancels.") + + +func _on_speech_bubble_placement_reset(_bubble_index: int) -> void: + _speech_placing = false + if _speech_composer != null: + _speech_composer.call("set_placement_pending", false) + + +func _on_speech_bubble_position_changed(bubble_index: int, center_rig: Vector2) -> void: + if _speech_composer == null or not _speech_composer.visible: + return + _speech_composer.call("set_bubble_placement_preview", bubble_index, center_rig) + + +func _on_speech_replay_requested() -> void: + if _speech_composer != null and _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.visible = true + var action: Dictionary = _speech_composer.call("get_preview_action") + var preview_state: String = String(_speech_composer.call("preview_state_name")) + _speech_preview.render_action(action, float(_speech_composer.call("preview_elapsed")), true, preview_state != "playing") + _sync_speech_preview_overflow(action) + + +func _on_speech_toolbar_preview_requested() -> void: + if _speech_composer != null and _speech_composer.visible: + _speech_composer.call("replay_preview_once") + + +func _on_speech_preview_state_changed(_state: String, elapsed: float, preview_visible: bool) -> void: + if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null: + return + _speech_preview.visible = preview_visible + if preview_visible: + var action: Dictionary = _speech_composer.call("get_preview_action") + _speech_preview.set_editor_effect_preview(_speech_composer.call("editor_effect_demo_context")) + _speech_preview.render_action(action, elapsed, true, _state != "playing") + _sync_speech_preview_overflow(action) + _refresh_speech_style_toolbar() + + +func _sync_speech_preview_overflow(action: Dictionary) -> void: + if _speech_preview == null or not is_instance_valid(_speech_preview) or _speech_composer == null: + return + var overflow: bool = _speech_preview.has_display_overflow() + var arrangement: String = String(action.get("arrangement", "together")) + var bubble_layout: String = String(action.get("bubble_layout", "stack")) + _speech_composer.call("set_preview_overflow", overflow, arrangement, bubble_layout) + + +func _on_speech_committed(action: Dictionary) -> void: + if _speech_session_kind == "recovered_unbound": + _speech_pending_commit_action = action.duplicate(true) + _speech_destination_dialog.popup_centered() + return + var speech_updates_uncommitted_rule: bool = _speech_session_kind == "rule_builder" or ( + _speech_session_kind == "action_editor" and _action_editor_kind in ["rule_add", "rule_edit"] + ) + if not _commit_speech_action(action): + _show_toast("Could not save speech here. Your draft is still open.") + return + _clear_speech_draft_file() + _finish_speech_composition(true) + if speech_updates_uncommitted_rule: + _rule_edit_uncommitted = true + if _speech_scene_save_pending: + _show_toast("Speech added to the rule. Finish the rule before saving the scene.") + else: + _continue_deferred_speech_scene_save() + + +func _on_speech_cancelled() -> void: + var context: String = _speech_session_kind + _speech_pending_commit_action.clear() + _speech_pending_scene_save_path = "" + _speech_pending_scene_save_as = false + _speech_scene_save_pending = false + _clear_speech_draft_file() + _finish_speech_composition(true) + if context == "action_editor": + _on_action_editor_cancelled() + elif context == "rule_builder": + _rule_step = RuleStep.ACTION_TARGET + _rule_hint = "Click the character who will speak, or choose another action" + _refresh_status() + + +func _finish_speech_composition(clear_recovery: bool = false) -> void: + if _speech_composer != null and _speech_composer.visible and not clear_recovery: + var matching_queue_edit: bool = false + var origin_check: Dictionary = _validate_speech_origin(_speech_origin) + var draft_value: Variant = _speech_composer.call("get_draft") + if not origin_check.is_empty() and String(origin_check.get("context", "")) == "action_editor" and String(origin_check.get("kind", "")) == "queue_edit" and draft_value is Dictionary: + matching_queue_edit = _speech_action_matches_queue_edit(draft_value as Dictionary, origin_check["actor"] as StickmanRig, int(origin_check["queue_index"])) + if matching_queue_edit: + _clear_speech_draft_file() + else: + _persist_speech_draft_now() + if clear_recovery: + _clear_speech_draft_file() + _speech_placing = false + _cancel_speech_bubble_pointer() + _speech_placement_bubble_index = 0 + if _speech_bubble_picker != null and is_instance_valid(_speech_bubble_picker): + _speech_bubble_picker.hide() + if _speech_token_picker != null and is_instance_valid(_speech_token_picker): + _speech_token_picker.hide() + if _speech_style_toolbar != null: + _speech_style_toolbar.call("close_chooser") + _speech_style_toolbar.call("set_message_dialog_open", false) + _speech_style_toolbar.hide() + if _speech_show_toolbar != null: + _speech_show_toolbar.hide() + if _speech_destination_dialog != null: + _speech_destination_dialog.hide() + if _speech_save_gate_dialog != null: + _speech_save_gate_dialog.hide() + if _speech_composer != null: + _speech_composer.call("set_placement_pending", false) + if _speech_preview != null and is_instance_valid(_speech_preview): + _speech_preview.queue_free() + _speech_preview = null + if _speech_actor != null and is_instance_valid(_speech_actor): + _speech_actor.set_speech_preview_suppressed(false) + _speech_actor = null + _speech_session_kind = "" + if _speech_composer != null: + _speech_composer.call("close_without_commit") + _speech_draft_timer = -1.0 + _speech_draft_dirty = false + _speech_origin.clear() + _speech_restored_unbound = false + + +func _capture_speech_origin(actor: StickmanRig, context: String) -> Dictionary: + var origin: Dictionary = { + "context": context, + "scene_path": _current_scene_path, + "scene_fingerprint": _speech_scene_fingerprint(), + "actor_file_id": _speech_actor_file_id(actor), + "actor_stk_path": actor.source_stk_path if actor != null and is_instance_valid(actor) else "", + "action_editor_kind": _action_editor_kind, + "action_editor_index": _action_editor_index, + "restore_queue": _action_editor_restore_queue, + "queue_index": -1, + "queue_action": {}, + "insert_after": _action_editor_insert_after, + } + if context != "action_editor" or actor == null or not is_instance_valid(actor): + return origin + if _action_editor_kind == "queue_edit" and _action_editor_rig == actor and _action_editor_action_id >= 0: + var queue_index: int = actor.index_for_action_id(_action_editor_action_id) + origin["queue_index"] = queue_index + origin["queue_action"] = _queue_action_wire_at(actor, queue_index) + elif _action_editor_kind == "queue_insert" and _action_editor_rig == actor: + var insert_index: int = _action_editor_index + if _action_editor_target_action_id >= 0: + insert_index = actor.index_for_action_id(_action_editor_target_action_id) + origin["queue_index"] = insert_index + origin["queue_action"] = _queue_action_wire_at(actor, insert_index) + return origin + + +func _speech_actor_file_id(actor: StickmanRig) -> String: + if actor == null or not is_instance_valid(actor): + return "" + var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0} + for child: Node in _world.get_children(): + if not child is Node2D: + continue + var node: Node2D = child as Node2D + var object_type: String = _speech_object_type(node) + if object_type.is_empty(): + continue + counters[object_type] = int(counters[object_type]) + 1 + if node == actor: + return "%s_%d" % [object_type, int(counters[object_type])] + return "" + + +func _speech_object_type(node: Node2D) -> String: + if node is TerrainBlock: + return "terrain" + if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER: + return "prop" + if node is STICKMAN_RIG or node.get_script() == MISSING_STICKMAN_PLACEHOLDER: + return "stickman" + if node is TriggerArea: + return "area" + return "" + + +func _find_speech_actor(file_id: String, source_path: String) -> StickmanRig: + if file_id.is_empty(): + return null + var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0} + for child: Node in _world.get_children(): + if not child is Node2D: + continue + var node: Node2D = child as Node2D + var object_type: String = _speech_object_type(node) + if object_type.is_empty(): + continue + counters[object_type] = int(counters[object_type]) + 1 + if "%s_%d" % [object_type, int(counters[object_type])] != file_id: + continue + if not node is StickmanRig: + return null + var actor: StickmanRig = node as StickmanRig + return actor if actor.source_stk_path == source_path else null + return null + + +func _speech_scene_fingerprint() -> String: + if _world == null or _camera == null: + return "" + var data: Dictionary = _collect_scene_data(false) + var stable: Dictionary = {"scene_name": _scene_name, "objects": data.get("objects", []), "event_rules": data.get("event_rules", [])} + var digest: HashingContext = HashingContext.new() + if digest.start(HashingContext.HASH_SHA256) != OK: + return "" + digest.update(JSON.stringify(stable).to_utf8_buffer()) + return digest.finish().hex_encode() + + +func _queue_action_wire_at(actor: StickmanRig, index: int) -> Dictionary: + if actor == null or not is_instance_valid(actor) or index < 0: + return {} + var queue: Array[Dictionary] = actor.get_queue() + if index >= queue.size(): + return {} + var one_action: Array[Dictionary] = [queue[index]] + var encoded: Dictionary = STAGE_SCENE_IO.encode_queue(one_action) + var actions: Array = encoded.get("actions", []) + return actions[0] if not actions.is_empty() and actions[0] is Dictionary else {} + + +func _validate_speech_origin(origin: Dictionary) -> Dictionary: + if _current_scene_path.is_empty() or String(origin.get("scene_path", "")) != _current_scene_path: + return {} + var scene_fingerprint_matches: bool = String(origin.get("scene_fingerprint", "")) == _speech_scene_fingerprint() + var actor: StickmanRig = _find_speech_actor(String(origin.get("actor_file_id", "")), String(origin.get("actor_stk_path", ""))) + if actor == null: + return {} + var context: String = String(origin.get("context", "")) + if context == "direct": + return {"actor": actor, "context": "direct"} if scene_fingerprint_matches else {} + if context != "action_editor": + return {} + var editor_kind: String = String(origin.get("action_editor_kind", "")) + if editor_kind == "queue_add": + return {"actor": actor, "context": "action_editor", "kind": editor_kind} if scene_fingerprint_matches else {} + if editor_kind != "queue_edit" and editor_kind != "queue_insert": + return {} + var queue_index: int = int(origin.get("queue_index", -1)) + var expected: Dictionary = origin.get("queue_action", {}) + if queue_index < 0 or expected.is_empty() or _queue_action_wire_at(actor, queue_index) != expected: + return {} + if editor_kind == "queue_edit": + var action_id: int = actor.action_id_at(queue_index) + if action_id < 0 or _queue_action_consumed(actor.get_action_status(action_id)): + return {} + return {"actor": actor, "context": "action_editor", "kind": editor_kind, "queue_index": queue_index} + + +func _check_speech_recovery() -> void: + if _speech_recovery_dialog != null and _speech_recovery_matches_loaded_scene(): + _speech_recovery_dialog.popup_centered() + + +func _speech_recovery_matches_loaded_scene() -> bool: + if _current_scene_path.is_empty(): + return false + if not FileAccess.file_exists(SPEECH_DRAFT_PATH) and not FileAccess.file_exists(SPEECH_DRAFT_PATH + ".bak"): + return false + var document: Dictionary = _read_speech_draft_document() + var origin_value: Variant = document.get("origin", null) + if not origin_value is Dictionary: + return false + var validated: Dictionary = _validate_speech_origin(origin_value as Dictionary) + if validated.is_empty(): + return false + var draft_value: Variant = document.get("draft", null) + if draft_value is Dictionary and String(validated.get("context", "")) == "action_editor" and String(validated.get("kind", "")) == "queue_edit": + if _speech_action_matches_queue_edit(draft_value as Dictionary, validated["actor"] as StickmanRig, int(validated["queue_index"])): + _clear_speech_draft_file() + return false + return true + + +func _speech_action_matches_queue_edit(draft: Dictionary, actor: StickmanRig, queue_index: int) -> bool: + if actor == null or not is_instance_valid(actor): + return false + var queue: Array[Dictionary] = actor.get_queue() + if queue_index < 0 or queue_index >= queue.size() or String(queue[queue_index].get("type", "")) != "speak": + return false + var draft_payload: Dictionary = SPEECH_PAYLOAD.normalize(draft) + var saved_payload: Dictionary = SPEECH_PAYLOAD.normalize(queue[queue_index]) + draft_payload.erase("legacy_fixed") + saved_payload.erase("legacy_fixed") + return draft_payload == saved_payload + + +func _read_speech_draft_document() -> Dictionary: + if not FileAccess.file_exists(SPEECH_DRAFT_PATH) and FileAccess.file_exists(SPEECH_DRAFT_PATH + ".bak"): + DirAccess.rename_absolute(ProjectSettings.globalize_path(SPEECH_DRAFT_PATH + ".bak"), ProjectSettings.globalize_path(SPEECH_DRAFT_PATH)) + if not FileAccess.file_exists(SPEECH_DRAFT_PATH): + return {} + var file: FileAccess = FileAccess.open(SPEECH_DRAFT_PATH, FileAccess.READ) + if file == null: + return {} + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + return parsed as Dictionary if parsed is Dictionary and int((parsed as Dictionary).get("schema", -1)) == SPEECH_DRAFT_SCHEMA else {} + + +func _on_speech_recovery_restore() -> void: + var document: Dictionary = _read_speech_draft_document() + var payload_value: Variant = document.get("draft", null) + var origin_value: Variant = document.get("origin", {}) + if payload_value == null or not payload_value is Dictionary: + _show_toast("That speech draft could not be restored.") + _clear_speech_draft_file() + _open_pending_speech_request() + return + _speech_restored_origin = origin_value as Dictionary if origin_value is Dictionary else {} + var validated: Dictionary = _validate_speech_origin(_speech_restored_origin) + var recovered_payload: Dictionary = (payload_value as Dictionary).duplicate(true) + var origin_action_value: Variant = _speech_restored_origin.get("queue_action", null) + if String(_speech_restored_origin.get("context", "")) == "action_editor" and String(_speech_restored_origin.get("action_editor_kind", "")) == "queue_edit" and origin_action_value is Dictionary and String((origin_action_value as Dictionary).get("type", "")) == "speak": + recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, origin_action_value as Dictionary) + var restored_from_pending_edit: bool = false + var pending_actor: StickmanRig = instance_from_id(int(_speech_pending_open.get("actor_id", 0))) as StickmanRig + var pending_initial_value: Variant = _speech_pending_open.get("initial", {}) + if pending_actor != null and is_instance_valid(pending_actor) and String(_speech_pending_open.get("context", "")) == "action_editor" and String(_speech_restored_origin.get("action_editor_kind", "")) == "queue_edit" and String(_speech_restored_origin.get("actor_file_id", "")) == _speech_actor_file_id(pending_actor) and String(_speech_restored_origin.get("actor_stk_path", "")) == pending_actor.source_stk_path and pending_initial_value is Dictionary: + var pending_initial: Dictionary = pending_initial_value as Dictionary + var pending_encoded: Dictionary = STAGE_SCENE_IO.encode_queue([pending_initial]) + var pending_actions: Array = pending_encoded.get("actions", []) + var saved_origin_action: Variant = _speech_restored_origin.get("queue_action", null) + if not pending_actions.is_empty() and pending_actions[0] == saved_origin_action: + recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, pending_initial) + if validated.is_empty(): + validated = { + "actor": pending_actor, + "context": "action_editor", + "kind": "queue_edit", + "queue_index": int(_speech_restored_origin.get("queue_index", -1)), + } + restored_from_pending_edit = true + if not validated.is_empty() and String(validated.get("context", "")) == "action_editor" and String(validated.get("kind", "")) == "queue_edit": + var saved_actor: StickmanRig = validated["actor"] + var saved_index: int = int(validated.get("queue_index", -1)) + var saved_queue: Array[Dictionary] = saved_actor.get_queue() + if saved_index >= 0 and saved_index < saved_queue.size(): + recovered_payload = _restore_empty_recovered_bubble_text(recovered_payload, saved_queue[saved_index]) + _speech_restored_payload = SPEECH_PAYLOAD.normalize(recovered_payload) + var selected_bubble: int = clampi(int(document.get("selected_bubble", 0)), 0, maxi(0, (_speech_restored_payload.get("bubbles", []) as Array).size() - 1)) + _speech_restored_selected_bubble = selected_bubble + if not validated.is_empty(): + var actor: StickmanRig = validated["actor"] + var context: String = String(validated["context"]) + if context == "action_editor": + var editor_kind: String = String(validated["kind"]) + _action_editor_kind = editor_kind + _action_editor_rig = actor + _action_editor_actor_id = int(actor.get_instance_id()) + _action_editor_restore_queue = bool(_speech_restored_origin.get("restore_queue", editor_kind == "queue_edit" or editor_kind == "queue_insert")) + _action_editor_insert_after = bool(_speech_restored_origin.get("insert_after", false)) + _action_editor_index = int(_speech_restored_origin.get("action_editor_index", -1)) + _action_editor_action_id = -1 + _action_editor_target_action_id = -1 + if editor_kind == "queue_edit": + _action_editor_index = int(validated["queue_index"]) + _action_editor_action_id = actor.action_id_at(_action_editor_index) + elif editor_kind == "queue_insert": + _action_editor_index = int(validated["queue_index"]) + _action_editor_target_action_id = actor.action_id_at(_action_editor_index) + _speech_recovery_in_progress = true + _open_speech_composer(actor, _speech_restored_payload, context == "action_editor", context) + _speech_recovery_in_progress = false + if not restored_from_pending_edit: + _speech_origin = _speech_restored_origin.duplicate(true) + _speech_composer.call("set_draft", _speech_restored_payload) + _speech_composer.call("set_selected_bubble_index", selected_bubble) + else: + _speech_restored_unbound = true + _speech_recovery_in_progress = true + _begin_capture(CaptureKind.STICKMAN, "Choose a character for this recovered speech", _on_speech_unbound_actor_capture, _on_speech_unbound_actor_cancel) + _speech_recovery_in_progress = false + _clear_pending_speech_request() + + +func _restore_empty_recovered_bubble_text(recovered: Dictionary, saved_action: Dictionary) -> Dictionary: + var result: Dictionary = recovered.duplicate(true) + var saved: Dictionary = SPEECH_PAYLOAD.normalize(saved_action) + var saved_bubbles: Array = saved.get("bubbles", []) + var recovered_bubbles_value: Variant = result.get("bubbles", null) + if recovered_bubbles_value is Array: + var recovered_bubbles: Array = recovered_bubbles_value + for index: int in mini(recovered_bubbles.size(), saved_bubbles.size()): + if not recovered_bubbles[index] is Dictionary or not saved_bubbles[index] is Dictionary: + continue + var bubble: Dictionary = recovered_bubbles[index] + if not String(bubble.get("text", "")).strip_edges().is_empty(): + continue + var saved_text: String = String((saved_bubbles[index] as Dictionary).get("text", "")) + if saved_text.strip_edges().is_empty(): + continue + bubble["text"] = saved_text + recovered_bubbles[index] = bubble + result["bubbles"] = recovered_bubbles + if not recovered_bubbles.is_empty() and recovered_bubbles[0] is Dictionary: + result["text"] = String((recovered_bubbles[0] as Dictionary).get("text", "")) + elif String(result.get("text", "")).strip_edges().is_empty(): + result["text"] = String(saved.get("text", "")) + elif String(result.get("text", "")).strip_edges().is_empty(): + result["text"] = String(saved.get("text", "")) + return result + + +func _on_speech_recovery_discard() -> void: + _clear_speech_draft_file() + _clear_pending_speech_request() + _open_pending_speech_request() + + +func _on_speech_unbound_actor_capture(value: Node2D) -> void: + if not value is StickmanRig: + _on_speech_unbound_actor_cancel() + return + _speech_recovery_in_progress = true + _open_speech_composer(value as StickmanRig, _speech_restored_payload, false, "recovered_unbound") + _speech_recovery_in_progress = false + _speech_restored_unbound = true + _speech_composer.call("set_selected_bubble_index", _speech_restored_selected_bubble) + + +func _on_speech_unbound_actor_cancel() -> void: + _speech_restored_unbound = false + _show_toast("Speech draft kept. Choose a character to continue.") + + +func _open_pending_speech_request() -> void: + if _speech_pending_open.is_empty(): + return + var actor: StickmanRig = instance_from_id(int(_speech_pending_open.get("actor_id", 0))) as StickmanRig + var initial: Dictionary = _speech_pending_open.get("initial", {}) + var reactive: bool = bool(_speech_pending_open.get("reactive", false)) + var context: String = String(_speech_pending_open.get("context", "")) + _clear_pending_speech_request() + if actor != null and is_instance_valid(actor): + _open_speech_composer(actor, initial, reactive, context) + + +func _clear_pending_speech_request() -> void: + _speech_pending_open.clear() + + +func _speech_payload_for_json(action: Dictionary) -> Dictionary: + var payload: Dictionary = action.duplicate(true) + var placement: Dictionary = payload.get("placement", {}) if payload.get("placement", {}) is Dictionary else {} + if String(placement.get("mode", "auto")) == "manual" and placement.get("center", null) is Vector2: + placement["center"] = STAGE_SCENE_IO.vector_to_data(placement["center"]) + payload["placement"] = placement + var bubbles: Array = payload.get("bubbles", []) + for index: int in bubbles.size(): + if not bubbles[index] is Dictionary: + continue + var bubble: Dictionary = bubbles[index] + var bubble_placement: Variant = bubble.get("placement", null) + if bubble_placement is Dictionary: + var bubble_position: Variant = (bubble_placement as Dictionary).get("center", null) + if String((bubble_placement as Dictionary).get("mode", "auto")) == "manual" and bubble_position is Vector2: + (bubble_placement as Dictionary)["center"] = STAGE_SCENE_IO.vector_to_data(bubble_position) + bubbles[index] = bubble + if not bubbles.is_empty(): + payload["bubbles"] = bubbles + return payload + + +func _persist_speech_draft_now() -> void: + if _speech_composer == null or not _speech_composer.visible or not _speech_draft_dirty: + return + var document: Dictionary = { + "schema": SPEECH_DRAFT_SCHEMA, + "modified_unix": Time.get_unix_time_from_system(), + "draft": _speech_payload_for_json(_speech_composer.call("get_draft")), + "selected_bubble": int(_speech_composer.call("selected_bubble_index")), + "origin": _speech_origin.duplicate(true), + } + var tmp_path: String = SPEECH_DRAFT_PATH + ".tmp" + var bak_path: String = SPEECH_DRAFT_PATH + ".bak" + var file: FileAccess = FileAccess.open(tmp_path, FileAccess.WRITE) + if file == null: + push_warning("Speech composer draft could not be written.") + return + file.store_string(JSON.stringify(document, "\t", false)) + file.flush() + file.close() + var tmp_abs: String = ProjectSettings.globalize_path(tmp_path) + var path_abs: String = ProjectSettings.globalize_path(SPEECH_DRAFT_PATH) + var bak_abs: String = ProjectSettings.globalize_path(bak_path) + if FileAccess.file_exists(bak_path): + DirAccess.remove_absolute(bak_abs) + if FileAccess.file_exists(SPEECH_DRAFT_PATH) and DirAccess.rename_absolute(path_abs, bak_abs) != OK: + DirAccess.remove_absolute(tmp_abs) + push_warning("Speech composer draft backup failed; keeping the previous draft.") + return + if DirAccess.rename_absolute(tmp_abs, path_abs) != OK: + if FileAccess.file_exists(bak_path): + DirAccess.rename_absolute(bak_abs, path_abs) + push_warning("Speech composer draft replacement failed.") + return + if FileAccess.file_exists(bak_path): + DirAccess.remove_absolute(bak_abs) + _speech_draft_dirty = false + + +func _clear_speech_draft_file() -> void: + _speech_draft_timer = -1.0 + _speech_draft_dirty = false + _speech_pending_commit_action.clear() + _speech_restored_payload.clear() + _speech_restored_origin.clear() + _speech_restored_selected_bubble = 0 + for path: String in [SPEECH_DRAFT_PATH, SPEECH_DRAFT_PATH + ".tmp", SPEECH_DRAFT_PATH + ".bak"]: + var absolute: String = ProjectSettings.globalize_path(path) + if FileAccess.file_exists(path): + DirAccess.remove_absolute(absolute) + + +func _commit_speech_action(action: Dictionary) -> bool: + var actor: StickmanRig = _speech_actor + match _speech_session_kind: + "direct": + if actor == null or not is_instance_valid(actor): + return false + if not _queue_action_and_confirm(actor, action): + _show_toast("Speech was not added to the character's queue. Keep editing and try again.") + return false + _show_toast("Speech added") + return true + "rule_builder": + var actions: Array = _rule_builder.get("actions", []) + if actions.is_empty(): + return false + var rule_action: Dictionary = actions.back() + var params: Dictionary = action.duplicate(true) + params.erase("type") + rule_action["params"] = params + actions[actions.size() - 1] = rule_action + _rule_builder["actions"] = actions + _open_rule_more_popup() + return true + "action_editor": + return _on_action_editor_committed(action) + return false + + +func _commit_unbound_recovered_speech() -> void: + if _speech_actor == null or not is_instance_valid(_speech_actor) or _speech_pending_commit_action.is_empty(): + return + if not _queue_action_and_confirm(_speech_actor, _speech_pending_commit_action): + _show_toast("Recovered speech was not added to the queue. Your draft is still open.") + return + _speech_pending_commit_action.clear() + _clear_speech_draft_file() + _finish_speech_composition(true) + _continue_deferred_speech_scene_save() + _show_toast("Recovered speech added to the action queue") + + +func _on_action_editor_committed(flat: Dictionary) -> bool: + if _action_editor == null: + return false + var succeeded: bool = false match _action_editor_kind: "queue_add": if _action_editor_rig != null and is_instance_valid(_action_editor_rig): - _action_editor_rig.queue_action(flat) + if not _queue_action_and_confirm(_action_editor_rig, flat): + _show_toast("Action was not added to the queue. Keep editing and try again.") + return false _show_toast("Action added") + succeeded = true "queue_edit": if _action_editor_rig != null and is_instance_valid(_action_editor_rig): var idx: int = _action_editor_rig.index_for_action_id(_action_editor_action_id) var status: int = _action_editor_rig.get_action_status(_action_editor_action_id) if idx >= 0 and not _queue_action_consumed(status) and _action_editor_rig.replace_action(idx, flat): _show_toast("Action updated") + succeeded = true else: _show_toast("That action is no longer editable") "queue_insert": @@ -4665,22 +6342,48 @@ func _on_action_editor_committed(flat: Dictionary) -> void: else: insert_index += 1 if _action_editor_insert_after else 0 if insert_index >= 0: - _action_editor_rig.insert_action(insert_index, flat) + if not _queue_action_and_confirm(_action_editor_rig, flat, insert_index): + _show_toast("Action was not inserted into the queue. Keep editing and try again.") + return false _show_toast("Action inserted") + succeeded = true else: _show_toast("That action can no longer be used as an insert point") "rule_add": - if _action_editor_actor_id >= 0: - if _rule_editor != null: - _rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id)) - else: - _pending_rule_action_flat = flat - _begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel) - return + var actor_id: int = _action_editor_actor_id + if actor_id < 0 and _speech_actor != null and is_instance_valid(_speech_actor): + actor_id = int(_speech_actor.get_instance_id()) + if actor_id >= 0 and _rule_editor != null: + _rule_editor.set_action(-1, ACTION_REGISTRY.to_rule_action(flat, actor_id)) + succeeded = true "rule_edit": - if _rule_editor != null: - _rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, _action_editor_actor_id)) + var actor_id: int = _action_editor_actor_id + if actor_id < 0 and _speech_actor != null and is_instance_valid(_speech_actor): + actor_id = int(_speech_actor.get_instance_id()) + if _rule_editor != null and actor_id >= 0: + _rule_editor.set_action(_action_editor_index, ACTION_REGISTRY.to_rule_action(flat, actor_id)) + succeeded = true + if not succeeded: + return false + _action_editor.hide() _restore_queue_panel() + return true + + +func _queue_action_and_confirm(rig: StickmanRig, action: Dictionary, index: int = -1) -> bool: + if rig == null or not is_instance_valid(rig): + return false + var before: Array[Dictionary] = rig.get_queue() + var expected_index: int = before.size() if index < 0 else clampi(index, 0, before.size()) + if index < 0: + rig.queue_action(action) + else: + rig.insert_action(index, action) + var after: Array[Dictionary] = rig.get_queue() + if after.size() != before.size() + 1 or expected_index >= after.size() or after[expected_index] != action: + return false + _mark_scene_dirty() + return true func _on_action_editor_cancelled() -> void: diff --git a/scripts/sandbox_theme.gd b/scripts/sandbox_theme.gd index 1cafe54..7306988 100644 --- a/scripts/sandbox_theme.gd +++ b/scripts/sandbox_theme.gd @@ -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() diff --git a/scripts/speech_composer.gd b/scripts/speech_composer.gd new file mode 100644 index 0000000..052157b --- /dev/null +++ b/scripts/speech_composer.gd @@ -0,0 +1,1281 @@ +class_name SpeechComposer +extends Control +## Stage-side comic speech editor. The stage owns the attached preview. + +const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd") +const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd") +const SPEECH_BUBBLE := preload("res://scripts/stickman_speech_bubble.gd") +const FIVE_BUBBLE_HINT: String = "Long story? One at a time can be easier to follow." +const MAX_BUBBLE_HINT: String = "You can add up to eight bubbles." + +enum PreviewState { STATIC, PLAYING, BLANK_HOLD } +const BLANK_HOLD_SECONDS: float = 1.0 +signal draft_changed(action: Dictionary) +signal placement_requested() +signal placement_reset_requested() +signal replay_requested() +signal preview_state_changed(state: String, elapsed: float, visible: bool) +signal committed(action: Dictionary) +signal cancelled() +signal validation_failed(message: String, bubble_index: int) +signal bubble_placement_requested(index: int) +signal bubble_placement_reset_requested(index: int) +signal style_context_changed(context: Dictionary) +signal message_dialog_closed() +signal stage_focus_changed(bubble_index: int) + +var _actor: StickmanRig = null +var _draft: Dictionary = {} +var _reactive_options: bool = false +var _selected_bubble: int = 0 +var _stage_focus_bubble: int = -1 +var _target: Dictionary = {} +var _style_category: StringName = &"" +var _effect_demo: Dictionary = {} +var _effect_demo_elapsed: float = 0.0 +var _effect_demo_remaining: float = 0.0 +var _caret_by_bubble: Array[Vector2i] = [] +var _scroll_by_bubble: Array[float] = [] +var _last_text_by_bubble: Array[String] = [] +var _undo_removed_bubble: Dictionary = {} +var _initial_action: Dictionary = {} +var _cancel_requires_confirmation: bool = false +var _cancel_confirm_dialog: ConfirmationDialog +var _sequence_hint: String = "" +var _five_bubble_hint_shown: bool = false +var _active_editor: TextEdit +var _emoji_grid: GridContainer +var _emoji_picker_scroll: ScrollContainer +var _emoji_picker_button: Button +var _message_dialog: PanelContainer +var _message_done_button: Button +var _message_frame_rect: Rect2 = Rect2() +var _message_stage_rect: Rect2 = Rect2() +var _message_toolbar_rect: Rect2 = Rect2() +var _overflow_label: Label +var _loading_editor: bool = false +var _replay_clock: float = 0.0 +var _replay_running: bool = false +var _preview_state: PreviewState = PreviewState.STATIC +var _blank_hold_clock: float = 0.0 +var _theme: SandboxTheme +var _placement_target_bubble: int = -1 + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + _theme = SANDBOX_THEME.new() + _theme.load_from() + theme = _theme.control_theme() + visible = false + _build_ui() + resized.connect(_layout_message_dialog) + resized.connect(_update_message_responsive_layout) + + +func _process(delta: float) -> void: + if _effect_demo_remaining > 0.0: + _effect_demo_elapsed += delta + _effect_demo_remaining = maxf(0.0, _effect_demo_remaining - delta) + if _effect_demo_remaining <= 0.0: + _stop_editor_effect_demo() + if _preview_state == PreviewState.PLAYING: + _replay_clock += delta + if _replay_clock >= float(_draft.get("duration", 0.0)): + _replay_clock = float(_draft.get("duration", _replay_clock)) + _preview_state = PreviewState.BLANK_HOLD + _blank_hold_clock = 0.0 + _replay_running = false + _emit_preview_state() + elif _preview_state == PreviewState.BLANK_HOLD: + _blank_hold_clock += delta + if _blank_hold_clock >= BLANK_HOLD_SECONDS: + _preview_state = PreviewState.STATIC + _replay_clock = float(_draft.get("duration", 0.0)) + _emit_preview_state() + + +func open_for(actor: StickmanRig, initial: Dictionary, reactive_options: bool) -> void: + _stop_editor_effect_demo() + _style_category = &"" + _actor = actor + _reactive_options = reactive_options + var initial_action: Dictionary = initial.duplicate(true) + if not initial_action.has("keep_acting"): + initial_action["keep_acting"] = reactive_options and not bool(initial_action.get("stop_current", false)) + _draft = SPEECH_PAYLOAD.normalize(initial_action) + _draft.erase("legacy_fixed") + if not _draft.has("bubbles") or not _draft["bubbles"] is Array: + _draft["bubbles"] = [] + if (_draft["bubbles"] as Array).is_empty(): + (_draft["bubbles"] as Array).append(_new_bubble()) + _selected_bubble = 0 + _undo_removed_bubble.clear() + _initial_action.clear() + _cancel_requires_confirmation = false + _sequence_hint = "" + _five_bubble_hint_shown = false + _update_bubble_count_hint() + _set_stage_focus(-1) + _target.clear() + _prepare_editor_state() + # Populate the editor before emitting preview_state_changed. The stage's + # preview handler reads get_draft() synchronously, which saves editor text. + _sync_editor() + _sync_controls() + visible = true + _stop_replay_to_static() + _emit_draft(false) + _initial_action = get_draft() + + +func set_draft(action: Dictionary) -> void: + _stop_editor_effect_demo() + _style_category = &"" + _target.clear() + _draft = SPEECH_PAYLOAD.normalize(action) + _draft.erase("legacy_fixed") + var bubbles: Array = _draft.get("bubbles", []) + _selected_bubble = clampi(_selected_bubble, 0, maxi(0, bubbles.size() - 1)) + _update_bubble_count_hint() + _undo_removed_bubble.clear() + _cancel_requires_confirmation = true + _set_stage_focus(-1) + _target.clear() + _prepare_editor_state() + _sync_editor() + _sync_controls() + _emit_draft() + _initial_action = get_draft() + + +func get_draft() -> Dictionary: + _save_active_editor_state() + var result: Dictionary = _draft.duplicate(true) + result.erase("legacy_fixed") + result["type"] = "speak" + var bubbles: Array = result.get("bubbles", []) + result["text"] = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else "" + result["duration"] = SPEECH_PAYLOAD.duration_for(result) + return result + + +func style_context() -> Dictionary: + var bubbles: Array = _draft.get("bubbles", []) + var bubble: Dictionary = _selected_bubble_data() + var text: String = String(bubble.get("text", "")) + var visual: Dictionary = bubble.get("visual", {}) + var target: Dictionary = _target.duplicate(true) + if not target.is_empty() and int(target.get("bubble_index", -1)) != _selected_bubble: + target.clear() + var token_style: Dictionary = _style_for_visual_target(visual, target) if not target.is_empty() else {} + return { + "bubble_index": _selected_bubble, + "bubble_count": bubbles.size(), + "can_move_earlier": _selected_bubble > 0, + "can_move_later": _selected_bubble >= 0 and _selected_bubble < bubbles.size() - 1, + "max_bubbles": SPEECH_PAYLOAD.MAX_BUBBLES, + "can_undo_remove": not _undo_removed_bubble.is_empty(), + "sequence_hint": _sequence_hint, + "target": target, + "bubble_style": String(visual.get("bubble_style", "talk")), + "font_preset": String(token_style.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))), + "size_preset": String(token_style.get("size_preset", visual.get("size_preset", "medium"))), + "color": String(token_style.get("color", visual.get("color", SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR))), + "effect": String(token_style.get("effect", visual.get("effect", "none"))), + "duration_mode": String(_draft.get("duration_mode", "just_enough")), + "text": text, + "category": String(_style_category), + } + + +func set_style_category(kind: StringName) -> void: + if not [&"frame", &"letters", &"effect"].has(kind): + return + _style_category = kind + _emit_style_context() + + +func apply_style_choice(kind: StringName, value: String) -> void: + if not _style_category_accepts(kind) or not _style_choice_valid(kind, value): + return + if kind == &"effect" and String(style_context().get("text", "")).strip_edges().is_empty(): + return + _save_active_editor_state() + if kind == &"timing": + _draft["duration_mode"] = value + _emit_draft() + _sync_controls() + _emit_style_context() + return + var bubble: Dictionary = _selected_bubble_data() + var visual: Dictionary = bubble.get("visual", {}).duplicate(true) + _apply_visual_choice(visual, kind, value, _target) + bubble["visual"] = visual + _store_selected_bubble(bubble) + if kind == &"effect": + if value == "none": + _stop_editor_effect_demo() + else: + _start_editor_effect_demo(value, _target) + elif kind in [&"font", &"size"] and not _effect_demo.is_empty(): + _stop_editor_effect_demo() + _emit_draft() + _sync_controls() + _emit_style_context() + + +func get_preview_action() -> Dictionary: + return get_draft() + + +func editor_effect_demo_context() -> Dictionary: + if _effect_demo.is_empty() or _effect_demo_remaining <= 0.0: + return {} + var result: Dictionary = _effect_demo.duplicate(true) + result["elapsed"] = _effect_demo_elapsed + return result + + +func select_bubble_token_from_stage(index: int, start: int, end: int) -> void: + var bubbles: Array = _draft.get("bubbles", []) + if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: + return + var text: String = String((bubbles[index] as Dictionary).get("text", "")) + if SPEECH_PAYLOAD.token_span_at(text, start) != Vector2i(start, end): + return + var next_target: Dictionary = {"bubble_index": index, "start": start, "end": end, "text": text.substr(start, end - start)} + if _target != next_target: + _stop_editor_effect_demo() + if index != _selected_bubble: + select_bubble_from_stage(index) + else: + _set_stage_focus(index) + _target = next_target + _sync_controls() + _emit_style_context() + + +func select_bubble_word_from_stage(index: int, start: int, end: int) -> void: + select_bubble_token_from_stage(index, start, end) + + +func _emit_style_context() -> void: + style_context_changed.emit(style_context()) + + +func _style_category_accepts(kind: StringName) -> bool: + var category: String = String(_style_category) + if category == "frame": + return kind == &"frame" + if category == "effect": + return kind == &"effect" + return category == "letters" and kind in [&"font", &"size", &"color"] + + +func _style_choice_valid(kind: StringName, value: String) -> bool: + match kind: + &"frame": return SPEECH_PAYLOAD.BUBBLE_STYLES.has(value) + &"font": return SPEECH_PAYLOAD.FONT_PRESETS.has(value) + &"size": return SPEECH_PAYLOAD.SIZE_PRESETS.has(value) + &"color": return SPEECH_PAYLOAD._valid_color(value) + &"effect": return value == "none" or SPEECH_PAYLOAD.EFFECTS.has(value) + return false + +func _start_editor_effect_demo(effect: String, target: Dictionary) -> void: + _stop_editor_effect_demo() + if not [&"shake", &"wave", &"bounce"].has(effect): + return + _effect_demo = { + "bubble_index": int(_selected_bubble), + "effect": effect, + } + if target.is_empty(): + _effect_demo["all_tokens"] = true + else: + _effect_demo["start"] = int(target.get("start", -1)) + _effect_demo["end"] = int(target.get("end", -1)) + _effect_demo_elapsed = 0.0 + _effect_demo_remaining = 1.0 + + +func _stop_editor_effect_demo() -> void: + _effect_demo.clear() + _effect_demo_elapsed = 0.0 + _effect_demo_remaining = 0.0 + + +func _apply_visual_choice(visual: Dictionary, kind: StringName, value: String, target: Dictionary) -> void: + if kind == &"frame": + visual["bubble_style"] = value + return + if kind == &"effect" and target.is_empty(): + visual["effect"] = value + _clear_word_style_property(visual, "effect") + return + var property: String = String(kind) + ("_preset" if kind in [&"font", &"size"] else "") + if target.is_empty(): + match kind: + &"font": visual["font_preset"] = value + &"size": visual["size_preset"] = value + &"color": visual["color"] = value + _clear_word_style_property(visual, property) + return + var text_style_start: int = int(target.get("start", -1)) + var styles: Array = visual.get("word_styles", []) + var style_index: int = -1 + for index: int in styles.size(): + if styles[index] is Dictionary and int((styles[index] as Dictionary).get("start", -2)) == text_style_start: + style_index = index + break + var style: Dictionary = (styles[style_index] as Dictionary).duplicate(true) if style_index >= 0 else {"start": text_style_start, "end": int(target.get("end", -1))} + if kind == &"effect" and value == "none" and String(visual.get("effect", "none")) == "none": + style.erase("effect") + else: + style[property] = value + if style.size() <= 2: + if style_index >= 0: + styles.remove_at(style_index) + else: + if style_index >= 0: + styles[style_index] = style + else: + styles.append(style) + visual["word_styles"] = styles + + +func _clear_word_style_property(visual: Dictionary, property: String) -> void: + var retained: Array[Dictionary] = [] + for raw_style: Variant in visual.get("word_styles", []): + if not raw_style is Dictionary: + continue + var style: Dictionary = (raw_style as Dictionary).duplicate(true) + style.erase(property) + if style.size() > 2: + retained.append(style) + visual["word_styles"] = retained + + +func _style_for_visual_target(visual: Dictionary, target: Dictionary) -> Dictionary: + var target_start: int = int(target.get("start", -1)) + for raw: Variant in visual.get("word_styles", []): + if raw is Dictionary and int((raw as Dictionary).get("start", -2)) == target_start: + return raw as Dictionary + return {} + + +func selected_bubble_index() -> int: + return _selected_bubble + + +func reactive_options_enabled() -> bool: + return _reactive_options + + +func stage_focus_bubble_index() -> int: + return _stage_focus_bubble + + +func clear_stage_focus() -> void: + var focus_changed: bool = _stage_focus_bubble >= 0 + if not focus_changed and _target.is_empty(): + return + _stage_focus_bubble = -1 + _style_category = &"" + _target.clear() + _sync_controls() + _emit_style_context() + if focus_changed: + stage_focus_changed.emit(-1) + + +func _set_stage_focus(index: int) -> void: + if _stage_focus_bubble == index: + return + _stage_focus_bubble = index + stage_focus_changed.emit(index) + + +func choose_speech_show(key: StringName, value: Variant) -> void: + _save_active_editor_state() + var normalized: Dictionary = SPEECH_PAYLOAD.normalize(_draft) + var valid: bool = false + match key: + &"arrangement": + valid = value is String and SPEECH_PAYLOAD.ARRANGEMENTS.has(String(value)) + &"connect_bubbles": + valid = value is bool and (_draft.get("bubbles", []) as Array).size() >= 2 + &"reveal": + valid = value is String and SPEECH_PAYLOAD.REVEAL_MODES.has(String(value)) + &"pop_in": + valid = value is bool + &"duration_mode": + valid = value is String and SPEECH_PAYLOAD.DURATION_MODES.has(String(value)) + &"keep_acting": + valid = value is bool + if not valid: + return + normalized[String(key)] = value + if key == &"keep_acting" and _reactive_options: + normalized["stop_current"] = not bool(value) + var bubbles: Array = normalized.get("bubbles", []) + if key in [&"reveal", &"pop_in"]: + for index: int in bubbles.size(): + if not bubbles[index] is Dictionary: + continue + var bubble: Dictionary = bubbles[index] as Dictionary + var visual: Dictionary = bubble.get("visual", {}).duplicate(true) + visual[String(key)] = value + bubble["visual"] = visual + bubbles[index] = bubble + _draft = normalized + _draft["bubbles"] = bubbles + else: + _draft = normalized + _emit_draft() + _sync_controls() + + +func choose_comic_chain(centers: Array) -> void: + # Compatibility entry point for callers from the earlier Comic chain UI. + apply_bubble_layout(centers, "stack") + choose_speech_show(&"arrangement", "take_turns") + choose_speech_show(&"connect_bubbles", true) + + +func set_selected_bubble_index(index: int) -> void: + var bubbles: Array = _draft.get("bubbles", []) + if bubbles.is_empty(): + return + var next_index: int = clampi(index, 0, bubbles.size() - 1) + if next_index == _selected_bubble: + return + _stop_editor_effect_demo() + _save_active_editor_state() + _selected_bubble = next_index + _set_stage_focus(next_index) + _target.clear() + _sync_editor() + _sync_controls() + _emit_style_context() + + +func select_bubble_from_stage(index: int) -> void: + var bubbles: Array = _draft.get("bubbles", []) + if index < 0 or index >= bubbles.size(): + return + _stop_editor_effect_demo() + if index != _selected_bubble: + _save_active_editor_state() + _selected_bubble = index + _target.clear() + _sync_editor() + else: + _target.clear() + _set_stage_focus(index) + _sync_controls() + _emit_style_context() + + +func preview_state_name() -> String: + match _preview_state: + PreviewState.PLAYING: return "playing" + PreviewState.BLANK_HOLD: return "blank_hold" + _: return "static" + + +func preview_visible() -> bool: + return _preview_state != PreviewState.BLANK_HOLD + + +func set_preview_overflow(has_overflow: bool, _arrangement: String, bubble_layout: String = "stack") -> void: + if _overflow_label == null: + return + if not has_overflow: + _overflow_label.text = "" + elif bubble_layout == "stack": + var bubbles: Array = _draft.get("bubbles", []) + _overflow_label.text = "This tall stack is hard to fit here. Try One at a time or move the bubbles." if bubbles.size() >= 4 else "This stack is tight in the preview. Try One at a time or move it." + else: + _overflow_label.text = "This frame is hard to fit here. Try a smaller size or move the bubble." + + +func preview_elapsed() -> float: + if _preview_state == PreviewState.PLAYING: + return _replay_clock + if _preview_state == PreviewState.BLANK_HOLD: + return float(_draft.get("duration", 0.0)) + return float(_draft.get("duration", 0.0)) + + +func contains_viewport_point(point: Vector2) -> bool: + if _cancel_confirm_dialog != null and _cancel_confirm_dialog.visible: + return true + if is_message_dialog_open(): + return true + return false + + +func panel_viewport_rect() -> Rect2: + return Rect2() + + +func clear_token_target() -> void: + _stop_editor_effect_demo() + _clear_word_target() + + +func set_placement_preview(local_center: Vector2) -> void: + if not local_center.is_finite(): + return + _stop_editor_effect_demo() + _draft["placement"] = {"mode": "manual", "center": local_center} + _emit_draft() + + +func set_bubble_placement_preview(index: int, local_center: Vector2) -> void: + if not local_center.is_finite(): + return + var bubbles: Array = _draft.get("bubbles", []) + if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: + return + _stop_editor_effect_demo() + var bubble: Dictionary = bubbles[index] + bubble["placement"] = {"mode": "manual", "center": local_center} + bubbles[index] = bubble + _draft["bubbles"] = bubbles + _emit_draft() + _sync_controls() + + +func apply_bubble_layout(centers: Array, preset: String = "") -> void: + _save_active_editor_state() + var bubbles: Array = _draft.get("bubbles", []) + if centers.size() != bubbles.size(): + return + if not preset.is_empty() and not SPEECH_PAYLOAD.BUBBLE_LAYOUT_MODES.has(preset): + return + for center_value: Variant in centers: + if not center_value is Vector2 or not (center_value as Vector2).is_finite(): + return + for index: int in bubbles.size(): + if not bubbles[index] is Dictionary: + return + var bubble: Dictionary = bubbles[index] + bubble["placement"] = {"mode": "manual", "center": centers[index]} + bubbles[index] = bubble + if not preset.is_empty(): + _draft["bubble_layout"] = preset + if preset == "same_spot": + _draft["connect_bubbles"] = false + _draft["bubbles"] = bubbles + _emit_draft() + _sync_controls() + + +func reset_placement() -> void: + _stop_editor_effect_demo() + _draft["placement"] = {"mode": "auto"} + set_placement_pending(false) + placement_reset_requested.emit() + _emit_draft() + + +func reset_bubble_placement(index: int) -> void: + var bubbles: Array = _draft.get("bubbles", []) + if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary: + return + _stop_editor_effect_demo() + var bubble: Dictionary = bubbles[index] + bubble.erase("placement") + bubbles[index] = bubble + _draft["bubbles"] = bubbles + set_placement_pending(false) + bubble_placement_reset_requested.emit(index) + _emit_draft() + _sync_controls() + + +func set_placement_pending(pending: bool, bubble_index: int = -1) -> void: + _placement_target_bubble = bubble_index if pending else -1 + + +func close_without_commit() -> void: + _stop_editor_effect_demo() + _style_category = &"" + _stage_focus_bubble = -1 + if _message_dialog != null: + _message_dialog.hide() + if _cancel_confirm_dialog != null: + _cancel_confirm_dialog.hide() + visible = false + _replay_running = false + _actor = null + _undo_removed_bubble.clear() + _clear_word_target(false) + + +func set_cancel_requires_confirmation(required: bool) -> void: + _cancel_requires_confirmation = required + + +func request_cancel_speech() -> void: + var changed: bool = _cancel_requires_confirmation or get_draft() != _initial_action + if changed and _cancel_confirm_dialog != null: + _cancel_confirm_dialog.popup_centered() + return + cancelled.emit() + + +func request_done_speech() -> void: + var action: Dictionary = get_draft() + var error: String = SPEECH_PAYLOAD.validate(action) + if not error.is_empty(): + var invalid_index: int = _first_invalid_bubble_index(action) + if invalid_index >= 0: + _select_bubble_for_message(invalid_index) + validation_failed.emit(error, invalid_index) + return + committed.emit(action) + + +func _first_invalid_bubble_index(action: Dictionary) -> int: + var bubbles: Array = action.get("bubbles", []) + for index: int in bubbles.size(): + if not bubbles[index] is Dictionary: + return index + var text: String = String((bubbles[index] as Dictionary).get("text", "")) + if text.strip_edges().is_empty() or text.length() > SPEECH_PAYLOAD.MAX_VISIBLE_CHARACTERS: + return index + return _selected_bubble if not bubbles.is_empty() else -1 + + +func _select_bubble_for_message(index: int) -> void: + var bubbles: Array = _draft.get("bubbles", []) + if index < 0 or index >= bubbles.size(): + return + _save_active_editor_state() + _selected_bubble = index + _target.clear() + _set_stage_focus(index) + _sync_editor() + _sync_controls() + _emit_style_context() + + +func open_message_dialog() -> void: + if _draft.is_empty() or _message_dialog == null: + return + _sync_editor() + _emoji_picker_scroll.hide() + _emoji_picker_button.set_pressed_no_signal(false) + _emoji_picker_button.text = "😀 Emoji" + _layout_message_dialog() + _message_dialog.show() + var last_line: int = maxi(0, _active_editor.get_line_count() - 1) + _active_editor.set_caret_line(last_line, true) + _active_editor.set_caret_column(_active_editor.get_line(last_line).length(), true) + _active_editor.grab_focus() + + +func is_message_dialog_open() -> bool: + return _message_dialog != null and _message_dialog.visible + + +func close_message_dialog() -> void: + if not is_message_dialog_open(): + return + _save_active_editor_state() + _active_editor.release_focus() + _message_dialog.hide() + _emoji_picker_scroll.hide() + message_dialog_closed.emit() + + +func set_message_dialog_anchor(frame_rect: Rect2, stage_rect: Rect2, toolbar_rect: Rect2) -> void: + _message_frame_rect = frame_rect + _message_stage_rect = stage_rect + _message_toolbar_rect = toolbar_rect + _layout_message_dialog() + + +func _build_ui() -> void: + _active_editor = TextEdit.new() + _active_editor.name = "ActiveBubbleText" + _active_editor.custom_minimum_size = Vector2(0.0, 132.0) + _active_editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _active_editor.placeholder_text = "Write what the character says…" + _active_editor.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + _active_editor.text_changed.connect(_on_active_text_changed) + _active_editor.caret_changed.connect(_remember_active_editor_state) + _cancel_confirm_dialog = ConfirmationDialog.new() + _cancel_confirm_dialog.title = "Discard speech changes?" + _cancel_confirm_dialog.dialog_text = "Your speech edits will be lost. Do you want to discard them?" + _cancel_confirm_dialog.get_ok_button().text = "Discard changes" + _cancel_confirm_dialog.get_cancel_button().text = "Keep editing" + _cancel_confirm_dialog.confirmed.connect(func() -> void: cancelled.emit()) + _cancel_confirm_dialog.dialog_hide_on_ok = true + add_child(_cancel_confirm_dialog) + _build_message_dialog() + + +func _build_message_dialog() -> void: + _message_dialog = PanelContainer.new() + _message_dialog.name = "MessageDialog" + _message_dialog.mouse_filter = Control.MOUSE_FILTER_STOP + _message_dialog.z_index = 100 + _message_dialog.hide() + _message_dialog.add_theme_stylebox_override("panel", _panel_style()) + add_child(_message_dialog) + var margin: MarginContainer = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_bottom", 12) + _message_dialog.add_child(margin) + var content: VBoxContainer = VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + margin.add_child(content) + var ribbon: PanelContainer = PanelContainer.new() + ribbon.add_theme_stylebox_override("panel", _section_ribbon_style()) + var title: Label = _section_label("Message") + title.add_theme_font_size_override("font_size", 23) + title.add_theme_color_override("font_color", Color.WHITE) + ribbon.add_child(title) + content.add_child(ribbon) + _active_editor.custom_minimum_size.y = 150.0 + _active_editor.size_flags_vertical = Control.SIZE_FILL + content.add_child(_active_editor) + _overflow_label = _section_label("") + _overflow_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(_overflow_label) + var emoji_row: HBoxContainer = HBoxContainer.new() + emoji_row.alignment = BoxContainer.ALIGNMENT_CENTER + _emoji_picker_button = _button("😀 Emoji") + _emoji_picker_button.toggle_mode = true + _emoji_picker_button.custom_minimum_size = Vector2(160.0, 56.0) + _emoji_picker_button.toggled.connect(_on_emoji_picker_toggled) + emoji_row.add_child(_emoji_picker_button) + content.add_child(emoji_row) + _emoji_picker_scroll = ScrollContainer.new() + _emoji_picker_scroll.name = "MessageEmojiPickerScroll" + _emoji_picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _emoji_picker_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + _emoji_picker_scroll.custom_minimum_size = Vector2(0.0, 156.0) + _emoji_picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _emoji_picker_scroll.hide() + content.add_child(_emoji_picker_scroll) + _emoji_grid = GridContainer.new() + _emoji_grid.name = "MessageEmojiGrid" + _emoji_grid.columns = 4 + _emoji_grid.add_theme_constant_override("h_separation", 7) + _emoji_grid.add_theme_constant_override("v_separation", 7) + _emoji_picker_scroll.add_child(_emoji_grid) + for emoji: String in _theme.emoji_items(): + var emoji_button: Button = _button(emoji) + emoji_button.custom_minimum_size = Vector2(56.0, 52.0) + emoji_button.add_theme_font_size_override("font_size", 26) + emoji_button.pressed.connect(_insert_emoji.bind(emoji)) + _emoji_grid.add_child(emoji_button) + var done_editing_row: HBoxContainer = HBoxContainer.new() + done_editing_row.alignment = BoxContainer.ALIGNMENT_CENTER + _message_done_button = _button("✓ Done editing") + _message_done_button.custom_minimum_size = Vector2(196.0, 56.0) + _message_done_button.add_theme_font_size_override("font_size", 18) + _message_done_button.pressed.connect(close_message_dialog) + done_editing_row.add_child(_message_done_button) + content.add_child(done_editing_row) + _layout_message_dialog() + + +func _on_emoji_picker_toggled(opened: bool) -> void: + _emoji_picker_scroll.visible = opened + _emoji_picker_button.text = "✕ Hide emoji" if opened else "😀 Emoji" + _layout_message_dialog() + + +func _layout_message_dialog() -> void: + if _message_dialog == null or not is_instance_valid(_message_dialog): + return + var viewport_size: Vector2 = get_viewport_rect().size + var stage_rect: Rect2 = _message_stage_rect if _message_stage_rect.size.x > 0.0 and _message_stage_rect.size.y > 0.0 else Rect2(Vector2(12.0, 12.0), viewport_size - Vector2(24.0, 24.0)) + var frame_rect: Rect2 = _message_frame_rect + var gap: float = 10.0 + 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 below_toolbar: float = maxf(stage_rect.position.y, _message_toolbar_rect.end.y + gap) + var side_layout: bool = side_space >= 180.0 and stage_rect.end.y - below_toolbar >= 150.0 + var dialog_width: float = minf(440.0, side_space) if side_layout else minf(440.0, stage_rect.size.x) + var dialog_x: float + var dialog_y: float + if side_layout: + dialog_x = frame_rect.end.x + gap if right_space >= left_space else frame_rect.position.x - gap - dialog_width + dialog_y = below_toolbar + else: + dialog_x = clampf(frame_rect.get_center().x - dialog_width * 0.5, stage_rect.position.x, maxf(stage_rect.position.x, stage_rect.end.x - dialog_width)) if frame_rect.size.x > 0.0 else stage_rect.get_center().x - dialog_width * 0.5 + var below_frame: float = frame_rect.end.y + gap + dialog_y = below_frame if stage_rect.end.y - below_frame >= 230.0 else stage_rect.position.y + var reserved_height: float = minf(550.0, stage_rect.size.y) + var max_anchor_y: float = maxf(stage_rect.position.y, stage_rect.end.y - reserved_height) + var stable_dialog_y: float = clampf(dialog_y, stage_rect.position.y, max_anchor_y) + var available_height: float = maxf(1.0, stage_rect.end.y - stable_dialog_y) + var desired_height: float = 550.0 if _emoji_picker_scroll.visible else 410.0 + var dialog_height: float = minf(desired_height, available_height) + if _emoji_picker_scroll.visible: + _emoji_picker_scroll.custom_minimum_size.y = clampf(dialog_height - 410.0, 32.0, 156.0) + _message_dialog.custom_minimum_size = Vector2(dialog_width, dialog_height) + _message_dialog.size = Vector2(dialog_width, dialog_height) + _message_dialog.position = Vector2(clampf(dialog_x, stage_rect.position.x, maxf(stage_rect.position.x, stage_rect.end.x - dialog_width)), stable_dialog_y) + + +func _update_message_responsive_layout() -> void: + if _emoji_grid != null and _message_dialog != null: + var emoji_size: float = minf(56.0, maxf(40.0, (_message_dialog.size.x - 72.0) / 4.0)) + for child: Node in _emoji_grid.get_children(): + if child is Button: + (child as Button).custom_minimum_size = Vector2(emoji_size, emoji_size) + (child as Button).add_theme_font_size_override("font_size", int(minf(26.0, emoji_size * 0.48))) + _layout_message_dialog() + + +func _section_label(text: String) -> Label: + var label: Label = Label.new() + label.text = text + label.add_theme_font_override("font", _theme.font()) + label.add_theme_font_size_override("font_size", 18) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return label + + +func _section_ribbon_style() -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = Color("#176B83") + 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 _panel_style() -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = _theme.color("global.bg_color", Color("#171B24F5")) + style.border_color = _theme.color("global.border_color", Color("#FFFFFF55")) + style.set_border_width_all(2) + style.set_corner_radius_all(12) + return style + + +func _button(text: String) -> Button: + var button: Button = Button.new() + button.text = text + button.custom_minimum_size.y = _theme.speech_number("ui.button_min_height", 44.0, 40.0, 72.0) + button.focus_mode = Control.FOCUS_NONE + return button + + +func _sample_button(sample: String, caption: String) -> Button: + var button: Button = Button.new() + button.text = sample + "\n" + caption + button.custom_minimum_size = Vector2(82.0, 64.0) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.focus_mode = Control.FOCUS_NONE + return button + + +func _new_bubble(visual_defaults: Dictionary = {}) -> Dictionary: + var visual: Dictionary = { + "bubble_style": "talk", + "font_preset": SPEECH_PAYLOAD.BASE_FONT_PRESET, + "size_preset": "medium", + "color": SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR, + "effect": "none", + "reveal": String(_draft.get("reveal", "instant")), + "pop_in": bool(_draft.get("pop_in", false)), + "word_styles": [], + } + for key: String in ["bubble_style", "font_preset", "size_preset", "color", "effect"]: + if visual_defaults.has(key): + visual[key] = visual_defaults[key] + return {"text": "", "visual": visual} + + +func _prepare_editor_state() -> void: + var bubbles: Array = _draft.get("bubbles", []) + while _caret_by_bubble.size() < bubbles.size(): + _caret_by_bubble.append(Vector2i.ZERO) + _scroll_by_bubble.append(0.0) + _last_text_by_bubble.append(String((bubbles[_last_text_by_bubble.size()] as Dictionary).get("text", "")) if bubbles[_last_text_by_bubble.size()] is Dictionary else "") + while _caret_by_bubble.size() > bubbles.size(): + _caret_by_bubble.pop_back() + _scroll_by_bubble.pop_back() + _last_text_by_bubble.pop_back() + for index: int in bubbles.size(): + if bubbles[index] is Dictionary: + _last_text_by_bubble[index] = String((bubbles[index] as Dictionary).get("text", "")) + + +func _sync_editor() -> void: + if _active_editor == null: + return + _prepare_editor_state() + var bubbles: Array = _draft.get("bubbles", []) + if bubbles.is_empty(): + return + var selected_text: String = String((bubbles[_selected_bubble] as Dictionary).get("text", "")) + _loading_editor = true + _active_editor.text = selected_text + var caret: Vector2i = _caret_by_bubble[_selected_bubble] + var line: int = clampi(caret.x, 0, maxi(0, _active_editor.get_line_count() - 1)) + _active_editor.set_caret_line(line, false) + _active_editor.set_caret_column(clampi(caret.y, 0, _active_editor.get_line(line).length()), false) + _active_editor.scroll_vertical = _scroll_by_bubble[_selected_bubble] + _loading_editor = false + + +func _save_active_editor_state() -> void: + if _active_editor == null or not is_instance_valid(_active_editor) or _loading_editor: + return + var bubbles: Array = _draft.get("bubbles", []) + if _selected_bubble < 0 or _selected_bubble >= bubbles.size(): + return + var bubble: Dictionary = bubbles[_selected_bubble] + bubble["text"] = _active_editor.text + bubbles[_selected_bubble] = bubble + _draft["bubbles"] = bubbles + _last_text_by_bubble[_selected_bubble] = _active_editor.text + _caret_by_bubble[_selected_bubble] = Vector2i(_active_editor.get_caret_line(), _active_editor.get_caret_column()) + _scroll_by_bubble[_selected_bubble] = _active_editor.scroll_vertical + + +func _remember_active_editor_state() -> void: + if _loading_editor: + return + _save_active_editor_state() + + +func _update_bubble_count_hint() -> void: + var bubbles: Array = _draft.get("bubbles", []) + if bubbles.size() >= SPEECH_PAYLOAD.MAX_BUBBLES: + _sequence_hint = MAX_BUBBLE_HINT + elif bubbles.size() >= 5: + if not _five_bubble_hint_shown: + _five_bubble_hint_shown = true + _sequence_hint = FIVE_BUBBLE_HINT + elif bubbles.size() < SPEECH_PAYLOAD.MAX_BUBBLES and _sequence_hint == MAX_BUBBLE_HINT: + _sequence_hint = "" + elif _sequence_hint == FIVE_BUBBLE_HINT or _sequence_hint == MAX_BUBBLE_HINT: + _sequence_hint = "" + + +func add_bubble_after_selected() -> void: + _save_active_editor_state() + var bubbles: Array = _draft.get("bubbles", []) + if bubbles.size() >= SPEECH_PAYLOAD.MAX_BUBBLES: + _sequence_hint = MAX_BUBBLE_HINT + _emit_style_context() + return + _undo_removed_bubble.clear() + var selected: Dictionary = _selected_bubble_data() + var selected_visual: Dictionary = _new_bubble_visual_defaults(selected) + var insertion_index: int = clampi(_selected_bubble + 1, 0, bubbles.size()) + bubbles.insert(insertion_index, _new_bubble(selected_visual)) + _draft["bubbles"] = bubbles + _caret_by_bubble.insert(insertion_index, Vector2i.ZERO) + _scroll_by_bubble.insert(insertion_index, 0.0) + _last_text_by_bubble.insert(insertion_index, "") + _prepare_editor_state() + _selected_bubble = insertion_index + _target.clear() + _style_category = &"" + _update_bubble_count_hint() + _set_stage_focus(_selected_bubble) + _sync_editor() + _sync_controls() + _emit_style_context() + _emit_draft() + call_deferred("open_message_dialog") + + +func _new_bubble_visual_defaults(source_bubble: Dictionary) -> Dictionary: + var visual: Dictionary = source_bubble.get("visual", {}) if source_bubble.get("visual", {}) is Dictionary else {} + var defaults: Dictionary = visual.duplicate(true) + var tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(String(source_bubble.get("text", ""))) + if tokens.is_empty(): + return defaults + var last_token: Vector2i = tokens.back() + var last_word: Vector2i = Vector2i(-1, -1) + var word_regex := RegEx.new() + if word_regex.compile(SPEECH_PAYLOAD.WORD_REGEX) == OK: + for match_result: RegExMatch in word_regex.search_all(String(source_bubble.get("text", ""))): + last_word = Vector2i(match_result.get_start(), match_result.get_end()) + if last_word.x >= 0: + last_token = last_word + for raw_style: Variant in visual.get("word_styles", []): + if not raw_style is Dictionary: + continue + var word_style: Dictionary = raw_style as Dictionary + if int(word_style.get("start", -1)) != last_token.x or int(word_style.get("end", -1)) != last_token.y: + continue + for property: String in ["font_preset", "size_preset", "color", "effect"]: + if word_style.has(property): + defaults[property] = word_style[property] + break + return defaults + + +func remove_selected_bubble() -> void: + _save_active_editor_state() + var bubbles: Array = _draft.get("bubbles", []) + if bubbles.size() <= 1: + return + _undo_removed_bubble = { + "index": _selected_bubble, + "bubble": (bubbles[_selected_bubble] as Dictionary).duplicate(true), + "caret": _caret_by_bubble[_selected_bubble], + "scroll": _scroll_by_bubble[_selected_bubble], + "text": _last_text_by_bubble[_selected_bubble], + } + bubbles.remove_at(_selected_bubble) + _caret_by_bubble.remove_at(_selected_bubble) + _scroll_by_bubble.remove_at(_selected_bubble) + _last_text_by_bubble.remove_at(_selected_bubble) + _draft["bubbles"] = bubbles + _update_bubble_count_hint() + _selected_bubble = mini(_selected_bubble, bubbles.size() - 1) + _set_stage_focus(_selected_bubble) + _target.clear() + _sync_editor() + _sync_controls() + _emit_style_context() + _emit_draft() + + +func undo_remove_bubble() -> void: + if _undo_removed_bubble.is_empty(): + return + var bubbles: Array = _draft.get("bubbles", []) + var index: int = clampi(int(_undo_removed_bubble.get("index", bubbles.size())), 0, bubbles.size()) + bubbles.insert(index, (_undo_removed_bubble.get("bubble", {}) as Dictionary).duplicate(true)) + _caret_by_bubble.insert(index, _undo_removed_bubble.get("caret", Vector2i.ZERO)) + _scroll_by_bubble.insert(index, float(_undo_removed_bubble.get("scroll", 0.0))) + _last_text_by_bubble.insert(index, String(_undo_removed_bubble.get("text", ""))) + _draft["bubbles"] = bubbles + _update_bubble_count_hint() + _selected_bubble = index + _undo_removed_bubble.clear() + _target.clear() + _set_stage_focus(index) + _sync_editor() + _sync_controls() + _emit_style_context() + _emit_draft() + + +func move_selected_bubble(offset: int) -> void: + _save_active_editor_state() + var bubbles: Array = _draft.get("bubbles", []) + var destination: int = _selected_bubble + offset + if destination < 0 or destination >= bubbles.size(): + return + _undo_removed_bubble.clear() + var bubble: Variant = bubbles.pop_at(_selected_bubble) + bubbles.insert(destination, bubble) + var caret: Vector2i = _caret_by_bubble.pop_at(_selected_bubble) + _caret_by_bubble.insert(destination, caret) + var scroll: float = _scroll_by_bubble.pop_at(_selected_bubble) + _scroll_by_bubble.insert(destination, scroll) + var text: String = _last_text_by_bubble.pop_at(_selected_bubble) + _last_text_by_bubble.insert(destination, text) + _selected_bubble = destination + _set_stage_focus(destination) + _draft["bubbles"] = bubbles + _update_bubble_count_hint() + _target.clear() + _sync_editor() + _sync_controls() + _emit_style_context() + _emit_draft() + + +func _on_active_text_changed() -> void: + if _loading_editor: + return + _stop_editor_effect_demo() + var bubbles: Array = _draft.get("bubbles", []) + if _selected_bubble < 0 or _selected_bubble >= bubbles.size(): + return + var bubble: Dictionary = bubbles[_selected_bubble] + var old_text: String = _last_text_by_bubble[_selected_bubble] + var new_text: String = _active_editor.text + _adjust_word_styles(bubble, old_text, new_text) + bubble["text"] = new_text + bubbles[_selected_bubble] = bubble + _draft["bubbles"] = bubbles + _last_text_by_bubble[_selected_bubble] = new_text + _caret_by_bubble[_selected_bubble] = Vector2i(_active_editor.get_caret_line(), _active_editor.get_caret_column()) + _scroll_by_bubble[_selected_bubble] = _active_editor.scroll_vertical + if not _target.is_empty() and int(_target.get("bubble_index", -1)) == _selected_bubble: + _clear_word_target(false) + _emit_style_context() + _emit_draft() + + +func _adjust_word_styles(bubble: Dictionary, old_text: String, new_text: String) -> void: + var visual: Dictionary = bubble.get("visual", {}) + var styles: Array = visual.get("word_styles", []) + var prefix: int = 0 + while prefix < old_text.length() and prefix < new_text.length() and old_text[prefix] == new_text[prefix]: + prefix += 1 + var old_suffix: int = old_text.length() + var new_suffix: int = new_text.length() + while old_suffix > prefix and new_suffix > prefix and old_text[old_suffix - 1] == new_text[new_suffix - 1]: + old_suffix -= 1 + new_suffix -= 1 + var shift: int = new_suffix - old_suffix + var source_styles: Array[Dictionary] = [] + for raw: Variant in styles: + if raw is Dictionary: + source_styles.append((raw as Dictionary).duplicate(true)) + source_styles.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("start", -1)) < int(b.get("start", -1))) + var updated: Array[Dictionary] = [] + var new_tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(new_text) + for style: Dictionary in source_styles: + var start: int = int(style.get("start", -1)) + var end: int = int(style.get("end", -1)) + var mapped_start: int = start + var mapped_end: int = end + if end <= prefix: + pass + elif start >= old_suffix: + mapped_start += shift + mapped_end += shift + else: + mapped_start = start if start < prefix else prefix + mapped_end = end + shift if end > old_suffix else new_suffix + if mapped_end <= mapped_start: + continue + for token: Vector2i in new_tokens: + if token.x >= mapped_end or token.y <= mapped_start: + continue + _append_style_for_token(updated, token, style) + if old_suffix == prefix and new_suffix > prefix: + var inherited_style: Dictionary = _style_for_inserted_text(old_text, styles, prefix) + if not inherited_style.is_empty(): + for token: Vector2i in new_tokens: + if token.x >= new_suffix or token.y <= prefix or _has_style_for_token(updated, token): + continue + _append_style_for_token(updated, token, inherited_style) + visual["word_styles"] = updated + bubble["visual"] = visual + + +func _append_style_for_token(styles: Array[Dictionary], token: Vector2i, source: Dictionary) -> void: + if _has_style_for_token(styles, token): + return + var copied: Dictionary = {"start": token.x, "end": token.y} + for property: String in ["font_preset", "size_preset", "color", "effect"]: + if source.has(property): + copied[property] = source[property] + if copied.size() > 2: + styles.append(copied) + + +func _has_style_for_token(styles: Array[Dictionary], token: Vector2i) -> bool: + for style: Dictionary in styles: + if int(style.get("start", -1)) == token.x and int(style.get("end", -1)) == token.y: + return true + return false + + +func _style_for_inserted_text(old_text: String, styles: Array, insertion_offset: int) -> Dictionary: + var tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(old_text) + var previous: Vector2i = Vector2i(-1, -1) + var following: Vector2i = Vector2i(-1, -1) + for span: Vector2i in tokens: + if span.x < insertion_offset and insertion_offset < span.y: + previous = span + break + if span.y <= insertion_offset: + previous = span + elif span.x >= insertion_offset and following.x < 0: + following = span + var source_span: Vector2i = previous if previous.x >= 0 else following + if source_span.x < 0: + return {} + for raw: Variant in styles: + if raw is Dictionary and int((raw as Dictionary).get("start", -1)) == source_span.x and int((raw as Dictionary).get("end", -1)) == source_span.y: + return (raw as Dictionary).duplicate(true) + return {} + + +func _insert_emoji(emoji: String) -> void: + if _active_editor == null: + return + _active_editor.grab_focus() + _active_editor.insert_text_at_caret(emoji) + + +func _on_replay_pressed() -> void: + replay_preview_once() + + +func replay_preview_once() -> void: + _stop_editor_effect_demo() + _replay_clock = 0.0 + _blank_hold_clock = 0.0 + _replay_running = true + _preview_state = PreviewState.PLAYING + _emit_preview_state() + replay_requested.emit() + + +func _sync_controls() -> void: + if _draft.is_empty() or not is_instance_valid(_active_editor): + return + + +func _selected_bubble_data() -> Dictionary: + var bubbles: Array = _draft.get("bubbles", []) + return bubbles[_selected_bubble] if _selected_bubble >= 0 and _selected_bubble < bubbles.size() and bubbles[_selected_bubble] is Dictionary else _new_bubble() + + +func _store_selected_bubble(bubble: Dictionary) -> void: + var bubbles: Array = _draft.get("bubbles", []) + if _selected_bubble >= 0 and _selected_bubble < bubbles.size(): + bubbles[_selected_bubble] = bubble + _draft["bubbles"] = bubbles + + +func _clear_word_target(update_controls: bool = true) -> void: + _target.clear() + if update_controls and _active_editor != null and is_instance_valid(_active_editor): + _sync_controls() + _emit_style_context() + + +func _stop_replay_to_static() -> void: + _replay_running = false + _preview_state = PreviewState.STATIC + _blank_hold_clock = 0.0 + _replay_clock = SPEECH_PAYLOAD.duration_for(_draft) + _emit_preview_state() + + +func _emit_preview_state() -> void: + preview_state_changed.emit(preview_state_name(), preview_elapsed(), preview_visible()) + + +func _emit_draft(stop_replay: bool = true) -> void: + if _draft.is_empty(): + return + _save_active_editor_state() + var bubbles: Array = _draft.get("bubbles", []) + _draft["text"] = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else "" + _draft["duration"] = SPEECH_PAYLOAD.duration_for(_draft) + if stop_replay: + _stop_replay_to_static() + draft_changed.emit(_draft.duplicate(true)) diff --git a/scripts/speech_composer.gd.uid b/scripts/speech_composer.gd.uid new file mode 100644 index 0000000..3e23169 --- /dev/null +++ b/scripts/speech_composer.gd.uid @@ -0,0 +1 @@ +uid://dlee4y2wnkupm diff --git a/scripts/speech_payload.gd b/scripts/speech_payload.gd new file mode 100644 index 0000000..0489d3f --- /dev/null +++ b/scripts/speech_payload.gd @@ -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 diff --git a/scripts/speech_payload.gd.uid b/scripts/speech_payload.gd.uid new file mode 100644 index 0000000..85e0ebe --- /dev/null +++ b/scripts/speech_payload.gd.uid @@ -0,0 +1 @@ +uid://djyp1epb6jai diff --git a/scripts/speech_show_toolbar.gd b/scripts/speech_show_toolbar.gd new file mode 100644 index 0000000..7bdbd58 --- /dev/null +++ b/scripts/speech_show_toolbar.gd @@ -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") diff --git a/scripts/speech_show_toolbar.gd.uid b/scripts/speech_show_toolbar.gd.uid new file mode 100644 index 0000000..e1feeb3 --- /dev/null +++ b/scripts/speech_show_toolbar.gd.uid @@ -0,0 +1 @@ +uid://cc3gg7du2suwg diff --git a/scripts/speech_style_toolbar.gd b/scripts/speech_style_toolbar.gd new file mode 100644 index 0000000..693936d --- /dev/null +++ b/scripts/speech_style_toolbar.gd @@ -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 diff --git a/scripts/speech_style_toolbar.gd.uid b/scripts/speech_style_toolbar.gd.uid new file mode 100644 index 0000000..fcd14e8 --- /dev/null +++ b/scripts/speech_style_toolbar.gd.uid @@ -0,0 +1 @@ +uid://0d2x5ns8go3v diff --git a/scripts/speech_word_effect.gd b/scripts/speech_word_effect.gd new file mode 100644 index 0000000..8ce2544 --- /dev/null +++ b/scripts/speech_word_effect.gd @@ -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 diff --git a/scripts/speech_word_effect.gd.uid b/scripts/speech_word_effect.gd.uid new file mode 100644 index 0000000..726fc58 --- /dev/null +++ b/scripts/speech_word_effect.gd.uid @@ -0,0 +1 @@ +uid://djm7bdf1q5ao0 diff --git a/scripts/stage_scene_io.gd b/scripts/stage_scene_io.gd index 53589c8..5613c07 100644 --- a/scripts/stage_scene_io.gd +++ b/scripts/stage_scene_io.gd @@ -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) diff --git a/scripts/stickman_action_runner.gd b/scripts/stickman_action_runner.gd index 994ad1b..75b9602 100644 --- a/scripts/stickman_action_runner.gd +++ b/scripts/stickman_action_runner.gd @@ -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) diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index d0bce9b..cc4a19f 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -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) diff --git a/scripts/stickman_speech_bubble.gd b/scripts/stickman_speech_bubble.gd index 26a6ded..2531bbe 100644 --- a/scripts/stickman_speech_bubble.gd +++ b/scripts/stickman_speech_bubble.gd @@ -1,51 +1,1874 @@ class_name SpeechBubble extends Node2D -## SpeechBubble - World-space speech bubble drawn in _draw() (Phase 3a). -## -## A child of the rig root at a fixed upward offset (SPEECH_BUBBLE_OFFSET), so -## it follows the figure and scales with the camera. Pure drawing, no -## hit-testing. Hidden by default. +## Shared deterministic speech renderer used by stage preview and playback. -const FONT_SIZE := 28 -const PADDING := Vector2(14.0, 10.0) -const TAIL_HEIGHT := 12.0 -const TAIL_WIDTH := 16.0 -const MAX_WIDTH := 320.0 -const BG_COLOR := Color(1.0, 1.0, 1.0, 0.95) -const BORDER_COLOR := Color(0.0, 0.0, 0.0, 0.6) -const TEXT_COLOR := Color(0.0, 0.0, 0.0, 1.0) +class BubbleFrameLayer extends Node2D: + var renderer: Node2D + var bubble_index: int = -1 -var _text: String = "" -var _bg_style: StyleBoxFlat + func _draw() -> void: + if renderer != null and is_instance_valid(renderer): + renderer.call("_draw_bubble_frame_layer", self, bubble_index) -func _init() -> void: + +class EditorOverlayLayer extends Node2D: + var renderer: Node2D + + func _draw() -> void: + if renderer != null and is_instance_valid(renderer): + renderer.call("_draw_editor_overlay_layer", self) + +const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd") +const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd") +const SPEECH_WORD_EFFECT := preload("res://scripts/speech_word_effect.gd") +const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd") +const AUTO_HEAD_GAP: float = 48.0 +const MAX_FRAME_WIDTH: float = 420.0 +const MIN_FRAME_WIDTH: float = 108.0 +const PADDING: Vector2 = Vector2(24.0, 18.0) +const SHOUT_PADDING: Vector2 = Vector2(30.0, 22.0) +const SHAPE_TEXT_SAFETY: Vector2 = Vector2(8.0, 6.0) +const FONT_LINE_HEIGHT_SAFETY: float = 1.15 +const STACK_GAP: float = 12.0 +const ORDER_BADGE_RADIUS: float = 21.0 +const ORDER_BADGE_HIT_RADIUS: float = 28.0 +const ORDER_BADGE_MIN_SPACING: float = 58.0 + +var _action: Dictionary = {} +var _elapsed: float = 0.0 +var _suppressed: bool = false +var _preview: bool = false +var _static_editor_preview: bool = false +var _placement_ghost: bool = false +var _mirrored: bool = false +var _screen_scale: float = 1.0 +var _fit_scale: float = 1.0 +var _display_offset: Vector2 = Vector2.ZERO +var _display_overflow: bool = false +var _last_viewport_size: Vector2 = Vector2(-1.0, -1.0) +var _last_canvas_transform: Transform2D = Transform2D.IDENTITY +var _last_rendered_token: Vector3i = Vector3i(-1, -1, -1) +var _last_layout_action: Dictionary = {} +var _last_layout_elapsed: float = -1.0 +var _head_center_rig: Vector2 = Vector2(0.0, -310.0) +var _head_radius_rig: float = 100.0 +var _selected_preview_bubble: int = -1 +var _selected_token: Vector3i = Vector3i(-1, -1, -1) +var _editor_effect_preview: Dictionary = {} +var _last_editor_effect_preview: Dictionary = {} +var _drag_bubble_index: int = -1 +var _drag_pointer_offset: Vector2 = Vector2.ZERO +var _labels: Array[RichTextLabel] = [] +var _frame_layers: Array[BubbleFrameLayer] = [] +var _layouts: Array[Dictionary] = [] +var _editor_overlay_layer: EditorOverlayLayer +var _label_meta_clicked: Dictionary = {} +var _theme: SandboxTheme +var _word_effect: RichTextEffect + +signal bubble_position_changed(bubble_index: int, center_rig: Vector2) +signal stage_word_selected(bubble_index: int, start: int, end: int) +signal stage_token_selected(bubble_index: int, start: int, end: int) +signal stage_line_tokens_requested(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) +signal stage_text_tap_detected(bubble_index: int) + + +func _ready() -> void: + _theme = SANDBOX_THEME.new() + _theme.load_from() + _word_effect = SPEECH_WORD_EFFECT.new() as RichTextEffect + _editor_overlay_layer = EditorOverlayLayer.new() + _editor_overlay_layer.name = "SpeechEditorOverlay" + _editor_overlay_layer.renderer = self + _editor_overlay_layer.z_index = 64 + add_child(_editor_overlay_layer) visible = false - _bg_style = StyleBoxFlat.new() - _bg_style.bg_color = BG_COLOR - _bg_style.border_color = BORDER_COLOR - _bg_style.set_border_width_all(2) - _bg_style.set_corner_radius_all(8) + + +func render_action(action: Dictionary, elapsed: float, preview: bool = false, static_editor_preview: bool = false) -> void: + var normalized_action: Dictionary = SPEECH_PAYLOAD.normalize(action) + var next_elapsed: float = maxf(0.0, elapsed) + var viewport: Viewport = get_viewport() + var viewport_size: Vector2 = viewport.get_visible_rect().size if viewport != null else Vector2.ZERO + var canvas_transform: Transform2D = viewport.get_canvas_transform() if viewport != null else Transform2D.IDENTITY + var previous_head: Vector2 = _head_center_rig + var previous_radius: float = _head_radius_rig + var previous_position: Vector2 = position + var previous_fit_scale: float = _fit_scale + var previous_display_offset: Vector2 = _display_offset + var previous_display_overflow: bool = _display_overflow + _action = normalized_action + _elapsed = next_elapsed + _preview = preview + _static_editor_preview = static_editor_preview + _fit_scale = 1.0 + _display_offset = Vector2.ZERO + _display_overflow = false + _resolve_live_head_geometry() + _update_screen_scale() + visible = not _suppressed and not _action.is_empty() + if not visible: + return + var layout_changed: bool = normalized_action != _last_layout_action or next_elapsed != _last_layout_elapsed or _last_viewport_size != viewport_size or _last_canvas_transform != canvas_transform or previous_head != _head_center_rig or previous_radius != _head_radius_rig or _last_rendered_token != _selected_token or _last_editor_effect_preview != _editor_effect_preview + if layout_changed or _layouts.is_empty(): + # Effect clock/style changes redraw text in the existing frames; only geometry changes remeasure the stack. + if _can_refresh_editor_content_in_place(viewport_size, canvas_transform, previous_head, previous_radius, next_elapsed): + position = previous_position + _fit_scale = previous_fit_scale + _display_offset = previous_display_offset + _display_overflow = previous_display_overflow + _update_node_scale() + _refresh_editor_content_in_place(next_elapsed) + else: + _rebuild_layout() + _last_layout_action = normalized_action.duplicate(true) + _last_layout_elapsed = next_elapsed + _last_rendered_token = _selected_token + _last_editor_effect_preview = _editor_effect_preview.duplicate(true) + else: + _fit_display_layout() + _last_viewport_size = viewport_size + _last_canvas_transform = canvas_transform + _queue_redraw_all() + func show_text(text: String) -> void: - _text = text - visible = true - queue_redraw() + # Legacy callers retain their authored fixed duration in the runner. + render_action({"type": "speak", "text": text, "duration": 2.0}, 0.0, false) + func hide_bubble() -> void: visible = false + _editor_effect_preview.clear() + + +func set_suppressed(value: bool) -> void: + _suppressed = value + if value: + _editor_effect_preview.clear() + visible = not _suppressed and not _action.is_empty() + _queue_redraw_all() + + +func set_placement_ghost(value: bool) -> void: + _placement_ghost = value + _queue_redraw_all() + + +func set_mirrored(value: bool) -> void: + _mirrored = value + _update_node_scale() + _queue_redraw_all() + + +func has_display_overflow() -> bool: + return _display_overflow + + +func set_selected_bubble_index(index: int) -> void: + if _selected_preview_bubble == index: + return + _selected_preview_bubble = index + for layout: Dictionary in _layouts: + var guide_only: bool = _preview and _static_editor_preview and index >= 0 and int(layout.get("index", -1)) != index + layout["guide_only"] = guide_only + var label: RichTextLabel = layout.get("label") as RichTextLabel + if label != null and is_instance_valid(label): + label.modulate.a = 0.5 if guide_only else 1.0 + _sync_preview_layer_order() + _queue_redraw_all() + + +func _sync_preview_layer_order() -> void: + var selected_frame_z: int = SPEECH_PAYLOAD.MAX_BUBBLES * 2 + 3 + for layout: Dictionary in _layouts: + var index: int = int(layout.get("index", -1)) + var label: RichTextLabel = layout.get("label") as RichTextLabel + if label != null and is_instance_valid(label): + label.z_index = SPEECH_PAYLOAD.MAX_BUBBLES * 2 + 4 if index == _selected_preview_bubble else index * 2 + 2 + if index >= 0 and index < _frame_layers.size(): + _frame_layers[index].z_index = selected_frame_z if index == _selected_preview_bubble else index * 2 + 1 + + +func set_selected_token(index: int, start: int, end: int) -> void: + var next_token: Vector3i = Vector3i(index, start, end) if index >= 0 and start >= 0 and end > start else Vector3i(-1, -1, -1) + if _selected_token == next_token: + return + _selected_token = next_token + _queue_redraw_all() + + +func _queue_redraw_all() -> void: + queue_redraw() + for layer: BubbleFrameLayer in _frame_layers: + if is_instance_valid(layer): + layer.queue_redraw() + if _editor_overlay_layer != null and is_instance_valid(_editor_overlay_layer): + _editor_overlay_layer.queue_redraw() + + +func set_editor_effect_preview(preview: Dictionary) -> void: + _editor_effect_preview = preview.duplicate(true) + + +func _can_refresh_editor_content_in_place(viewport_size: Vector2, canvas_transform: Transform2D, previous_head: Vector2, previous_radius: float, next_elapsed: float) -> bool: + if not _preview or not _static_editor_preview or _layouts.is_empty() or _last_layout_action.is_empty(): + return false + if _last_viewport_size != viewport_size or _last_canvas_transform != canvas_transform or previous_head != _head_center_rig or previous_radius != _head_radius_rig: + return false + if next_elapsed != _last_layout_elapsed: + return false + return _speech_geometry_signature(_last_layout_action) == _speech_geometry_signature(_action) + + +func _speech_geometry_signature(action: Dictionary) -> Dictionary: + var bubble_signatures: Array[Dictionary] = [] + for raw_bubble: Variant in action.get("bubbles", []): + if not raw_bubble is Dictionary: + continue + var bubble: Dictionary = raw_bubble as Dictionary + var visual: Dictionary = bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {} + var typography_overrides: Array[Dictionary] = [] + for raw_style: Variant in visual.get("word_styles", []): + if not raw_style is Dictionary: + continue + var style: Dictionary = raw_style as Dictionary + if style.has("font_preset") or style.has("size_preset"): + typography_overrides.append({ + "start": style.get("start", -1), + "end": style.get("end", -1), + "font_preset": style.get("font_preset", ""), + "size_preset": style.get("size_preset", ""), + }) + bubble_signatures.append({ + "text": String(bubble.get("text", "")), + "bubble_style": String(visual.get("bubble_style", "talk")), + "font_preset": String(visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)), + "size_preset": String(visual.get("size_preset", "medium")), + "pop_in": bool(visual.get("pop_in", false)), + "word_styles": typography_overrides, + "placement": bubble.get("placement", null), + }) + return { + "bubbles": bubble_signatures, + "placement": action.get("placement", null), + "arrangement": String(action.get("arrangement", "together")), + } + + +func _refresh_editor_content_in_place(elapsed: float) -> void: + var guide_only_bubble: int = _selected_preview_bubble + for layout: Dictionary in _layouts: + var bubble_index: int = int(layout.get("index", -1)) + var bubble: Dictionary = _bubble_data(bubble_index) + if bubble.is_empty(): + continue + var visual: Dictionary = bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {} + var text: String = String(bubble.get("text", "")) + var label: RichTextLabel = layout.get("label") as RichTextLabel + layout["visual"] = visual + layout["elapsed"] = elapsed + layout["guide_only"] = guide_only_bubble >= 0 and bubble_index != guide_only_bubble + if label == null or not is_instance_valid(label): + continue + label.modulate.a = 0.5 if bool(layout["guide_only"]) else 1.0 + _render_label_text(label, text, visual, elapsed, bubble_index) + label.visible_characters = _visible_character_count(text, visual, elapsed, float(_interval_for(bubble_index))) + + +func selected_frame_viewport_rect(index: int) -> Rect2: + if not _preview or not _static_editor_preview: + return Rect2() + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) == index: + var frame: Rect2 = _layout_frame(layout) + frame.position += _display_offset + return _transform_rect(get_global_transform_with_canvas(), frame) + return Rect2() + + +func group_frames_viewport_rect() -> Rect2: + if not _preview or not _static_editor_preview or _layouts.is_empty(): + return Rect2() + var group_rect: Rect2 = Rect2() + var has_frame: bool = false + for layout: Dictionary in _layouts: + var frame: Rect2 = _layout_frame(layout) + frame.position += _display_offset + var viewport_frame: Rect2 = _transform_rect(get_global_transform_with_canvas(), frame) + group_rect = viewport_frame if not has_frame else group_rect.merge(viewport_frame) + has_frame = true + return group_rect if has_frame else Rect2() + + +func bubble_layout_centers(preset: StringName) -> Array[Vector2]: + var centers: Array[Vector2] = [] + var bubbles: Array = _action.get("bubbles", []) + if not _preview or not _static_editor_preview or bubbles.is_empty() or _layouts.size() != bubbles.size(): + return centers + if preset != &"stack" and preset != &"same_spot": + return centers + var frame_sizes: Array[Vector2] = [] + var found: PackedByteArray = PackedByteArray() + centers.resize(bubbles.size()) + frame_sizes.resize(bubbles.size()) + found.resize(bubbles.size()) + for layout: Dictionary in _layouts: + var index: int = int(layout.get("index", -1)) + if index < 0 or index >= bubbles.size(): + continue + var frame: Rect2 = layout.get("frame", Rect2()) + centers[index] = position + frame.get_center() + frame_sizes[index] = frame.size + found[index] = 1 + for index: int in found.size(): + if found[index] == 0: + return [] + var first_center: Vector2 = centers[0] + if preset == &"same_spot": + for index: int in centers.size(): + centers[index] = first_center + return centers + var previous_center: Vector2 = first_center + var previous_height: float = frame_sizes[0].y + for index: int in range(1, centers.size()): + var side_step: float = 22.0 if index % 2 == 1 else -22.0 + var next_center := Vector2( + first_center.x + side_step, + previous_center.y - (previous_height + frame_sizes[index].y) * 0.5 - STACK_GAP + ) + centers[index] = next_center + previous_center = next_center + previous_height = frame_sizes[index].y + return centers + + +func suggest_added_bubble_center(index: int) -> Dictionary: + var added_layout: Dictionary = {} + var below_layout: Dictionary = {} + for layout: Dictionary in _layouts: + var layout_index: int = int(layout.get("index", -1)) + if layout_index == index: + added_layout = layout + elif layout_index == index + 1: + below_layout = layout + if added_layout.is_empty() or below_layout.is_empty(): + return {} + var added_rect: Rect2 = _layout_frame(added_layout) + var below_rect: Rect2 = _layout_frame(below_layout) + if not added_rect.grow(5.0).intersects(below_rect.grow(5.0)): + return {} + + var overlap_y: float = minf(added_rect.end.y, below_rect.end.y) - maxf(added_rect.position.y, below_rect.position.y) + var shifted_rect: Rect2 = added_rect + shifted_rect.position.y += maxf(10.0, minf(22.0, overlap_y * 0.4)) + var blocking_left: float = INF + var blocking_right: float = -INF + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) == index: + continue + var other_rect: Rect2 = _layout_frame(layout) + if shifted_rect.position.y >= other_rect.end.y or shifted_rect.end.y <= other_rect.position.y: + continue + blocking_left = minf(blocking_left, other_rect.position.x) + blocking_right = maxf(blocking_right, other_rect.end.x) + if not is_finite(blocking_left) or not is_finite(blocking_right): + return {} + + var horizontal_gap: float = 16.0 + var vertical_center: float = shifted_rect.get_center().y + var left_center: Vector2 = Vector2(blocking_left - horizontal_gap - added_rect.size.x * 0.5, vertical_center) + var right_center: Vector2 = Vector2(blocking_right + horizontal_gap + added_rect.size.x * 0.5, vertical_center) + var screen_transform: Transform2D = get_global_transform_with_canvas() + var viewport_size: Vector2 = get_viewport_rect().size + var screen_width: float = added_rect.size.x * screen_transform.x.length() + var left_screen_x: float = (screen_transform * (left_center + _display_offset)).x + var right_screen_x: float = (screen_transform * (right_center + _display_offset)).x + var left_room: float = left_screen_x - screen_width * 0.5 - 14.0 + var right_room: float = viewport_size.x - right_screen_x - screen_width * 0.5 - 14.0 + var choose_left: bool + if left_room >= 0.0 and right_room < 0.0: + choose_left = true + elif right_room >= 0.0 and left_room < 0.0: + choose_left = false + elif left_room >= 0.0 and right_room >= 0.0: + choose_left = absf(left_center.x - added_rect.get_center().x) <= absf(right_center.x - added_rect.get_center().x) + else: + choose_left = left_room >= right_room + var resolved_center: Vector2 = left_center if choose_left else right_center + return {"collided": true, "center": position + resolved_center} + + +func text_line_tokens_at_viewport(index: int, viewport_position: Vector2) -> Array[Vector2i]: + var result: Array[Vector2i] = [] + var bubble: Dictionary = _bubble_data(index) + if bubble.is_empty(): + return result + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) != index: + continue + var label: RichTextLabel = layout.get("label") as RichTextLabel + if label == null or not is_instance_valid(label) or not label.get_global_rect().has_point(viewport_position): + return result + var local_point: Vector2 = label.get_global_transform_with_canvas().affine_inverse() * viewport_position + var nearest_line: int = -1 + var nearest_distance: float = INF + for line_index: int in label.get_line_count(): + var line_top: float = label.get_line_offset(line_index) + var line_bottom: float = line_top + float(label.get_line_height(line_index)) + var distance: float = maxf(maxf(line_top - local_point.y, 0.0), local_point.y - line_bottom) + if distance < nearest_distance: + nearest_distance = distance + nearest_line = line_index + if nearest_line < 0 or nearest_distance > 36.0: + return result + var text: String = String(bubble.get("text", "")) + for span: Vector2i in SPEECH_PAYLOAD.token_spans(text): + if label.get_character_line(span.x) == nearest_line: + result.append(span) + return result + return result + + +func hit_test_bubbles(world_position: Vector2) -> Array[int]: + var hits: Array[int] = [] + if not _preview or not _static_editor_preview or not visible: + return hits + var local_position: Vector2 = to_local(world_position) - _display_offset + for layout_index: int in range(_layouts.size() - 1, -1, -1): + var layout: Dictionary = _layouts[layout_index] + if _layout_frame(layout).has_point(local_position): + hits.append(int(layout.get("index", -1))) + return hits + + +func hit_test_bubble_for_drag(world_position: Vector2) -> int: + if not _preview or not _static_editor_preview or not visible: + return -1 + var local_position: Vector2 = to_local(world_position) - _display_offset + var selected_index: int = -1 + var selected_area: float = INF + # Iterate front to back so equal-sized bubbles keep the existing topmost tie-break. + for layout_index: int in range(_layouts.size() - 1, -1, -1): + var layout: Dictionary = _layouts[layout_index] + var frame: Rect2 = _layout_frame(layout) + if not frame.has_point(local_position): + continue + var frame_area: float = frame.get_area() + if frame_area < selected_area: + selected_area = frame_area + selected_index = int(layout.get("index", -1)) + return selected_index + + +func hit_test_order_badge(world_position: Vector2) -> int: + if not _preview or not _static_editor_preview or not visible: + return -1 + var scale: float = _badge_canvas_scale() + var local_position: Vector2 = to_local(world_position) + var badge_centers: Dictionary = _order_badge_centers() + for layout_index: int in range(_layouts.size() - 1, -1, -1): + var index: int = int(_layouts[layout_index].get("index", -1)) + if not badge_centers.has(index): + continue + var center: Vector2 = badge_centers[index] + if local_position.distance_to(center) <= ORDER_BADGE_HIT_RADIUS / scale: + return index + return -1 + + +func order_badge_viewport_rects() -> Array[Rect2]: + var result: Array[Rect2] = [] + if not _preview or not _static_editor_preview or not visible: + return result + var canvas_transform: Transform2D = get_global_transform_with_canvas() + var badge_centers: Dictionary = _order_badge_centers() + var reserve_radius: float = ORDER_BADGE_RADIUS + 5.0 + for layout: Dictionary in _layouts: + var index: int = int(layout.get("index", -1)) + if not badge_centers.has(index): + continue + var center: Vector2 = canvas_transform * Vector2(badge_centers[index]) + result.append(Rect2(center - Vector2.ONE * reserve_radius, Vector2.ONE * reserve_radius * 2.0)) + return result + + +func _order_badge_centers() -> Dictionary: + var centers: Dictionary = {} + if _layouts.is_empty(): + return centers + var canvas_transform: Transform2D = get_global_transform_with_canvas() + var inverse_transform: Transform2D = canvas_transform.affine_inverse() + var spacing: float = ORDER_BADGE_MIN_SPACING + var desired_screen: Dictionary = {} + var frame_rects_screen: Dictionary = {} + var pending: Array[int] = [] + for layout: Dictionary in _layouts: + var index: int = int(layout.get("index", -1)) + if index < 0: + continue + var frame: Rect2 = _layout_frame(layout) + frame.position += _display_offset + var screen_frame: Rect2 = _transform_rect(canvas_transform, frame) + frame_rects_screen[index] = screen_frame + desired_screen[index] = canvas_transform * frame.position + pending.append(index) + if pending.is_empty(): + return centers + + var groups: Array = [] + while not pending.is_empty(): + var group: Array[int] = [pending.pop_front()] + var expanded: bool = true + while expanded: + expanded = false + for candidate_index: int in pending.duplicate(): + var candidate_frame: Rect2 = frame_rects_screen[candidate_index] + for grouped_index: int in group: + var grouped_frame: Rect2 = frame_rects_screen[grouped_index] + var badges_are_crowded: bool = Vector2(desired_screen[candidate_index]).distance_to(Vector2(desired_screen[grouped_index])) < spacing + if badges_are_crowded or candidate_frame.intersects(grouped_frame): + group.append(candidate_index) + pending.erase(candidate_index) + expanded = true + break + group.sort() + groups.append(group) + + var placed_screen: Array[Vector2] = [] + for group_variant: Variant in groups: + var group: Array = group_variant + var group_rect: Rect2 = Rect2(Vector2.ZERO, Vector2.ZERO) + var has_rect: bool = false + for group_index_variant: Variant in group: + var group_index: int = int(group_index_variant) + var frame_rect: Rect2 = frame_rects_screen[group_index] + group_rect = frame_rect if not has_rect else group_rect.merge(frame_rect) + has_rect = true + if not has_rect: + continue + var overlapping_frames: bool = false + var largest_frame: Rect2 = group_rect + var largest_frame_area: float = -1.0 + for group_position: int in range(group.size()): + var group_index: int = int(group[group_position]) + var frame_rect: Rect2 = frame_rects_screen[group_index] + var frame_area: float = frame_rect.get_area() + if frame_area > largest_frame_area: + largest_frame = frame_rect + largest_frame_area = frame_area + for other_position: int in range(group_position + 1, group.size()): + var other_index: int = int(group[other_position]) + if frame_rect.intersects(frame_rects_screen[other_index]): + overlapping_frames = true + break + var badge_cluster_anchor: Rect2 = largest_frame if overlapping_frames else group_rect + + var candidate_layouts: Array = [] + if group.size() == 1: + var desired: Vector2 = desired_screen[int(group[0])] + if _badge_candidate_fits(desired, placed_screen, spacing): + candidate_layouts.append([desired]) + if candidate_layouts.is_empty(): + # Keep a cluster together. Use the familiar row above the bubbles first, + # then a side rail, and only use the lower row when the stage is tight. + candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"above")) + candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"right")) + candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"left")) + candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"below")) + + var chosen_positions: Array = [] + for candidate_layout_index: int in range(candidate_layouts.size()): + var candidate_positions_variant: Variant = candidate_layouts[candidate_layout_index] + var candidate_positions: Array = candidate_positions_variant + var allow_top_margin: bool = overlapping_frames and candidate_layout_index == 0 + if _badge_cluster_fits(candidate_positions, placed_screen, spacing, allow_top_margin): + chosen_positions = candidate_positions + break + if chosen_positions.is_empty(): + # Last-resort placement still avoids toolbars and already placed badges. + for group_index_variant: Variant in group: + var group_index: int = int(group_index_variant) + var fallback: Vector2 = _find_badge_fallback_position(Vector2(desired_screen[group_index]), placed_screen, spacing) + chosen_positions.append(fallback) + placed_screen.append(fallback) + else: + for position_variant: Variant in chosen_positions: + placed_screen.append(Vector2(position_variant)) + for position_index: int in range(mini(group.size(), chosen_positions.size())): + centers[int(group[position_index])] = inverse_transform * Vector2(chosen_positions[position_index]) + return centers + + +func _badge_cluster_positions(group_rect: Rect2, count: int, arrangement: StringName) -> Array[Vector2]: + var result: Array[Vector2] = [] + if count <= 0: + return result + var spacing: float = ORDER_BADGE_MIN_SPACING + if arrangement == &"left" or arrangement == &"right": + var x: float = group_rect.position.x - ORDER_BADGE_RADIUS - 8.0 if arrangement == &"left" else group_rect.end.x + ORDER_BADGE_RADIUS + 8.0 + var first_y: float = group_rect.get_center().y - float(count - 1) * spacing * 0.5 + for item_index: int in range(count): + result.append(Vector2(x, first_y + float(item_index) * spacing)) + return result + var y: float = group_rect.position.y - ORDER_BADGE_RADIUS - 8.0 if arrangement == &"above" else group_rect.end.y + ORDER_BADGE_RADIUS + 8.0 + var first_x: float = group_rect.get_center().x - float(count - 1) * spacing * 0.5 + for item_index: int in range(count): + result.append(Vector2(first_x + float(item_index) * spacing, y)) + return result + + +func _badge_candidate_fits(candidate: Vector2, placed: Array[Vector2], spacing: float, allow_top_margin: bool = false) -> bool: + if _badge_position_is_crowded(candidate, placed, spacing): + return false + var viewport_size: Vector2 = get_viewport_rect().size + var top_margin: float = ORDER_BADGE_RADIUS + 12.0 if allow_top_margin else 72.0 + var bottom_margin: float = 48.0 + var stage_rect := Rect2(Vector2(12.0, top_margin), Vector2(maxf(1.0, viewport_size.x - 24.0), maxf(1.0, viewport_size.y - top_margin - bottom_margin))) + var half_size: float = ORDER_BADGE_RADIUS + 3.0 + var badge_rect := Rect2(candidate - Vector2.ONE * half_size, Vector2.ONE * half_size * 2.0) + return stage_rect.encloses(badge_rect) + + +func _badge_cluster_fits(candidates: Array, placed: Array[Vector2], spacing: float, allow_top_margin: bool = false) -> bool: + var local_positions: Array[Vector2] = [] + for candidate_variant: Variant in candidates: + var candidate: Vector2 = Vector2(candidate_variant) + if not _badge_candidate_fits(candidate, placed, spacing, allow_top_margin) or _badge_position_is_crowded(candidate, local_positions, spacing): + return false + local_positions.append(candidate) + return not local_positions.is_empty() + + +func _find_badge_fallback_position(origin: Vector2, placed: Array[Vector2], spacing: float) -> Vector2: + var directions: Array[Vector2] = [ + Vector2(0.0, -1.0), Vector2(1.0, 0.0), Vector2(-1.0, 0.0), Vector2(0.0, 1.0), + Vector2(1.0, -1.0).normalized(), Vector2(-1.0, -1.0).normalized(), + Vector2(1.0, 1.0).normalized(), Vector2(-1.0, 1.0).normalized(), + ] + if _badge_candidate_fits(origin, placed, spacing): + return origin + for ring: int in range(1, SPEECH_PAYLOAD.MAX_BUBBLES * 3 + 1): + for direction: Vector2 in directions: + var candidate: Vector2 = origin + direction * spacing * float(ring) + if _badge_candidate_fits(candidate, placed, spacing): + return candidate + return origin + + +func _badge_position_is_crowded(candidate: Vector2, placed: Array[Vector2], spacing: float) -> bool: + for previous: Vector2 in placed: + if candidate.distance_to(previous) < spacing: + return true + return false + + +func _badge_canvas_scale() -> float: + var canvas_transform: Transform2D = get_global_transform_with_canvas() + return maxf(0.001, (canvas_transform.x.length() + canvas_transform.y.length()) * 0.5) + + +func begin_bubble_drag(world_position: Vector2, selected_index: int) -> bool: + if not _preview or not _static_editor_preview or not visible: + return false + var local_position: Vector2 = to_local(world_position) - _display_offset + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) != selected_index: + continue + var frame: Rect2 = _layout_frame(layout) + if frame.has_point(local_position): + _drag_bubble_index = selected_index + _drag_pointer_offset = local_position - frame.get_center() + return true + return false + + +func update_bubble_drag(world_position: Vector2) -> void: + if _drag_bubble_index < 0: + return + var local_position: Vector2 = to_local(world_position) - _display_offset - _drag_pointer_offset + bubble_position_changed.emit(_drag_bubble_index, position + local_position) + + +func end_bubble_drag() -> void: + _drag_bubble_index = -1 + + +func _update_screen_scale() -> void: + var viewport: Viewport = get_viewport() + var camera: Camera2D = viewport.get_camera_2d() if viewport != null else null + _screen_scale = clampf(1.0 / maxf(camera.zoom.x, 0.001), 0.65, 3.0) if camera != null else 1.0 + _update_node_scale() + + +func _update_node_scale() -> void: + var amount: float = _screen_scale * _fit_scale + # The rig mirrors its root when facing left, so counter-mirror this child tree. + scale = Vector2(-amount if _mirrored else amount, amount) + + +func _resolve_live_head_geometry() -> void: + var rig: Node = get_parent() + if rig == null or not rig.has_method("speech_head_geometry"): + return + var raw: Variant = rig.call("speech_head_geometry") + if not raw is Dictionary: + return + var geometry: Dictionary = raw as Dictionary + var center: Variant = geometry.get("center", null) + var radius: Variant = geometry.get("radius", null) + if center is Vector2 and radius is float or center is Vector2 and radius is int: + if (center as Vector2).is_finite() and is_finite(float(radius)) and float(radius) > 0.0: + _head_center_rig = center as Vector2 + _head_radius_rig = clampf(float(radius), 1.0, 10000.0) + + +func _visible_cards() -> Array[Dictionary]: + var bubbles: Array = _action.get("bubbles", []) + var visible_cards: Array[Dictionary] = [] + if bubbles.is_empty(): + return visible_cards + var arrangement: String = String(_action.get("arrangement", "together")) + if _static_editor_preview or arrangement == "together": + for i: int in bubbles.size(): + if bubbles[i] is Dictionary: + visible_cards.append({"index": i, "elapsed": _elapsed}) + return visible_cards + var intervals: PackedFloat32Array = SPEECH_PAYLOAD.bubble_intervals(_action) + var clock: float = _elapsed + if arrangement == "take_turns" or arrangement == "comic_chain": + var active_index: int = intervals.size() - 1 + var active_elapsed: float = 0.0 + for i: int in intervals.size(): + if clock < intervals[i] or i == intervals.size() - 1: + active_index = i + active_elapsed = minf(clock, intervals[i]) + break + clock -= intervals[i] + var keep_previous_visible: bool = bool(_action.get("connect_bubbles", false)) or arrangement == "comic_chain" + var first_visible: int = 0 if keep_previous_visible else active_index + for i: int in range(first_visible, active_index + 1): + if i >= bubbles.size() or not bubbles[i] is Dictionary: + continue + var elapsed: float = active_elapsed if i == active_index else intervals[i] + visible_cards.append({"index": i, "elapsed": elapsed}) + return visible_cards + clock = _elapsed + for i: int in intervals.size(): + if clock < intervals[i] or i == intervals.size() - 1: + visible_cards.append({"index": i, "elapsed": minf(clock, intervals[i])}) + break + clock -= intervals[i] + return visible_cards + + +func _rebuild_layout() -> void: + var cards: Array[Dictionary] = _visible_cards() + _layouts.clear() + _ensure_labels(cards.size()) + var bubbles: Array = _action.get("bubbles", []) + _ensure_frame_layers(bubbles.size()) + for frame_layer: BubbleFrameLayer in _frame_layers: + frame_layer.visible = false + var frame_sizes: Array[Vector2] = [] + var frame_paddings: Array[Vector2] = [] + var total_height: float = 0.0 + for raw_bubble: Variant in bubbles: + if not raw_bubble is Dictionary: + frame_sizes.append(Vector2(MIN_FRAME_WIDTH, 58.0)) + frame_paddings.append(PADDING) + continue + var bubble: Dictionary = raw_bubble as Dictionary + var visual: Dictionary = bubble.get("visual", {}) + var measurement: Dictionary = _measure_frame_layout(String(bubble.get("text", "")), visual) + var size: Vector2 = measurement["size"] + frame_sizes.append(size) + frame_paddings.append(measurement["padding"]) + total_height += size.y + if bubbles.size() > 1: + total_height += float(bubbles.size() - 1) * STACK_GAP + var stack_origin: Vector2 = _head_center_rig + Vector2(0.0, -_head_radius_rig - total_height * 0.5 - AUTO_HEAD_GAP) + var group_placement: Dictionary = _action.get("placement", {"mode": "auto"}) + if String(group_placement.get("mode", "auto")) == "manual": + var group_center: Variant = group_placement.get("center", Vector2.ZERO) + position = group_center if group_center is Vector2 else Vector2.ZERO + else: + position = stack_origin + var stack_centers: Array[float] = [] + var cursor_y: float = -total_height * 0.5 + for bubble_index: int in frame_sizes.size(): + var size: Vector2 = frame_sizes[bubble_index] + stack_centers.append(cursor_y + size.y * 0.5) + cursor_y += size.y + STACK_GAP + for card_index: int in cards.size(): + var index: int = int(cards[card_index]["index"]) + var bubble: Dictionary = bubbles[index] + var visual: Dictionary = bubble.get("visual", {}) + var frame_size: Vector2 = frame_sizes[index] + var label_padding: Vector2 = frame_paddings[index] + var placement_value: Variant = bubble.get("placement", null) + var stagger: float = -18.0 if index % 2 == 0 else 18.0 + var frame_center: Vector2 = Vector2(stagger, stack_centers[index]) + var authored_placement: bool = placement_value is Dictionary + var manual_bubble_placement: bool = false + if placement_value is Dictionary: + var bubble_placement: Dictionary = placement_value as Dictionary + if String(bubble_placement.get("mode", "auto")) == "manual": + var manual_center: Variant = bubble_placement.get("center", Vector2.ZERO) + if manual_center is Vector2: + frame_center = (manual_center as Vector2) - position + manual_bubble_placement = true + var frame := Rect2(frame_center - frame_size * 0.5, frame_size) + var label: RichTextLabel = _labels[card_index] + label.z_index = index * 2 + 2 + label.position = frame.position + label_padding + label.size = frame.size - label_padding * 2.0 + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + var local_elapsed: float = float(cards[card_index]["elapsed"]) + var entrance_scale: float = _entrance_scale(visual, local_elapsed) + label.pivot_offset = label.size * 0.5 + label.scale = Vector2.ONE * entrance_scale + label.mouse_filter = Control.MOUSE_FILTER_STOP if _preview and _static_editor_preview else Control.MOUSE_FILTER_IGNORE + label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _preview and _static_editor_preview else Control.CURSOR_ARROW + _render_label_text(label, String(bubble.get("text", "")), visual, local_elapsed, index) + label.visible_characters = _visible_character_count(String(bubble.get("text", "")), visual, local_elapsed, float(_interval_for(index))) + var guide_only: bool = _preview and _static_editor_preview and _selected_preview_bubble >= 0 and index != _selected_preview_bubble + label.modulate.a = 0.5 if guide_only else 1.0 + var frame_layer: BubbleFrameLayer = _frame_layers[index] + frame_layer.renderer = self + frame_layer.bubble_index = index + frame_layer.z_index = index * 2 + 1 + frame_layer.visible = true + _layouts.append({"index": index, "frame": frame, "visual": visual, "elapsed": local_elapsed, "entrance_scale": entrance_scale, "label": label, "padding": label_padding, "authored_placement": authored_placement, "independent_placement": manual_bubble_placement, "guide_only": guide_only, "show_tail": manual_bubble_placement or card_index == cards.size() - 1}) + _fit_display_layout() + _sync_preview_layer_order() + visible = not _suppressed and not _layouts.is_empty() + + +func _fit_display_layout() -> void: + if _layouts.is_empty(): + return + var local_bounds: Rect2 = _layout_frame(_layouts[0]) + for layout: Dictionary in _layouts.slice(1): + local_bounds = local_bounds.merge(_layout_frame(layout)) + var viewport: Viewport = get_viewport() + if viewport == null: + return + var viewport_size: Vector2 = viewport.get_visible_rect().size + var left: float = 16.0 + var right: float = viewport_size.x - 16.0 + var top: float = 72.0 + var bottom: float = viewport_size.y - 48.0 + var available: Vector2 = Vector2(maxf(1.0, right - left), maxf(1.0, bottom - top)) + var screen_bounds: Rect2 = _transform_rect(get_global_transform_with_canvas(), local_bounds) + var fit: float = minf(available.x / maxf(screen_bounds.size.x, 1.0), available.y / maxf(screen_bounds.size.y, 1.0)) + if fit < 1.0: + _fit_scale = maxf(0.6, fit) + _display_overflow = fit < 0.6 + _update_node_scale() + screen_bounds = _transform_rect(get_global_transform_with_canvas(), local_bounds) + var screen_delta := Vector2.ZERO + if screen_bounds.position.x < left: + screen_delta.x = left - screen_bounds.position.x + elif screen_bounds.end.x > right: + screen_delta.x = right - screen_bounds.end.x + var has_manual_bubble: bool = false + for raw_bubble: Variant in _action.get("bubbles", []): + if not raw_bubble is Dictionary: + continue + var bubble_placement: Variant = (raw_bubble as Dictionary).get("placement", null) + if bubble_placement is Dictionary and String((bubble_placement as Dictionary).get("mode", "auto")) == "manual": + has_manual_bubble = true + break + var automatic_group: bool = String(_action.get("placement", {}).get("mode", "auto")) != "manual" and not has_manual_bubble + if automatic_group and screen_bounds.position.y < top: + var rig: Node2D = get_parent() as Node2D + var rig_bounds_world: Rect2 = STAGE_SPAWNER.get_world_aabb(rig) if rig != null else Rect2() + var figure_bounds: Rect2 = _transform_rect(get_viewport().get_canvas_transform(), rig_bounds_world) + if figure_bounds.size.x <= 0.0 or figure_bounds.size.y <= 0.0: + var transform: Transform2D = get_global_transform_with_canvas() + var head_screen: Vector2 = transform * _head_point_in_bubble() + figure_bounds = Rect2(head_screen - Vector2(_head_radius_rig * _screen_scale, _head_radius_rig * _screen_scale), Vector2.ONE * _head_radius_rig * 2.0 * _screen_scale) + var side_gap: float = 18.0 + var side_right: float = figure_bounds.end.x + screen_bounds.size.x * 0.5 + side_gap + var side_left: float = figure_bounds.position.x - screen_bounds.size.x * 0.5 - side_gap + var right_fits: bool = side_right + screen_bounds.size.x * 0.5 <= right + var left_fits: bool = side_left - screen_bounds.size.x * 0.5 >= left + var choose_right: bool = right_fits or (not left_fits and right - figure_bounds.end.x >= figure_bounds.position.x - left) + var desired_center_x: float = side_right if choose_right else side_left + screen_delta.x = desired_center_x - screen_bounds.get_center().x + var min_center_y: float = top + screen_bounds.size.y * 0.5 + var max_center_y: float = maxf(min_center_y, bottom - screen_bounds.size.y * 0.5) + var side_center_y: float = clampf(figure_bounds.get_center().y, min_center_y, max_center_y) + screen_delta.y = side_center_y - screen_bounds.get_center().y + if not right_fits and not left_fits: + _display_overflow = true + elif screen_bounds.position.y < top: + _display_overflow = true + elif screen_bounds.end.y > bottom: + screen_delta.y = bottom - screen_bounds.end.y + _display_offset = get_global_transform_with_canvas().basis_xform_inv(screen_delta) + for layout: Dictionary in _layouts: + var frame: Rect2 = layout["frame"] + var label: RichTextLabel = layout["label"] + var label_padding: Vector2 = layout.get("padding", PADDING) + label.position = frame.position + label_padding + _display_offset + + +func _layout_frame(layout: Dictionary) -> Rect2: + var frame: Rect2 = layout["frame"] + var factor: float = float(layout.get("entrance_scale", 1.0)) + var size: Vector2 = frame.size * factor + return Rect2(frame.get_center() - size * 0.5, size) + + +func _entrance_scale(visual: Dictionary, elapsed: float) -> float: + if not bool(visual.get("pop_in", false)): + return 1.0 + var duration: float = _theme.speech_number("animation.pop_duration", 0.26, 0.1, 0.8) if _theme != null else 0.26 + var overshoot_end: float = duration * 0.58 + if elapsed < overshoot_end: + return lerpf(0.52, 1.16, clampf(elapsed / maxf(overshoot_end, 0.001), 0.0, 1.0)) + return lerpf(1.16, 1.0, clampf((elapsed - overshoot_end) / maxf(duration - overshoot_end, 0.001), 0.0, 1.0)) + + +func _transform_rect(transform: Transform2D, rect: Rect2) -> Rect2: + var a: Vector2 = transform * rect.position + var b: Vector2 = transform * Vector2(rect.end.x, rect.position.y) + var c: Vector2 = transform * rect.end + var d: Vector2 = transform * Vector2(rect.position.x, rect.end.y) + var result := Rect2(a, Vector2.ZERO) + result = result.expand(b).expand(c).expand(d) + return result + + +func _measure_frame(text: String, visual: Dictionary) -> Vector2: + return _measure_frame_layout(text, visual)["size"] + + +func _measure_frame_layout(text: String, visual: Dictionary) -> Dictionary: + var label_padding: Vector2 = _padding_for_visual(visual) + var base_font_id: String = String(visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)) + var font_size: int = _font_size(String(visual.get("size_preset", "medium")), base_font_id) + var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, _typed_word_styles(visual.get("word_styles", []))) + var line_widths: Array[float] = [0.0] + var base_font: Font = _theme.speech_font(base_font_id) + var line_heights: Array[float] = [float(base_font.get_height(font_size)) * FONT_LINE_HEIGHT_SAFETY] + for run: Dictionary in runs: + var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))) + var run_size: int = _font_size(String(run.get("size_preset", visual.get("size_preset", "medium"))), font_id) + var font: Font = _theme.speech_font(font_id) + var segments: PackedStringArray = String(run.get("text", "")).split("\n", true) + for segment_index: int in segments.size(): + line_widths[line_widths.size() - 1] += font.get_string_size(segments[segment_index], HORIZONTAL_ALIGNMENT_LEFT, -1.0, run_size).x + line_heights[line_heights.size() - 1] = maxf(line_heights.back(), float(font.get_height(run_size)) * FONT_LINE_HEIGHT_SAFETY) + if segment_index < segments.size() - 1: + line_widths.append(0.0) + line_heights.append(float(font.get_height(run_size)) * FONT_LINE_HEIGHT_SAFETY) + var max_width: float = MAX_FRAME_WIDTH - label_padding.x * 2.0 + var content_width: float = minf(max_width, maxf(80.0, line_widths.max())) + var measured_height: float = 0.0 + for line_index: int in line_widths.size(): + var wraps: int = maxi(1, int(ceil(line_widths[line_index] / maxf(content_width, 1.0)))) + measured_height += line_heights[line_index] * float(wraps) + var content_size := Vector2(content_width, measured_height) + var style: String = String(visual.get("bubble_style", "talk")) + if style in ["shout", "think"]: + label_padding = _padding_to_fit_shape(style, content_size, label_padding) + var width: float = maxf(MIN_FRAME_WIDTH, content_size.x + label_padding.x * 2.0) + var height: float = maxf(58.0, content_size.y + label_padding.y * 2.0) + return {"size": Vector2(width, height), "padding": label_padding} + + +func _padding_to_fit_shape(style: String, content_size: Vector2, initial_padding: Vector2) -> Vector2: + var padding: Vector2 = initial_padding + # Burst and cloud outlines dip inward, so fit the full text box to their actual contours. + for _attempt: int in 64: + var frame_size := Vector2( + maxf(MIN_FRAME_WIDTH, content_size.x + padding.x * 2.0), + maxf(58.0, content_size.y + padding.y * 2.0) + ) + if _shape_contains_text_rect(style, frame_size, content_size + SHAPE_TEXT_SAFETY * 2.0): + return padding + padding += Vector2(3.0, 3.0) + return padding + + +func _shape_contains_text_rect(style: String, frame_size: Vector2, text_rect_size: Vector2) -> bool: + var frame := Rect2(-frame_size * 0.5, frame_size) + var outline: PackedVector2Array = _frame_outline_points(style, frame) + var half_text: Vector2 = text_rect_size * 0.5 + var text_rect := Rect2(-half_text, text_rect_size) + var corners: Array[Vector2] = [ + Vector2(-half_text.x, -half_text.y), + Vector2(half_text.x, -half_text.y), + Vector2(half_text.x, half_text.y), + Vector2(-half_text.x, half_text.y), + ] + # Shout bursts and thought clouds are concave: keep the outline itself out + # of the text area as well as checking the text rectangle's perimeter. + for outline_index: int in outline.size(): + var outline_start: Vector2 = outline[outline_index] + var outline_end: Vector2 = outline[(outline_index + 1) % outline.size()] + if text_rect.has_point(outline_start): + return false + for rect_edge: int in corners.size(): + var intersection: Variant = Geometry2D.segment_intersects_segment( + outline_start, + outline_end, + corners[rect_edge], + corners[(rect_edge + 1) % corners.size()] + ) + if intersection != null: + return false + const EDGE_SAMPLES: int = 8 + for edge_index: int in corners.size(): + var start: Vector2 = corners[edge_index] + var end: Vector2 = corners[(edge_index + 1) % corners.size()] + for sample_index: int in range(EDGE_SAMPLES + 1): + var point: Vector2 = start.lerp(end, float(sample_index) / float(EDGE_SAMPLES)) + if not Geometry2D.is_point_in_polygon(point, outline): + return false + return true + + +func _padding_for_visual(visual: Dictionary) -> Vector2: + return SHOUT_PADDING if String(visual.get("bubble_style", "talk")) == "shout" else PADDING + + +func _ensure_labels(count: int) -> void: + while _labels.size() < count: + var label := RichTextLabel.new() + label.bbcode_enabled = false + label.fit_content = false + label.scroll_active = false + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.meta_underlined = false + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_color_override("default_color", Color("#202020")) + label.add_theme_font_override("normal_font", _theme.font()) + label.install_effect(_word_effect) + label.meta_clicked.connect(_on_label_meta_clicked) + label.gui_input.connect(_on_label_gui_input.bind(label)) + add_child(label) + _labels.append(label) + for i: int in _labels.size(): + _labels[i].visible = i < count + + +func _ensure_frame_layers(count: int) -> void: + while _frame_layers.size() < count: + var index: int = _frame_layers.size() + var frame_layer := BubbleFrameLayer.new() + frame_layer.name = "SpeechBubbleFrame%d" % (index + 1) + frame_layer.renderer = self + frame_layer.bubble_index = index + add_child(frame_layer) + _frame_layers.append(frame_layer) + func _draw() -> void: - if _text.is_empty(): + if _suppressed: return - var font := ThemeDB.fallback_font - var text_size := font.get_string_size(_text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE) - var box_size := text_size + PADDING * 2.0 - var box := Rect2(Vector2(-box_size.x * 0.5, -TAIL_HEIGHT - box_size.y), box_size) - draw_style_box(_bg_style, box) - draw_colored_polygon(PackedVector2Array([ - Vector2(-TAIL_WIDTH * 0.5, -TAIL_HEIGHT), - Vector2(TAIL_WIDTH * 0.5, -TAIL_HEIGHT), - Vector2(0.0, 0.0), - ]), BG_COLOR) - var baseline := Vector2(box.position.x + PADDING.x, box.position.y + PADDING.y + font.get_ascent(FONT_SIZE)) - draw_string(font, baseline, _text, HORIZONTAL_ALIGNMENT_LEFT, MAX_WIDTH, FONT_SIZE, TEXT_COLOR) + if _comic_chain_links_visible(): + for layout: Dictionary in _layouts: + var bubble_index: int = int(layout.get("index", -1)) + if bubble_index < 0: + continue + var frame: Rect2 = _layout_frame(layout) + frame.position += _display_offset + var visual: Dictionary = layout.get("visual", {}) + var style: String = String(visual.get("bubble_style", "talk")) + var colors: Dictionary = _style_colors(style) + var guide_only: bool = bool(layout.get("guide_only", false)) + var ghost: bool = _placement_ghost or guide_only + var ghost_fill_alpha: float = 1.0 if guide_only and not _placement_ghost else 0.15 + var target: Vector2 = _comic_chain_connector_target(bubble_index, frame) + if style in ["talk", "shout", "whisper"]: + draw_speech_balloon_to_point(self, style, frame, colors["fill"], colors["outline"], target, ghost, 3, bubble_index > 0, ghost_fill_alpha) + elif style == "think": + _draw_thought_connector(self, frame, target, colors["fill"], ghost) + return +func _draw_bubble_frame_layer(canvas: CanvasItem, bubble_index: int) -> void: + if _suppressed: + return + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) != bubble_index: + continue + var frame: Rect2 = _layout_frame(layout) + frame.position += _display_offset + var visual: Dictionary = layout["visual"] + var style: String = String(visual.get("bubble_style", "talk")) + var colors: Dictionary = _style_colors(style) + var guide_only: bool = bool(layout.get("guide_only", false)) + var ghost: bool = _placement_ghost or guide_only + var ghost_fill_alpha: float = 1.0 if guide_only and not _placement_ghost else 0.15 + var chain_connector: bool = _comic_chain_links_visible() + var show_tail: bool = bool(layout.get("show_tail", false)) and not bool(layout.get("guide_only", false)) and not chain_connector + if show_tail and style in ["talk", "shout", "whisper"]: + draw_speech_balloon(canvas, style, frame, colors["fill"], colors["outline"], _head_point_in_bubble(), _local_head_radius(), ghost, 3, ghost_fill_alpha) + else: + draw_style_shape(canvas, style, frame, colors["fill"], colors["outline"], ghost, 3, ghost_fill_alpha) + if show_tail and style == "think": + var head_point: Vector2 = _head_point_in_bubble() + var rim: Vector2 = _head_rim_point(frame, head_point, _local_head_radius()) + _draw_thought_connector(canvas, frame, rim, colors["fill"], ghost) + return + + +func _comic_chain_links_visible() -> bool: + return bool(_action.get("connect_bubbles", false)) or String(_action.get("arrangement", "together")) == "comic_chain" + + +func _comic_chain_connector_target(bubble_index: int, current_frame: Rect2) -> Vector2: + var head_point: Vector2 = _head_point_in_bubble() + if not _comic_chain_links_visible() or bubble_index <= 0: + return _head_rim_point(current_frame, head_point, _local_head_radius()) + for previous_layout: Dictionary in _layouts: + if int(previous_layout.get("index", -1)) != bubble_index - 1: + continue + var previous_frame: Rect2 = _layout_frame(previous_layout) + previous_frame.position += _display_offset + var previous_visual: Dictionary = previous_layout.get("visual", {}) + var previous_style: String = String(previous_visual.get("bubble_style", "talk")) + var boundary: Vector2 = _style_boundary_point(previous_style, previous_frame, current_frame.get_center()) + var inward: Vector2 = (previous_frame.get_center() - current_frame.get_center()).normalized() + return boundary + inward * 10.0 + return _head_rim_point(current_frame, head_point, _local_head_radius()) + + +func _style_boundary_point(style: String, frame: Rect2, target: Vector2) -> Vector2: + var contour: PackedVector2Array = _frame_outline_points(style, frame) + var boundary_hit: Dictionary = _ray_polygon_boundary(contour, frame.get_center(), target) + if not boundary_hit.is_empty(): + return boundary_hit["point"] + return _ray_rect_boundary(frame, target) + + +func _draw_thought_connector(canvas: CanvasItem, frame: Rect2, target: Vector2, fill: Color, ghost: bool = false) -> void: + var start: Vector2 = _style_boundary_point("think", frame, target) + var gap: Vector2 = target - start + if gap.length() < 8.0: + return + var draw_fill: Color = fill + if ghost: + draw_fill.a = 0.15 + var ratios: Array[float] = [0.12, 0.49, 0.84] + var radii: Array[float] = [7.0, 5.0, 3.5] + for index: int in ratios.size(): + canvas.draw_circle(start + gap * ratios[index], radii[index], draw_fill) + + +func _draw_thought_connector_halo(canvas: CanvasItem, frame: Rect2, target: Vector2) -> void: + var start: Vector2 = _style_boundary_point("think", frame, target) + var gap: Vector2 = target - start + if gap.length() < 8.0: + return + var ratios: Array[float] = [0.12, 0.49, 0.84] + var radii: Array[float] = [7.0, 5.0, 3.5] + for index: int in ratios.size(): + var center: Vector2 = start + gap * ratios[index] + canvas.draw_arc(center, radii[index] + 4.0, 0.0, TAU, 24, Color("#171B24"), 5.0, true) + canvas.draw_arc(center, radii[index] + 2.0, 0.0, TAU, 24, Color("#FFD84D"), 3.0, true) + + +func _draw_editor_overlay_layer(canvas: CanvasItem) -> void: + if _suppressed: + return + _draw_selected_token_marker(canvas) + if _preview and _static_editor_preview and _selected_preview_bubble >= 0: + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) != _selected_preview_bubble: + continue + var selected_frame: Rect2 = _layout_frame(layout) + selected_frame.position += _display_offset + var selected_visual: Dictionary = layout.get("visual", {}) + var selected_style: String = String(selected_visual.get("bubble_style", "talk")) + var selected_chain_connector: bool = _comic_chain_links_visible() and selected_style in ["talk", "shout", "whisper"] + var selected_has_tail: bool = (bool(layout.get("show_tail", false)) or selected_chain_connector) and selected_style in ["talk", "shout", "whisper"] + if selected_has_tail: + if selected_chain_connector: + var chain_target: Vector2 = _comic_chain_connector_target(_selected_preview_bubble, selected_frame) + draw_speech_balloon_to_point(canvas, selected_style, selected_frame.grow(5.0), Color(0.0, 0.0, 0.0, 0.0), Color("#171B24"), chain_target, false, 9, _selected_preview_bubble > 0) + draw_speech_balloon_to_point(canvas, selected_style, selected_frame.grow(3.0), Color(0.0, 0.0, 0.0, 0.0), Color("#FFD84D"), chain_target, false, 4, _selected_preview_bubble > 0) + else: + draw_speech_balloon(canvas, selected_style, selected_frame.grow(5.0), Color(0.0, 0.0, 0.0, 0.0), Color("#171B24"), _head_point_in_bubble(), _local_head_radius(), false, 9) + draw_speech_balloon(canvas, selected_style, selected_frame.grow(3.0), Color(0.0, 0.0, 0.0, 0.0), Color("#FFD84D"), _head_point_in_bubble(), _local_head_radius(), false, 4) + else: + draw_style_shape(canvas, selected_style, selected_frame.grow(5.0), Color(0.0, 0.0, 0.0, 0.0), Color("#171B24"), false, 9) + draw_style_shape(canvas, selected_style, selected_frame.grow(3.0), Color(0.0, 0.0, 0.0, 0.0), Color("#FFD84D"), false, 4) + if _comic_chain_links_visible() and selected_style == "think": + var thought_target: Vector2 = _comic_chain_connector_target(_selected_preview_bubble, selected_frame) + _draw_thought_connector_halo(canvas, selected_frame, thought_target) + break + if _preview and _static_editor_preview: + _draw_order_badges(canvas) + + +func _draw_order_badges(canvas: CanvasItem) -> void: + var font: Font = _theme.font() if _theme != null else ThemeDB.fallback_font + var badge_scale: float = _badge_canvas_scale() + var radius: float = ORDER_BADGE_RADIUS / badge_scale + var font_size: int = maxi(1, roundi(30.0 / badge_scale)) + var badge_centers: Dictionary = _order_badge_centers() + for layout: Dictionary in _layouts: + var index: int = int(layout.get("index", -1)) + if index < 0: + continue + var frame: Rect2 = _layout_frame(layout) + var center: Vector2 = badge_centers.get(index, frame.position + _display_offset) + var is_selected: bool = index == _selected_preview_bubble + var badge_fill: Color = Color("#FFD84D") if is_selected else Color("#17202B") + var badge_ring: Color = Color("#17202B") if is_selected else Color("#FFD84D") + var badge_text: Color = Color("#17202B") if is_selected else Color.WHITE + canvas.draw_circle(center, radius, badge_fill) + canvas.draw_arc(center, radius, 0.0, TAU, 32, badge_ring, 3.5 / badge_scale, true) + var number: String = str(index + 1) + var text_width: float = font.get_string_size(number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x + var ascent: float = font.get_ascent(font_size) + canvas.draw_string(font, center + Vector2(-text_width * 0.5, ascent * 0.34), number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, badge_text) + + +func _draw_selected_token_marker(canvas: CanvasItem) -> void: + if not _preview or not _static_editor_preview or _selected_token.x < 0 or _selected_token.y < 0 or _selected_token.z <= _selected_token.y: + return + for layout: Dictionary in _layouts: + if int(layout.get("index", -1)) != _selected_token.x: + continue + var label: RichTextLabel = layout.get("label") as RichTextLabel + if label == null or not is_instance_valid(label): + return + var label_to_bubble: Transform2D = get_global_transform_with_canvas().affine_inverse() * label.get_global_transform_with_canvas() + var bubble: Dictionary = _bubble_data(_selected_token.x) + var text: String = String(bubble.get("text", "")) + var visual: Dictionary = layout.get("visual", {}) + for label_rect: Rect2 in _selected_token_line_rects(label, text, visual, _selected_token.y, _selected_token.z): + var top_left: Vector2 = label_to_bubble * label_rect.position + var top_right: Vector2 = label_to_bubble * Vector2(label_rect.end.x, label_rect.position.y) + var bottom_left: Vector2 = label_to_bubble * Vector2(label_rect.position.x, label_rect.end.y) + var bottom_right: Vector2 = label_to_bubble * label_rect.end + var bubble_rect: Rect2 = Rect2(top_left, Vector2.ZERO).expand(top_right).expand(bottom_left).expand(bottom_right) + var marker_rect: Rect2 = bubble_rect + marker_rect.position -= Vector2(4.0, 5.0) + marker_rect.size += Vector2(8.0, 10.0) + var halo: StyleBoxFlat = StyleBoxFlat.new() + halo.bg_color = Color(0.0, 0.0, 0.0, 0.0) + halo.border_color = Color("#17202B") + halo.set_border_width_all(4) + halo.set_corner_radius_all(7) + canvas.draw_style_box(halo, marker_rect.grow(1.0)) + var marker: StyleBoxFlat = StyleBoxFlat.new() + marker.bg_color = Color("#FFD84D") + marker.bg_color.a = 0.16 + marker.border_color = Color("#FFD84D") + marker.set_border_width_all(2) + marker.set_corner_radius_all(6) + canvas.draw_style_box(marker, marker_rect) + return + + +func _selected_token_line_rects(label: RichTextLabel, text: String, visual: Dictionary, start: int, end: int) -> Array[Rect2]: + var result: Array[Rect2] = [] + var line_spans: Dictionary = {} + var upper: int = mini(end, mini(text.length(), label.get_total_character_count())) + for character_index: int in range(maxi(0, start), upper): + var line_index: int = label.get_character_line(character_index) + if line_spans.has(line_index): + var current_span: Vector2i = line_spans[line_index] + current_span.x = mini(current_span.x, character_index) + current_span.y = maxi(current_span.y, character_index + 1) + line_spans[line_index] = current_span + else: + line_spans[line_index] = Vector2i(character_index, character_index + 1) + for line_key: Variant in line_spans: + var line_index: int = int(line_key) + var visible_range: Vector2i = label.get_line_range(line_index) + var selected_range: Vector2i = line_spans[line_key] + var line_start: int = clampi(visible_range.x, 0, text.length()) + var selected_start: int = maxi(line_start, selected_range.x) + var selected_end: int = mini(text.length(), selected_range.y) + if selected_end <= selected_start: + continue + var line_center_offset: float = maxf(0.0, (label.size.x - float(label.get_line_width(line_index))) * 0.5) + var x: float = line_center_offset + _measure_styled_text_range(text, visual, line_start, selected_start) + var width: float = _measure_styled_text_range(text, visual, selected_start, selected_end) + if width <= 0.0: + continue + var line_height: float = float(label.get_line_height(line_index)) + var selected_text_height: float = _text_height_for_range(text, visual, selected_start, selected_end) + var marker_height: float = minf(line_height, maxf(16.0, selected_text_height + 4.0)) + # RichTextLabel applies its vertical-alignment offset while drawing, but + # get_line_offset() returns the line's unaligned content offset. Mirror the + # centering here so the editor marker follows the text when the bubble has + # enough spare height to vertically center multiple lines. + var text_vertical_offset: float = _label_text_vertical_offset(label) + var marker_y: float = text_vertical_offset + label.get_line_offset(line_index) + line_height - marker_height + result.append(Rect2(Vector2(x, marker_y), Vector2(width, marker_height))) + return result + + +func _label_text_vertical_offset(label: RichTextLabel) -> float: + var text_top: float = 0.0 + var text_height: float = label.size.y + var normal_style: StyleBox = label.get_theme_stylebox("normal") + if normal_style != null: + text_top = normal_style.get_offset().y + text_height -= normal_style.get_minimum_size().y + var content_height: float = float(label.get_content_height()) + var alignment_offset: float = 0.0 + if label.vertical_alignment == VERTICAL_ALIGNMENT_CENTER and text_height > content_height: + alignment_offset = (text_height - content_height) * 0.5 + return text_top + alignment_offset + + +func _text_height_for_range(text: String, visual: Dictionary, start: int, end: int) -> float: + var styles: Array[Dictionary] = _typed_word_styles(visual.get("word_styles", [])) + var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, styles) + var result: float = 0.0 + for run: Dictionary in runs: + var run_start: int = int(run.get("start", 0)) + var run_end: int = int(run.get("end", 0)) + var fragment_start: int = maxi(start, run_start) + var fragment_end: int = mini(end, run_end) + if fragment_end <= fragment_start: + continue + var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))) + var size_id: String = String(run.get("size_preset", visual.get("size_preset", "medium"))) + var font: Font = _theme.speech_font(font_id) + var font_size: int = _font_size(size_id, font_id) + var fragment: String = text.substr(fragment_start, fragment_end - fragment_start) + var measured_height: float = font.get_string_size(fragment, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).y + result = maxf(result, measured_height) + if result > 0: + return result + var fallback_font_id: String = String(visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)) + var fallback_size: int = _font_size(String(visual.get("size_preset", "medium")), fallback_font_id) + return _theme.speech_font(fallback_font_id).get_height(fallback_size) + + +func _measure_styled_text_range(text: String, visual: Dictionary, start: int, end: int) -> float: + if end <= start: + return 0.0 + var styles: Array[Dictionary] = _typed_word_styles(visual.get("word_styles", [])) + var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, styles) + var width: float = 0.0 + for run: Dictionary in runs: + var run_start: int = int(run.get("start", 0)) + var run_end: int = int(run.get("end", 0)) + var fragment_start: int = maxi(start, run_start) + var fragment_end: int = mini(end, run_end) + if fragment_end <= fragment_start: + continue + var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))) + var size_id: String = String(run.get("size_preset", visual.get("size_preset", "medium"))) + var font: Font = _theme.speech_font(font_id) + var fragment: String = text.substr(fragment_start, fragment_end - fragment_start) + width += font.get_string_size(fragment, HORIZONTAL_ALIGNMENT_LEFT, -1.0, _font_size(size_id, font_id)).x + return width + + +static func draw_style_shape(canvas: CanvasItem, style: String, frame: Rect2, fill: Color, outline: Color, ghost: bool = false, border_width: int = 3, ghost_fill_alpha: float = 0.15) -> void: + var draw_fill: Color = fill + var draw_outline: Color = outline + if ghost: + draw_fill.a = ghost_fill_alpha + draw_outline.a = 0.95 + if style == "whisper": + draw_fill.a *= 0.78 + if style == "shout": + var burst: PackedVector2Array = _burst_points(frame) + canvas.draw_colored_polygon(burst, draw_fill) + var closed_burst: PackedVector2Array = burst.duplicate() + closed_burst.append(burst[0]) + canvas.draw_polyline(closed_burst, draw_outline, float(border_width), true) + elif style == "think": + var cloud: PackedVector2Array = _cloud_points(frame) + canvas.draw_colored_polygon(cloud, draw_fill) + var closed_cloud: PackedVector2Array = cloud.duplicate() + closed_cloud.append(cloud[0]) + canvas.draw_polyline(closed_cloud, draw_outline, float(border_width), true) + else: + var box := StyleBoxFlat.new() + box.bg_color = draw_fill + box.border_color = draw_outline + box.set_border_width_all(4 if style == "sfx" else border_width) + box.set_corner_radius_all(16 if style == "talk" else 4) + canvas.draw_style_box(box, frame) + if style == "whisper": + _draw_dashed_rect_on(canvas, frame.grow(-2.0), draw_outline, float(border_width), 8.0) + if ghost: + _draw_dashed_rect_on(canvas, frame, draw_outline, 2.0, 8.0) + + +static func draw_speech_balloon(canvas: CanvasItem, style: String, frame: Rect2, fill: Color, outline: Color, head_center: Vector2, head_radius: float, ghost: bool = false, border_width: int = 3, ghost_fill_alpha: float = 0.15) -> void: + var direction: Vector2 = (head_center - frame.get_center()).normalized() + if direction.is_zero_approx(): + direction = Vector2.DOWN + var tail_tip: Vector2 = head_center - direction * (head_radius + 5.0) + draw_speech_balloon_to_point(canvas, style, frame, fill, outline, tail_tip, ghost, border_width, false, ghost_fill_alpha) + + +static func draw_speech_balloon_to_point(canvas: CanvasItem, style: String, frame: Rect2, fill: Color, outline: Color, tail_tip: Vector2, ghost: bool = false, border_width: int = 3, blunt_tip: bool = false, ghost_fill_alpha: float = 0.15) -> void: + var body_contour: PackedVector2Array = _frame_outline_points(style, frame) + var tail: Dictionary = _speaker_tail_geometry(body_contour, frame.get_center(), tail_tip, style == "shout", 8.0 if blunt_tip else 0.0) + var contour: PackedVector2Array = tail["contour"] + if contour.size() < 3: + draw_style_shape(canvas, style, frame, fill, outline, ghost, border_width, ghost_fill_alpha) + return + var draw_fill: Color = fill + var draw_outline: Color = outline + if ghost: + draw_fill.a = ghost_fill_alpha + draw_outline.a = 0.95 + if style == "whisper": + draw_fill.a *= 0.78 + _draw_speaker_tail_fill(canvas, tail.get("left_edge", PackedVector2Array()), tail.get("right_edge", PackedVector2Array()), draw_fill) + _draw_style_fill(canvas, style, frame, draw_fill) + var closed_contour: PackedVector2Array = contour.duplicate() + closed_contour.append(contour[0]) + canvas.draw_polyline(closed_contour, draw_outline, float(border_width), true) + if style == "whisper": + _draw_dashed_rect_on(canvas, frame.grow(-4.0), draw_outline, float(border_width), 8.0) + if ghost: + _draw_dashed_polygon_on(canvas, contour, draw_outline, 2.0, 8.0) + + +static func _draw_style_fill(canvas: CanvasItem, style: String, frame: Rect2, fill: Color) -> void: + if style == "shout": + canvas.draw_colored_polygon(_burst_points(frame), fill) + elif style == "think": + canvas.draw_colored_polygon(_cloud_points(frame), fill) + else: + var box := StyleBoxFlat.new() + box.bg_color = fill + box.border_color = fill + box.set_border_width_all(0) + box.set_corner_radius_all(16 if style == "talk" else 4) + canvas.draw_style_box(box, frame) + + +static func _draw_speaker_tail_fill(canvas: CanvasItem, left_edge: PackedVector2Array, right_edge: PackedVector2Array, fill: Color) -> void: + if left_edge.size() < 2 or right_edge.size() != left_edge.size(): + return + for index: int in range(left_edge.size() - 1): + var left_start: Vector2 = left_edge[index] + var left_end: Vector2 = left_edge[index + 1] + var right_start: Vector2 = right_edge[index] + var right_end: Vector2 = right_edge[index + 1] + _draw_fill_triangle(canvas, left_start, left_end, right_end, fill) + _draw_fill_triangle(canvas, left_start, right_end, right_start, fill) + + +static func _draw_fill_triangle(canvas: CanvasItem, a: Vector2, b: Vector2, c: Vector2, fill: Color) -> void: + if absf((b - a).cross(c - a)) < 0.01: + return + canvas.draw_colored_polygon(PackedVector2Array([a, b, c]), fill) + + +static func _frame_outline_points(style: String, frame: Rect2) -> PackedVector2Array: + if style == "shout": + return _burst_points(frame) + if style == "think": + return _cloud_points(frame) + var corner_radius: float = 16.0 if style == "talk" else 4.0 + return _rounded_rect_points(frame, corner_radius, 5) + + +static func _rounded_rect_points(frame: Rect2, requested_radius: float, samples_per_corner: int) -> PackedVector2Array: + var points := PackedVector2Array() + var radius: float = clampf(requested_radius, 0.0, minf(frame.size.x, frame.size.y) * 0.5) + var centers: Array[Vector2] = [ + Vector2(frame.end.x - radius, frame.position.y + radius), + Vector2(frame.end.x - radius, frame.end.y - radius), + Vector2(frame.position.x + radius, frame.end.y - radius), + Vector2(frame.position.x + radius, frame.position.y + radius), + ] + var starts: Array[float] = [-PI * 0.5, 0.0, PI * 0.5, PI] + var samples: int = maxi(2, samples_per_corner) + for corner_index: int in range(4): + for sample_index: int in range(samples + 1): + var angle: float = starts[corner_index] + PI * 0.5 * float(sample_index) / float(samples) + points.append(centers[corner_index] + Vector2(cos(angle), sin(angle)) * radius) + return points + + +static func _speaker_tail_geometry(contour: PackedVector2Array, origin: Vector2, tip: Vector2, shout: bool, tip_half_width: float = 0.0) -> Dictionary: + var no_tail: Dictionary = {"contour": contour, "left_edge": PackedVector2Array(), "right_edge": PackedVector2Array()} + if contour.size() < 3 or origin.distance_to(tip) < 12.0: + return no_tail + var boundary_hit: Dictionary = _ray_polygon_boundary(contour, origin, tip) + if boundary_hit.is_empty(): + return no_tail + var boundary: Vector2 = boundary_hit["point"] + if boundary.distance_to(tip) < 12.0: + return no_tail + var cumulative: Array[float] = [0.0] + for index: int in range(contour.size()): + cumulative.append(cumulative.back() + contour[index].distance_to(contour[(index + 1) % contour.size()])) + var perimeter: float = cumulative.back() + if perimeter <= 0.001: + return no_tail + var edge_index: int = int(boundary_hit["edge_index"]) + var edge_length: float = cumulative[edge_index + 1] - cumulative[edge_index] + var hit_distance: float = cumulative[edge_index] + edge_length * float(boundary_hit["edge_t"]) + var half_width: float = 12.0 if shout else 14.0 + var base_start_distance: float = hit_distance - half_width + var base_end_distance: float = hit_distance + half_width + var base_start: Vector2 = _polygon_point_at_distance(contour, cumulative, base_start_distance) + var base_end: Vector2 = _polygon_point_at_distance(contour, cumulative, base_end_distance) + var base_tangent: Vector2 = (base_end - base_start).normalized() + if base_tangent.is_zero_approx(): + return no_tail + var outward: Vector2 = base_tangent.rotated(-PI * 0.5) + var tail_direction: Vector2 = (tip - boundary).normalized() + var tail_length: float = boundary.distance_to(tip) + var control_one: Vector2 = boundary + outward * minf(tail_length * 0.44, 52.0) + var control_two: Vector2 = tip - tail_direction * minf(tail_length * 0.22, 30.0) + var sample_count: int = 6 if shout else 10 + var left_edge := PackedVector2Array([base_start]) + var right_edge := PackedVector2Array([base_end]) + for sample_index: int in range(1, sample_count + 1): + var t: float = float(sample_index) / float(sample_count) + left_edge.append(_tail_edge_point(boundary, tip, control_one, control_two, half_width, t, -1.0, shout, sample_index, sample_count, tip_half_width)) + right_edge.append(_tail_edge_point(boundary, tip, control_one, control_two, half_width, t, 1.0, false, sample_index, sample_count, tip_half_width)) + var result := PackedVector2Array([base_end]) + var start_distance: float = base_end_distance + var end_distance: float = base_start_distance + perimeter + if start_distance >= perimeter: + start_distance -= perimeter + end_distance -= perimeter + for cycle: int in range(2): + for vertex_index: int in range(contour.size()): + var vertex_distance: float = cumulative[vertex_index] + float(cycle) * perimeter + if vertex_distance > start_distance + 0.001 and vertex_distance < end_distance - 0.001: + result.append(contour[vertex_index]) + result.append(base_start) + for sample_index: int in range(1, sample_count + 1): + result.append(left_edge[sample_index]) + for sample_index: int in range(sample_count, -1, -1): + result.append(right_edge[sample_index]) + return {"contour": result, "left_edge": left_edge, "right_edge": right_edge} + + +static func _tail_edge_point(start: Vector2, end: Vector2, control_one: Vector2, control_two: Vector2, half_width: float, t: float, side: float, shout_kink: bool, sample_index: int, sample_count: int, tip_half_width: float = 0.0) -> Vector2: + var center: Vector2 = _cubic_bezier_point(start, control_one, control_two, end, t) + var tangent: Vector2 = _cubic_bezier_tangent(start, control_one, control_two, end, t).normalized() + if tangent.is_zero_approx(): + tangent = (end - start).normalized() + var normal: Vector2 = tangent.rotated(PI * 0.5) + var tapered_width: float = half_width * pow(maxf(0.0, 1.0 - t), 1.15) + var half_at_t: float = lerpf(tapered_width, tip_half_width, pow(t, 4.0)) + var result: Vector2 = center + normal * half_at_t * side + if shout_kink and sample_index == maxi(1, roundi(float(sample_count) * 0.42)): + result += normal * 4.0 + return result + + +static func _cubic_bezier_point(start: Vector2, control_one: Vector2, control_two: Vector2, end: Vector2, t: float) -> Vector2: + var inverse_t: float = 1.0 - t + return start * inverse_t * inverse_t * inverse_t + control_one * 3.0 * inverse_t * inverse_t * t + control_two * 3.0 * inverse_t * t * t + end * t * t * t + + +static func _cubic_bezier_tangent(start: Vector2, control_one: Vector2, control_two: Vector2, end: Vector2, t: float) -> Vector2: + var inverse_t: float = 1.0 - t + return (control_one - start) * 3.0 * inverse_t * inverse_t + (control_two - control_one) * 6.0 * inverse_t * t + (end - control_two) * 3.0 * t * t + + +static func _ray_polygon_boundary(contour: PackedVector2Array, origin: Vector2, target: Vector2) -> Dictionary: + var direction: Vector2 = (target - origin).normalized() + if direction.is_zero_approx(): + return {} + var nearest_distance: float = INF + var nearest_edge: int = -1 + var nearest_t: float = 0.0 + for edge_index: int in range(contour.size()): + var edge_start: Vector2 = contour[edge_index] + var edge_end: Vector2 = contour[(edge_index + 1) % contour.size()] + var edge: Vector2 = edge_end - edge_start + var denominator: float = direction.x * edge.y - direction.y * edge.x + if absf(denominator) < 0.00001: + continue + var delta: Vector2 = edge_start - origin + var ray_distance: float = (delta.x * edge.y - delta.y * edge.x) / denominator + var edge_t: float = (delta.x * direction.y - delta.y * direction.x) / denominator + if ray_distance >= 0.0 and edge_t >= 0.0 and edge_t <= 1.0 and ray_distance < nearest_distance: + nearest_distance = ray_distance + nearest_edge = edge_index + nearest_t = edge_t + if nearest_edge < 0: + return {} + return { + "point": origin + direction * nearest_distance, + "edge_index": nearest_edge, + "edge_t": nearest_t, + } + + +static func _polygon_point_at_distance(contour: PackedVector2Array, cumulative: Array[float], distance: float) -> Vector2: + var perimeter: float = cumulative.back() + if perimeter <= 0.001: + return contour[0] if not contour.is_empty() else Vector2.ZERO + var wrapped_distance: float = fposmod(distance, perimeter) + for edge_index: int in range(contour.size()): + var edge_start_distance: float = cumulative[edge_index] + var edge_end_distance: float = cumulative[edge_index + 1] + if wrapped_distance > edge_end_distance and edge_index < contour.size() - 1: + continue + var edge_length: float = edge_end_distance - edge_start_distance + var ratio: float = clampf((wrapped_distance - edge_start_distance) / maxf(edge_length, 0.001), 0.0, 1.0) + return contour[edge_index].lerp(contour[(edge_index + 1) % contour.size()], ratio) + return contour[0] + + +static func _draw_dashed_polygon_on(canvas: CanvasItem, contour: PackedVector2Array, color: Color, width: float, dash: float) -> void: + for index: int in range(contour.size()): + canvas.draw_dashed_line(contour[index], contour[(index + 1) % contour.size()], color, width, dash) + + +static func _ray_rect_boundary(rect: Rect2, target: Vector2) -> Vector2: + var center: Vector2 = rect.get_center() + var direction: Vector2 = (target - center).normalized() + if direction.is_zero_approx(): + return Vector2(center.x, rect.end.y) + var half: Vector2 = rect.size * 0.5 + var x_scale: float = half.x / maxf(absf(direction.x), 0.0001) + var y_scale: float = half.y / maxf(absf(direction.y), 0.0001) + return center + direction * minf(x_scale, y_scale) + + +static func _burst_points(frame: Rect2) -> PackedVector2Array: + var points := PackedVector2Array() + var center: Vector2 = frame.get_center() + for index: int in 24: + var angle: float = TAU * float(index) / 24.0 - PI * 0.5 + var radius: float = 1.0 if index % 2 == 0 else 0.88 + points.append(center + Vector2(cos(angle) * frame.size.x * 0.5 * radius, sin(angle) * frame.size.y * 0.5 * radius)) + return points + + +static func _cloud_points(frame: Rect2) -> PackedVector2Array: + var points := PackedVector2Array() + var center: Vector2 = frame.get_center() + for index: int in 40: + var angle: float = TAU * float(index) / 40.0 + var scallop: float = 1.0 + 0.08 * cos(angle * 8.0) + points.append(center + Vector2(cos(angle) * frame.size.x * 0.5 * scallop, sin(angle) * frame.size.y * 0.5 * scallop)) + return points + + +static func _draw_dashed_rect_on(canvas: CanvasItem, rect: Rect2, color: Color, width: float, dash: float) -> void: + for side: int in 4: + var start: Vector2 = rect.position + var end: Vector2 = rect.end + match side: + 0: end = Vector2(rect.end.x, rect.position.y) + 1: start = Vector2(rect.end.x, rect.position.y); end = rect.end + 2: start = rect.end; end = Vector2(rect.position.x, rect.end.y) + 3: start = Vector2(rect.position.x, rect.end.y); end = rect.position + canvas.draw_dashed_line(start, end, color, width, dash) + + +func _head_point_in_bubble() -> Vector2: + var rig: Node2D = get_parent() as Node2D + return to_local(rig.to_global(_head_center_rig)) if rig != null else -position + + +func _local_head_radius() -> float: + return _head_radius_rig / maxf(absf(scale.x), 0.001) + + +func _head_rim_point(frame: Rect2, head_center: Vector2, head_radius: float) -> Vector2: + var direction: Vector2 = (frame.get_center() - head_center).normalized() + if direction.is_zero_approx(): + direction = Vector2.UP + return head_center + direction * (head_radius + 5.0) + +func _draw_burst(frame: Rect2, fill: Color, outline: Color) -> void: + var points := PackedVector2Array() + var center: Vector2 = frame.get_center() + var radius_x: float = frame.size.x * 0.5 + var radius_y: float = frame.size.y * 0.5 + for i: int in 24: + var angle: float = TAU * float(i) / 24.0 - PI * 0.5 + var radius: float = 1.0 if i % 2 == 0 else 0.88 + points.append(center + Vector2(cos(angle) * radius_x * radius, sin(angle) * radius_y * radius)) + draw_colored_polygon(points, fill) + var closed := points.duplicate() + closed.append(points[0]) + draw_polyline(closed, outline, 4.0, true) + + +func _draw_cloud(frame: Rect2, fill: Color, outline: Color) -> void: + var points := PackedVector2Array() + var center: Vector2 = frame.get_center() + for i: int in 40: + var angle: float = TAU * float(i) / 40.0 + var scallop: float = 1.0 + 0.08 * cos(angle * 8.0) + points.append(center + Vector2(cos(angle) * frame.size.x * 0.5 * scallop, sin(angle) * frame.size.y * 0.5 * scallop)) + draw_colored_polygon(points, fill) + var closed := points.duplicate() + closed.append(points[0]) + draw_polyline(closed, outline, 3.0, true) + + +func _draw_dashed_rect(rect: Rect2, color: Color, width: float, dash: float) -> void: + for side: int in 4: + var start: Vector2 = rect.position + var end: Vector2 = rect.end + match side: + 0: end = Vector2(rect.end.x, rect.position.y) + 1: start = Vector2(rect.end.x, rect.position.y); end = rect.end + 2: start = rect.end; end = Vector2(rect.position.x, rect.end.y) + 3: start = Vector2(rect.position.x, rect.end.y); end = rect.position + draw_dashed_line(start, end, color, width, dash) + + +func _style_colors(style: String) -> Dictionary: + var fallback: Dictionary = { + "talk": {"fill": Color("#FFFDF6"), "outline": Color("#202020")}, + "shout": {"fill": Color("#FFD642"), "outline": Color("#D92F26")}, + "think": {"fill": Color("#E9F5FF"), "outline": Color("#37698B")}, + "whisper": {"fill": Color("#F2F2F2"), "outline": Color("#6D7885")}, + "sfx": {"fill": Color("#FFDD39"), "outline": Color("#D92F26")}, + } + var fill: Color = fallback.get(style, fallback["talk"])["fill"] + var outline: Color = fallback.get(style, fallback["talk"])["outline"] + if _theme != null: + fill = _theme.color("speech.styles.%s.fill" % style, fill) + outline = _theme.color("speech.styles.%s.outline" % style, outline) + return {"fill": fill, "outline": outline} + + +func _render_label_text(label: RichTextLabel, text: String, visual: Dictionary, elapsed: float, bubble_index: int) -> void: + label.clear() + var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, _typed_word_styles(visual.get("word_styles", []))) + for run: Dictionary in runs: + var run_start: int = int(run.get("start", -1)) + var run_end: int = int(run.get("end", -1)) + var token_span: Vector2i = SPEECH_PAYLOAD.token_span_at(text, run_start) + var is_token: bool = token_span == Vector2i(run_start, run_end) + if is_token: + label.push_meta({"bubble_index": bubble_index, "start": run_start, "end": run_end}, RichTextLabel.META_UNDERLINE_NEVER) + var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))) + var size_id: String = String(run.get("size_preset", visual.get("size_preset", "medium"))) + var color: String = String(run.get("color", visual.get("color", SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR))) + var effect: String = String(run.get("effect", visual.get("effect", "none"))) + var effect_clock: float = elapsed + var demo_matches_bubble: bool = _preview and _static_editor_preview and int(_editor_effect_preview.get("bubble_index", -1)) == bubble_index + var demo_all_tokens: bool = demo_matches_bubble and bool(_editor_effect_preview.get("all_tokens", false)) + var demo_matches_token: bool = demo_matches_bubble and int(_editor_effect_preview.get("start", -1)) == run_start and int(_editor_effect_preview.get("end", -1)) == run_end + var demo_applies: bool = demo_all_tokens or demo_matches_token + if demo_applies: + effect = String(_editor_effect_preview.get("effect", "")) + effect_clock = float(_editor_effect_preview.get("elapsed", 0.0)) + label.push_font(_theme.speech_font(font_id), _font_size(size_id, font_id)) + if SPEECH_PAYLOAD._valid_color(color): + label.push_color(Color(color)) + var animate_effect: bool = effect in ["shake", "wave", "bounce"] and (not (_preview and _static_editor_preview) or demo_applies) + if animate_effect: + label.push_customfx(_word_effect, {"kind": effect, "clock": effect_clock}) + label.add_text(String(run.get("text", ""))) + if animate_effect: + label.pop() + if SPEECH_PAYLOAD._valid_color(color): + label.pop() + label.pop() + if is_token: + label.pop() + + +func _on_label_meta_clicked(meta: Variant) -> void: + if not _preview or not _static_editor_preview or not meta is Dictionary: + return + var value: Dictionary = meta as Dictionary + var bubble_index: int = int(value.get("bubble_index", -1)) + var start: int = int(value.get("start", -1)) + var end: int = int(value.get("end", -1)) + if bubble_index < 0 or start < 0 or end <= start: + return + stage_token_selected.emit(bubble_index, start, end) + stage_word_selected.emit(bubble_index, start, end) + _label_meta_clicked[bubble_index] = true + + +func _on_label_gui_input(event: InputEvent, label: RichTextLabel) -> void: + if not _preview or not _static_editor_preview or not event is InputEventMouseButton: + return + var mouse_event: InputEventMouseButton = event as InputEventMouseButton + if mouse_event.button_index != MOUSE_BUTTON_LEFT: + return + var bubble_index: int = -1 + for layout: Dictionary in _layouts: + if layout.get("label") == label: + bubble_index = int(layout.get("index", -1)) + break + if bubble_index < 0: + return + if mouse_event.pressed: + _label_meta_clicked[bubble_index] = false + return + stage_text_tap_detected.emit(bubble_index) + var viewport_position: Vector2 = label.get_global_transform_with_canvas() * label.get_local_mouse_position() + var spans: Array[Vector2i] = text_line_tokens_at_viewport(bubble_index, viewport_position) + if not spans.is_empty(): + call_deferred("_emit_line_token_picker_if_needed", bubble_index, spans, viewport_position) + + +func _emit_line_token_picker_if_needed(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void: + if bool(_label_meta_clicked.get(bubble_index, false)): + _label_meta_clicked[bubble_index] = false + return + stage_line_tokens_requested.emit(bubble_index, spans, viewport_position) + + +func _typed_word_styles(raw: Variant) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + if raw is Array: + for item: Variant in raw: + if item is Dictionary: + result.append(item as Dictionary) + return result + + +func _bubble_data(index: int) -> Dictionary: + var bubbles: Array = _action.get("bubbles", []) + return bubbles[index] as Dictionary if index >= 0 and index < bubbles.size() and bubbles[index] is Dictionary else {} + + +func _escape_bbcode(value: String) -> String: + return value.replace("[", "[[") + + +func _font_size(size_preset: String, font_preset: String = SPEECH_PAYLOAD.BASE_FONT_PRESET) -> int: + if _theme != null: + return _theme.speech_font_size(font_preset, size_preset) + match size_preset: + "small": return 20 + "huge": return 40 + _: return 28 + + +func _interval_for(index: int) -> float: + var intervals: PackedFloat32Array = SPEECH_PAYLOAD.bubble_intervals(_action) + return intervals[index] if index >= 0 and index < intervals.size() else maxf(1.5, float(_action.get("duration", 2.0))) + + +func _visible_character_count(text: String, visual: Dictionary, elapsed: float, interval: float) -> int: + var reveal: String = String(visual.get("reveal", "instant")) + if reveal == "instant": + return -1 + var words: Array = [] + var regex := RegEx.new() + if regex.compile(SPEECH_PAYLOAD.WORD_REGEX) == OK: + words = regex.search_all(text) + var reveal_budget: float = minf((0.045 * float(text.length())) if reveal == "letter" else (0.20 * float(words.size())), interval * 0.55) + if reveal_budget <= 0.0: + return -1 + var fraction: float = clampf(elapsed / reveal_budget, 0.0, 1.0) + if reveal == "letter": + return int(floor(float(text.length()) * fraction)) + var allowed_words: int = int(floor(float(words.size()) * fraction)) + if allowed_words >= words.size(): + return -1 + return int(words[allowed_words].get_end()) if allowed_words > 0 else 0 diff --git a/specs/phase_13_speech_bubbles.md b/specs/phase_13_speech_bubbles.md new file mode 100644 index 0000000..923a26a --- /dev/null +++ b/specs/phase_13_speech_bubbles.md @@ -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. diff --git a/specs/speech_badge_stability.md b/specs/speech_badge_stability.md new file mode 100644 index 0000000..8645dd2 --- /dev/null +++ b/specs/speech_badge_stability.md @@ -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. diff --git a/specs/speech_bubble_layout_presets.md b/specs/speech_bubble_layout_presets.md new file mode 100644 index 0000000..3d37bb7 --- /dev/null +++ b/specs/speech_bubble_layout_presets.md @@ -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. diff --git a/specs/speech_bubble_visuals.md b/specs/speech_bubble_visuals.md new file mode 100644 index 0000000..32490a6 --- /dev/null +++ b/specs/speech_bubble_visuals.md @@ -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. diff --git a/specs/speech_comic_chain_layout.md b/specs/speech_comic_chain_layout.md new file mode 100644 index 0000000..5ca724d --- /dev/null +++ b/specs/speech_comic_chain_layout.md @@ -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. diff --git a/specs/speech_message_emoji_timing.md b/specs/speech_message_emoji_timing.md new file mode 100644 index 0000000..2ce44b8 --- /dev/null +++ b/specs/speech_message_emoji_timing.md @@ -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. diff --git a/specs/speech_stage_only_editor.md b/specs/speech_stage_only_editor.md new file mode 100644 index 0000000..e94fbd9 --- /dev/null +++ b/specs/speech_stage_only_editor.md @@ -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. diff --git a/specs/speech_style_toolbar.md b/specs/speech_style_toolbar.md new file mode 100644 index 0000000..52a98ab --- /dev/null +++ b/specs/speech_style_toolbar.md @@ -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. diff --git a/specs/speech_style_toolbar_live_edit.md b/specs/speech_style_toolbar_live_edit.md new file mode 100644 index 0000000..b8d82a3 --- /dev/null +++ b/specs/speech_style_toolbar_live_edit.md @@ -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. diff --git a/specs/speech_style_toolbar_refinements.md b/specs/speech_style_toolbar_refinements.md new file mode 100644 index 0000000..c52a84e --- /dev/null +++ b/specs/speech_style_toolbar_refinements.md @@ -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. diff --git a/specs/speech_whole_sequence_show.md b/specs/speech_whole_sequence_show.md new file mode 100644 index 0000000..24da7a3 --- /dev/null +++ b/specs/speech_whole_sequence_show.md @@ -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] │ +└──────────────────────────────┘ +``` diff --git a/speech_padding_syntax.log b/speech_padding_syntax.log new file mode 100644 index 0000000..ba22ab3 --- /dev/null +++ b/speech_padding_syntax.log @@ -0,0 +1,2 @@ +Godot Engine v4.7.1.stable.official.a13da4feb - https://godotengine.org +