Files
stickman/scripts/speech/speech_payload.gd
T

522 lines
23 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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