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