Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 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
+1
View File
@@ -0,0 +1 @@
uid://djyp1epb6jai
+626
View File
@@ -0,0 +1,626 @@
class_name SpeechShowToolbar
extends Control
## Stage-side controls that apply presentation choices to an entire speech.
signal choice_selected(key: StringName, value: Variant)
signal layout_preset_requested(preset: StringName)
signal preview_speech_requested()
signal done_speech_requested()
signal cancel_speech_requested()
var _theme: SandboxTheme
var _action: Dictionary = {}
var _group_rect: Rect2 = Rect2()
var _stage_rect: Rect2 = Rect2()
var _badge_avoidance_rects: Array[Rect2] = []
var _busy: bool = false
var _pin_top_while_more_open: bool = false
var _preserve_top_on_layout: bool = false
var _pinned_panel_top: float = 0.0
var _panel: PanelContainer
var _content: VBoxContainer
var _body_scroll: ScrollContainer
var _body_content: VBoxContainer
var _title_ribbon: PanelContainer
var _buttons_by_key: Dictionary = {}
var _button_values_by_key: Dictionary = {}
var _more_button: Button
var _more_content: VBoxContainer
var _keep_acting_button: Button
var _preview_button: Button
var _done_button: Button
var _cancel_button: Button
var _layout_preset_buttons: Array[Button] = []
var _layout_preset_values: Array[StringName] = []
var _connect_bubbles_button: Button
class BubbleLayoutSample extends Control:
var same_spot: bool = false
func _ready() -> void:
custom_minimum_size = Vector2(58.0, 42.0)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
var boxes: Array[Rect2] = []
if same_spot:
boxes = [Rect2(4.0, 5.0, 34.0, 22.0), Rect2(10.0, 9.0, 34.0, 22.0), Rect2(16.0, 13.0, 34.0, 22.0)]
else:
boxes = [Rect2(2.0, 20.0, 32.0, 20.0), Rect2(13.0, 10.0, 32.0, 20.0), Rect2(24.0, 0.0, 32.0, 20.0)]
for index: int in boxes.size():
var bubble_style: StyleBoxFlat = StyleBoxFlat.new()
bubble_style.bg_color = Color("#FFFDF6") if index == boxes.size() - 1 else Color("#DDE7F0")
bubble_style.border_color = Color("#202733")
bubble_style.set_border_width_all(2)
bubble_style.set_corner_radius_all(5)
draw_style_box(bubble_style, boxes[index])
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 79
_build_ui()
visible = false
func configure(theme_data: SandboxTheme) -> void:
_theme = theme_data
if is_inside_tree() and _panel != null:
_apply_theme()
func set_context(action: Dictionary, _reactive_options: bool = false) -> void:
_action = action.duplicate(true)
if _more_button != null:
_more_button.visible = true
_more_button.text = "▾ More speech settings" if _more_content != null and _more_content.visible else "▸ More speech settings"
if _keep_acting_button != null:
_keep_acting_button.set_pressed_no_signal(bool(_action.get("keep_acting", false)))
_refresh_choices()
_refresh_layout_buttons()
_layout_panel()
func set_anchor_rect(group_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void:
var anchor_changed: bool = _rect_changed(_group_rect, group_viewport_rect) or _rect_changed(_stage_rect, stage_viewport_rect)
_group_rect = group_viewport_rect
_stage_rect = stage_viewport_rect
if anchor_changed and _more_content != null and _more_content.visible:
_pin_top_while_more_open = false
visible = not _busy and _group_rect.size.x > 0.0 and _group_rect.size.y > 0.0 and _stage_rect.size.x > 0.0 and _stage_rect.size.y > 0.0
if visible:
_layout_panel()
func set_badge_avoidance_rects(rects: Array[Rect2]) -> void:
if _badge_avoidance_rects == rects:
return
_badge_avoidance_rects = rects.duplicate()
if visible:
_layout_panel()
func set_busy(busy: bool) -> void:
_busy = busy
for key: Variant in _buttons_by_key.keys():
for button: Button in _buttons_by_key[key]:
button.disabled = busy
for button: Button in [_more_button, _preview_button, _done_button, _cancel_button, _keep_acting_button]:
if button != null:
button.disabled = busy
_refresh_layout_buttons()
_refresh_choices()
visible = not busy and _group_rect.size.x > 0.0 and _group_rect.size.y > 0.0 and _stage_rect.size.x > 0.0 and _stage_rect.size.y > 0.0
if visible:
_layout_panel()
func contains_viewport_point(point: Vector2) -> bool:
return visible and _panel != null and _panel.visible and _panel.get_global_rect().has_point(point)
func panel_viewport_rect() -> Rect2:
return _panel.get_global_rect() if _panel != null and is_instance_valid(_panel) and _panel.visible else Rect2()
func _build_ui() -> void:
_panel = PanelContainer.new()
_panel.name = "SpeechShowPanel"
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_panel)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 9)
margin.add_theme_constant_override("margin_bottom", 9)
_panel.add_child(margin)
_content = VBoxContainer.new()
_content.add_theme_constant_override("separation", 8)
margin.add_child(_content)
_body_scroll = ScrollContainer.new()
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_body_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_content.add_child(_body_scroll)
_body_content = VBoxContainer.new()
_body_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_content.add_theme_constant_override("separation", 8)
_body_scroll.add_child(_body_content)
_title_ribbon = PanelContainer.new()
_title_ribbon.name = "SpeechShowRibbon"
_body_content.add_child(_title_ribbon)
var title: Label = Label.new()
title.text = "Show speech"
title.add_theme_font_size_override("font_size", 22)
title.add_theme_color_override("font_color", Color.WHITE)
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title_ribbon.add_child(title)
_add_choice_row("💬 Show bubbles", &"arrangement", [["Show all", "together"], ["One at a time", "take_turns"]])
_add_layout_preset_row()
_add_connect_bubbles_row()
_more_button = Button.new()
_more_button.text = "▸ More speech settings"
_more_button.toggle_mode = true
_more_button.custom_minimum_size = Vector2(220.0, _hit_height())
_more_button.focus_mode = Control.FOCUS_NONE
_more_button.toggled.connect(_on_more_toggled)
_body_content.add_child(_more_button)
_more_content = VBoxContainer.new()
_more_content.add_theme_constant_override("separation", 4)
_more_content.hide()
_body_content.add_child(_more_content)
_add_choice_row("🔤 Words appear", &"reveal", [["All at once", "instant"], ["Letter by letter", "letter"], ["Word by word", "word"]], _more_content)
_add_choice_row("✨ Bubble motion", &"pop_in", [["Bounce in", true], ["Just appear", false]], _more_content)
_add_choice_row("⏱ Timing", &"duration_mode", [["Normal", "just_enough"], ["Slow", "longer"], ["Slower", "longest"]], _more_content)
var keep_acting_row := HBoxContainer.new()
keep_acting_row.add_theme_constant_override("separation", 10)
_more_content.add_child(keep_acting_row)
var keep_acting_label := Label.new()
keep_acting_label.text = "🎭 Keep acting while talking"
keep_acting_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
keep_acting_label.add_theme_font_size_override("font_size", 19)
keep_acting_label.add_theme_color_override("font_color", Color("#F2F4F8"))
keep_acting_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
keep_acting_row.add_child(keep_acting_label)
_keep_acting_button = Button.new()
_keep_acting_button.toggle_mode = true
_keep_acting_button.custom_minimum_size = Vector2(88.0, _hit_height())
_keep_acting_button.focus_mode = Control.FOCUS_NONE
_keep_acting_button.add_theme_font_size_override("font_size", 17)
_keep_acting_button.toggled.connect(_on_keep_acting_toggled)
keep_acting_row.add_child(_keep_acting_button)
var footer: HFlowContainer = HFlowContainer.new()
footer.add_theme_constant_override("h_separation", 8)
footer.add_theme_constant_override("v_separation", 8)
_content.add_child(footer)
_preview_button = _action_button("▶ Preview speech")
_preview_button.pressed.connect(func() -> void: preview_speech_requested.emit())
footer.add_child(_preview_button)
_done_button = _action_button("✓ Done speech")
_done_button.pressed.connect(func() -> void: done_speech_requested.emit())
footer.add_child(_done_button)
_cancel_button = _action_button("Cancel speech")
_cancel_button.pressed.connect(func() -> void: cancel_speech_requested.emit())
footer.add_child(_cancel_button)
_apply_theme()
func _add_choice_row(label_text: String, key: StringName, choices: Array, parent: Control = null) -> void:
var row: VBoxContainer = VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
var row_parent: Control = parent if parent != null else _body_content
row_parent.add_child(row)
var heading: Label = Label.new()
heading.text = label_text
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Color("#F2F4F8"))
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(heading)
var flow: HFlowContainer = HFlowContainer.new()
flow.add_theme_constant_override("h_separation", 7)
flow.add_theme_constant_override("v_separation", 7)
row.add_child(flow)
var buttons: Array[Button] = []
var values: Array[Variant] = []
for pair: Array in choices:
var choice_value: Variant = pair[1]
var button: Button = Button.new()
button.text = String(pair[0])
button.toggle_mode = true
button.custom_minimum_size = Vector2(136.0, _hit_height())
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 17)
button.pressed.connect(_on_choice_pressed.bind(key, choice_value))
flow.add_child(button)
buttons.append(button)
values.append(choice_value)
_buttons_by_key[key] = buttons
_button_values_by_key[key] = values
func _add_layout_preset_row() -> void:
var row: VBoxContainer = VBoxContainer.new()
row.add_theme_constant_override("separation", 4)
_body_content.add_child(row)
var heading: Label = Label.new()
heading.text = "🗨️ Bubble layout"
heading.add_theme_font_size_override("font_size", 19)
heading.add_theme_color_override("font_color", Color("#F2F4F8"))
heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(heading)
var flow: HFlowContainer = HFlowContainer.new()
flow.add_theme_constant_override("h_separation", 7)
flow.add_theme_constant_override("v_separation", 7)
row.add_child(flow)
_add_layout_preset_button(flow, "Stack", &"stack", false, "Arrange the bubbles upward from Bubble 1.")
_add_layout_preset_button(flow, "Same spot", &"same_spot", true, "Put every bubble at Bubble 1's position.")
func _add_connect_bubbles_row() -> void:
var row: HBoxContainer = HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
_body_content.add_child(row)
var label: Label = Label.new()
label.text = "🔗 Connect bubbles"
label.add_theme_font_size_override("font_size", 19)
label.add_theme_color_override("font_color", Color("#F2F4F8"))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(label)
_connect_bubbles_button = Button.new()
_connect_bubbles_button.toggle_mode = true
_connect_bubbles_button.custom_minimum_size = Vector2(112.0, _hit_height())
_connect_bubbles_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_connect_bubbles_button.focus_mode = Control.FOCUS_NONE
_connect_bubbles_button.add_theme_font_size_override("font_size", 17)
_connect_bubbles_button.pressed.connect(_on_connect_bubbles_pressed)
row.add_child(_connect_bubbles_button)
func _add_layout_preset_button(parent: HFlowContainer, caption: String, preset: StringName, same_spot: bool, hint: String) -> void:
var button: Button = Button.new()
var button_width: float = 184.0 if same_spot else 150.0
button.custom_minimum_size = Vector2(button_width, maxf(64.0, _hit_height()))
button.toggle_mode = true
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.focus_mode = Control.FOCUS_NONE
button.tooltip_text = hint
button.pressed.connect(func() -> void: layout_preset_requested.emit(preset))
var margin: MarginContainer = MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 5)
margin.add_theme_constant_override("margin_right", 5)
margin.add_theme_constant_override("margin_top", 4)
margin.add_theme_constant_override("margin_bottom", 4)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(margin)
var contents: HBoxContainer = HBoxContainer.new()
contents.add_theme_constant_override("separation", 2)
contents.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(contents)
var sample: BubbleLayoutSample = BubbleLayoutSample.new()
sample.same_spot = same_spot
contents.add_child(sample)
var label: Label = Label.new()
label.text = caption
label.add_theme_font_size_override("font_size", 16)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
contents.add_child(label)
parent.add_child(button)
_layout_preset_buttons.append(button)
_layout_preset_values.append(preset)
func _on_choice_pressed(key: StringName, value: Variant) -> void:
if _busy:
return
_action[String(key)] = value
_refresh_choices()
choice_selected.emit(key, value)
func _on_connect_bubbles_pressed() -> void:
if _busy or _connect_bubbles_button == null:
return
var enabled: bool = not bool(_action.get("connect_bubbles", false))
_action["connect_bubbles"] = enabled
_refresh_connect_bubbles_button()
choice_selected.emit(&"connect_bubbles", enabled)
func _on_keep_acting_toggled(enabled: bool) -> void:
if _busy or _keep_acting_button == null:
return
_action["keep_acting"] = enabled
_refresh_keep_acting_toggle()
choice_selected.emit(&"keep_acting", enabled)
func _on_more_toggled(opened: bool) -> void:
if _busy:
return
_more_content.visible = opened
_more_button.text = "▾ More speech settings" if opened else "▸ More speech settings"
if opened:
_pinned_panel_top = _panel.position.y
_pin_top_while_more_open = true
else:
_pin_top_while_more_open = false
_preserve_top_on_layout = true
_layout_panel()
func _refresh_choices() -> void:
for raw_key: Variant in _buttons_by_key.keys():
var key: StringName = StringName(raw_key)
var buttons: Array = _buttons_by_key[key]
var values: Array = _button_values_by_key[key]
var current: Variant = _action.get(String(key), _default_value(key))
for index: int in buttons.size():
var button: Button = buttons[index] as Button
var selected: bool = current == values[index]
button.set_pressed_no_signal(selected)
_apply_choice_style(button, selected)
button.disabled = _busy
_refresh_connect_bubbles_button()
_refresh_keep_acting_toggle()
func _refresh_layout_buttons() -> void:
var bubbles: Array = _action.get("bubbles", [])
var disabled: bool = _busy or bubbles.size() <= 1
var selected_layout: String = String(_action.get("bubble_layout", "stack"))
for index: int in _layout_preset_buttons.size():
var button: Button = _layout_preset_buttons[index]
var selected: bool = index < _layout_preset_values.size() and String(_layout_preset_values[index]) == selected_layout
button.disabled = disabled
button.set_pressed_no_signal(selected)
_apply_choice_style(button, selected)
if _connect_bubbles_button != null:
_connect_bubbles_button.disabled = _busy or bubbles.size() < 2
_connect_bubbles_button.set_pressed_no_signal(bool(_action.get("connect_bubbles", false)))
_connect_bubbles_button.text = "On" if bool(_action.get("connect_bubbles", false)) else "Off"
_apply_choice_style(_connect_bubbles_button, bool(_action.get("connect_bubbles", false)))
func _refresh_connect_bubbles_button() -> void:
if _connect_bubbles_button == null:
return
var bubbles: Array = _action.get("bubbles", []) if _action.get("bubbles", []) is Array else []
var enabled: bool = bool(_action.get("connect_bubbles", false))
_connect_bubbles_button.disabled = _busy or bubbles.size() < 2
_connect_bubbles_button.set_pressed_no_signal(enabled)
_connect_bubbles_button.text = "On" if enabled else "Off"
_apply_choice_style(_connect_bubbles_button, enabled)
func _refresh_keep_acting_toggle() -> void:
if _keep_acting_button == null:
return
var enabled: bool = bool(_action.get("keep_acting", false))
_keep_acting_button.disabled = _busy
_keep_acting_button.set_pressed_no_signal(enabled)
_keep_acting_button.text = "ON" if enabled else "OFF"
_apply_choice_style(_keep_acting_button, enabled)
func _default_value(key: StringName) -> Variant:
match key:
&"arrangement": return "together"
&"reveal": return "instant"
&"pop_in": return false
&"duration_mode": return "just_enough"
return null
func _layout_panel() -> void:
if _panel == null or _stage_rect.size.x <= 0.0 or _stage_rect.size.y <= 0.0:
return
var gap: float = _gap()
var max_width: float = minf(_max_width(), _stage_rect.size.x)
var preserve_top: bool = _preserve_top_on_layout or (_pin_top_while_more_open and _more_content != null and _more_content.visible)
var pinned_top: float = _pinned_panel_top if _pin_top_while_more_open and _more_content != null and _more_content.visible else _panel.position.y
pinned_top = clampf(pinned_top, _stage_rect.position.y, _stage_rect.end.y)
var available_height: float = _stage_rect.size.y
if preserve_top:
available_height = maxf(1.0, _stage_rect.end.y - pinned_top)
var left_space: float = maxf(0.0, _group_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _group_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var panel_width: float = max_width
var panel_height: float
var panel_position: Vector2
if side_space >= minf(max_width, 300.0):
panel_width = minf(max_width, side_space)
panel_height = _fit_panel_height(panel_width, available_height)
var use_right: bool = right_space >= left_space
panel_position.x = _group_rect.end.x + gap if use_right else _group_rect.position.x - panel_width - gap
panel_position.y = clampf(_group_rect.get_center().y - panel_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - panel_height))
else:
panel_width = max_width
panel_height = _fit_panel_height(panel_width, available_height)
var below_space: float = _stage_rect.end.y - _group_rect.end.y - gap
var above_space: float = _group_rect.position.y - _stage_rect.position.y - gap
var place_below: bool = below_space >= panel_height or below_space >= above_space
panel_position.x = clampf(_group_rect.get_center().x - panel_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - panel_width))
panel_position.y = _group_rect.end.y + gap if place_below else _group_rect.position.y - panel_height - gap
panel_position.x = clampf(panel_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - panel_width))
if preserve_top:
panel_position.y = pinned_top
else:
panel_position.y = clampf(panel_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - panel_height))
panel_position = _position_avoiding_badges(panel_position, Vector2(panel_width, panel_height), _group_rect, preserve_top, pinned_top)
_panel.position = panel_position
_panel.size = Vector2(panel_width, panel_height)
_preserve_top_on_layout = false
func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, lock_y: bool, locked_y: float) -> Vector2:
var candidates: Array[Vector2] = [preferred]
var gap: float = _gap()
var center: Vector2 = anchor.get_center()
if lock_y:
candidates.append(Vector2(anchor.position.x - size.x - gap, locked_y))
candidates.append(Vector2(anchor.end.x + gap, locked_y))
candidates.append(Vector2(_stage_rect.position.x, locked_y))
candidates.append(Vector2(_stage_rect.end.x - size.x, locked_y))
else:
candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5))
candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y))
candidates.append(_stage_rect.get_center() - size * 0.5)
var best: Vector2 = _clamp_panel_position(preferred, size, lock_y, locked_y)
var best_collisions: int = _badge_collision_count(best, size)
var best_distance: float = best.distance_squared_to(preferred)
for candidate: Vector2 in candidates:
var clamped: Vector2 = _clamp_panel_position(candidate, size, lock_y, locked_y)
var collisions: int = _badge_collision_count(clamped, size)
var distance: float = clamped.distance_squared_to(preferred)
if collisions < best_collisions or (collisions == best_collisions and distance < best_distance):
best = clamped
best_collisions = collisions
best_distance = distance
return best
func _clamp_panel_position(candidate: Vector2, size: Vector2, lock_y: bool, locked_y: float) -> Vector2:
var y: float = locked_y if lock_y else clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y))
return Vector2(clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)), y)
func _badge_collision_count(position: Vector2, size: Vector2) -> int:
var panel_rect: Rect2 = Rect2(position, size)
var count: int = 0
for badge_rect: Rect2 in _badge_avoidance_rects:
if panel_rect.intersects(badge_rect):
count += 1
return count
func _rect_changed(previous: Rect2, next: Rect2, epsilon: float = 2.0) -> bool:
if previous.size == Vector2.ZERO:
return true
return previous.position.distance_to(next.position) > epsilon or previous.size.distance_to(next.size) > epsilon
func _fit_panel_height(panel_width: float, available_height: float) -> float:
_panel.custom_minimum_size = Vector2(panel_width, 0.0)
_body_scroll.custom_minimum_size.y = 0.0
_panel.reset_size()
var fixed_height: float = _panel.get_combined_minimum_size().y
var body_height: float = _body_content.get_combined_minimum_size().y
var max_height: float = maxf(1.0, available_height)
var body_room: float = maxf(48.0, max_height - fixed_height)
_body_scroll.custom_minimum_size.y = minf(body_height, body_room)
_panel.reset_size()
return minf(maxf(1.0, _panel.get_combined_minimum_size().y), max_height)
func _apply_theme() -> void:
if _panel == null:
return
_panel.add_theme_stylebox_override("panel", _panel_style())
if _title_ribbon != null:
_title_ribbon.add_theme_stylebox_override("panel", _ribbon_style())
_refresh_choices()
_apply_action_button_styles()
func _apply_choice_style(button: Button, selected: bool) -> void:
button.add_theme_stylebox_override("normal", _choice_style(selected, false))
button.add_theme_stylebox_override("hover", _choice_style(selected, true))
button.add_theme_stylebox_override("pressed", _choice_style(selected, true))
button.add_theme_stylebox_override("focus", _choice_style(selected, true))
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
func _action_button(caption: String) -> Button:
var button: Button = Button.new()
button.text = caption
button.custom_minimum_size = Vector2(136.0, _hit_height())
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 17)
return button
func _apply_action_button_styles() -> void:
for button: Button in [_preview_button, _done_button, _cancel_button, _more_button]:
if button == null:
continue
button.add_theme_stylebox_override("normal", _choice_style(false, false))
button.add_theme_stylebox_override("hover", _choice_style(false, true))
button.add_theme_stylebox_override("pressed", _choice_style(false, true))
button.add_theme_color_override("font_color", Color.WHITE)
for button: Button in _layout_preset_buttons:
button.add_theme_stylebox_override("normal", _choice_style(false, false))
button.add_theme_stylebox_override("hover", _choice_style(false, true))
button.add_theme_stylebox_override("pressed", _choice_style(false, true))
button.add_theme_color_override("font_color", Color.WHITE)
if _connect_bubbles_button != null:
_connect_bubbles_button.add_theme_stylebox_override("normal", _choice_style(false, false))
_connect_bubbles_button.add_theme_stylebox_override("hover", _choice_style(bool(_action.get("connect_bubbles", false)), true))
_connect_bubbles_button.add_theme_stylebox_override("pressed", _choice_style(bool(_action.get("connect_bubbles", false)), true))
_connect_bubbles_button.add_theme_color_override("font_color", Color.WHITE)
_refresh_layout_buttons()
func _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7")
style.border_color = _border_color()
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.set_content_margin_all(8)
return style
func _ribbon_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0")
style.set_corner_radius_all(7)
style.set_content_margin(SIDE_LEFT, 9.0)
style.set_content_margin(SIDE_TOP, 3.0)
style.set_content_margin(SIDE_RIGHT, 9.0)
style.set_content_margin(SIDE_BOTTOM, 3.0)
return style
func _choice_style(selected: bool, hovered: bool) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color("#176B83") if selected else (Color("#344154") if hovered else Color("#202733"))
style.border_color = _selection_color() if selected else (Color("#D8E0EA") if hovered else _border_color())
style.set_border_width_all(4 if selected else 2)
style.set_corner_radius_all(8)
style.set_content_margin_all(6)
return style
func _hit_height() -> float:
return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0
func _max_width() -> float:
return _theme.speech_toolbar_number("max_width", 440.0, 260.0, 620.0) if _theme != null else 440.0
func _gap() -> float:
return _theme.speech_toolbar_number("gap", 12.0, 6.0, 24.0) if _theme != null else 12.0
func _border_color() -> Color:
return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF")
func _selection_color() -> Color:
return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D")
@@ -0,0 +1 @@
uid://cc3gg7du2suwg
+849
View File
@@ -0,0 +1,849 @@
class_name SpeechStyleToolbar
extends Control
## Transient stage-side speech styling controls. The composer owns every edit.
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
class FramePreview extends Control:
const RENDERER := preload("res://scripts/speech/stickman_speech_bubble.gd")
var frame_id: String = "talk"
var fill: Color = Color.WHITE
var outline: Color = Color.BLACK
func _draw() -> void:
var frame: Rect2 = Rect2(5.0, 6.0, maxf(30.0, size.x - 10.0), maxf(28.0, size.y - 12.0))
RENDERER.draw_style_shape(self, frame_id, frame, fill, outline, false, 3)
class FrameSample extends Button:
var frame_id: String = "talk"
var fill: Color = Color.WHITE
var outline: Color = Color.BLACK
var compact: bool = false
func configure(style_id: String, fill_color: Color, outline_color: Color, use_compact_layout: bool, card_width: float) -> void:
frame_id = style_id
fill = fill_color
outline = outline_color
compact = use_compact_layout
custom_minimum_size = Vector2(card_width, 92.0) if compact else Vector2(118.0, 108.0)
text = ""
var card_layout: VBoxContainer = VBoxContainer.new()
card_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
card_layout.offset_left = 6.0
card_layout.offset_top = 6.0
card_layout.offset_right = -6.0
card_layout.offset_bottom = -6.0
card_layout.add_theme_constant_override("separation", 2)
card_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(card_layout)
var preview: FramePreview = FramePreview.new()
preview.frame_id = frame_id
preview.fill = fill
preview.outline = outline
preview.custom_minimum_size = Vector2(0.0, 56.0 if compact else 68.0)
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_layout.add_child(preview)
var title: Label = Label.new()
title.text = style_id.capitalize()
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 16 if compact else 18)
title.add_theme_color_override("font_color", Color("#F8FAFC"))
title.custom_minimum_size.y = 24.0 if compact else 28.0
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
card_layout.add_child(title)
signal chooser_open_requested(kind: StringName)
signal choice_selected(kind: StringName, value: String)
signal sequence_action_requested(action: StringName)
signal done_styling_requested()
signal message_edit_requested(opened: bool)
var _theme: SandboxTheme
var _context: Dictionary = {}
var _stage_rect: Rect2 = Rect2()
var _frame_rect: Rect2 = Rect2()
var _badge_avoidance_rects: Array[Rect2] = []
var _bar: PanelContainer
var _bar_layout: VBoxContainer
var _bar_scroll: ScrollContainer
var _bar_content: VBoxContainer
var _speech_flow: GridContainer
var _bar_title: Label
var _bar_ribbon: PanelContainer
var _bubble_position_label: Label
var _message_button: Button
var _done_styling_button: Button
var _earlier_button: Button
var _later_button: Button
var _add_bubble_button: Button
var _remove_bubble_button: Button
var _undo_remove_button: Button
var _sequence_hint_label: Label
var _chooser: PanelContainer
var _chooser_layout: VBoxContainer
var _chooser_ribbon: PanelContainer
var _chooser_scroll: ScrollContainer
var _chooser_content: VBoxContainer
var _chooser_title: Label
var _category_buttons: Array[Button] = []
var _category_group: ButtonGroup
var _category_kinds: Array[StringName] = [&"frame", &"letters", &"effect"]
var _choice_buttons: Dictionary = {}
var _active_kind: StringName = &""
var _busy: bool = false
var _message_dialog_open: bool = false
var _compact_choices: bool = false
var _choice_width: float = 440.0
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 80
_build_ui()
visible = false
func configure(theme_data: SandboxTheme) -> void:
_theme = theme_data
if is_inside_tree() and _bar != null:
_apply_theme_styles()
func set_context(context: Dictionary) -> void:
var changed: bool = _context != context
_context = context.duplicate(true)
var choice_layout_changed: bool = _prepare_choice_layout()
_sync_category_disabled()
if _message_button != null:
_message_button.disabled = _busy
_sync_sequence_controls()
if not _active_kind.is_empty():
if choice_layout_changed:
_build_choices(_active_kind, false)
elif changed:
_refresh_choice_states()
_sync_category_buttons()
_layout_panels()
func set_anchor_rect(frame_viewport_rect: Rect2, stage_viewport_rect: Rect2) -> void:
_frame_rect = frame_viewport_rect
_stage_rect = stage_viewport_rect
visible = frame_viewport_rect.size.x > 0.0 and frame_viewport_rect.size.y > 0.0
var choice_layout_changed: bool = _prepare_choice_layout()
if choice_layout_changed and not _active_kind.is_empty():
_build_choices(_active_kind, false)
_layout_panels()
func set_badge_avoidance_rects(rects: Array[Rect2]) -> void:
if _badge_avoidance_rects == rects:
return
_badge_avoidance_rects = rects.duplicate()
_layout_panels()
func present_chooser(kind: StringName) -> void:
if _busy or not [&"frame", &"letters", &"effect"].has(kind):
return
_active_kind = kind
_prepare_choice_layout()
_build_choices(kind)
_chooser.visible = true
_sync_category_buttons()
_layout_panels()
func close_chooser() -> void:
if not is_chooser_open():
return
_active_kind = &""
_chooser.hide()
_sync_category_buttons()
func dismiss_styling() -> void:
close_chooser()
hide()
done_styling_requested.emit()
func is_chooser_open() -> bool:
return _chooser != null and _chooser.visible
func contains_viewport_point(point: Vector2) -> bool:
if not visible:
return false
if _bar != null and _bar.visible and _bar.get_global_rect().has_point(point):
return true
return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point)
func chooser_contains_viewport_point(point: Vector2) -> bool:
return _chooser != null and _chooser.visible and _chooser.get_global_rect().has_point(point)
func toolbar_viewport_rect() -> Rect2:
return _bar.get_global_rect() if _bar != null and is_instance_valid(_bar) else Rect2()
func set_message_dialog_open(opened: bool) -> void:
_message_dialog_open = opened
if _message_button != null:
_message_button.set_pressed_no_signal(opened)
_apply_category_button_style(_message_button, opened)
func set_busy(busy: bool) -> void:
_busy = busy
_sync_category_disabled()
if _message_button != null:
_message_button.disabled = busy
if _done_styling_button != null:
_done_styling_button.disabled = busy
_sync_sequence_controls()
if _chooser != null:
_chooser.mouse_filter = Control.MOUSE_FILTER_STOP if busy else Control.MOUSE_FILTER_PASS
_refresh_choice_states()
func _sync_category_disabled() -> void:
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
for index: int in range(_category_buttons.size()):
var is_effect_button: bool = index < _category_kinds.size() and _category_kinds[index] == &"effect"
_category_buttons[index].disabled = _busy or (is_effect_button and not has_message)
func _sync_sequence_controls() -> void:
if _context.is_empty():
return
var count: int = int(_context.get("bubble_count", 1))
var limit: int = int(_context.get("max_bubbles", 8))
if _bubble_position_label != null:
var selected_index: int = clampi(int(_context.get("bubble_index", 0)), 0, maxi(0, count - 1))
_bubble_position_label.text = "Bubble %d of %d" % [selected_index + 1, count]
if _earlier_button != null:
_earlier_button.disabled = _busy or not bool(_context.get("can_move_earlier", false))
if _later_button != null:
_later_button.disabled = _busy or not bool(_context.get("can_move_later", false))
if _add_bubble_button != null:
_add_bubble_button.disabled = _busy or count >= limit
_add_bubble_button.tooltip_text = "Eight bubbles is the limit." if count >= limit else "Add a bubble after this one"
if _remove_bubble_button != null:
_remove_bubble_button.disabled = _busy or count <= 1
if _undo_remove_button != null:
var can_undo_removed_bubble: bool = bool(_context.get("can_undo_remove", false))
_undo_remove_button.visible = can_undo_removed_bubble
_undo_remove_button.disabled = _busy or not can_undo_removed_bubble
if _sequence_hint_label != null:
var hint: String = String(_context.get("sequence_hint", ""))
if count >= limit and hint.is_empty():
hint = "You can add up to eight bubbles."
_sequence_hint_label.text = hint
_sequence_hint_label.visible = not hint.is_empty()
func _build_ui() -> void:
_bar = PanelContainer.new()
_bar.name = "SpeechStyleBar"
_bar.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_bar)
_bar_layout = VBoxContainer.new()
_bar_layout.add_theme_constant_override("separation", 8)
_bar.add_child(_bar_layout)
_bar_ribbon = PanelContainer.new()
_bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_bar_layout.add_child(_bar_ribbon)
_bar_title = Label.new()
_bar_title.text = "Speech Style"
_bar_title.add_theme_font_size_override("font_size", 24)
_bar_title.add_theme_color_override("font_color", Color("#FFFFFF"))
_bar_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_bar_ribbon.add_child(_bar_title)
_bar_scroll = ScrollContainer.new()
_bar_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_bar_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_bar_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_bar_layout.add_child(_bar_scroll)
_bar_content = VBoxContainer.new()
_bar_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_bar_content.add_theme_constant_override("separation", 8)
_bar_scroll.add_child(_bar_content)
var sequence_flow: HFlowContainer = _new_button_flow()
_bar_content.add_child(sequence_flow)
_bubble_position_label = _group_label("Bubble 1 of 1")
_bubble_position_label.add_theme_font_size_override("font_size", 20)
_bubble_position_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sequence_flow.add_child(_bubble_position_label)
_earlier_button = _make_button("↑ Earlier")
_earlier_button.tooltip_text = "Move this bubble earlier in the speech"
_earlier_button.pressed.connect(_on_sequence_action_pressed.bind(&"earlier"))
sequence_flow.add_child(_earlier_button)
_later_button = _make_button("↓ Later")
_later_button.tooltip_text = "Move this bubble later in the speech"
_later_button.pressed.connect(_on_sequence_action_pressed.bind(&"later"))
sequence_flow.add_child(_later_button)
_category_group = ButtonGroup.new()
_category_group.allow_unpress = false
_speech_flow = GridContainer.new()
_speech_flow.columns = 2
_speech_flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_speech_flow.add_theme_constant_override("h_separation", 8)
_speech_flow.add_theme_constant_override("v_separation", 8)
_bar_content.add_child(_speech_flow)
_message_button = _make_button("✏ Message")
_message_button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height()))
_message_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_message_button.toggle_mode = true
_message_button.toggled.connect(_on_message_toggled)
_speech_flow.add_child(_message_button)
_apply_category_button_style(_message_button, false)
_add_category_button("🗨 Frame", &"frame", _speech_flow)
_add_category_button("🔤 Letters", &"letters", _speech_flow)
_add_category_button("✨ Effects", &"effect", _speech_flow)
var bubble_flow: HFlowContainer = _new_button_flow()
_bar_content.add_child(bubble_flow)
_add_bubble_button = _make_button("➕ Add bubble")
_add_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"add"))
bubble_flow.add_child(_add_bubble_button)
_remove_bubble_button = _make_button("🗑 Remove bubble")
_remove_bubble_button.pressed.connect(_on_sequence_action_pressed.bind(&"remove"))
bubble_flow.add_child(_remove_bubble_button)
_undo_remove_button = _make_button("↶ Undo")
_undo_remove_button.visible = false
_undo_remove_button.pressed.connect(_on_sequence_action_pressed.bind(&"undo_remove"))
bubble_flow.add_child(_undo_remove_button)
_sequence_hint_label = _group_label("")
_sequence_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_sequence_hint_label.visible = false
_bar_content.add_child(_sequence_hint_label)
var done_margin: MarginContainer = MarginContainer.new()
done_margin.add_theme_constant_override("margin_top", 8)
_bar_layout.add_child(done_margin)
var footer: CenterContainer = CenterContainer.new()
done_margin.add_child(footer)
_done_styling_button = _make_button("✓ Done styling")
_done_styling_button.custom_minimum_size = Vector2(220.0, maxf(56.0, _hit_height()))
_done_styling_button.add_theme_font_size_override("font_size", 19)
_done_styling_button.pressed.connect(dismiss_styling)
footer.add_child(_done_styling_button)
_chooser = PanelContainer.new()
_chooser.name = "SpeechStyleChooser"
_chooser.mouse_filter = Control.MOUSE_FILTER_STOP
_chooser.hide()
add_child(_chooser)
_chooser_layout = VBoxContainer.new()
_chooser_layout.add_theme_constant_override("separation", 8)
_chooser.add_child(_chooser_layout)
_chooser_ribbon = PanelContainer.new()
_chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_chooser_layout.add_child(_chooser_ribbon)
_chooser_title = Label.new()
_chooser_title.add_theme_font_size_override("font_size", 22)
_chooser_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_chooser_title.add_theme_color_override("font_color", Color("#FFFFFF"))
_chooser_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chooser_ribbon.add_child(_chooser_title)
_chooser_scroll = ScrollContainer.new()
_chooser_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_chooser_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_chooser_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_chooser_layout.add_child(_chooser_scroll)
_chooser_content = VBoxContainer.new()
_chooser_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_chooser_content.add_theme_constant_override("separation", 8)
_chooser_scroll.add_child(_chooser_content)
_apply_theme_styles()
func _new_button_flow() -> HFlowContainer:
var flow: HFlowContainer = HFlowContainer.new()
flow.size_flags_horizontal = Control.SIZE_EXPAND_FILL
flow.add_theme_constant_override("h_separation", 8)
flow.add_theme_constant_override("v_separation", 8)
return flow
func _group_label(caption: String) -> Label:
var label: Label = Label.new()
label.text = caption
label.add_theme_font_size_override("font_size", 17)
label.add_theme_color_override("font_color", Color("#D6E1EF"))
return label
func _add_category_button(caption: String, kind: StringName, parent: Control) -> void:
var button: Button = _make_button(caption)
button.custom_minimum_size = Vector2(0.0, maxf(56.0, _hit_height()))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.toggle_mode = true
button.button_group = _category_group
button.add_theme_font_size_override("font_size", 21)
button.pressed.connect(_on_category_pressed.bind(kind))
_category_buttons.append(button)
parent.add_child(button)
_apply_category_button_style(button, false)
func _on_message_toggled(opened: bool) -> void:
if _busy:
_message_button.set_pressed_no_signal(_message_dialog_open)
return
set_message_dialog_open(opened)
if opened:
close_chooser()
message_edit_requested.emit(opened)
func _on_category_pressed(kind: StringName) -> void:
if _busy:
return
if _message_dialog_open:
set_message_dialog_open(false)
message_edit_requested.emit(false)
if is_chooser_open() and _active_kind == kind:
_sync_category_buttons()
return
chooser_open_requested.emit(kind)
func _on_sequence_action_pressed(action: StringName) -> void:
if _busy:
return
sequence_action_requested.emit(action)
func _sync_category_buttons() -> void:
for index: int in range(_category_buttons.size()):
var button: Button = _category_buttons[index]
var active: bool = is_chooser_open() and index < _category_kinds.size() and _category_kinds[index] == _active_kind
button.set_pressed_no_signal(active)
_apply_category_button_style(button, active)
func _apply_category_button_style(button: Button, active: bool) -> void:
var normal: StyleBoxFlat = StyleBoxFlat.new()
normal.bg_color = Color("#176B83") if active else Color("#344255")
normal.border_color = _selection_color() if active else Color("#C6D1DE")
normal.set_border_width_all(4 if active else 2)
normal.set_corner_radius_all(9)
normal.set_content_margin_all(8)
var hover: StyleBoxFlat = normal.duplicate() as StyleBoxFlat
if not active:
hover.bg_color = Color("#40546D")
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_color_override("font_color", Color("#FFFFFF"))
func _build_choices(kind: StringName, relayout: bool = true) -> void:
for child: Node in _chooser_content.get_children():
_chooser_content.remove_child(child)
child.free()
_chooser_scroll.custom_minimum_size.y = 0.0
_choice_buttons.clear()
var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {}
var target_text: String = String(target.get("text", ""))
_chooser_title.text = _chooser_title_for(kind, target_text)
if kind == &"frame":
var frames: GridContainer = GridContainer.new()
frames.columns = 2 if _compact_choices else 3
frames.add_theme_constant_override("h_separation", 6)
frames.add_theme_constant_override("v_separation", 8)
_chooser_content.add_child(frames)
for frame_id: String in SPEECH_PAYLOAD.BUBBLE_STYLES:
var sample: FrameSample = FrameSample.new()
var card_width: float = minf(86.0, maxf(60.0, (_choice_width - 48.0) * 0.5)) if _compact_choices else 118.0
sample.configure(frame_id, _frame_color(frame_id, "fill", Color("#FFFDF6")), _frame_color(frame_id, "outline", Color("#202020")), _compact_choices, card_width)
_configure_choice(sample, &"frame", frame_id)
frames.add_child(sample)
elif kind == &"letters":
_add_subheading("Font")
var fonts: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(fonts)
for font_id: String in SPEECH_PAYLOAD.FONT_PRESETS:
var font_choice: Button = _make_button("Aa\n" + font_id.capitalize())
font_choice.custom_minimum_size = Vector2(76.0, 86.0) if _compact_choices else Vector2(120.0, 92.0)
font_choice.add_theme_font_override("font", _theme.speech_font(font_id) if _theme != null else ThemeDB.fallback_font)
var font_preview_size: int = 24
if _theme != null:
font_preview_size = clampi(roundi(float(_theme.speech_font_size(font_id, "medium")) * 0.7), 24, 28)
font_choice.add_theme_font_size_override("font_size", font_preview_size)
_configure_choice(font_choice, &"font", font_id)
fonts.add_child(font_choice)
_add_subheading("Size")
var sizes: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(sizes)
for size_id: String in SPEECH_PAYLOAD.SIZE_PRESETS:
var size_choice: Button = _make_button("Aa\n" + size_id.capitalize())
size_choice.custom_minimum_size = Vector2(76.0, 76.0) if _compact_choices else Vector2(120.0, 84.0)
size_choice.add_theme_font_size_override("font_size", _size_font_size(size_id))
_configure_choice(size_choice, &"size", size_id)
sizes.add_child(size_choice)
_add_subheading("Color")
var colors: GridContainer = GridContainer.new()
colors.columns = clampi(floori((_choice_width - 24.0) / 72.0), 1, 4)
colors.add_theme_constant_override("h_separation", 8)
colors.add_theme_constant_override("v_separation", 8)
_chooser_content.add_child(colors)
if _theme != null:
for entry: Dictionary in _theme.speech_text_colors():
var color: Color = entry.get("color", Color.BLACK)
var color_id: String = "#" + color.to_html(false)
var swatch: Button = _make_button("")
swatch.custom_minimum_size = Vector2(64.0, 64.0)
swatch.tooltip_text = String(entry.get("name", "Color"))
_configure_choice(swatch, &"color", color_id)
colors.add_child(swatch)
else:
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
if not has_message:
var hint: Label = Label.new()
hint.text = "Add a message before choosing an effect."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_chooser_content.add_child(hint)
var effects: HFlowContainer = HFlowContainer.new()
_chooser_content.add_child(effects)
var effect_labels: Dictionary = {"shake": "〰 Shake", "wave": "🌊 Wave", "bounce": "⬆ Bounce", "none": "○ No effect"}
for effect_id: String in ["shake", "wave", "bounce", "none"]:
var effect_choice: Button = _make_button(String(effect_labels[effect_id]))
effect_choice.custom_minimum_size = Vector2(136.0, 60.0)
effect_choice.add_theme_font_size_override("font_size", 20)
effect_choice.disabled = not has_message
_configure_choice(effect_choice, &"effect", effect_id)
effects.add_child(effect_choice)
if relayout:
_layout_panels()
func _configure_choice(button: Button, kind: StringName, value: String) -> void:
button.focus_mode = Control.FOCUS_NONE
button.custom_minimum_size.y = maxf(button.custom_minimum_size.y, _hit_height())
button.pressed.connect(_on_choice_pressed.bind(kind, value))
_choice_buttons[String(kind) + ":" + value] = {"button": button, "kind": kind, "value": value}
_refresh_choice_state(button, kind, value)
func _on_choice_pressed(kind: StringName, value: String) -> void:
if _busy:
return
if kind == &"effect" and String(_context.get("text", "")).strip_edges().is_empty():
return
choice_selected.emit(kind, value)
func _refresh_choice_state(button: Button, kind: StringName, value: String) -> void:
var selected: String = ""
match kind:
&"frame": selected = String(_context.get("bubble_style", "talk"))
&"font": selected = String(_context.get("font_preset", "comic"))
&"size": selected = String(_context.get("size_preset", "medium"))
&"color": selected = String(_context.get("color", "#202020")).to_lower()
&"effect": selected = String(_context.get("effect", "none"))
var chosen: bool = selected.to_lower() == value.to_lower()
var base_color: String = value if kind == &"color" else ("#176B83" if chosen else "#344255")
var has_message: bool = not String(_context.get("text", "")).strip_edges().is_empty()
button.disabled = _busy or (kind == &"effect" and not has_message)
button.add_theme_stylebox_override("normal", _choice_button_style(chosen, base_color, false))
button.add_theme_stylebox_override("hover", _choice_button_style(chosen, base_color, true))
button.add_theme_stylebox_override("pressed", _choice_button_style(chosen, base_color, true))
button.add_theme_stylebox_override("focus", _choice_button_style(chosen, base_color, false))
button.add_theme_color_override("font_color", Color("#FFFFFF"))
func _refresh_choice_states() -> void:
var target: Dictionary = _context.get("target", {}) if _context.get("target", {}) is Dictionary else {}
_chooser_title.text = _chooser_title_for(_active_kind, String(target.get("text", "")))
for entry_value: Variant in _choice_buttons.values():
if not entry_value is Dictionary:
continue
var entry: Dictionary = entry_value as Dictionary
var button: Button = entry.get("button") as Button
if button == null or not is_instance_valid(button):
continue
_refresh_choice_state(button, StringName(entry.get("kind", "")), String(entry.get("value", "")))
func _chooser_title_for(kind: StringName, target_text: String) -> String:
match kind:
&"frame": return "Choose a frame"
&"letters": return "Choose how the letters look"
&"effect": return "Choose an effect for %s" % target_text if not target_text.is_empty() else "Choose an effect for the whole bubble"
return "Choose a style"
func _choice_button_style(chosen: bool, base_color: String, hovered: bool) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color(base_color)
if hovered:
style.bg_color = style.bg_color.lightened(0.12)
style.border_color = _selection_color() if chosen else Color("#F8FAFC")
style.set_border_width_all(5 if chosen else 2)
style.set_corner_radius_all(8)
style.set_content_margin_all(6)
return style
func _add_subheading(text: String) -> void:
var label: Label = Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 20)
label.add_theme_color_override("font_color", _selection_color())
_chooser_content.add_child(label)
func _layout_panels() -> void:
if _bar == null or _stage_rect.size.x <= 0.0:
return
var choice_layout_changed: bool = _prepare_choice_layout()
if choice_layout_changed and _chooser != null and _chooser.visible and not _active_kind.is_empty():
_build_choices(_active_kind, false)
var max_width: float = _max_width()
var gap: float = _gap()
var stage_width: float = _stage_rect.size.x
var stage_height: float = _stage_rect.size.y
var bar_width: float = minf(max_width, stage_width)
var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var toolbar_side_layout: bool = side_space >= minf(max_width, 300.0)
if toolbar_side_layout:
bar_width = minf(max_width, side_space)
var bar_height: float = _fit_bar_height(bar_width, stage_height)
var bar_position: Vector2
if toolbar_side_layout:
var toolbar_on_right: bool = right_space >= left_space
bar_position.x = _frame_rect.end.x + gap if toolbar_on_right else _frame_rect.position.x - bar_width - gap
bar_position.y = clampf(_frame_rect.get_center().y - bar_height * 0.5, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height))
else:
var above_space: float = _frame_rect.position.y - _stage_rect.position.y - gap
var below_space: float = _stage_rect.end.y - _frame_rect.end.y - gap
var place_above: bool = above_space >= bar_height or above_space >= below_space
bar_position.x = clampf(_frame_rect.get_center().x - bar_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width))
bar_position.y = _frame_rect.position.y - bar_height - gap if place_above else _frame_rect.end.y + gap
bar_position.x = clampf(bar_position.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - bar_width))
bar_position.y = clampf(bar_position.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - bar_height))
bar_position = _position_avoiding_badges(bar_position, Vector2(bar_width, bar_height), _frame_rect)
_bar.position = bar_position
_bar.size = Vector2(bar_width, bar_height)
if _chooser == null or not _chooser.visible:
return
var below_toolbar: float = maxf(_stage_rect.position.y, _bar.position.y + _bar.size.y + gap)
var use_right: bool = right_space >= left_space
var chooser_side_space: float = right_space if use_right else left_space
var side_layout: bool = chooser_side_space >= 180.0 and _stage_rect.end.y - below_toolbar >= 150.0
var chooser_width: float = minf(440.0, chooser_side_space) if side_layout else minf(440.0, _stage_rect.size.x)
_chooser.custom_minimum_size = Vector2(chooser_width, 0.0)
var content_height: float = _chooser_content.get_combined_minimum_size().y
# ScrollContainer doesn't include its child's vertical minimum in the panel's
# natural size. Seed it here so short choosers fit their content and long ones
# can be capped below without collapsing the choice area to zero height.
_chooser_scroll.custom_minimum_size.y = content_height
_chooser.reset_size()
_chooser.size.x = chooser_width
var natural_height: float = maxf(1.0, _chooser.get_combined_minimum_size().y)
var fixed_height: float = maxf(0.0, natural_height - content_height)
var max_height: float = maxf(150.0, _stage_rect.size.y * 0.82)
var chooser_y: float
if side_layout:
chooser_y = below_toolbar
max_height = minf(max_height, maxf(1.0, _stage_rect.end.y - chooser_y))
else:
var below_y: float = _frame_rect.end.y + gap
var below_room: float = _stage_rect.end.y - below_y
var above_y: float = _stage_rect.position.y
var above_room: float = _bar.position.y - gap - above_y
if below_room >= above_room:
chooser_y = below_y
max_height = minf(max_height, maxf(1.0, below_room))
else:
chooser_y = above_y
max_height = minf(max_height, maxf(1.0, above_room))
var chooser_height: float = minf(natural_height, max_height)
if natural_height > max_height:
_chooser_scroll.custom_minimum_size.y = maxf(64.0, max_height - fixed_height)
_chooser.reset_size()
_chooser.custom_minimum_size = Vector2(chooser_width, chooser_height)
_chooser.size = Vector2(chooser_width, chooser_height)
var chooser_x: float
if side_layout:
chooser_x = _frame_rect.end.x + gap if use_right else _frame_rect.position.x - gap - chooser_width
else:
chooser_x = clampf(_frame_rect.get_center().x - chooser_width * 0.5, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - chooser_width))
chooser_y = clampf(chooser_y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - chooser_height))
var chooser_position: Vector2 = Vector2(chooser_x, chooser_y)
var toolbar_obstruction: Array[Rect2] = [Rect2(_bar.position, _bar.size)]
_chooser.position = _position_avoiding_badges(chooser_position, Vector2(chooser_width, chooser_height), _frame_rect, toolbar_obstruction)
func _position_avoiding_badges(preferred: Vector2, size: Vector2, anchor: Rect2, extra_obstructions: Array[Rect2] = []) -> Vector2:
var candidates: Array[Vector2] = [preferred]
var gap: float = _gap()
var center: Vector2 = anchor.get_center()
candidates.append(Vector2(anchor.position.x - size.x - gap, center.y - size.y * 0.5))
candidates.append(Vector2(anchor.end.x + gap, center.y - size.y * 0.5))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.position.y - size.y - gap))
candidates.append(Vector2(center.x - size.x * 0.5, anchor.end.y + gap))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.position.y))
candidates.append(Vector2(_stage_rect.position.x, _stage_rect.end.y - size.y))
candidates.append(Vector2(_stage_rect.end.x - size.x, _stage_rect.end.y - size.y))
candidates.append(_stage_rect.get_center() - size * 0.5)
var best: Vector2 = _clamp_panel_position(preferred, size)
var best_collisions: int = _badge_collision_count(best, size, extra_obstructions)
var best_distance: float = best.distance_squared_to(preferred)
for candidate: Vector2 in candidates:
var clamped: Vector2 = _clamp_panel_position(candidate, size)
var collisions: int = _badge_collision_count(clamped, size, extra_obstructions)
var distance: float = clamped.distance_squared_to(preferred)
if collisions < best_collisions or (collisions == best_collisions and distance < best_distance):
best = clamped
best_collisions = collisions
best_distance = distance
if best_collisions == 0 and is_zero_approx(best_distance):
return best
return best
func _clamp_panel_position(candidate: Vector2, size: Vector2) -> Vector2:
return Vector2(
clampf(candidate.x, _stage_rect.position.x, maxf(_stage_rect.position.x, _stage_rect.end.x - size.x)),
clampf(candidate.y, _stage_rect.position.y, maxf(_stage_rect.position.y, _stage_rect.end.y - size.y))
)
func _badge_collision_count(position: Vector2, size: Vector2, extra_obstructions: Array[Rect2]) -> int:
var panel_rect: Rect2 = Rect2(position, size)
var count: int = 0
for badge_rect: Rect2 in _badge_avoidance_rects:
if panel_rect.intersects(badge_rect):
count += 1
for obstruction: Rect2 in extra_obstructions:
if panel_rect.intersects(obstruction):
count += 1
return count
func _fit_bar_height(bar_width: float, available_height: float) -> float:
_bar.custom_minimum_size = Vector2(bar_width, 0.0)
_bar_scroll.custom_minimum_size.y = 0.0
_bar.reset_size()
var fixed_height: float = _bar.get_combined_minimum_size().y
var content_height: float = _bar_content.get_combined_minimum_size().y
var max_height: float = maxf(1.0, available_height)
var body_room: float = maxf(48.0, max_height - fixed_height)
_bar_scroll.custom_minimum_size.y = minf(content_height, body_room)
_bar.reset_size()
return minf(maxf(54.0, _bar.get_combined_minimum_size().y), max_height)
func _prepare_choice_layout() -> bool:
var previous_compact: bool = _compact_choices
var previous_width: float = _choice_width
var gap: float = _gap()
var left_space: float = maxf(0.0, _frame_rect.position.x - _stage_rect.position.x - gap)
var right_space: float = maxf(0.0, _stage_rect.end.x - _frame_rect.end.x - gap)
var side_space: float = maxf(left_space, right_space)
var can_place_to_side: bool = side_space >= 180.0
_choice_width = minf(440.0, side_space) if can_place_to_side else minf(440.0, _stage_rect.size.x)
_compact_choices = can_place_to_side and _choice_width < 320.0
return previous_compact != _compact_choices or not is_equal_approx(previous_width, _choice_width)
func _apply_theme_styles() -> void:
if _bar == null or _chooser == null:
return
_bar.add_theme_stylebox_override("panel", _panel_style())
_chooser.add_theme_stylebox_override("panel", _panel_style())
_bar_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_chooser_ribbon.add_theme_stylebox_override("panel", _ribbon_style(_ribbon_color()))
_sync_category_buttons()
var action_normal: StyleBoxFlat = _choice_button_style(false, "#344255", false)
var action_hover: StyleBoxFlat = _choice_button_style(false, "#40546D", true)
for action_button: Button in [_done_styling_button]:
if action_button == null:
continue
action_button.add_theme_stylebox_override("normal", action_normal)
action_button.add_theme_stylebox_override("hover", action_hover)
action_button.add_theme_stylebox_override("pressed", action_normal)
action_button.add_theme_stylebox_override("focus", action_normal)
action_button.add_theme_color_override("font_color", Color("#FFFFFF"))
_apply_category_button_style(_message_button, _message_dialog_open)
if _theme != null:
var gap: int = int(_theme.speech_toolbar_number("gap", 8.0, 4.0, 20.0))
_speech_flow.add_theme_constant_override("h_separation", gap)
_speech_flow.add_theme_constant_override("v_separation", gap)
_sync_sequence_controls()
func _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("speech.toolbar.surface", Color("#1C2431F7")) if _theme != null else Color("#1C2431F7")
style.border_color = _border_color()
style.set_border_width_all(2)
style.set_corner_radius_all(12)
style.set_content_margin_all(8)
return style
func _ribbon_style(color: Color) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = color
style.set_corner_radius_all(7)
style.set_content_margin(SIDE_LEFT, 10.0)
style.set_content_margin(SIDE_TOP, 4.0)
style.set_content_margin(SIDE_RIGHT, 10.0)
style.set_content_margin(SIDE_BOTTOM, 4.0)
return style
func _ribbon_color() -> Color:
return _theme.color("speech.toolbar.ribbon", Color("#2385A0")) if _theme != null else Color("#2385A0")
func _make_button(caption: String) -> Button:
var button: Button = Button.new()
button.text = caption
button.custom_minimum_size.y = _hit_height()
button.focus_mode = Control.FOCUS_NONE
button.add_theme_font_size_override("font_size", 18)
return button
func _hit_height() -> float:
return _theme.speech_toolbar_number("min_hit_height", 56.0, 44.0, 80.0) if _theme != null else 56.0
func _max_width() -> float:
return _theme.speech_toolbar_number("max_width", 560.0, 300.0, 720.0) if _theme != null else 560.0
func _gap() -> float:
return _theme.speech_toolbar_number("gap", 10.0, 4.0, 24.0) if _theme != null else 10.0
func _border_color() -> Color:
return _theme.color("speech.toolbar.border", Color("#9EADBF")) if _theme != null else Color("#9EADBF")
func _selection_color() -> Color:
return _theme.color("speech.toolbar.selection_halo", Color("#FFD84D")) if _theme != null else Color("#FFD84D")
func _frame_color(frame_id: String, channel: String, fallback: Color) -> Color:
return _theme.speech_color(frame_id, channel, fallback) if _theme != null else fallback
func _size_font_size(size_id: String) -> int:
match size_id:
"small": return 20
"huge": return 40
_: return 28
@@ -0,0 +1 @@
uid://0d2x5ns8go3v
+18
View File
@@ -0,0 +1,18 @@
extends RichTextEffect
## Per-word motions driven entirely by the owning speech group's saved clock.
var bbcode: String = "speechmotion"
func _process_custom_fx(char_fx: CharFXTransform) -> bool:
var env: Dictionary = char_fx.env
var kind: String = String(env.get("kind", ""))
var clock: float = float(env.get("clock", 0.0))
var index: float = float(char_fx.relative_index)
match kind:
"shake":
char_fx.offset += Vector2(sin(clock * 34.0 + index * 11.0) * 2.8, cos(clock * 41.0 + index * 7.0) * 1.8)
"wave":
char_fx.offset.y += sin(clock * 11.0 - index * 0.72) * 6.0
"bounce":
char_fx.offset.y -= absf(sin(clock * 7.0 - index * 0.42)) * 8.0
return true
+1
View File
@@ -0,0 +1 @@
uid://djm7bdf1q5ao0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://b22w8sfbhd0y8