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
+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