Implement speech style toolbar live-edit refinements and whole speech show controls

- Added a new specification for the speech style toolbar live-edit, detailing immediate style application, scoped effect demonstration, and hover feedback.
- Introduced refinements to the speech style toolbar, including improved chooser sizing, exclusive stage editing during speech composition, and removal of redundant controls.
- Documented new stage controls for managing how the whole speech shows, including options for bubble arrangement, word appearance, and bubble motion.
- Created a new log file for speech padding syntax to track engine versioning.
This commit is contained in:
2026-10-03 14:49:22 -04:00
parent 6a1359f244
commit 6666173c4f
42 changed files with 9163 additions and 165 deletions
+12 -21
View File
@@ -13,6 +13,7 @@ const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
signal committed(action: Dictionary)
signal cancelled()
signal target_requested()
signal speech_requested(initial: Dictionary)
var _mode: String = "new"
var _initial: Dictionary = {}
@@ -74,6 +75,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
_mode = "edit"
_initial = action.duplicate(true)
var type := String(action.get("type", "walk_to"))
if type == "speak":
speech_requested.emit(_initial.duplicate(true))
return
_has_target = action.has("target") and action["target"] is Vector2
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
_prop_target_id = String(action.get("target_prop_id", ""))
@@ -202,6 +206,9 @@ func _current_type() -> String:
func _on_type_selected(_index: int) -> void:
if _current_type() == "speak":
speech_requested.emit(_initial.duplicate(true))
return
_rebuild_params(_current_type())
@@ -264,23 +271,9 @@ func _rebuild_params(type: String) -> void:
throw_row.add_child(_set_target_btn)
_update_throw_target_label()
"speak":
var tlabel := Label.new()
tlabel.text = "Text:"
_params_box.add_child(tlabel)
_text_edit = LineEdit.new()
_text_edit.placeholder_text = "Say something…"
_text_edit.text = String(_initial.get("text", ""))
_params_box.add_child(_text_edit)
var dlabel := Label.new()
dlabel.text = "Duration (s):"
_params_box.add_child(dlabel)
_duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0)))
_params_box.add_child(_duration_spin)
if _show_reactive_options and ACTION_REGISTRY.is_overlay_eligible(type):
_stop_current_check = CheckBox.new()
_stop_current_check.text = "Stop what he's doing?"
_stop_current_check.button_pressed = bool(_initial.get("stop_current", false))
_params_box.add_child(_stop_current_check)
var speech_label := Label.new()
speech_label.text = "Opening the comic speech composer…"
_params_box.add_child(speech_label)
"wait":
var wlabel := Label.new()
wlabel.text = "Duration (s):"
@@ -349,10 +342,8 @@ func _on_ok_pressed() -> void:
return
action["target_position"] = _throw_target_pos
"speak":
action["text"] = _text_edit.text if _text_edit != null else ""
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
if _show_reactive_options and _stop_current_check != null:
action["stop_current"] = _stop_current_check.button_pressed
speech_requested.emit(_initial.duplicate(true))
return
"wait":
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
committed.emit(action)
+18 -14
View File
@@ -106,8 +106,11 @@ static func reactive_role(action: Dictionary) -> ReactiveRole:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
var role: ReactiveRole = int(tpl.get("reactive_role", ReactiveRole.SEQUENTIAL))
var params: Dictionary = action.get("params", {})
if type == "speak" and bool(params.get("stop_current", action.get("stop_current", false))):
return ReactiveRole.SEQUENTIAL
if type == "speak":
if params.has("keep_acting") or action.has("keep_acting"):
return ReactiveRole.OVERLAY if bool(params.get("keep_acting", action.get("keep_acting", false))) else ReactiveRole.SEQUENTIAL
if bool(params.get("stop_current", action.get("stop_current", false))):
return ReactiveRole.SEQUENTIAL
return role
@@ -119,10 +122,9 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
"walk_to":
params["target"] = action.get("target", Vector2.ZERO)
"speak":
params["text"] = String(action.get("text", ""))
params["duration"] = float(action.get("duration", 2.0))
if bool(action.get("stop_current", false)):
params["stop_current"] = true
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
if action.has(key):
params[key] = action[key].duplicate(true) if action[key] is Dictionary or action[key] is Array else action[key]
"wait":
params["duration"] = float(action.get("duration", 0.0))
"pick_up_prop":
@@ -144,13 +146,12 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
"walk_to":
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
"speak":
var speak: Dictionary = {
"type": "speak",
"text": String(params.get("text", "")),
"duration": float(params.get("duration", 2.0)),
}
if bool(params.get("stop_current", false)):
speak["stop_current"] = true
var speak: Dictionary = {"type": "speak"}
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
if params.has(key):
speak[key] = params[key].duplicate(true) if params[key] is Dictionary or params[key] is Array else params[key]
if not speak.has("keep_acting"):
speak["keep_acting"] = not bool(params.get("stop_current", false))
return speak
"wait":
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
@@ -180,7 +181,10 @@ static func summarize(action: Dictionary) -> String:
"speak":
var text := String(action.get("text", params.get("text", "")))
var dur := float(action.get("duration", params.get("duration", 2.0)))
var suffix: String = " — stops current" if bool(params.get("stop_current", action.get("stop_current", false))) else ""
var has_keep_acting: bool = params.has("keep_acting") or action.has("keep_acting")
var keep_acting: bool = bool(params.get("keep_acting", action.get("keep_acting", false)))
var legacy_stop_current: bool = bool(params.get("stop_current", action.get("stop_current", false)))
var suffix: String = " — keeps acting" if has_keep_acting and keep_acting else (" — stops current" if not has_keep_acting and legacy_stop_current else "")
return "Speak \"%s\" (%ss)%s" % [text, _fmt_duration(dur), suffix]
"wait":
var wd := float(action.get("duration", params.get("duration", 0.0)))
+1743 -40
View File
File diff suppressed because it is too large Load Diff
+66 -3
View File
@@ -8,14 +8,15 @@ const DEFAULT_PATH: String = "res://sandbox_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
@@ -81,7 +82,18 @@ func font(component_path: String = "", preset_id: String = "ui") -> Font:
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
if path.is_empty(): path = String(value("fonts.ui_font", ""))
if path.is_empty(): return ThemeDB.fallback_font
if path.is_empty():
if preset_id == "ui": return ThemeDB.fallback_font
var system_key: String = "system:" + preset_id
if _font_cache.has(system_key): return _font_cache[system_key]
var system_font := SystemFont.new()
match preset_id:
"comic": system_font.font_names = PackedStringArray(["Comic Sans MS", "Comic Neue", "Chalkboard", "Comic Sans", "sans-serif"])
"pixel": system_font.font_names = PackedStringArray(["Consolas", "Courier New", "Liberation Mono", "monospace"])
"scary": system_font.font_names = PackedStringArray(["Impact", "Chiller", "Papyrus", "fantasy", "sans-serif"])
_: system_font.font_names = PackedStringArray(["Arial", "Liberation Sans", "sans-serif"])
_font_cache[system_key] = system_font
return system_font
if _font_cache.has(path): return _font_cache[path]
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
if resource is Font:
@@ -90,6 +102,18 @@ func font(component_path: String = "", preset_id: String = "ui") -> Font:
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
return ThemeDB.fallback_font
func speech_font(preset_id: String = "ui") -> Font:
return font("", preset_id)
func speech_font_size(preset_id: String, size_preset: String) -> int:
var base_size: int = integer("fonts.presets.%s.speech_base_size" % preset_id, 28, 8, 96)
var scale: float = 1.0
match size_preset:
"small": scale = 0.72
"huge": scale = 1.43
return clampi(roundi(float(base_size) * scale), 8, 128)
func emoji_font() -> Font:
var path: String = String(value("fonts.emoji_font", ""))
if path.is_empty(): return font()
@@ -152,6 +176,45 @@ func emoji_items() -> Array[String]:
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
return result
func speech_color(frame: String, channel: String, fallback: Color) -> Color:
if not ["talk", "shout", "think", "whisper", "sfx"].has(frame) or not ["fill", "outline"].has(channel):
return fallback
return color("speech.styles.%s.%s" % [frame, channel], fallback)
func speech_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
if not ["ui.panel_width", "ui.button_min_height", "ui.heading_size", "layout.max_width", "layout.stack_gap", "layout.screen_margin", "layout.bubble_padding", "animation.letter_interval", "animation.word_interval", "animation.pop_duration"].has(token):
return fallback
return number("speech." + token, fallback, minimum, maximum)
func speech_toolbar_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
if not ["gap", "max_width", "min_hit_height"].has(token):
return fallback
return number("speech.toolbar." + token, fallback, minimum, maximum)
func speech_text_colors() -> Array[Dictionary]:
var result: Array[Dictionary] = []
var raw_colors: Variant = value("speech.text_colors", [])
if not raw_colors is Array:
return result
for raw: Variant in raw_colors as Array:
if not raw is Dictionary:
continue
var item: Dictionary = raw as Dictionary
var name: String = String(item.get("name", "")).strip_edges()
var color_text: String = String(item.get("color", ""))
if not name.is_empty() and _is_valid_hex_color(color_text):
result.append({"name": name, "color": Color.from_string(color_text, Color.BLACK)})
return result
func _is_valid_hex_color(text: String) -> bool:
if text.length() != 7 and text.length() != 9 or not text.begins_with("#"):
return false
for index: int in range(1, text.length()):
if not "0123456789abcdefABCDEF".contains(text.substr(index, 1)):
return false
return true
func invalidate_caches() -> void:
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://dlee4y2wnkupm
+521
View File
@@ -0,0 +1,521 @@
class_name SpeechPayload
extends RefCounted
## Validated, scene-independent helpers for comic speech actions.
const MAX_BUBBLES: int = 8
const MAX_VISIBLE_CHARACTERS: int = 240
const MIN_INTERVAL: float = 1.5
const MAX_INTERVAL: float = 12.0
const BASE_FONT_PRESET: String = "comic"
const DEFAULT_TEXT_COLOR: String = "#202020"
const ARRANGEMENTS: Array[String] = ["together", "take_turns"]
const BUBBLE_LAYOUT_MODES: Array[String] = ["stack", "same_spot"]
const DURATION_MODES: Array[String] = ["just_enough", "longer", "longest"]
const BUBBLE_STYLES: Array[String] = ["talk", "shout", "think", "whisper", "sfx"]
const FONT_PRESETS: Array[String] = ["ui", "comic", "pixel", "scary"]
const SIZE_PRESETS: Array[String] = ["small", "medium", "huge"]
const REVEAL_MODES: Array[String] = ["instant", "letter", "word"]
const EFFECTS: Array[String] = ["shake", "wave", "bounce"]
const WORD_REGEX: String = "[\\p{L}\\p{N}]+(?:['’][\\p{L}\\p{N}]+)*"
static func normalize(raw: Dictionary) -> Dictionary:
var source: Dictionary = raw.duplicate(true)
var legacy: bool = not source.get("bubbles", null) is Array
var out: Dictionary = {
"type": "speak",
"text": String(source.get("text", "")),
"duration": _number_or(source.get("duration", 2.0), 2.0),
"stop_current": bool(source.get("stop_current", false)),
"keep_acting": bool(source.get("keep_acting", false)),
"arrangement": "together",
"bubble_layout": "stack",
"connect_bubbles": false,
"reveal": "instant",
"pop_in": false,
"duration_mode": "just_enough",
"placement": {"mode": "auto"},
"bubbles": [],
}
var source_arrangement: String = String(source.get("arrangement", "together"))
var legacy_comic_chain: bool = source_arrangement == "comic_chain" or String(source.get("bubble_layout", "")) == "comic_chain"
var migrated_arrangement: String = "take_turns" if source_arrangement == "comic_chain" else _validated_choice(source_arrangement, ARRANGEMENTS, "together")
var source_layout: String = String(source.get("bubble_layout", "stack"))
var migrated_layout: String = "same_spot" if not legacy_comic_chain and source_layout == "same_spot" else "stack"
var migrated_connect: bool = legacy_comic_chain or bool(source.get("connect_bubbles", false))
if migrated_connect and migrated_layout == "same_spot":
migrated_layout = "stack"
if legacy:
var visual: Dictionary = source.get("visual", {}) if source.get("visual", {}) is Dictionary else {}
var legacy_text: String = String(source.get("text", ""))
out["bubbles"] = [{"text": legacy_text, "visual": _normalize_visual(visual, legacy_text)}]
out["legacy_fixed"] = true
out["arrangement"] = migrated_arrangement
out["bubble_layout"] = migrated_layout
out["connect_bubbles"] = migrated_connect
out["duration_mode"] = _validated_choice(source.get("duration_mode", "just_enough"), DURATION_MODES, "just_enough")
if source.has("placement"):
out["placement"] = _normalize_placement(source.get("placement"))
else:
out["arrangement"] = migrated_arrangement
out["bubble_layout"] = migrated_layout
out["connect_bubbles"] = migrated_connect
out["duration_mode"] = String(source.get("duration_mode", "just_enough"))
if not DURATION_MODES.has(out["duration_mode"]):
out["duration_mode"] = "just_enough"
var raw_placement: Variant = source.get("placement", {"mode": "auto"})
if raw_placement is Dictionary:
var placement: Dictionary = (raw_placement as Dictionary).duplicate(true)
if String(placement.get("mode", "auto")) == "manual":
var center: Variant = placement.get("center", Vector2.ZERO)
if center is Dictionary:
center = Vector2(float((center as Dictionary).get("x", 0.0)), float((center as Dictionary).get("y", 0.0)))
if center is Vector2 and (center as Vector2).is_finite():
placement["center"] = center
placement["mode"] = "manual"
else:
placement = {"mode": "auto"}
else:
placement = {"mode": "auto"}
out["placement"] = placement
var raw_bubbles: Array = source.get("bubbles", [])
var bubbles: Array[Dictionary] = []
for item: Variant in raw_bubbles.slice(0, MAX_BUBBLES):
if item is Dictionary:
var bubble: Dictionary = item as Dictionary
var bubble_text: String = String(bubble.get("text", ""))
if bubbles.is_empty() and bubble_text.strip_edges().is_empty():
var root_text: String = String(source.get("text", ""))
if not root_text.strip_edges().is_empty():
bubble_text = root_text
var normalized_bubble: Dictionary = {"text": bubble_text, "visual": _normalize_visual(bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {}, bubble_text)}
if bubble.has("placement"):
normalized_bubble["placement"] = _normalize_placement(bubble.get("placement"))
bubbles.append(normalized_bubble)
if bubbles.is_empty():
var root_fallback_text: String = String(source.get("text", ""))
if not root_fallback_text.strip_edges().is_empty():
var root_visual: Dictionary = source.get("visual", {}) if source.get("visual", {}) is Dictionary else {}
bubbles.append({"text": root_fallback_text, "visual": _normalize_visual(root_visual, root_fallback_text)})
out["bubbles"] = bubbles
if not bubbles.is_empty():
out["text"] = String(bubbles[0].get("text", ""))
# Whole-speech choices are canonical. Old multi-bubble actions may disagree;
# adopting the first bubble gives those scenes a deterministic shared setting.
var first_visual: Dictionary = {}
var normalized_bubbles: Array = out.get("bubbles", [])
if not normalized_bubbles.is_empty() and normalized_bubbles[0] is Dictionary:
var first_bubble: Dictionary = normalized_bubbles[0] as Dictionary
first_visual = first_bubble.get("visual", {}) if first_bubble.get("visual", {}) is Dictionary else {}
var fallback_visual: Dictionary = source.get("visual", {}) if source.get("visual", {}) is Dictionary else {}
var reveal_fallback: String = String(first_visual.get("reveal", fallback_visual.get("reveal", "instant")))
var reveal_value: Variant = source.get("reveal", reveal_fallback)
var reveal: String = String(reveal_value) if reveal_value is String and REVEAL_MODES.has(String(reveal_value)) else (reveal_fallback if REVEAL_MODES.has(reveal_fallback) else "instant")
var pop_fallback: bool = bool(first_visual.get("pop_in", fallback_visual.get("pop_in", false)))
var pop_value: Variant = source.get("pop_in", pop_fallback)
var pop_in: bool = bool(pop_value) if pop_value is bool else pop_fallback
out["reveal"] = reveal
out["pop_in"] = pop_in
for index: int in normalized_bubbles.size():
if not normalized_bubbles[index] is Dictionary:
continue
var bubble: Dictionary = normalized_bubbles[index] as Dictionary
var visual: Dictionary = bubble.get("visual", {}).duplicate(true)
visual["reveal"] = reveal
visual["pop_in"] = pop_in
bubble["visual"] = visual
normalized_bubbles[index] = bubble
out["bubbles"] = normalized_bubbles
if not normalized_bubbles.is_empty():
out["duration"] = duration_for(out)
return out
static func _validated_choice(value: Variant, choices: Array[String], fallback: String) -> String:
var choice: String = String(value)
return choice if choices.has(choice) else fallback
static func validate(raw: Variant) -> String:
if not raw is Dictionary:
return "Speech must be a dictionary."
var data: Dictionary = raw as Dictionary
if String(data.get("type", "speak")) != "speak":
return "Speech action type is invalid."
var duration_value: Variant = data.get("duration", 2.0)
if not _finite_number(duration_value) or float(duration_value) < 0.0 or float(duration_value) > 3600.0:
return "Speech duration is out of range."
if data.has("reveal") and not REVEAL_MODES.has(String(data.get("reveal", ""))):
return "Speech reveal is invalid."
if data.has("pop_in") and not data.get("pop_in") is bool:
return "Speech bubble motion is invalid."
if data.has("keep_acting") and not data.get("keep_acting") is bool:
return "Speech action setting is invalid."
var bubbles_value: Variant = data.get("bubbles", null)
if bubbles_value == null:
return ""
if not bubbles_value is Array or (bubbles_value as Array).is_empty() or (bubbles_value as Array).size() > MAX_BUBBLES:
return "Speech needs between one and eight bubbles."
if not ARRANGEMENTS.has(String(data.get("arrangement", "together"))) and String(data.get("arrangement", "")) != "comic_chain":
return "Speech arrangement is invalid."
if data.has("bubble_layout") and not BUBBLE_LAYOUT_MODES.has(String(data.get("bubble_layout", ""))) and not ["standard", "comic_chain"].has(String(data.get("bubble_layout", ""))):
return "Speech bubble layout is invalid."
if data.has("connect_bubbles") and not data.get("connect_bubbles") is bool:
return "Speech bubble connection setting is invalid."
if not DURATION_MODES.has(String(data.get("duration_mode", "just_enough"))):
return "Speech reading time is invalid."
var placement: Variant = data.get("placement", {"mode": "auto"})
if not placement is Dictionary:
return "Speech placement is invalid."
if String((placement as Dictionary).get("mode", "auto")) == "manual":
var center: Variant = (placement as Dictionary).get("center", null)
if center is Dictionary:
if not _finite_number((center as Dictionary).get("x")) or not _finite_number((center as Dictionary).get("y")):
return "Speech placement is invalid."
center = Vector2(float((center as Dictionary)["x"]), float((center as Dictionary)["y"]))
if not center is Vector2 or not (center as Vector2).is_finite() or absf((center as Vector2).x) > 100000.0 or absf((center as Vector2).y) > 100000.0:
return "Speech placement is out of range."
var authored_bubbles: Array = bubbles_value as Array
for bubble_index: int in authored_bubbles.size():
var raw_bubble: Variant = authored_bubbles[bubble_index]
if not raw_bubble is Dictionary:
return "Speech bubble is malformed."
var bubble: Dictionary = raw_bubble as Dictionary
var text: String = String(bubble.get("text", ""))
if text.strip_edges().is_empty() or text.length() > MAX_VISIBLE_CHARACTERS:
return "Each bubble needs text and must fit within 240 characters."
if bubble.has("placement"):
var bubble_placement_error: String = _validate_placement(bubble.get("placement"))
if not bubble_placement_error.is_empty():
return "Bubble %d %s" % [bubble_index + 1, bubble_placement_error]
var visual: Variant = bubble.get("visual", {})
if not visual is Dictionary:
return "Bubble appearance is malformed."
var v: Dictionary = visual as Dictionary
if not BUBBLE_STYLES.has(String(v.get("bubble_style", "talk"))) or not FONT_PRESETS.has(String(v.get("font_preset", BASE_FONT_PRESET))) or not SIZE_PRESETS.has(String(v.get("size_preset", "medium"))) or not REVEAL_MODES.has(String(v.get("reveal", "instant"))):
return "Bubble appearance contains an unknown choice."
if String(v.get("effect", "none")) != "none" and not EFFECTS.has(String(v.get("effect", "none"))):
return "Bubble effect is invalid."
if not _valid_color(String(v.get("color", DEFAULT_TEXT_COLOR))):
return "Bubble text color is invalid."
var word_styles: Variant = v.get("word_styles", [])
if not word_styles is Array:
return "Word styles are malformed."
var seen: Dictionary = {}
for raw_style: Variant in word_styles as Array:
if not raw_style is Dictionary:
return "Word style is malformed."
var style: Dictionary = raw_style as Dictionary
if not _integer_number(style.get("start")) or not _integer_number(style.get("end")):
return "Word style range is invalid."
var start: int = int(style["start"])
var end: int = int(style["end"])
if start < 0 or end <= start or end > text.length() or token_span_at(text, start) != Vector2i(start, end):
return "Text style must target one complete word or punctuation mark."
if seen.has(start):
return "Word styles cannot repeat or overlap."
seen[start] = true
for key: String in ["font_preset"]:
if style.has(key) and not FONT_PRESETS.has(String(style[key])):
return "Word style font is invalid."
if style.has("size_preset") and not SIZE_PRESETS.has(String(style["size_preset"])):
return "Word style size is invalid."
if style.has("color") and not _valid_color(String(style["color"])):
return "Word style color is invalid."
if style.has("effect") and String(style["effect"]) != "none" and not EFFECTS.has(String(style["effect"])):
return "Word style effect is invalid."
return ""
static func _normalize_placement(raw: Variant) -> Dictionary:
if not raw is Dictionary:
return {"mode": "auto"}
var placement: Dictionary = (raw as Dictionary).duplicate(true)
if String(placement.get("mode", "auto")) != "manual":
return {"mode": "auto"}
var center: Variant = placement.get("center", null)
if center is Dictionary:
if not _finite_number((center as Dictionary).get("x")) or not _finite_number((center as Dictionary).get("y")):
return {"mode": "auto"}
center = Vector2(float((center as Dictionary)["x"]), float((center as Dictionary)["y"]))
if not center is Vector2 or not (center as Vector2).is_finite():
return {"mode": "auto"}
placement["mode"] = "manual"
placement["center"] = center
return placement
static func _validate_placement(raw: Variant) -> String:
if not raw is Dictionary:
return "placement is invalid."
var placement: Dictionary = raw as Dictionary
var mode: String = String(placement.get("mode", "auto"))
if mode == "auto":
return ""
if mode != "manual":
return "placement mode is invalid."
var center: Variant = placement.get("center", null)
if center is Dictionary:
if not _finite_number((center as Dictionary).get("x")) or not _finite_number((center as Dictionary).get("y")):
return "placement is invalid."
center = Vector2(float((center as Dictionary)["x"]), float((center as Dictionary)["y"]))
if not center is Vector2 or not (center as Vector2).is_finite():
return "placement is out of range."
if absf((center as Vector2).x) > 100000.0 or absf((center as Vector2).y) > 100000.0:
return "placement is out of range."
return ""
static func duration_for(raw: Dictionary) -> float:
if bool(raw.get("legacy_fixed", false)):
return maxf(0.0, _number_or(raw.get("duration", 2.0), 2.0))
var bubbles: Array = raw.get("bubbles", []) if raw.get("bubbles", []) is Array else []
if bubbles.is_empty():
return maxf(0.0, _number_or(raw.get("duration", 2.0), 2.0))
var arrangement: String = String(raw.get("arrangement", "together"))
var mode: String = String(raw.get("duration_mode", "just_enough"))
var first_visual: Dictionary = (bubbles[0] as Dictionary).get("visual", {}) if bubbles[0] is Dictionary and (bubbles[0] as Dictionary).get("visual", {}) is Dictionary else {}
var shared_reveal: String = String(raw.get("reveal", first_visual.get("reveal", "instant")))
var total: float = 0.0
for item: Variant in bubbles:
if not item is Dictionary:
continue
var bubble: Dictionary = item as Dictionary
var words: int = _word_count(String(bubble.get("text", "")))
var interval: float = maxf(MIN_INTERVAL, 1.0 + 0.2 * float(words))
match mode:
"longer": interval *= 1.5
"longest": interval *= 2.0
var visual: Dictionary = bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {}
var reveal: String = shared_reveal
var characters: int = String(bubble.get("text", "")).length()
var reveal_time: float = 0.0
if reveal == "letter":
reveal_time = minf(0.045 * float(characters), interval * 0.55)
elif reveal == "word":
reveal_time = minf(0.20 * float(words), interval * 0.55)
interval = clampf(maxf(interval, reveal_time + 0.8), MIN_INTERVAL, MAX_INTERVAL)
if arrangement == "take_turns" or arrangement == "comic_chain":
total += interval
else:
total = maxf(total, interval)
return total
static func bubble_intervals(raw: Dictionary) -> PackedFloat32Array:
var result := PackedFloat32Array()
var bubbles: Array = raw.get("bubbles", []) if raw.get("bubbles", []) is Array else []
for item: Variant in bubbles:
if item is Dictionary:
var partial: Dictionary = raw.duplicate(true)
partial["bubbles"] = [item]
partial["arrangement"] = "together"
partial["duration_mode"] = raw.get("duration_mode", "just_enough")
result.append(duration_for(partial))
return result
static func plain_summary(raw: Dictionary) -> String:
var bubbles: Array = raw.get("bubbles", []) if raw.get("bubbles", []) is Array else []
var first: String = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else String(raw.get("text", ""))
first = first.replace("\n", " ").strip_edges()
if first.length() > 42:
first = first.substr(0, 39) + "…"
return first
static func word_span_at(text: String, index: int) -> Vector2i:
return token_span_at(text, index)
static func token_span_at(text: String, index: int) -> Vector2i:
if index < 0 or index >= text.length():
return Vector2i(-1, -1)
for span: Vector2i in token_spans(text):
if index >= span.x and index < span.y:
return span
return Vector2i(-1, -1)
static func token_spans(text: String) -> Array[Vector2i]:
var result: Array[Vector2i] = []
var word_ends: Dictionary = {}
var regex := RegEx.new()
if regex.compile(WORD_REGEX) == OK:
for match_result: RegExMatch in regex.search_all(text):
word_ends[match_result.get_start()] = match_result.get_end()
var index: int = 0
while index < text.length():
if word_ends.has(index):
var word_end: int = int(word_ends[index])
result.append(Vector2i(index, word_end))
index = word_end
continue
var character: String = text.substr(index, 1)
if character.strip_edges().is_empty():
index += 1
continue
var code: int = text.unicode_at(index)
if code == 0x2E or code == 0x21 or code == 0x3F:
var run_end: int = index + 1
while run_end < text.length() and text.unicode_at(run_end) == code:
run_end += 1
result.append(Vector2i(index, run_end))
index = run_end
continue
var grapheme: Vector2i = _symbol_grapheme_span(text, index)
if grapheme.x < 0 or grapheme.y <= index:
grapheme = Vector2i(index, index + 1)
result.append(grapheme)
index = grapheme.y
return result
static func _symbol_grapheme_span(text: String, index: int) -> Vector2i:
var start: int = index
while start > 0:
var current_code: int = text.unicode_at(start)
var previous_code: int = text.unicode_at(start - 1)
if _is_grapheme_extender(current_code) or previous_code == 0x200D:
start -= 1 if _is_grapheme_extender(current_code) else 2
continue
if _is_regional_indicator(current_code) and _is_regional_indicator(previous_code):
var regional_count: int = 0
var scan: int = start - 1
while scan >= 0 and _is_regional_indicator(text.unicode_at(scan)):
regional_count += 1
scan -= 1
if regional_count % 2 == 1:
start -= 1
continue
break
var end: int = start + 1
while end < text.length():
var next_code: int = text.unicode_at(end)
if _is_grapheme_extender(next_code):
end += 1
continue
if next_code == 0x200D and end + 1 < text.length():
end += 2
continue
if end == start + 1 and _is_regional_indicator(text.unicode_at(start)) and _is_regional_indicator(next_code):
end += 1
continue
break
return Vector2i(start, end)
static func _is_grapheme_extender(code: int) -> bool:
return (code >= 0xFE00 and code <= 0xFE0F) \
or (code >= 0xE0100 and code <= 0xE01EF) \
or (code >= 0x1F3FB and code <= 0x1F3FF) \
or (code >= 0xE0020 and code <= 0xE007F) \
or code == 0x20E3 or code == 0x200D
static func _is_regional_indicator(code: int) -> bool:
return code >= 0x1F1E6 and code <= 0x1F1FF
static func rich_text_runs(text: String, word_styles: Array[Dictionary]) -> Array[Dictionary]:
var boundaries: Array[int] = [0, text.length()]
for span: Vector2i in token_spans(text):
boundaries.append(span.x)
boundaries.append(span.y)
for style: Dictionary in word_styles:
var start: int = int(style.get("start", -1))
var end: int = int(style.get("end", -1))
if start >= 0 and end > start and end <= text.length() and token_span_at(text, start) == Vector2i(start, end):
boundaries.append(start)
boundaries.append(end)
boundaries.sort()
var unique: Array[int] = []
for boundary: int in boundaries:
if unique.is_empty() or unique.back() != boundary:
unique.append(boundary)
var runs: Array[Dictionary] = []
for i: int in range(unique.size() - 1):
var start: int = unique[i]
var end: int = unique[i + 1]
if end <= start:
continue
var run: Dictionary = {"start": start, "end": end, "text": text.substr(start, end - start)}
for style: Dictionary in word_styles:
if int(style.get("start", -1)) == start and int(style.get("end", -1)) == end:
run.merge(style, true)
break
runs.append(run)
return runs
static func _normalize_visual(source: Dictionary, text: String = "") -> Dictionary:
var visual: Dictionary = {
"bubble_style": String(source.get("bubble_style", "talk")),
"font_preset": String(source.get("font_preset", BASE_FONT_PRESET)),
"size_preset": String(source.get("size_preset", "medium")),
"color": String(source.get("color", DEFAULT_TEXT_COLOR)),
"effect": String(source.get("effect", "none")),
"reveal": String(source.get("reveal", "instant")),
"pop_in": bool(source.get("pop_in", false)),
"word_styles": [],
}
if not BUBBLE_STYLES.has(visual["bubble_style"]): visual["bubble_style"] = "talk"
if not FONT_PRESETS.has(visual["font_preset"]): visual["font_preset"] = BASE_FONT_PRESET
if not SIZE_PRESETS.has(visual["size_preset"]): visual["size_preset"] = "medium"
if visual["effect"] != "none" and not EFFECTS.has(visual["effect"]): visual["effect"] = "none"
if not REVEAL_MODES.has(visual["reveal"]): visual["reveal"] = "instant"
if not _valid_color(visual["color"]): visual["color"] = DEFAULT_TEXT_COLOR
var styles: Array[Dictionary] = []
for item: Variant in source.get("word_styles", []):
if item is Dictionary:
var style: Dictionary = (item as Dictionary).duplicate(true)
if not _integer_number(style.get("start")) or not _integer_number(style.get("end")):
continue
var start: int = int(style.get("start", -1))
var end: int = int(style.get("end", -1))
if start < 0 or end <= start or end > text.length() or token_span_at(text, start) != Vector2i(start, end):
continue
style["start"] = start
style["end"] = end
if style.has("font_preset") and not FONT_PRESETS.has(String(style["font_preset"])): style.erase("font_preset")
if style.has("size_preset") and not SIZE_PRESETS.has(String(style["size_preset"])): style.erase("size_preset")
if style.has("color") and not _valid_color(String(style["color"])): style.erase("color")
if style.has("effect") and String(style["effect"]) != "none" and not EFFECTS.has(String(style["effect"])): style.erase("effect")
for previous: Dictionary in styles:
if int(previous.get("start", -1)) == start:
style.clear()
break
if style.size() > 2:
styles.append(style)
visual["word_styles"] = styles
return visual
static func _word_count(text: String) -> int:
var regex := RegEx.new()
if regex.compile(WORD_REGEX) != OK:
return 0
return regex.search_all(text).size()
static func _valid_color(value: String) -> bool:
if value.length() != 7 and value.length() != 9:
return false
if not value.begins_with("#"):
return false
for i: int in range(1, value.length()):
if not "0123456789abcdefABCDEF".contains(value.substr(i, 1)):
return false
return true
static func _finite_number(value: Variant) -> bool:
return (value is int or value is float) and is_finite(float(value))
static func _integer_number(value: Variant) -> bool:
return _finite_number(value) and is_equal_approx(float(value), roundf(float(value)))
static func _number_or(value: Variant, fallback: float) -> float:
return float(value) if _finite_number(value) else fallback
+1
View File
@@ -0,0 +1 @@
uid://djyp1epb6jai
+626
View File
@@ -0,0 +1,626 @@
class_name SpeechShowToolbar
extends Control
## Stage-side controls that apply presentation choices to an entire speech.
signal choice_selected(key: StringName, value: Variant)
signal layout_preset_requested(preset: StringName)
signal preview_speech_requested()
signal done_speech_requested()
signal cancel_speech_requested()
var _theme: SandboxTheme
var _action: Dictionary = {}
var _group_rect: Rect2 = Rect2()
var _stage_rect: Rect2 = Rect2()
var _badge_avoidance_rects: Array[Rect2] = []
var _busy: bool = false
var _pin_top_while_more_open: bool = false
var _preserve_top_on_layout: bool = false
var _pinned_panel_top: float = 0.0
var _panel: PanelContainer
var _content: VBoxContainer
var _body_scroll: ScrollContainer
var _body_content: VBoxContainer
var _title_ribbon: PanelContainer
var _buttons_by_key: Dictionary = {}
var _button_values_by_key: Dictionary = {}
var _more_button: Button
var _more_content: VBoxContainer
var _keep_acting_button: Button
var _preview_button: Button
var _done_button: Button
var _cancel_button: Button
var _layout_preset_buttons: Array[Button] = []
var _layout_preset_values: Array[StringName] = []
var _connect_bubbles_button: Button
class BubbleLayoutSample extends Control:
var same_spot: bool = false
func _ready() -> void:
custom_minimum_size = Vector2(58.0, 42.0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
var boxes: Array[Rect2] = []
if same_spot:
boxes = [Rect2(4.0, 5.0, 34.0, 22.0), Rect2(10.0, 9.0, 34.0, 22.0), Rect2(16.0, 13.0, 34.0, 22.0)]
else:
boxes = [Rect2(2.0, 20.0, 32.0, 20.0), Rect2(13.0, 10.0, 32.0, 20.0), Rect2(24.0, 0.0, 32.0, 20.0)]
for index: int in boxes.size():
var bubble_style: StyleBoxFlat = StyleBoxFlat.new()
bubble_style.bg_color = Color("#FFFDF6") if index == boxes.size() - 1 else Color("#DDE7F0")
bubble_style.border_color = Color("#202733")
bubble_style.set_border_width_all(2)
bubble_style.set_corner_radius_all(5)
draw_style_box(bubble_style, boxes[index])
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 79
_build_ui()
visible = false
func configure(theme_data: SandboxTheme) -> void:
_theme = theme_data
if is_inside_tree() and _panel != null:
_apply_theme()
func set_context(action: Dictionary, _reactive_options: bool = false) -> void:
_action = action.duplicate(true)
if _more_button != null:
_more_button.visible = true
_more_button.text = "▾ More speech settings" if _more_content != null and _more_content.visible else "▸ More speech settings"
if _keep_acting_button != null:
_keep_acting_button.set_pressed_no_signal(bool(_action.get("keep_acting", false)))
_refresh_choices()
_refresh_layout_buttons()
_layout_panel()
func set_anchor_rect(group_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void:
var anchor_changed: bool = _rect_changed(_group_rect, group_viewport_rect) or _rect_changed(_stage_rect, stage_viewport_rect)
_group_rect = group_viewport_rect
_stage_rect = stage_viewport_rect
if anchor_changed and _more_content != null and _more_content.visible:
_pin_top_while_more_open = false
visible = not _busy and _group_rect.size.x > 0.0 and _group_rect.size.y > 0.0 and _stage_rect.size.x > 0.0 and _stage_rect.size.y > 0.0
if visible:
_layout_panel()
func set_badge_avoidance_rects(rects: Array[Rect2]) -> void:
if _badge_avoidance_rects == rects:
return
_badge_avoidance_rects = rects.duplicate()
if visible:
_layout_panel()
func set_busy(busy: bool) -> void:
_busy = busy
for key: Variant in _buttons_by_key.keys():
for button: Button in _buttons_by_key[key]:
button.disabled = busy
for button: Button in [_more_button, _preview_button, _done_button, _cancel_button, _keep_acting_button]:
if button != null:
button.disabled = busy
_refresh_layout_buttons()
_refresh_choices()
visible = not busy and _group_rect.size.x > 0.0 and _group_rect.size.y > 0.0 and _stage_rect.size.x > 0.0 and _stage_rect.size.y > 0.0
if visible:
_layout_panel()
func contains_viewport_point(point: Vector2) -> bool:
return visible and _panel != null and _panel.visible and _panel.get_global_rect().has_point(point)
func panel_viewport_rect() -> Rect2:
return _panel.get_global_rect() if _panel != null and is_instance_valid(_panel) and _panel.visible else Rect2()
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.name = "SpeechShowPanel"
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_panel)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 9)
margin.add_theme_constant_override("margin_bottom", 9)
_panel.add_child(margin)
_content = VBoxContainer.new()
_content.add_theme_constant_override("separation", 8)
margin.add_child(_content)
_body_scroll = ScrollContainer.new()
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_body_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_content.add_child(_body_scroll)
_body_content = VBoxContainer.new()
_body_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_content.add_theme_constant_override("separation", 8)
_body_scroll.add_child(_body_content)
_title_ribbon = PanelContainer.new()
_title_ribbon.name = "SpeechShowRibbon"
_body_content.add_child(_title_ribbon)
var title: Label = Label.new()
title.text = "Show speech"
title.add_theme_font_size_override("font_size", 22)
title.add_theme_color_override("font_color", Color.WHITE)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title_ribbon.add_child(title)
_add_choice_row("💬 Show bubbles", &"arrangement", [["Show all", "together"], ["One at a time", "take_turns"]])
_add_layout_preset_row()
_add_connect_bubbles_row()
_more_button = Button.new()
_more_button.text = "▸ More speech settings"
_more_button.toggle_mode = true
_more_button.custom_minimum_size = Vector2(220.0, _hit_height())
_more_button.focus_mode = Control.FOCUS_NONE
_more_button.toggled.connect(_on_more_toggled)
_body_content.add_child(_more_button)
_more_content = VBoxContainer.new()
_more_content.add_theme_constant_override("separation", 4)
_more_content.hide()
_body_content.add_child(_more_content)
_add_choice_row("🔤 Words appear", &"reveal", [["All at once", "instant"], ["Letter by letter", "letter"], ["Word by word", "word"]], _more_content)
_add_choice_row("✨ Bubble motion", &"pop_in", [["Bounce in", true], ["Just appear", false]], _more_content)
_add_choice_row("⏱ Timing", &"duration_mode", [["Normal", "just_enough"], ["Slow", "longer"], ["Slower", "longest"]], _more_content)
var keep_acting_row := HBoxContainer.new()
keep_acting_row.add_theme_constant_override("separation", 10)
_more_content.add_child(keep_acting_row)
var keep_acting_label := Label.new()
keep_acting_label.text = "🎭 Keep acting while talking"
keep_acting_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
keep_acting_label.add_theme_font_size_override("font_size", 19)
keep_acting_label.add_theme_color_override("font_color", Color("#F2F4F8"))
keep_acting_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
keep_acting_row.add_child(keep_acting_label)
_keep_acting_button = Button.new()
_keep_acting_button.toggle_mode = true
_keep_acting_button.custom_minimum_size = Vector2(88.0, _hit_height())
_keep_acting_button.focus_mode = Control.FOCUS_NONE
_keep_acting_button.add_theme_font_size_override("font_size", 17)
_keep_acting_button.toggled.connect(_on_keep_acting_toggled)
keep_acting_row.add_child(_keep_acting_button)
var footer: HFlowContainer = HFlowContainer.new()
footer.add_theme_constant_override("h_separation", 8)
footer.add_theme_constant_override("v_separation", 8)
_content.add_child(footer)
_preview_button = _action_button("▶ Preview speech")
_preview_button.pressed.connect(func() -> void: preview_speech_requested.emit())
footer.add_child(_preview_button)
_done_button = _action_button("✓ Done speech")
_done_button.pressed.connect(func() -> void: done_speech_requested.emit())
footer.add_child(_done_button)
_cancel_button = _action_button("Cancel speech")
_cancel_button.pressed.connect(func() -> void: cancel_speech_requested.emit())
footer.add_child(_cancel_button)
_apply_theme()
func _add_choice_row(label_text: String, key: StringName, choices: Array, parent: Control = null) -> void:
var row: VBoxContainer = VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
var row_parent: Control = parent if parent != null else _body_content
row_parent.add_child(row)
var heading: Label = Label.new()
heading.text = label_text
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Color("#F2F4F8"))
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(heading)
var flow: HFlowContainer = HFlowContainer.new()
flow.add_theme_constant_override("h_separation", 7)
flow.add_theme_constant_override("v_separation", 7)
row.add_child(flow)
var buttons: Array[Button] = []
var values: Array[Variant] = []
for pair: Array in choices:
var choice_value: Variant = pair[1]
var button: Button = Button.new()
button.text = String(pair[0])
button.toggle_mode = true
button.custom_minimum_size = Vector2(136.0, _hit_height())
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 17)
button.pressed.connect(_on_choice_pressed.bind(key, choice_value))
flow.add_child(button)
buttons.append(button)
values.append(choice_value)
_buttons_by_key[key] = buttons
_button_values_by_key[key] = values
func _add_layout_preset_row() -> void:
var row: VBoxContainer = VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
_body_content.add_child(row)
var heading: Label = Label.new()
heading.text = "🗨️ Bubble layout"
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Color("#F2F4F8"))
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(heading)
var flow: HFlowContainer = HFlowContainer.new()
flow.add_theme_constant_override("h_separation", 7)
flow.add_theme_constant_override("v_separation", 7)
row.add_child(flow)
_add_layout_preset_button(flow, "Stack", &"stack", false, "Arrange the bubbles upward from Bubble 1.")
_add_layout_preset_button(flow, "Same spot", &"same_spot", true, "Put every bubble at Bubble 1's position.")
func _add_connect_bubbles_row() -> void:
var row: HBoxContainer = HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
_body_content.add_child(row)
var label: Label = Label.new()
label.text = "🔗 Connect bubbles"
label.add_theme_font_size_override("font_size", 19)
label.add_theme_color_override("font_color", Color("#F2F4F8"))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(label)
_connect_bubbles_button = Button.new()
_connect_bubbles_button.toggle_mode = true
_connect_bubbles_button.custom_minimum_size = Vector2(112.0, _hit_height())
_connect_bubbles_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_connect_bubbles_button.focus_mode = Control.FOCUS_NONE
_connect_bubbles_button.add_theme_font_size_override("font_size", 17)
_connect_bubbles_button.pressed.connect(_on_connect_bubbles_pressed)
row.add_child(_connect_bubbles_button)
func _add_layout_preset_button(parent: HFlowContainer, caption: String, preset: StringName, same_spot: bool, hint: String) -> void:
var button: Button = Button.new()
var button_width: float = 184.0 if same_spot else 150.0
button.custom_minimum_size = Vector2(button_width, maxf(64.0, _hit_height()))
button.toggle_mode = true
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.focus_mode = Control.FOCUS_NONE
button.tooltip_text = hint
button.pressed.connect(func() -> void: layout_preset_requested.emit(preset))
var margin: MarginContainer = MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 5)
margin.add_theme_constant_override("margin_right", 5)
margin.add_theme_constant_override("margin_top", 4)
margin.add_theme_constant_override("margin_bottom", 4)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(margin)
var contents: HBoxContainer = HBoxContainer.new()
contents.add_theme_constant_override("separation", 2)
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(contents)
var sample: BubbleLayoutSample = BubbleLayoutSample.new()
sample.same_spot = same_spot
contents.add_child(sample)
var label: Label = Label.new()
label.text = caption
label.add_theme_font_size_override("font_size", 16)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
contents.add_child(label)
parent.add_child(button)
_layout_preset_buttons.append(button)
_layout_preset_values.append(preset)
func _on_choice_pressed(key: StringName, value: Variant) -> void:
if _busy:
return
_action[String(key)] = value
_refresh_choices()
choice_selected.emit(key, value)
func _on_connect_bubbles_pressed() -> void:
if _busy or _connect_bubbles_button == null:
return
var enabled: bool = not bool(_action.get("connect_bubbles", false))
_action["connect_bubbles"] = enabled
_refresh_connect_bubbles_button()
choice_selected.emit(&"connect_bubbles", enabled)
func _on_keep_acting_toggled(enabled: bool) -> void:
if _busy or _keep_acting_button == null:
return
_action["keep_acting"] = enabled
_refresh_keep_acting_toggle()
choice_selected.emit(&"keep_acting", enabled)
func _on_more_toggled(opened: bool) -> void:
if _busy:
return
_more_content.visible = opened
_more_button.text = "▾ More speech settings" if opened else "▸ More speech settings"
if opened:
_pinned_panel_top = _panel.position.y
_pin_top_while_more_open = true
else:
_pin_top_while_more_open = false
_preserve_top_on_layout = true
_layout_panel()
func _refresh_choices() -> void:
for raw_key: Variant in _buttons_by_key.keys():
var key: StringName = StringName(raw_key)
var buttons: Array = _buttons_by_key[key]
var values: Array = _button_values_by_key[key]
var current: Variant = _action.get(String(key), _default_value(key))
for index: int in buttons.size():
var button: Button = buttons[index] as Button
var selected: bool = current == values[index]
button.set_pressed_no_signal(selected)
_apply_choice_style(button, selected)
button.disabled = _busy
_refresh_connect_bubbles_button()
_refresh_keep_acting_toggle()
func _refresh_layout_buttons() -> void:
var bubbles: Array = _action.get("bubbles", [])
var disabled: bool = _busy or bubbles.size() <= 1
var selected_layout: String = String(_action.get("bubble_layout", "stack"))
for index: int in _layout_preset_buttons.size():
var button: Button = _layout_preset_buttons[index]
var selected: bool = index < _layout_preset_values.size() and String(_layout_preset_values[index]) == selected_layout
button.disabled = disabled
button.set_pressed_no_signal(selected)
_apply_choice_style(button, selected)
if _connect_bubbles_button != null:
_connect_bubbles_button.disabled = _busy or bubbles.size() < 2
_connect_bubbles_button.set_pressed_no_signal(bool(_action.get("connect_bubbles", false)))
_connect_bubbles_button.text = "On" if bool(_action.get("connect_bubbles", false)) else "Off"
_apply_choice_style(_connect_bubbles_button, bool(_action.get("connect_bubbles", false)))
func _refresh_connect_bubbles_button() -> void:
if _connect_bubbles_button == null:
return
var bubbles: Array = _action.get("bubbles", []) if _action.get("bubbles", []) is Array else []
var enabled: bool = bool(_action.get("connect_bubbles", false))
_connect_bubbles_button.disabled = _busy or bubbles.size() < 2
_connect_bubbles_button.set_pressed_no_signal(enabled)
_connect_bubbles_button.text = "On" if enabled else "Off"
_apply_choice_style(_connect_bubbles_button, enabled)
func _refresh_keep_acting_toggle() -> void:
if _keep_acting_button == null:
return
var enabled: bool = bool(_action.get("keep_acting", false))
_keep_acting_button.disabled = _busy
_keep_acting_button.set_pressed_no_signal(enabled)
_keep_acting_button.text = "ON" if enabled else "OFF"
_apply_choice_style(_keep_acting_button, enabled)
func _default_value(key: StringName) -> Variant:
match key:
&"arrangement": return "together"
&"reveal": return "instant"
&"pop_in": return false
&"duration_mode": return "just_enough"
return null
func _layout_panel() -> void:
if _panel == null or _stage_rect.size.x <= 0.0 or _stage_rect.size.y <= 0.0:
return
var gap: float = _gap()
var max_width: float = minf(_max_width(), _stage_rect.size.x)
var preserve_top: bool = _preserve_top_on_layout or (_pin_top_while_more_open and _more_content != null and _more_content.visible)
var pinned_top: float = _pinned_panel_top if _pin_top_while_more_open and _more_content != null and _more_content.visible else _panel.position.y
pinned_top = clampf(pinned_top, _stage_rect.position.y, _stage_rect.end.y)
var available_height: float = _stage_rect.size.y
if preserve_top:
available_height = maxf(1.0, _stage_rect.end.y - pinned_top)
var left_space: float = maxf(0.0, _group_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _group_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var panel_width: float = max_width
var panel_height: float
var panel_position: Vector2
if side_space >= minf(max_width, 300.0):
panel_width = minf(max_width, side_space)
panel_height = _fit_panel_height(panel_width, available_height)
var use_right: bool = right_space >= left_space
panel_position.x = _group_rect.end.x + gap if use_right else _group_rect.position.x - panel_width - gap
panel_position.y = clampf(_group_rect.get_center().y - panel_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - panel_height))
else:
panel_width = max_width
panel_height = _fit_panel_height(panel_width, available_height)
var below_space: float = _stage_rect.end.y - _group_rect.end.y - gap
var above_space: float = _group_rect.position.y - _stage_rect.position.y - gap
var place_below: bool = below_space >= panel_height or below_space >= above_space
panel_position.x = clampf(_group_rect.get_center().x - panel_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - panel_width))
panel_position.y = _group_rect.end.y + gap if place_below else _group_rect.position.y - panel_height - gap
panel_position.x = clampf(panel_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - panel_width))
if preserve_top:
panel_position.y = pinned_top
else:
panel_position.y = clampf(panel_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - panel_height))
panel_position = _position_avoiding_badges(panel_position, Vector2(panel_width, panel_height), _group_rect, preserve_top, pinned_top)
_panel.position = panel_position
_panel.size = Vector2(panel_width, panel_height)
_preserve_top_on_layout = false
func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, lock_y: bool, locked_y: float) -> Vector2:
var candidates: Array[Vector2] = [preferred]
var gap: float = _gap()
var center: Vector2 = anchor.get_center()
if lock_y:
candidates.append(Vector2(anchor.position.x - size.x - gap, locked_y))
candidates.append(Vector2(anchor.end.x + gap, locked_y))
candidates.append(Vector2(_stage_rect.position.x, locked_y))
candidates.append(Vector2(_stage_rect.end.x - size.x, locked_y))
else:
candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5))
candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y))
candidates.append(_stage_rect.get_center() - size * 0.5)
var best: Vector2 = _clamp_panel_position(preferred, size, lock_y, locked_y)
var best_collisions: int = _badge_collision_count(best, size)
var best_distance: float = best.distance_squared_to(preferred)
for candidate: Vector2 in candidates:
var clamped: Vector2 = _clamp_panel_position(candidate, size, lock_y, locked_y)
var collisions: int = _badge_collision_count(clamped, size)
var distance: float = clamped.distance_squared_to(preferred)
if collisions < best_collisions or (collisions == best_collisions and distance < best_distance):
best = clamped
best_collisions = collisions
best_distance = distance
return best
func _clamp_panel_position(candidate: Vector2, size: Vector2, lock_y: bool, locked_y: float) -> Vector2:
var y: float = locked_y if lock_y else clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y))
return Vector2(clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)), y)
func _badge_collision_count(position: Vector2, size: Vector2) -> int:
var panel_rect: Rect2 = Rect2(position, size)
var count: int = 0
for badge_rect: Rect2 in _badge_avoidance_rects:
if panel_rect.intersects(badge_rect):
count += 1
return count
func _rect_changed(previous: Rect2, next: Rect2, epsilon: float = 2.0) -> bool:
if previous.size == Vector2.ZERO:
return true
return previous.position.distance_to(next.position) > epsilon or previous.size.distance_to(next.size) > epsilon
func _fit_panel_height(panel_width: float, available_height: float) -> float:
_panel.custom_minimum_size = Vector2(panel_width, 0.0)
_body_scroll.custom_minimum_size.y = 0.0
_panel.reset_size()
var fixed_height: float = _panel.get_combined_minimum_size().y
var body_height: float = _body_content.get_combined_minimum_size().y
var max_height: float = maxf(1.0, available_height)
var body_room: float = maxf(48.0, max_height - fixed_height)
_body_scroll.custom_minimum_size.y = minf(body_height, body_room)
_panel.reset_size()
return minf(maxf(1.0, _panel.get_combined_minimum_size().y), max_height)
func _apply_theme() -> void:
if _panel == null:
return
_panel.add_theme_stylebox_override("panel", _panel_style())
if _title_ribbon != null:
_title_ribbon.add_theme_stylebox_override("panel", _ribbon_style())
_refresh_choices()
_apply_action_button_styles()
func _apply_choice_style(button: Button, selected: bool) -> void:
button.add_theme_stylebox_override("normal", _choice_style(selected, false))
button.add_theme_stylebox_override("hover", _choice_style(selected, true))
button.add_theme_stylebox_override("pressed", _choice_style(selected, true))
button.add_theme_stylebox_override("focus", _choice_style(selected, true))
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
func _action_button(caption: String) -> Button:
var button: Button = Button.new()
button.text = caption
button.custom_minimum_size = Vector2(136.0, _hit_height())
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 17)
return button
func _apply_action_button_styles() -> void:
for button: Button in [_preview_button, _done_button, _cancel_button, _more_button]:
if button == null:
continue
button.add_theme_stylebox_override("normal", _choice_style(false, false))
button.add_theme_stylebox_override("hover", _choice_style(false, true))
button.add_theme_stylebox_override("pressed", _choice_style(false, true))
button.add_theme_color_override("font_color", Color.WHITE)
for button: Button in _layout_preset_buttons:
button.add_theme_stylebox_override("normal", _choice_style(false, false))
button.add_theme_stylebox_override("hover", _choice_style(false, true))
button.add_theme_stylebox_override("pressed", _choice_style(false, true))
button.add_theme_color_override("font_color", Color.WHITE)
if _connect_bubbles_button != null:
_connect_bubbles_button.add_theme_stylebox_override("normal", _choice_style(false, false))
_connect_bubbles_button.add_theme_stylebox_override("hover", _choice_style(bool(_action.get("connect_bubbles", false)), true))
_connect_bubbles_button.add_theme_stylebox_override("pressed", _choice_style(bool(_action.get("connect_bubbles", false)), true))
_connect_bubbles_button.add_theme_color_override("font_color", Color.WHITE)
_refresh_layout_buttons()
func _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7")
style.border_color = _border_color()
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.set_content_margin_all(8)
return style
func _ribbon_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0")
style.set_corner_radius_all(7)
style.set_content_margin(SIDE_LEFT, 9.0)
style.set_content_margin(SIDE_TOP, 3.0)
style.set_content_margin(SIDE_RIGHT, 9.0)
style.set_content_margin(SIDE_BOTTOM, 3.0)
return style
func _choice_style(selected: bool, hovered: bool) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color("#176B83") if selected else (Color("#344154") if hovered else Color("#202733"))
style.border_color = _selection_color() if selected else (Color("#D8E0EA") if hovered else _border_color())
style.set_border_width_all(4 if selected else 2)
style.set_corner_radius_all(8)
style.set_content_margin_all(6)
return style
func _hit_height() -> float:
return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0
func _max_width() -> float:
return _theme.speech_toolbar_number("max_width", 440.0, 260.0, 620.0) if _theme != null else 440.0
func _gap() -> float:
return _theme.speech_toolbar_number("gap", 12.0, 6.0, 24.0) if _theme != null else 12.0
func _border_color() -> Color:
return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF")
func _selection_color() -> Color:
return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D")
+1
View File
@@ -0,0 +1 @@
uid://cc3gg7du2suwg
+849
View File
@@ -0,0 +1,849 @@
class_name SpeechStyleToolbar
extends Control
## Transient stage-side speech styling controls. The composer owns every edit.
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
class FramePreview extends Control:
const RENDERER := preload("res://scripts/stickman_speech_bubble.gd")
var frame_id: String = "talk"
var fill: Color = Color.WHITE
var outline: Color = Color.BLACK
func _draw() -> void:
var frame: Rect2 = Rect2(5.0, 6.0, maxf(30.0, size.x - 10.0), maxf(28.0, size.y - 12.0))
RENDERER.draw_style_shape(self, frame_id, frame, fill, outline, false, 3)
class FrameSample extends Button:
var frame_id: String = "talk"
var fill: Color = Color.WHITE
var outline: Color = Color.BLACK
var compact: bool = false
func configure(style_id: String, fill_color: Color, outline_color: Color, use_compact_layout: bool, card_width: float) -> void:
frame_id = style_id
fill = fill_color
outline = outline_color
compact = use_compact_layout
custom_minimum_size = Vector2(card_width, 92.0) if compact else Vector2(118.0, 108.0)
text = ""
var card_layout: VBoxContainer = VBoxContainer.new()
card_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
card_layout.offset_left = 6.0
card_layout.offset_top = 6.0
card_layout.offset_right = -6.0
card_layout.offset_bottom = -6.0
card_layout.add_theme_constant_override("separation", 2)
card_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(card_layout)
var preview: FramePreview = FramePreview.new()
preview.frame_id = frame_id
preview.fill = fill
preview.outline = outline
preview.custom_minimum_size = Vector2(0.0, 56.0 if compact else 68.0)
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_layout.add_child(preview)
var title: Label = Label.new()
title.text = style_id.capitalize()
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 16 if compact else 18)
title.add_theme_color_override("font_color", Color("#F8FAFC"))
title.custom_minimum_size.y = 24.0 if compact else 28.0
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_layout.add_child(title)
signal chooser_open_requested(kind: StringName)
signal choice_selected(kind: StringName, value: String)
signal sequence_action_requested(action: StringName)
signal done_styling_requested()
signal message_edit_requested(opened: bool)
var _theme: SandboxTheme
var _context: Dictionary = {}
var _stage_rect: Rect2 = Rect2()
var _frame_rect: Rect2 = Rect2()
var _badge_avoidance_rects: Array[Rect2] = []
var _bar: PanelContainer
var _bar_layout: VBoxContainer
var _bar_scroll: ScrollContainer
var _bar_content: VBoxContainer
var _speech_flow: GridContainer
var _bar_title: Label
var _bar_ribbon: PanelContainer
var _bubble_position_label: Label
var _message_button: Button
var _done_styling_button: Button
var _earlier_button: Button
var _later_button: Button
var _add_bubble_button: Button
var _remove_bubble_button: Button
var _undo_remove_button: Button
var _sequence_hint_label: Label
var _chooser: PanelContainer
var _chooser_layout: VBoxContainer
var _chooser_ribbon: PanelContainer
var _chooser_scroll: ScrollContainer
var _chooser_content: VBoxContainer
var _chooser_title: Label
var _category_buttons: Array[Button] = []
var _category_group: ButtonGroup
var _category_kinds: Array[StringName] = [&"frame", &"letters", &"effect"]
var _choice_buttons: Dictionary = {}
var _active_kind: StringName = &""
var _busy: bool = false
var _message_dialog_open: bool = false
var _compact_choices: bool = false
var _choice_width: float = 440.0
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 80
_build_ui()
visible = false
func configure(theme_data: SandboxTheme) -> void:
_theme = theme_data
if is_inside_tree() and _bar != null:
_apply_theme_styles()
func set_context(context: Dictionary) -> void:
var changed: bool = _context != context
_context = context.duplicate(true)
var choice_layout_changed: bool = _prepare_choice_layout()
_sync_category_disabled()
if _message_button != null:
_message_button.disabled = _busy
_sync_sequence_controls()
if not _active_kind.is_empty():
if choice_layout_changed:
_build_choices(_active_kind, false)
elif changed:
_refresh_choice_states()
_sync_category_buttons()
_layout_panels()
func set_anchor_rect(frame_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void:
_frame_rect = frame_viewport_rect
_stage_rect = stage_viewport_rect
visible = frame_viewport_rect.size.x > 0.0 and frame_viewport_rect.size.y > 0.0
var choice_layout_changed: bool = _prepare_choice_layout()
if choice_layout_changed and not _active_kind.is_empty():
_build_choices(_active_kind, false)
_layout_panels()
func set_badge_avoidance_rects(rects: Array[Rect2]) -> void:
if _badge_avoidance_rects == rects:
return
_badge_avoidance_rects = rects.duplicate()
_layout_panels()
func present_chooser(kind: StringName) -> void:
if _busy or not [&"frame", &"letters", &"effect"].has(kind):
return
_active_kind = kind
_prepare_choice_layout()
_build_choices(kind)
_chooser.visible = true
_sync_category_buttons()
_layout_panels()
func close_chooser() -> void:
if not is_chooser_open():
return
_active_kind = &""
_chooser.hide()
_sync_category_buttons()
func dismiss_styling() -> void:
close_chooser()
hide()
done_styling_requested.emit()
func is_chooser_open() -> bool:
return _chooser != null and _chooser.visible
func contains_viewport_point(point: Vector2) -> bool:
if not visible:
return false
if _bar != null and _bar.visible and _bar.get_global_rect().has_point(point):
return true
return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point)
func chooser_contains_viewport_point(point: Vector2) -> bool:
return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point)
func toolbar_viewport_rect() -> Rect2:
return _bar.get_global_rect() if _bar != null and is_instance_valid(_bar) else Rect2()
func set_message_dialog_open(opened: bool) -> void:
_message_dialog_open = opened
if _message_button != null:
_message_button.set_pressed_no_signal(opened)
_apply_category_button_style(_message_button, opened)
func set_busy(busy: bool) -> void:
_busy = busy
_sync_category_disabled()
if _message_button != null:
_message_button.disabled = busy
if _done_styling_button != null:
_done_styling_button.disabled = busy
_sync_sequence_controls()
if _chooser != null:
_chooser.mouse_filter = Control.MOUSE_FILTER_STOP if busy else Control.MOUSE_FILTER_PASS
_refresh_choice_states()
func _sync_category_disabled() -> void:
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
for index: int in range(_category_buttons.size()):
var is_effect_button: bool = index < _category_kinds.size() and _category_kinds[index] == &"effect"
_category_buttons[index].disabled = _busy or (is_effect_button and not has_message)
func _sync_sequence_controls() -> void:
if _context.is_empty():
return
var count: int = int(_context.get("bubble_count", 1))
var limit: int = int(_context.get("max_bubbles", 8))
if _bubble_position_label != null:
var selected_index: int = clampi(int(_context.get("bubble_index", 0)), 0, maxi(0, count - 1))
_bubble_position_label.text = "Bubble %d of %d" % [selected_index + 1, count]
if _earlier_button != null:
_earlier_button.disabled = _busy or not bool(_context.get("can_move_earlier", false))
if _later_button != null:
_later_button.disabled = _busy or not bool(_context.get("can_move_later", false))
if _add_bubble_button != null:
_add_bubble_button.disabled = _busy or count >= limit
_add_bubble_button.tooltip_text = "Eight bubbles is the limit." if count >= limit else "Add a bubble after this one"
if _remove_bubble_button != null:
_remove_bubble_button.disabled = _busy or count <= 1
if _undo_remove_button != null:
var can_undo_removed_bubble: bool = bool(_context.get("can_undo_remove", false))
_undo_remove_button.visible = can_undo_removed_bubble
_undo_remove_button.disabled = _busy or not can_undo_removed_bubble
if _sequence_hint_label != null:
var hint: String = String(_context.get("sequence_hint", ""))
if count >= limit and hint.is_empty():
hint = "You can add up to eight bubbles."
_sequence_hint_label.text = hint
_sequence_hint_label.visible = not hint.is_empty()
func _build_ui() -> void:
_bar = PanelContainer.new()
_bar.name = "SpeechStyleBar"
_bar.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_bar)
_bar_layout = VBoxContainer.new()
_bar_layout.add_theme_constant_override("separation", 8)
_bar.add_child(_bar_layout)
_bar_ribbon = PanelContainer.new()
_bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_bar_layout.add_child(_bar_ribbon)
_bar_title = Label.new()
_bar_title.text = "Speech Style"
_bar_title.add_theme_font_size_override("font_size", 24)
_bar_title.add_theme_color_override("font_color", Color("#FFFFFF"))
_bar_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bar_ribbon.add_child(_bar_title)
_bar_scroll = ScrollContainer.new()
_bar_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_bar_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_bar_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_bar_layout.add_child(_bar_scroll)
_bar_content = VBoxContainer.new()
_bar_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_bar_content.add_theme_constant_override("separation", 8)
_bar_scroll.add_child(_bar_content)
var sequence_flow: HFlowContainer = _new_button_flow()
_bar_content.add_child(sequence_flow)
_bubble_position_label = _group_label("Bubble 1 of 1")
_bubble_position_label.add_theme_font_size_override("font_size", 20)
_bubble_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sequence_flow.add_child(_bubble_position_label)
_earlier_button = _make_button("↑ Earlier")
_earlier_button.tooltip_text = "Move this bubble earlier in the speech"
_earlier_button.pressed.connect(_on_sequence_action_pressed.bind(&"earlier"))
sequence_flow.add_child(_earlier_button)
_later_button = _make_button("↓ Later")
_later_button.tooltip_text = "Move this bubble later in the speech"
_later_button.pressed.connect(_on_sequence_action_pressed.bind(&"later"))
sequence_flow.add_child(_later_button)
_category_group = ButtonGroup.new()
_category_group.allow_unpress = false
_speech_flow = GridContainer.new()
_speech_flow.columns = 2
_speech_flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_speech_flow.add_theme_constant_override("h_separation", 8)
_speech_flow.add_theme_constant_override("v_separation", 8)
_bar_content.add_child(_speech_flow)
_message_button = _make_button("✏ Message")
_message_button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height()))
_message_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_message_button.toggle_mode = true
_message_button.toggled.connect(_on_message_toggled)
_speech_flow.add_child(_message_button)
_apply_category_button_style(_message_button, false)
_add_category_button("🗨 Frame", &"frame", _speech_flow)
_add_category_button("🔤 Letters", &"letters", _speech_flow)
_add_category_button("✨ Effects", &"effect", _speech_flow)
var bubble_flow: HFlowContainer = _new_button_flow()
_bar_content.add_child(bubble_flow)
_add_bubble_button = _make_button("➕ Add bubble")
_add_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"add"))
bubble_flow.add_child(_add_bubble_button)
_remove_bubble_button = _make_button("🗑 Remove bubble")
_remove_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"remove"))
bubble_flow.add_child(_remove_bubble_button)
_undo_remove_button = _make_button("↶ Undo")
_undo_remove_button.visible = false
_undo_remove_button.pressed.connect(_on_sequence_action_pressed.bind(&"undo_remove"))
bubble_flow.add_child(_undo_remove_button)
_sequence_hint_label = _group_label("")
_sequence_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_sequence_hint_label.visible = false
_bar_content.add_child(_sequence_hint_label)
var done_margin: MarginContainer = MarginContainer.new()
done_margin.add_theme_constant_override("margin_top", 8)
_bar_layout.add_child(done_margin)
var footer: CenterContainer = CenterContainer.new()
done_margin.add_child(footer)
_done_styling_button = _make_button("✓ Done styling")
_done_styling_button.custom_minimum_size = Vector2(220.0, maxf(56.0, _hit_height()))
_done_styling_button.add_theme_font_size_override("font_size", 19)
_done_styling_button.pressed.connect(dismiss_styling)
footer.add_child(_done_styling_button)
_chooser = PanelContainer.new()
_chooser.name = "SpeechStyleChooser"
_chooser.mouse_filter = Control.MOUSE_FILTER_STOP
_chooser.hide()
add_child(_chooser)
_chooser_layout = VBoxContainer.new()
_chooser_layout.add_theme_constant_override("separation", 8)
_chooser.add_child(_chooser_layout)
_chooser_ribbon = PanelContainer.new()
_chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_chooser_layout.add_child(_chooser_ribbon)
_chooser_title = Label.new()
_chooser_title.add_theme_font_size_override("font_size", 22)
_chooser_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_chooser_title.add_theme_color_override("font_color", Color("#FFFFFF"))
_chooser_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chooser_ribbon.add_child(_chooser_title)
_chooser_scroll = ScrollContainer.new()
_chooser_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_chooser_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_chooser_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_chooser_layout.add_child(_chooser_scroll)
_chooser_content = VBoxContainer.new()
_chooser_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_chooser_content.add_theme_constant_override("separation", 8)
_chooser_scroll.add_child(_chooser_content)
_apply_theme_styles()
func _new_button_flow() -> HFlowContainer:
var flow: HFlowContainer = HFlowContainer.new()
flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
flow.add_theme_constant_override("h_separation", 8)
flow.add_theme_constant_override("v_separation", 8)
return flow
func _group_label(caption: String) -> Label:
var label: Label = Label.new()
label.text = caption
label.add_theme_font_size_override("font_size", 17)
label.add_theme_color_override("font_color", Color("#D6E1EF"))
return label
func _add_category_button(caption: String, kind: StringName, parent: Control) -> void:
var button: Button = _make_button(caption)
button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height()))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.toggle_mode = true
button.button_group = _category_group
button.add_theme_font_size_override("font_size", 21)
button.pressed.connect(_on_category_pressed.bind(kind))
_category_buttons.append(button)
parent.add_child(button)
_apply_category_button_style(button, false)
func _on_message_toggled(opened: bool) -> void:
if _busy:
_message_button.set_pressed_no_signal(_message_dialog_open)
return
set_message_dialog_open(opened)
if opened:
close_chooser()
message_edit_requested.emit(opened)
func _on_category_pressed(kind: StringName) -> void:
if _busy:
return
if _message_dialog_open:
set_message_dialog_open(false)
message_edit_requested.emit(false)
if is_chooser_open() and _active_kind == kind:
_sync_category_buttons()
return
chooser_open_requested.emit(kind)
func _on_sequence_action_pressed(action: StringName) -> void:
if _busy:
return
sequence_action_requested.emit(action)
func _sync_category_buttons() -> void:
for index: int in range(_category_buttons.size()):
var button: Button = _category_buttons[index]
var active: bool = is_chooser_open() and index < _category_kinds.size() and _category_kinds[index] == _active_kind
button.set_pressed_no_signal(active)
_apply_category_button_style(button, active)
func _apply_category_button_style(button: Button, active: bool) -> void:
var normal: StyleBoxFlat = StyleBoxFlat.new()
normal.bg_color = Color("#176B83") if active else Color("#344255")
normal.border_color = _selection_color() if active else Color("#C6D1DE")
normal.set_border_width_all(4 if active else 2)
normal.set_corner_radius_all(9)
normal.set_content_margin_all(8)
var hover: StyleBoxFlat = normal.duplicate() as StyleBoxFlat
if not active:
hover.bg_color = Color("#40546D")
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_color_override("font_color", Color("#FFFFFF"))
func _build_choices(kind: StringName, relayout: bool = true) -> void:
for child: Node in _chooser_content.get_children():
_chooser_content.remove_child(child)
child.free()
_chooser_scroll.custom_minimum_size.y = 0.0
_choice_buttons.clear()
var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {}
var target_text: String = String(target.get("text", ""))
_chooser_title.text = _chooser_title_for(kind, target_text)
if kind == &"frame":
var frames: GridContainer = GridContainer.new()
frames.columns = 2 if _compact_choices else 3
frames.add_theme_constant_override("h_separation", 6)
frames.add_theme_constant_override("v_separation", 8)
_chooser_content.add_child(frames)
for frame_id: String in SPEECH_PAYLOAD.BUBBLE_STYLES:
var sample: FrameSample = FrameSample.new()
var card_width: float = minf(86.0, maxf(60.0, (_choice_width - 48.0) * 0.5)) if _compact_choices else 118.0
sample.configure(frame_id, _frame_color(frame_id, "fill", Color("#FFFDF6")), _frame_color(frame_id, "outline", Color("#202020")), _compact_choices, card_width)
_configure_choice(sample, &"frame", frame_id)
frames.add_child(sample)
elif kind == &"letters":
_add_subheading("Font")
var fonts: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(fonts)
for font_id: String in SPEECH_PAYLOAD.FONT_PRESETS:
var font_choice: Button = _make_button("Aa\n" + font_id.capitalize())
font_choice.custom_minimum_size = Vector2(76.0, 86.0) if _compact_choices else Vector2(120.0, 92.0)
font_choice.add_theme_font_override("font", _theme.speech_font(font_id) if _theme != null else ThemeDB.fallback_font)
var font_preview_size: int = 24
if _theme != null:
font_preview_size = clampi(roundi(float(_theme.speech_font_size(font_id, "medium")) * 0.7), 24, 28)
font_choice.add_theme_font_size_override("font_size", font_preview_size)
_configure_choice(font_choice, &"font", font_id)
fonts.add_child(font_choice)
_add_subheading("Size")
var sizes: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(sizes)
for size_id: String in SPEECH_PAYLOAD.SIZE_PRESETS:
var size_choice: Button = _make_button("Aa\n" + size_id.capitalize())
size_choice.custom_minimum_size = Vector2(76.0, 76.0) if _compact_choices else Vector2(120.0, 84.0)
size_choice.add_theme_font_size_override("font_size", _size_font_size(size_id))
_configure_choice(size_choice, &"size", size_id)
sizes.add_child(size_choice)
_add_subheading("Color")
var colors: GridContainer = GridContainer.new()
colors.columns = clampi(floori((_choice_width - 24.0) / 72.0), 1, 4)
colors.add_theme_constant_override("h_separation", 8)
colors.add_theme_constant_override("v_separation", 8)
_chooser_content.add_child(colors)
if _theme != null:
for entry: Dictionary in _theme.speech_text_colors():
var color: Color = entry.get("color", Color.BLACK)
var color_id: String = "#" + color.to_html(false)
var swatch: Button = _make_button("")
swatch.custom_minimum_size = Vector2(64.0, 64.0)
swatch.tooltip_text = String(entry.get("name", "Color"))
_configure_choice(swatch, &"color", color_id)
colors.add_child(swatch)
else:
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
if not has_message:
var hint: Label = Label.new()
hint.text = "Add a message before choosing an effect."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_chooser_content.add_child(hint)
var effects: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(effects)
var effect_labels: Dictionary = {"shake": "〰 Shake", "wave": "🌊 Wave", "bounce": "⬆ Bounce", "none": "○ No effect"}
for effect_id: String in ["shake", "wave", "bounce", "none"]:
var effect_choice: Button = _make_button(String(effect_labels[effect_id]))
effect_choice.custom_minimum_size = Vector2(136.0, 60.0)
effect_choice.add_theme_font_size_override("font_size", 20)
effect_choice.disabled = not has_message
_configure_choice(effect_choice, &"effect", effect_id)
effects.add_child(effect_choice)
if relayout:
_layout_panels()
func _configure_choice(button: Button, kind: StringName, value: String) -> void:
button.focus_mode = Control.FOCUS_NONE
button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, _hit_height())
button.pressed.connect(_on_choice_pressed.bind(kind, value))
_choice_buttons[String(kind) + ":" + value] = {"button": button, "kind": kind, "value": value}
_refresh_choice_state(button, kind, value)
func _on_choice_pressed(kind: StringName, value: String) -> void:
if _busy:
return
if kind == &"effect" and String(_context.get("text", "")).strip_edges().is_empty():
return
choice_selected.emit(kind, value)
func _refresh_choice_state(button: Button, kind: StringName, value: String) -> void:
var selected: String = ""
match kind:
&"frame": selected = String(_context.get("bubble_style", "talk"))
&"font": selected = String(_context.get("font_preset", "comic"))
&"size": selected = String(_context.get("size_preset", "medium"))
&"color": selected = String(_context.get("color", "#202020")).to_lower()
&"effect": selected = String(_context.get("effect", "none"))
var chosen: bool = selected.to_lower() == value.to_lower()
var base_color: String = value if kind == &"color" else ("#176B83" if chosen else "#344255")
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
button.disabled = _busy or (kind == &"effect" and not has_message)
button.add_theme_stylebox_override("normal", _choice_button_style(chosen, base_color, false))
button.add_theme_stylebox_override("hover", _choice_button_style(chosen, base_color, true))
button.add_theme_stylebox_override("pressed", _choice_button_style(chosen, base_color, true))
button.add_theme_stylebox_override("focus", _choice_button_style(chosen, base_color, false))
button.add_theme_color_override("font_color", Color("#FFFFFF"))
func _refresh_choice_states() -> void:
var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {}
_chooser_title.text = _chooser_title_for(_active_kind, String(target.get("text", "")))
for entry_value: Variant in _choice_buttons.values():
if not entry_value is Dictionary:
continue
var entry: Dictionary = entry_value as Dictionary
var button: Button = entry.get("button") as Button
if button == null or not is_instance_valid(button):
continue
_refresh_choice_state(button, StringName(entry.get("kind", "")), String(entry.get("value", "")))
func _chooser_title_for(kind: StringName, target_text: String) -> String:
match kind:
&"frame": return "Choose a frame"
&"letters": return "Choose how the letters look"
&"effect": return "Choose an effect for %s" % target_text if not target_text.is_empty() else "Choose an effect for the whole bubble"
return "Choose a style"
func _choice_button_style(chosen: bool, base_color: String, hovered: bool) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(base_color)
if hovered:
style.bg_color = style.bg_color.lightened(0.12)
style.border_color = _selection_color() if chosen else Color("#F8FAFC")
style.set_border_width_all(5 if chosen else 2)
style.set_corner_radius_all(8)
style.set_content_margin_all(6)
return style
func _add_subheading(text: String) -> void:
var label: Label = Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 20)
label.add_theme_color_override("font_color", _selection_color())
_chooser_content.add_child(label)
func _layout_panels() -> void:
if _bar == null or _stage_rect.size.x <= 0.0:
return
var choice_layout_changed: bool = _prepare_choice_layout()
if choice_layout_changed and _chooser != null and _chooser.visible and not _active_kind.is_empty():
_build_choices(_active_kind, false)
var max_width: float = _max_width()
var gap: float = _gap()
var stage_width: float = _stage_rect.size.x
var stage_height: float = _stage_rect.size.y
var bar_width: float = minf(max_width, stage_width)
var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var toolbar_side_layout: bool = side_space >= minf(max_width, 300.0)
if toolbar_side_layout:
bar_width = minf(max_width, side_space)
var bar_height: float = _fit_bar_height(bar_width, stage_height)
var bar_position: Vector2
if toolbar_side_layout:
var toolbar_on_right: bool = right_space >= left_space
bar_position.x = _frame_rect.end.x + gap if toolbar_on_right else _frame_rect.position.x - bar_width - gap
bar_position.y = clampf(_frame_rect.get_center().y - bar_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height))
else:
var above_space: float = _frame_rect.position.y - _stage_rect.position.y - gap
var below_space: float = _stage_rect.end.y - _frame_rect.end.y - gap
var place_above: bool = above_space >= bar_height or above_space >= below_space
bar_position.x = clampf(_frame_rect.get_center().x - bar_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width))
bar_position.y = _frame_rect.position.y - bar_height - gap if place_above else _frame_rect.end.y + gap
bar_position.x = clampf(bar_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width))
bar_position.y = clampf(bar_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height))
bar_position = _position_avoiding_badges(bar_position, Vector2(bar_width, bar_height), _frame_rect)
_bar.position = bar_position
_bar.size = Vector2(bar_width, bar_height)
if _chooser == null or not _chooser.visible:
return
var below_toolbar: float = maxf(_stage_rect.position.y, _bar.position.y + _bar.size.y + gap)
var use_right: bool = right_space >= left_space
var chooser_side_space: float = right_space if use_right else left_space
var side_layout: bool = chooser_side_space >= 180.0 and _stage_rect.end.y - below_toolbar >= 150.0
var chooser_width: float = minf(440.0, chooser_side_space) if side_layout else minf(440.0, _stage_rect.size.x)
_chooser.custom_minimum_size = Vector2(chooser_width, 0.0)
var content_height: float = _chooser_content.get_combined_minimum_size().y
# ScrollContainer doesn't include its child's vertical minimum in the panel's
# natural size. Seed it here so short choosers fit their content and long ones
# can be capped below without collapsing the choice area to zero height.
_chooser_scroll.custom_minimum_size.y = content_height
_chooser.reset_size()
_chooser.size.x = chooser_width
var natural_height: float = maxf(1.0, _chooser.get_combined_minimum_size().y)
var fixed_height: float = maxf(0.0, natural_height - content_height)
var max_height: float = maxf(150.0, _stage_rect.size.y * 0.82)
var chooser_y: float
if side_layout:
chooser_y = below_toolbar
max_height = minf(max_height, maxf(1.0, _stage_rect.end.y - chooser_y))
else:
var below_y: float = _frame_rect.end.y + gap
var below_room: float = _stage_rect.end.y - below_y
var above_y: float = _stage_rect.position.y
var above_room: float = _bar.position.y - gap - above_y
if below_room >= above_room:
chooser_y = below_y
max_height = minf(max_height, maxf(1.0, below_room))
else:
chooser_y = above_y
max_height = minf(max_height, maxf(1.0, above_room))
var chooser_height: float = minf(natural_height, max_height)
if natural_height > max_height:
_chooser_scroll.custom_minimum_size.y = maxf(64.0, max_height - fixed_height)
_chooser.reset_size()
_chooser.custom_minimum_size = Vector2(chooser_width, chooser_height)
_chooser.size = Vector2(chooser_width, chooser_height)
var chooser_x: float
if side_layout:
chooser_x = _frame_rect.end.x + gap if use_right else _frame_rect.position.x - gap - chooser_width
else:
chooser_x = clampf(_frame_rect.get_center().x - chooser_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - chooser_width))
chooser_y = clampf(chooser_y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - chooser_height))
var chooser_position: Vector2 = Vector2(chooser_x, chooser_y)
var toolbar_obstruction: Array[Rect2] = [Rect2(_bar.position, _bar.size)]
_chooser.position = _position_avoiding_badges(chooser_position, Vector2(chooser_width, chooser_height), _frame_rect, toolbar_obstruction)
func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, extra_obstructions: Array[Rect2] = []) -> Vector2:
var candidates: Array[Vector2] = [preferred]
var gap: float = _gap()
var center: Vector2 = anchor.get_center()
candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5))
candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y))
candidates.append(_stage_rect.get_center() - size * 0.5)
var best: Vector2 = _clamp_panel_position(preferred, size)
var best_collisions: int = _badge_collision_count(best, size, extra_obstructions)
var best_distance: float = best.distance_squared_to(preferred)
for candidate: Vector2 in candidates:
var clamped: Vector2 = _clamp_panel_position(candidate, size)
var collisions: int = _badge_collision_count(clamped, size, extra_obstructions)
var distance: float = clamped.distance_squared_to(preferred)
if collisions < best_collisions or (collisions == best_collisions and distance < best_distance):
best = clamped
best_collisions = collisions
best_distance = distance
if best_collisions == 0 and is_zero_approx(best_distance):
return best
return best
func _clamp_panel_position(candidate: Vector2, size: Vector2) -> Vector2:
return Vector2(
clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)),
clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y))
)
func _badge_collision_count(position: Vector2, size: Vector2, extra_obstructions: Array[Rect2]) -> int:
var panel_rect: Rect2 = Rect2(position, size)
var count: int = 0
for badge_rect: Rect2 in _badge_avoidance_rects:
if panel_rect.intersects(badge_rect):
count += 1
for obstruction: Rect2 in extra_obstructions:
if panel_rect.intersects(obstruction):
count += 1
return count
func _fit_bar_height(bar_width: float, available_height: float) -> float:
_bar.custom_minimum_size = Vector2(bar_width, 0.0)
_bar_scroll.custom_minimum_size.y = 0.0
_bar.reset_size()
var fixed_height: float = _bar.get_combined_minimum_size().y
var content_height: float = _bar_content.get_combined_minimum_size().y
var max_height: float = maxf(1.0, available_height)
var body_room: float = maxf(48.0, max_height - fixed_height)
_bar_scroll.custom_minimum_size.y = minf(content_height, body_room)
_bar.reset_size()
return minf(maxf(54.0, _bar.get_combined_minimum_size().y), max_height)
func _prepare_choice_layout() -> bool:
var previous_compact: bool = _compact_choices
var previous_width: float = _choice_width
var gap: float = _gap()
var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var can_place_to_side: bool = side_space >= 180.0
_choice_width = minf(440.0, side_space) if can_place_to_side else minf(440.0, _stage_rect.size.x)
_compact_choices = can_place_to_side and _choice_width < 320.0
return previous_compact != _compact_choices or not is_equal_approx(previous_width, _choice_width)
func _apply_theme_styles() -> void:
if _bar == null or _chooser == null:
return
_bar.add_theme_stylebox_override("panel", _panel_style())
_chooser.add_theme_stylebox_override("panel", _panel_style())
_bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_sync_category_buttons()
var action_normal: StyleBoxFlat = _choice_button_style(false, "#344255", false)
var action_hover: StyleBoxFlat = _choice_button_style(false, "#40546D", true)
for action_button: Button in [_done_styling_button]:
if action_button == null:
continue
action_button.add_theme_stylebox_override("normal", action_normal)
action_button.add_theme_stylebox_override("hover", action_hover)
action_button.add_theme_stylebox_override("pressed", action_normal)
action_button.add_theme_stylebox_override("focus", action_normal)
action_button.add_theme_color_override("font_color", Color("#FFFFFF"))
_apply_category_button_style(_message_button, _message_dialog_open)
if _theme != null:
var gap: int = int(_theme.speech_toolbar_number("gap", 8.0, 4.0, 20.0))
_speech_flow.add_theme_constant_override("h_separation", gap)
_speech_flow.add_theme_constant_override("v_separation", gap)
_sync_sequence_controls()
func _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7")
style.border_color = _border_color()
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.set_content_margin_all(8)
return style
func _ribbon_style(color: Color) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = color
style.set_corner_radius_all(7)
style.set_content_margin(SIDE_LEFT, 10.0)
style.set_content_margin(SIDE_TOP, 4.0)
style.set_content_margin(SIDE_RIGHT, 10.0)
style.set_content_margin(SIDE_BOTTOM, 4.0)
return style
func _ribbon_color() -> Color:
return _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0")
func _make_button(caption: String) -> Button:
var button: Button = Button.new()
button.text = caption
button.custom_minimum_size.y = _hit_height()
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 18)
return button
func _hit_height() -> float:
return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0
func _max_width() -> float:
return _theme.speech_toolbar_number("max_width", 560.0, 300.0, 720.0) if _theme != null else 560.0
func _gap() -> float:
return _theme.speech_toolbar_number("gap", 10.0, 4.0, 24.0) if _theme != null else 10.0
func _border_color() -> Color:
return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF")
func _selection_color() -> Color:
return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D")
func _frame_color(frame_id: String, channel: String, fallback: Color) -> Color:
return _theme.speech_color(frame_id, channel, fallback) if _theme != null else fallback
func _size_font_size(size_id: String) -> int:
match size_id:
"small": return 20
"huge": return 40
_: return 28
+1
View File
@@ -0,0 +1 @@
uid://0d2x5ns8go3v
+18
View File
@@ -0,0 +1,18 @@
extends RichTextEffect
## Per-word motions driven entirely by the owning speech group's saved clock.
var bbcode: String = "speechmotion"
func _process_custom_fx(char_fx: CharFXTransform) -> bool:
var env: Dictionary = char_fx.env
var kind: String = String(env.get("kind", ""))
var clock: float = float(env.get("clock", 0.0))
var index: float = float(char_fx.relative_index)
match kind:
"shake":
char_fx.offset += Vector2(sin(clock * 34.0 + index * 11.0) * 2.8, cos(clock * 41.0 + index * 7.0) * 1.8)
"wave":
char_fx.offset.y += sin(clock * 11.0 - index * 0.72) * 6.0
"bounce":
char_fx.offset.y -= absf(sin(clock * 7.0 - index * 0.42)) * 8.0
return true
+1
View File
@@ -0,0 +1 @@
uid://djm7bdf1q5ao0
+114 -10
View File
@@ -6,6 +6,7 @@ extends RefCounted
const FILE_VERSION: String = "1.1"
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/trigger_registry.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
@@ -25,10 +26,11 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for action: Dictionary in actions:
for action_index: int in actions.size():
var action: Dictionary = actions[action_index]
var encoded := _encode_flat_action(action)
if encoded.is_empty():
warnings.append("Skipped invalid queue action.")
warnings.append("Skipped invalid queue action %d (%s)." % [action_index + 1, String(action.get("type", "unknown"))])
else:
out.append(encoded)
return {"actions": out, "warnings": warnings}
@@ -36,13 +38,14 @@ static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
static func decode_queue(entries: Array) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
for entry: Variant in entries:
for entry_index: int in entries.size():
var entry: Variant = entries[entry_index]
if not entry is Dictionary:
warnings.append("Skipped malformed queue action.")
warnings.append("Skipped malformed queue action %d (unknown type)." % (entry_index + 1))
continue
var decoded := _decode_flat_action(entry as Dictionary)
if decoded.is_empty():
warnings.append("Skipped invalid queue action.")
warnings.append("Skipped invalid queue action %d (%s)." % [entry_index + 1, String((entry as Dictionary).get("type", "unknown"))])
else:
out.append(decoded)
return {"actions": out, "warnings": warnings}
@@ -199,7 +202,7 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
"walk_to":
if not action.get("target") is Vector2: return {}
return {"type": type, "target": vector_to_data(action["target"])}
"speak": return {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false))} if _finite_number(action.get("duration", 2.0)) else {}
"speak": return _encode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
@@ -222,10 +225,7 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
if not is_vector_data(action.get("target")): return {}
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
"speak":
if not _finite_number(action.get("duration", 2.0)): return {}
var out := {"type": type, "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0))}
if bool(action.get("stop_current", false)): out["stop_current"] = true
return out
return _decode_speech(action)
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
"pick_up_prop":
var prop_id := String(action.get("target_prop_id", ""))
@@ -247,6 +247,110 @@ static func _unmap_ref(value: Variant, map: Dictionary, unavailable: Dictionary)
if not value is String or unavailable.has(value) or not map.has(value): return -2
return int(map[value])
static func _encode_speech(action: Dictionary) -> Dictionary:
if not _finite_number(action.get("duration", 2.0)):
return {}
var out: Dictionary = {"type": "speak", "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false)), "keep_acting": bool(action.get("keep_acting", false))}
if not action.has("bubbles"):
if action.get("visual", null) is Dictionary:
out["visual"] = (action["visual"] as Dictionary).duplicate(true)
for key: String in ["reveal", "pop_in"]:
if action.has(key):
out[key] = action[key]
var legacy_speech: Dictionary = SPEECH_PAYLOAD.normalize(action)
for key: String in ["arrangement", "bubble_layout", "connect_bubbles"]:
out[key] = legacy_speech.get(key)
if action.get("placement", null) is Dictionary:
var legacy_placement: Dictionary = (action["placement"] as Dictionary).duplicate(true)
if String(legacy_placement.get("mode", "auto")) == "manual" and legacy_placement.get("center", null) is Vector2:
legacy_placement["center"] = vector_to_data(legacy_placement["center"])
out["placement"] = legacy_placement
return out
var checked: Dictionary = action.duplicate(true)
checked["type"] = "speak"
var error: String = SPEECH_PAYLOAD.validate(checked)
if not error.is_empty():
return {}
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(checked)
for key: String in ["arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "keep_acting"]:
if normalized.has(key):
out[key] = normalized[key]
var encoded_bubbles: Array[Dictionary] = []
for raw_bubble: Variant in normalized.get("bubbles", []):
if not raw_bubble is Dictionary:
return {}
var bubble: Dictionary = (raw_bubble as Dictionary).duplicate(true)
var bubble_placement_value: Variant = bubble.get("placement", null)
if bubble_placement_value is Dictionary:
var bubble_placement: Dictionary = bubble_placement_value
if String(bubble_placement.get("mode", "auto")) == "manual":
var bubble_center: Variant = bubble_placement.get("center", null)
if bubble_center is Vector2:
bubble_placement["center"] = vector_to_data(bubble_center)
bubble["placement"] = bubble_placement
encoded_bubbles.append(bubble)
out["bubbles"] = encoded_bubbles
var placement: Dictionary = normalized.get("placement", {"mode": "auto"}).duplicate(true)
if String(placement.get("mode", "auto")) == "manual":
var center: Variant = placement.get("center", Vector2.ZERO)
if center is Vector2:
placement["center"] = vector_to_data(center)
out["placement"] = placement
return out
static func _decode_speech(action: Dictionary) -> Dictionary:
if not _finite_number(action.get("duration", 2.0)):
return {}
var raw: Dictionary = action.duplicate(true)
if raw.has("placement") and raw["placement"] is Dictionary:
var placement: Dictionary = raw["placement"]
if String(placement.get("mode", "auto")) == "manual":
if not is_vector_data(placement.get("center")):
return {}
placement["center"] = data_to_vector(placement["center"], Vector2.ZERO)
if raw.get("bubbles", null) is Array:
var decoded_bubbles: Array = []
for raw_bubble: Variant in raw["bubbles"] as Array:
if not raw_bubble is Dictionary:
return {}
var bubble: Dictionary = (raw_bubble as Dictionary).duplicate(true)
if bubble.has("placement"):
if not bubble["placement"] is Dictionary:
return {}
var bubble_placement: Dictionary = bubble["placement"]
if String(bubble_placement.get("mode", "auto")) == "manual":
if not is_vector_data(bubble_placement.get("center")):
return {}
bubble_placement["center"] = data_to_vector(bubble_placement["center"], Vector2.ZERO)
decoded_bubbles.append(bubble)
raw["bubbles"] = decoded_bubbles
if not raw.has("bubbles"):
var legacy: Dictionary = {"type": "speak", "text": String(raw.get("text", "")), "duration": float(raw.get("duration", 2.0))}
if raw.get("visual", null) is Dictionary:
legacy["visual"] = (raw["visual"] as Dictionary).duplicate(true)
if raw.get("placement", null) is Dictionary:
legacy["placement"] = (raw["placement"] as Dictionary).duplicate(true)
if bool(raw.get("stop_current", false)):
legacy["stop_current"] = true
if raw.has("keep_acting"):
legacy["keep_acting"] = bool(raw.get("keep_acting", false))
for key: String in ["reveal", "pop_in"]:
if raw.has(key):
legacy[key] = raw[key]
var legacy_speech: Dictionary = SPEECH_PAYLOAD.normalize(raw)
for key: String in ["arrangement", "bubble_layout", "connect_bubbles"]:
legacy[key] = legacy_speech.get(key)
return legacy
if raw.has("bubbles"):
var error: String = SPEECH_PAYLOAD.validate(raw)
if not error.is_empty():
return {}
var decoded: Dictionary = SPEECH_PAYLOAD.normalize(raw)
decoded.erase("legacy_fixed")
return decoded
static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary:
var type := String(trigger.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
+42 -20
View File
@@ -6,7 +6,7 @@ extends Node
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
@@ -40,6 +40,7 @@ var _phase_timer: float = 0.0
var _primary_speech: Dictionary = {}
var _overlay_speech: Dictionary = {}
var _speech_bubble: Node2D = null
var _speech_preview_suppressed: bool = false
var _action_ids: Array[int] = []
var _next_action_id: int = 1
var _current_action_id: int = -1
@@ -90,15 +91,33 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
func speak(text: String, duration: float) -> void:
_primary_speech = {"text": text, "time_left": maxf(duration, 0.0)}
speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
func speak_action(action: Dictionary) -> void:
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action)
var duration: float = SPEECH_PAYLOAD.duration_for(normalized)
_primary_speech = {"action": normalized, "elapsed": 0.0, "duration": duration}
_show_visible_speech()
func speak_overlay(text: String, duration: float) -> void:
_overlay_speech = {"text": text, "time_left": maxf(duration, 0.0)}
speak_overlay_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
func speak_overlay_action(action: Dictionary) -> void:
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(action)
var duration: float = SPEECH_PAYLOAD.duration_for(normalized)
_overlay_speech = {"action": normalized, "elapsed": 0.0, "duration": duration}
_show_visible_speech()
func set_speech_preview_suppressed(value: bool) -> void:
_speech_preview_suppressed = value
if _speech_bubble != null and is_instance_valid(_speech_bubble):
(_speech_bubble as SpeechBubble).set_suppressed(value)
func hide_speech() -> void:
_primary_speech.clear()
_overlay_speech.clear()
@@ -108,7 +127,7 @@ func hide_speech() -> void:
func sync_facing(profile: int) -> void:
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE
(_speech_bubble as SpeechBubble).set_mirrored(profile == StickmanRig.FacingProfile.LEFT)
func set_authored_queue(actions: Array[Dictionary]) -> bool:
@@ -266,7 +285,7 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
for action: Dictionary in actions:
var role: int = ACTION_REGISTRY.reactive_role(action)
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
speak_overlay_action(action)
continue
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
if not takeover_seen:
@@ -394,8 +413,12 @@ func _begin_action(action: Dictionary) -> void:
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
_finish_action()
"speak":
_action_phase = ActionPhase.SPEAKING
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
if bool(action.get("keep_acting", false)):
speak_overlay_action(action)
_finish_action()
else:
_action_phase = ActionPhase.SPEAKING
speak_action(action)
"wait":
_phase_timer = float(action.get("duration", 0.0))
_action_phase = ActionPhase.WAITING
@@ -523,22 +546,20 @@ func _is_blocked() -> bool:
func _update_speech(delta: float) -> void:
var visible_owner_changed: bool = false
if not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
if float(_primary_speech["time_left"]) <= 0.0:
_primary_speech.clear()
speech_finished.emit()
if not _overlay_speech.is_empty():
_overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta
if float(_overlay_speech["time_left"]) <= 0.0:
_overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta
if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)):
_overlay_speech.clear()
visible_owner_changed = true
speech_finished.emit()
elif not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta
if float(_primary_speech.get("elapsed", 0.0)) >= float(_primary_speech.get("duration", 0.0)):
_primary_speech.clear()
speech_finished.emit()
if _overlay_speech.is_empty() and _primary_speech.is_empty():
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
elif visible_owner_changed and _overlay_speech.is_empty():
else:
_show_visible_speech()
@@ -546,11 +567,12 @@ func _show_visible_speech() -> void:
if _rig == null:
return
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as Node2D
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as SpeechBubble
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
_rig.add_child(_speech_bubble)
sync_facing(_rig.facing_profile)
(_speech_bubble as SpeechBubble).set_suppressed(_speech_preview_suppressed)
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
if not visible_speech.is_empty():
_speech_bubble.show_text(String(visible_speech.get("text", "")))
var action: Dictionary = visible_speech.get("action", {})
(_speech_bubble as SpeechBubble).render_action(action, float(visible_speech.get("elapsed", 0.0)), false)
+33
View File
@@ -410,6 +410,29 @@ func get_facing_profile() -> int:
return int(facing_profile)
func speech_head_geometry() -> Dictionary:
var head_visual: Node2D = _body_container.get_node_or_null(NodePath("Head")) as Node2D if _body_container != null and is_instance_valid(_body_container) else null
if state == RigState.RAGDOLL and _ragdoll_component != null and is_instance_valid(_ragdoll_component):
var body_nodes: Variant = _ragdoll_component.call("get_body_nodes")
if body_nodes is Array:
for raw_body: Variant in body_nodes as Array:
if not raw_body is RigidBody2D:
continue
var body: RigidBody2D = raw_body as RigidBody2D
if String(body.get_meta("part_name", "")) == "head":
var body_radius: float = float(body.get_meta("radius", 100.0))
var body_scale: Vector2 = body.global_scale.abs()
return {"center": to_local(body.global_position), "radius": body_radius * maxf(body_scale.x, body_scale.y)}
if head_visual != null and is_instance_valid(head_visual):
var raw_radius: Variant = head_visual.get("radius")
var radius: float = float(raw_radius) if raw_radius is int or raw_radius is float else 100.0
var head_scale: Vector2 = head_visual.global_scale.abs()
return {"center": to_local(head_visual.global_position), "radius": radius * maxf(head_scale.x, head_scale.y)}
if _head_bone != null and is_instance_valid(_head_bone):
return {"center": to_local(_head_bone.global_position), "radius": 100.0 * maxf(absf(_head_bone.global_scale.x), absf(_head_bone.global_scale.y))}
return {"center": Vector2(0.0, -310.0), "radius": 100.0}
func set_joint_bend_flipped(joint: String, flipped: bool) -> void:
match joint:
"LeftArm":
@@ -785,6 +808,16 @@ func speak(text: String, duration: float) -> void:
_action_runner_component.speak(text, duration)
func speak_action(action: Dictionary) -> void:
if _action_runner_component != null:
_action_runner_component.speak_action(action)
func set_speech_preview_suppressed(value: bool) -> void:
if _action_runner_component != null:
_action_runner_component.set_speech_preview_suppressed(value)
func _speak_overlay(text: String, duration: float) -> void:
if _action_runner_component != null:
_action_runner_component.speak_overlay(text, duration)
File diff suppressed because it is too large Load Diff