- 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.
522 lines
23 KiB
GDScript
522 lines
23 KiB
GDScript
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
|