Files
stickman/scripts/speech_composer.gd
T
ryan 6666173c4f 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.
2026-10-03 14:49:22 -04:00

1282 lines
45 KiB
GDScript

class_name SpeechComposer
extends Control
## Stage-side comic speech editor. The stage owns the attached preview.
const SPEECH_PAYLOAD := preload("res://scripts/speech_payload.gd")
const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd")
const SPEECH_BUBBLE := preload("res://scripts/stickman_speech_bubble.gd")
const FIVE_BUBBLE_HINT: String = "Long story? One at a time can be easier to follow."
const MAX_BUBBLE_HINT: String = "You can add up to eight bubbles."
enum PreviewState { STATIC, PLAYING, BLANK_HOLD }
const BLANK_HOLD_SECONDS: float = 1.0
signal draft_changed(action: Dictionary)
signal placement_requested()
signal placement_reset_requested()
signal replay_requested()
signal preview_state_changed(state: String, elapsed: float, visible: bool)
signal committed(action: Dictionary)
signal cancelled()
signal validation_failed(message: String, bubble_index: int)
signal bubble_placement_requested(index: int)
signal bubble_placement_reset_requested(index: int)
signal style_context_changed(context: Dictionary)
signal message_dialog_closed()
signal stage_focus_changed(bubble_index: int)
var _actor: StickmanRig = null
var _draft: Dictionary = {}
var _reactive_options: bool = false
var _selected_bubble: int = 0
var _stage_focus_bubble: int = -1
var _target: Dictionary = {}
var _style_category: StringName = &""
var _effect_demo: Dictionary = {}
var _effect_demo_elapsed: float = 0.0
var _effect_demo_remaining: float = 0.0
var _caret_by_bubble: Array[Vector2i] = []
var _scroll_by_bubble: Array[float] = []
var _last_text_by_bubble: Array[String] = []
var _undo_removed_bubble: Dictionary = {}
var _initial_action: Dictionary = {}
var _cancel_requires_confirmation: bool = false
var _cancel_confirm_dialog: ConfirmationDialog
var _sequence_hint: String = ""
var _five_bubble_hint_shown: bool = false
var _active_editor: TextEdit
var _emoji_grid: GridContainer
var _emoji_picker_scroll: ScrollContainer
var _emoji_picker_button: Button
var _message_dialog: PanelContainer
var _message_done_button: Button
var _message_frame_rect: Rect2 = Rect2()
var _message_stage_rect: Rect2 = Rect2()
var _message_toolbar_rect: Rect2 = Rect2()
var _overflow_label: Label
var _loading_editor: bool = false
var _replay_clock: float = 0.0
var _replay_running: bool = false
var _preview_state: PreviewState = PreviewState.STATIC
var _blank_hold_clock: float = 0.0
var _theme: SandboxTheme
var _placement_target_bubble: int = -1
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_theme = SANDBOX_THEME.new()
_theme.load_from()
theme = _theme.control_theme()
visible = false
_build_ui()
resized.connect(_layout_message_dialog)
resized.connect(_update_message_responsive_layout)
func _process(delta: float) -> void:
if _effect_demo_remaining > 0.0:
_effect_demo_elapsed += delta
_effect_demo_remaining = maxf(0.0, _effect_demo_remaining - delta)
if _effect_demo_remaining <= 0.0:
_stop_editor_effect_demo()
if _preview_state == PreviewState.PLAYING:
_replay_clock += delta
if _replay_clock >= float(_draft.get("duration", 0.0)):
_replay_clock = float(_draft.get("duration", _replay_clock))
_preview_state = PreviewState.BLANK_HOLD
_blank_hold_clock = 0.0
_replay_running = false
_emit_preview_state()
elif _preview_state == PreviewState.BLANK_HOLD:
_blank_hold_clock += delta
if _blank_hold_clock >= BLANK_HOLD_SECONDS:
_preview_state = PreviewState.STATIC
_replay_clock = float(_draft.get("duration", 0.0))
_emit_preview_state()
func open_for(actor: StickmanRig, initial: Dictionary, reactive_options: bool) -> void:
_stop_editor_effect_demo()
_style_category = &""
_actor = actor
_reactive_options = reactive_options
var initial_action: Dictionary = initial.duplicate(true)
if not initial_action.has("keep_acting"):
initial_action["keep_acting"] = reactive_options and not bool(initial_action.get("stop_current", false))
_draft = SPEECH_PAYLOAD.normalize(initial_action)
_draft.erase("legacy_fixed")
if not _draft.has("bubbles") or not _draft["bubbles"] is Array:
_draft["bubbles"] = []
if (_draft["bubbles"] as Array).is_empty():
(_draft["bubbles"] as Array).append(_new_bubble())
_selected_bubble = 0
_undo_removed_bubble.clear()
_initial_action.clear()
_cancel_requires_confirmation = false
_sequence_hint = ""
_five_bubble_hint_shown = false
_update_bubble_count_hint()
_set_stage_focus(-1)
_target.clear()
_prepare_editor_state()
# Populate the editor before emitting preview_state_changed. The stage's
# preview handler reads get_draft() synchronously, which saves editor text.
_sync_editor()
_sync_controls()
visible = true
_stop_replay_to_static()
_emit_draft(false)
_initial_action = get_draft()
func set_draft(action: Dictionary) -> void:
_stop_editor_effect_demo()
_style_category = &""
_target.clear()
_draft = SPEECH_PAYLOAD.normalize(action)
_draft.erase("legacy_fixed")
var bubbles: Array = _draft.get("bubbles", [])
_selected_bubble = clampi(_selected_bubble, 0, maxi(0, bubbles.size() - 1))
_update_bubble_count_hint()
_undo_removed_bubble.clear()
_cancel_requires_confirmation = true
_set_stage_focus(-1)
_target.clear()
_prepare_editor_state()
_sync_editor()
_sync_controls()
_emit_draft()
_initial_action = get_draft()
func get_draft() -> Dictionary:
_save_active_editor_state()
var result: Dictionary = _draft.duplicate(true)
result.erase("legacy_fixed")
result["type"] = "speak"
var bubbles: Array = result.get("bubbles", [])
result["text"] = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else ""
result["duration"] = SPEECH_PAYLOAD.duration_for(result)
return result
func style_context() -> Dictionary:
var bubbles: Array = _draft.get("bubbles", [])
var bubble: Dictionary = _selected_bubble_data()
var text: String = String(bubble.get("text", ""))
var visual: Dictionary = bubble.get("visual", {})
var target: Dictionary = _target.duplicate(true)
if not target.is_empty() and int(target.get("bubble_index", -1)) != _selected_bubble:
target.clear()
var token_style: Dictionary = _style_for_visual_target(visual, target) if not target.is_empty() else {}
return {
"bubble_index": _selected_bubble,
"bubble_count": bubbles.size(),
"can_move_earlier": _selected_bubble > 0,
"can_move_later": _selected_bubble >= 0 and _selected_bubble < bubbles.size() - 1,
"max_bubbles": SPEECH_PAYLOAD.MAX_BUBBLES,
"can_undo_remove": not _undo_removed_bubble.is_empty(),
"sequence_hint": _sequence_hint,
"target": target,
"bubble_style": String(visual.get("bubble_style", "talk")),
"font_preset": String(token_style.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))),
"size_preset": String(token_style.get("size_preset", visual.get("size_preset", "medium"))),
"color": String(token_style.get("color", visual.get("color", SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR))),
"effect": String(token_style.get("effect", visual.get("effect", "none"))),
"duration_mode": String(_draft.get("duration_mode", "just_enough")),
"text": text,
"category": String(_style_category),
}
func set_style_category(kind: StringName) -> void:
if not [&"frame", &"letters", &"effect"].has(kind):
return
_style_category = kind
_emit_style_context()
func apply_style_choice(kind: StringName, value: String) -> void:
if not _style_category_accepts(kind) or not _style_choice_valid(kind, value):
return
if kind == &"effect" and String(style_context().get("text", "")).strip_edges().is_empty():
return
_save_active_editor_state()
if kind == &"timing":
_draft["duration_mode"] = value
_emit_draft()
_sync_controls()
_emit_style_context()
return
var bubble: Dictionary = _selected_bubble_data()
var visual: Dictionary = bubble.get("visual", {}).duplicate(true)
_apply_visual_choice(visual, kind, value, _target)
bubble["visual"] = visual
_store_selected_bubble(bubble)
if kind == &"effect":
if value == "none":
_stop_editor_effect_demo()
else:
_start_editor_effect_demo(value, _target)
elif kind in [&"font", &"size"] and not _effect_demo.is_empty():
_stop_editor_effect_demo()
_emit_draft()
_sync_controls()
_emit_style_context()
func get_preview_action() -> Dictionary:
return get_draft()
func editor_effect_demo_context() -> Dictionary:
if _effect_demo.is_empty() or _effect_demo_remaining <= 0.0:
return {}
var result: Dictionary = _effect_demo.duplicate(true)
result["elapsed"] = _effect_demo_elapsed
return result
func select_bubble_token_from_stage(index: int, start: int, end: int) -> void:
var bubbles: Array = _draft.get("bubbles", [])
if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary:
return
var text: String = String((bubbles[index] as Dictionary).get("text", ""))
if SPEECH_PAYLOAD.token_span_at(text, start) != Vector2i(start, end):
return
var next_target: Dictionary = {"bubble_index": index, "start": start, "end": end, "text": text.substr(start, end - start)}
if _target != next_target:
_stop_editor_effect_demo()
if index != _selected_bubble:
select_bubble_from_stage(index)
else:
_set_stage_focus(index)
_target = next_target
_sync_controls()
_emit_style_context()
func select_bubble_word_from_stage(index: int, start: int, end: int) -> void:
select_bubble_token_from_stage(index, start, end)
func _emit_style_context() -> void:
style_context_changed.emit(style_context())
func _style_category_accepts(kind: StringName) -> bool:
var category: String = String(_style_category)
if category == "frame":
return kind == &"frame"
if category == "effect":
return kind == &"effect"
return category == "letters" and kind in [&"font", &"size", &"color"]
func _style_choice_valid(kind: StringName, value: String) -> bool:
match kind:
&"frame": return SPEECH_PAYLOAD.BUBBLE_STYLES.has(value)
&"font": return SPEECH_PAYLOAD.FONT_PRESETS.has(value)
&"size": return SPEECH_PAYLOAD.SIZE_PRESETS.has(value)
&"color": return SPEECH_PAYLOAD._valid_color(value)
&"effect": return value == "none" or SPEECH_PAYLOAD.EFFECTS.has(value)
return false
func _start_editor_effect_demo(effect: String, target: Dictionary) -> void:
_stop_editor_effect_demo()
if not [&"shake", &"wave", &"bounce"].has(effect):
return
_effect_demo = {
"bubble_index": int(_selected_bubble),
"effect": effect,
}
if target.is_empty():
_effect_demo["all_tokens"] = true
else:
_effect_demo["start"] = int(target.get("start", -1))
_effect_demo["end"] = int(target.get("end", -1))
_effect_demo_elapsed = 0.0
_effect_demo_remaining = 1.0
func _stop_editor_effect_demo() -> void:
_effect_demo.clear()
_effect_demo_elapsed = 0.0
_effect_demo_remaining = 0.0
func _apply_visual_choice(visual: Dictionary, kind: StringName, value: String, target: Dictionary) -> void:
if kind == &"frame":
visual["bubble_style"] = value
return
if kind == &"effect" and target.is_empty():
visual["effect"] = value
_clear_word_style_property(visual, "effect")
return
var property: String = String(kind) + ("_preset" if kind in [&"font", &"size"] else "")
if target.is_empty():
match kind:
&"font": visual["font_preset"] = value
&"size": visual["size_preset"] = value
&"color": visual["color"] = value
_clear_word_style_property(visual, property)
return
var text_style_start: int = int(target.get("start", -1))
var styles: Array = visual.get("word_styles", [])
var style_index: int = -1
for index: int in styles.size():
if styles[index] is Dictionary and int((styles[index] as Dictionary).get("start", -2)) == text_style_start:
style_index = index
break
var style: Dictionary = (styles[style_index] as Dictionary).duplicate(true) if style_index >= 0 else {"start": text_style_start, "end": int(target.get("end", -1))}
if kind == &"effect" and value == "none" and String(visual.get("effect", "none")) == "none":
style.erase("effect")
else:
style[property] = value
if style.size() <= 2:
if style_index >= 0:
styles.remove_at(style_index)
else:
if style_index >= 0:
styles[style_index] = style
else:
styles.append(style)
visual["word_styles"] = styles
func _clear_word_style_property(visual: Dictionary, property: String) -> void:
var retained: Array[Dictionary] = []
for raw_style: Variant in visual.get("word_styles", []):
if not raw_style is Dictionary:
continue
var style: Dictionary = (raw_style as Dictionary).duplicate(true)
style.erase(property)
if style.size() > 2:
retained.append(style)
visual["word_styles"] = retained
func _style_for_visual_target(visual: Dictionary, target: Dictionary) -> Dictionary:
var target_start: int = int(target.get("start", -1))
for raw: Variant in visual.get("word_styles", []):
if raw is Dictionary and int((raw as Dictionary).get("start", -2)) == target_start:
return raw as Dictionary
return {}
func selected_bubble_index() -> int:
return _selected_bubble
func reactive_options_enabled() -> bool:
return _reactive_options
func stage_focus_bubble_index() -> int:
return _stage_focus_bubble
func clear_stage_focus() -> void:
var focus_changed: bool = _stage_focus_bubble >= 0
if not focus_changed and _target.is_empty():
return
_stage_focus_bubble = -1
_style_category = &""
_target.clear()
_sync_controls()
_emit_style_context()
if focus_changed:
stage_focus_changed.emit(-1)
func _set_stage_focus(index: int) -> void:
if _stage_focus_bubble == index:
return
_stage_focus_bubble = index
stage_focus_changed.emit(index)
func choose_speech_show(key: StringName, value: Variant) -> void:
_save_active_editor_state()
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(_draft)
var valid: bool = false
match key:
&"arrangement":
valid = value is String and SPEECH_PAYLOAD.ARRANGEMENTS.has(String(value))
&"connect_bubbles":
valid = value is bool and (_draft.get("bubbles", []) as Array).size() >= 2
&"reveal":
valid = value is String and SPEECH_PAYLOAD.REVEAL_MODES.has(String(value))
&"pop_in":
valid = value is bool
&"duration_mode":
valid = value is String and SPEECH_PAYLOAD.DURATION_MODES.has(String(value))
&"keep_acting":
valid = value is bool
if not valid:
return
normalized[String(key)] = value
if key == &"keep_acting" and _reactive_options:
normalized["stop_current"] = not bool(value)
var bubbles: Array = normalized.get("bubbles", [])
if key in [&"reveal", &"pop_in"]:
for index: int in bubbles.size():
if not bubbles[index] is Dictionary:
continue
var bubble: Dictionary = bubbles[index] as Dictionary
var visual: Dictionary = bubble.get("visual", {}).duplicate(true)
visual[String(key)] = value
bubble["visual"] = visual
bubbles[index] = bubble
_draft = normalized
_draft["bubbles"] = bubbles
else:
_draft = normalized
_emit_draft()
_sync_controls()
func choose_comic_chain(centers: Array) -> void:
# Compatibility entry point for callers from the earlier Comic chain UI.
apply_bubble_layout(centers, "stack")
choose_speech_show(&"arrangement", "take_turns")
choose_speech_show(&"connect_bubbles", true)
func set_selected_bubble_index(index: int) -> void:
var bubbles: Array = _draft.get("bubbles", [])
if bubbles.is_empty():
return
var next_index: int = clampi(index, 0, bubbles.size() - 1)
if next_index == _selected_bubble:
return
_stop_editor_effect_demo()
_save_active_editor_state()
_selected_bubble = next_index
_set_stage_focus(next_index)
_target.clear()
_sync_editor()
_sync_controls()
_emit_style_context()
func select_bubble_from_stage(index: int) -> void:
var bubbles: Array = _draft.get("bubbles", [])
if index < 0 or index >= bubbles.size():
return
_stop_editor_effect_demo()
if index != _selected_bubble:
_save_active_editor_state()
_selected_bubble = index
_target.clear()
_sync_editor()
else:
_target.clear()
_set_stage_focus(index)
_sync_controls()
_emit_style_context()
func preview_state_name() -> String:
match _preview_state:
PreviewState.PLAYING: return "playing"
PreviewState.BLANK_HOLD: return "blank_hold"
_: return "static"
func preview_visible() -> bool:
return _preview_state != PreviewState.BLANK_HOLD
func set_preview_overflow(has_overflow: bool, _arrangement: String, bubble_layout: String = "stack") -> void:
if _overflow_label == null:
return
if not has_overflow:
_overflow_label.text = ""
elif bubble_layout == "stack":
var bubbles: Array = _draft.get("bubbles", [])
_overflow_label.text = "This tall stack is hard to fit here. Try One at a time or move the bubbles." if bubbles.size() >= 4 else "This stack is tight in the preview. Try One at a time or move it."
else:
_overflow_label.text = "This frame is hard to fit here. Try a smaller size or move the bubble."
func preview_elapsed() -> float:
if _preview_state == PreviewState.PLAYING:
return _replay_clock
if _preview_state == PreviewState.BLANK_HOLD:
return float(_draft.get("duration", 0.0))
return float(_draft.get("duration", 0.0))
func contains_viewport_point(point: Vector2) -> bool:
if _cancel_confirm_dialog != null and _cancel_confirm_dialog.visible:
return true
if is_message_dialog_open():
return true
return false
func panel_viewport_rect() -> Rect2:
return Rect2()
func clear_token_target() -> void:
_stop_editor_effect_demo()
_clear_word_target()
func set_placement_preview(local_center: Vector2) -> void:
if not local_center.is_finite():
return
_stop_editor_effect_demo()
_draft["placement"] = {"mode": "manual", "center": local_center}
_emit_draft()
func set_bubble_placement_preview(index: int, local_center: Vector2) -> void:
if not local_center.is_finite():
return
var bubbles: Array = _draft.get("bubbles", [])
if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary:
return
_stop_editor_effect_demo()
var bubble: Dictionary = bubbles[index]
bubble["placement"] = {"mode": "manual", "center": local_center}
bubbles[index] = bubble
_draft["bubbles"] = bubbles
_emit_draft()
_sync_controls()
func apply_bubble_layout(centers: Array, preset: String = "") -> void:
_save_active_editor_state()
var bubbles: Array = _draft.get("bubbles", [])
if centers.size() != bubbles.size():
return
if not preset.is_empty() and not SPEECH_PAYLOAD.BUBBLE_LAYOUT_MODES.has(preset):
return
for center_value: Variant in centers:
if not center_value is Vector2 or not (center_value as Vector2).is_finite():
return
for index: int in bubbles.size():
if not bubbles[index] is Dictionary:
return
var bubble: Dictionary = bubbles[index]
bubble["placement"] = {"mode": "manual", "center": centers[index]}
bubbles[index] = bubble
if not preset.is_empty():
_draft["bubble_layout"] = preset
if preset == "same_spot":
_draft["connect_bubbles"] = false
_draft["bubbles"] = bubbles
_emit_draft()
_sync_controls()
func reset_placement() -> void:
_stop_editor_effect_demo()
_draft["placement"] = {"mode": "auto"}
set_placement_pending(false)
placement_reset_requested.emit()
_emit_draft()
func reset_bubble_placement(index: int) -> void:
var bubbles: Array = _draft.get("bubbles", [])
if index < 0 or index >= bubbles.size() or not bubbles[index] is Dictionary:
return
_stop_editor_effect_demo()
var bubble: Dictionary = bubbles[index]
bubble.erase("placement")
bubbles[index] = bubble
_draft["bubbles"] = bubbles
set_placement_pending(false)
bubble_placement_reset_requested.emit(index)
_emit_draft()
_sync_controls()
func set_placement_pending(pending: bool, bubble_index: int = -1) -> void:
_placement_target_bubble = bubble_index if pending else -1
func close_without_commit() -> void:
_stop_editor_effect_demo()
_style_category = &""
_stage_focus_bubble = -1
if _message_dialog != null:
_message_dialog.hide()
if _cancel_confirm_dialog != null:
_cancel_confirm_dialog.hide()
visible = false
_replay_running = false
_actor = null
_undo_removed_bubble.clear()
_clear_word_target(false)
func set_cancel_requires_confirmation(required: bool) -> void:
_cancel_requires_confirmation = required
func request_cancel_speech() -> void:
var changed: bool = _cancel_requires_confirmation or get_draft() != _initial_action
if changed and _cancel_confirm_dialog != null:
_cancel_confirm_dialog.popup_centered()
return
cancelled.emit()
func request_done_speech() -> void:
var action: Dictionary = get_draft()
var error: String = SPEECH_PAYLOAD.validate(action)
if not error.is_empty():
var invalid_index: int = _first_invalid_bubble_index(action)
if invalid_index >= 0:
_select_bubble_for_message(invalid_index)
validation_failed.emit(error, invalid_index)
return
committed.emit(action)
func _first_invalid_bubble_index(action: Dictionary) -> int:
var bubbles: Array = action.get("bubbles", [])
for index: int in bubbles.size():
if not bubbles[index] is Dictionary:
return index
var text: String = String((bubbles[index] as Dictionary).get("text", ""))
if text.strip_edges().is_empty() or text.length() > SPEECH_PAYLOAD.MAX_VISIBLE_CHARACTERS:
return index
return _selected_bubble if not bubbles.is_empty() else -1
func _select_bubble_for_message(index: int) -> void:
var bubbles: Array = _draft.get("bubbles", [])
if index < 0 or index >= bubbles.size():
return
_save_active_editor_state()
_selected_bubble = index
_target.clear()
_set_stage_focus(index)
_sync_editor()
_sync_controls()
_emit_style_context()
func open_message_dialog() -> void:
if _draft.is_empty() or _message_dialog == null:
return
_sync_editor()
_emoji_picker_scroll.hide()
_emoji_picker_button.set_pressed_no_signal(false)
_emoji_picker_button.text = "😀 Emoji"
_layout_message_dialog()
_message_dialog.show()
var last_line: int = maxi(0, _active_editor.get_line_count() - 1)
_active_editor.set_caret_line(last_line, true)
_active_editor.set_caret_column(_active_editor.get_line(last_line).length(), true)
_active_editor.grab_focus()
func is_message_dialog_open() -> bool:
return _message_dialog != null and _message_dialog.visible
func close_message_dialog() -> void:
if not is_message_dialog_open():
return
_save_active_editor_state()
_active_editor.release_focus()
_message_dialog.hide()
_emoji_picker_scroll.hide()
message_dialog_closed.emit()
func set_message_dialog_anchor(frame_rect: Rect2, stage_rect: Rect2, toolbar_rect: Rect2) -> void:
_message_frame_rect = frame_rect
_message_stage_rect = stage_rect
_message_toolbar_rect = toolbar_rect
_layout_message_dialog()
func _build_ui() -> void:
_active_editor = TextEdit.new()
_active_editor.name = "ActiveBubbleText"
_active_editor.custom_minimum_size = Vector2(0.0, 132.0)
_active_editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_active_editor.placeholder_text = "Write what the character says…"
_active_editor.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
_active_editor.text_changed.connect(_on_active_text_changed)
_active_editor.caret_changed.connect(_remember_active_editor_state)
_cancel_confirm_dialog = ConfirmationDialog.new()
_cancel_confirm_dialog.title = "Discard speech changes?"
_cancel_confirm_dialog.dialog_text = "Your speech edits will be lost. Do you want to discard them?"
_cancel_confirm_dialog.get_ok_button().text = "Discard changes"
_cancel_confirm_dialog.get_cancel_button().text = "Keep editing"
_cancel_confirm_dialog.confirmed.connect(func() -> void: cancelled.emit())
_cancel_confirm_dialog.dialog_hide_on_ok = true
add_child(_cancel_confirm_dialog)
_build_message_dialog()
func _build_message_dialog() -> void:
_message_dialog = PanelContainer.new()
_message_dialog.name = "MessageDialog"
_message_dialog.mouse_filter = Control.MOUSE_FILTER_STOP
_message_dialog.z_index = 100
_message_dialog.hide()
_message_dialog.add_theme_stylebox_override("panel", _panel_style())
add_child(_message_dialog)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
margin.add_theme_constant_override("margin_right", 14)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 12)
_message_dialog.add_child(margin)
var content: VBoxContainer = VBoxContainer.new()
content.add_theme_constant_override("separation", 10)
margin.add_child(content)
var ribbon: PanelContainer = PanelContainer.new()
ribbon.add_theme_stylebox_override("panel", _section_ribbon_style())
var title: Label = _section_label("Message")
title.add_theme_font_size_override("font_size", 23)
title.add_theme_color_override("font_color", Color.WHITE)
ribbon.add_child(title)
content.add_child(ribbon)
_active_editor.custom_minimum_size.y = 150.0
_active_editor.size_flags_vertical = Control.SIZE_FILL
content.add_child(_active_editor)
_overflow_label = _section_label("")
_overflow_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
content.add_child(_overflow_label)
var emoji_row: HBoxContainer = HBoxContainer.new()
emoji_row.alignment = BoxContainer.ALIGNMENT_CENTER
_emoji_picker_button = _button("😀 Emoji")
_emoji_picker_button.toggle_mode = true
_emoji_picker_button.custom_minimum_size = Vector2(160.0, 56.0)
_emoji_picker_button.toggled.connect(_on_emoji_picker_toggled)
emoji_row.add_child(_emoji_picker_button)
content.add_child(emoji_row)
_emoji_picker_scroll = ScrollContainer.new()
_emoji_picker_scroll.name = "MessageEmojiPickerScroll"
_emoji_picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_emoji_picker_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO
_emoji_picker_scroll.custom_minimum_size = Vector2(0.0, 156.0)
_emoji_picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_emoji_picker_scroll.hide()
content.add_child(_emoji_picker_scroll)
_emoji_grid = GridContainer.new()
_emoji_grid.name = "MessageEmojiGrid"
_emoji_grid.columns = 4
_emoji_grid.add_theme_constant_override("h_separation", 7)
_emoji_grid.add_theme_constant_override("v_separation", 7)
_emoji_picker_scroll.add_child(_emoji_grid)
for emoji: String in _theme.emoji_items():
var emoji_button: Button = _button(emoji)
emoji_button.custom_minimum_size = Vector2(56.0, 52.0)
emoji_button.add_theme_font_size_override("font_size", 26)
emoji_button.pressed.connect(_insert_emoji.bind(emoji))
_emoji_grid.add_child(emoji_button)
var done_editing_row: HBoxContainer = HBoxContainer.new()
done_editing_row.alignment = BoxContainer.ALIGNMENT_CENTER
_message_done_button = _button("✓ Done editing")
_message_done_button.custom_minimum_size = Vector2(196.0, 56.0)
_message_done_button.add_theme_font_size_override("font_size", 18)
_message_done_button.pressed.connect(close_message_dialog)
done_editing_row.add_child(_message_done_button)
content.add_child(done_editing_row)
_layout_message_dialog()
func _on_emoji_picker_toggled(opened: bool) -> void:
_emoji_picker_scroll.visible = opened
_emoji_picker_button.text = "✕ Hide emoji" if opened else "😀 Emoji"
_layout_message_dialog()
func _layout_message_dialog() -> void:
if _message_dialog == null or not is_instance_valid(_message_dialog):
return
var viewport_size: Vector2 = get_viewport_rect().size
var stage_rect: Rect2 = _message_stage_rect if _message_stage_rect.size.x > 0.0 and _message_stage_rect.size.y > 0.0 else Rect2(Vector2(12.0, 12.0), viewport_size - Vector2(24.0, 24.0))
var frame_rect: Rect2 = _message_frame_rect
var gap: float = 10.0
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 below_toolbar: float = maxf(stage_rect.position.y, _message_toolbar_rect.end.y + gap)
var side_layout: bool = side_space >= 180.0 and stage_rect.end.y - below_toolbar >= 150.0
var dialog_width: float = minf(440.0, side_space) if side_layout else minf(440.0, stage_rect.size.x)
var dialog_x: float
var dialog_y: float
if side_layout:
dialog_x = frame_rect.end.x + gap if right_space >= left_space else frame_rect.position.x - gap - dialog_width
dialog_y = below_toolbar
else:
dialog_x = clampf(frame_rect.get_center().x - dialog_width * 0.5, stage_rect.position.x, maxf(stage_rect.position.x, stage_rect.end.x - dialog_width)) if frame_rect.size.x > 0.0 else stage_rect.get_center().x - dialog_width * 0.5
var below_frame: float = frame_rect.end.y + gap
dialog_y = below_frame if stage_rect.end.y - below_frame >= 230.0 else stage_rect.position.y
var reserved_height: float = minf(550.0, stage_rect.size.y)
var max_anchor_y: float = maxf(stage_rect.position.y, stage_rect.end.y - reserved_height)
var stable_dialog_y: float = clampf(dialog_y, stage_rect.position.y, max_anchor_y)
var available_height: float = maxf(1.0, stage_rect.end.y - stable_dialog_y)
var desired_height: float = 550.0 if _emoji_picker_scroll.visible else 410.0
var dialog_height: float = minf(desired_height, available_height)
if _emoji_picker_scroll.visible:
_emoji_picker_scroll.custom_minimum_size.y = clampf(dialog_height - 410.0, 32.0, 156.0)
_message_dialog.custom_minimum_size = Vector2(dialog_width, dialog_height)
_message_dialog.size = Vector2(dialog_width, dialog_height)
_message_dialog.position = Vector2(clampf(dialog_x, stage_rect.position.x, maxf(stage_rect.position.x, stage_rect.end.x - dialog_width)), stable_dialog_y)
func _update_message_responsive_layout() -> void:
if _emoji_grid != null and _message_dialog != null:
var emoji_size: float = minf(56.0, maxf(40.0, (_message_dialog.size.x - 72.0) / 4.0))
for child: Node in _emoji_grid.get_children():
if child is Button:
(child as Button).custom_minimum_size = Vector2(emoji_size, emoji_size)
(child as Button).add_theme_font_size_override("font_size", int(minf(26.0, emoji_size * 0.48)))
_layout_message_dialog()
func _section_label(text: String) -> Label:
var label: Label = Label.new()
label.text = text
label.add_theme_font_override("font", _theme.font())
label.add_theme_font_size_override("font_size", 18)
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
return label
func _section_ribbon_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = Color("#176B83")
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 _panel_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _theme.color("global.bg_color", Color("#171B24F5"))
style.border_color = _theme.color("global.border_color", Color("#FFFFFF55"))
style.set_border_width_all(2)
style.set_corner_radius_all(12)
return style
func _button(text: String) -> Button:
var button: Button = Button.new()
button.text = text
button.custom_minimum_size.y = _theme.speech_number("ui.button_min_height", 44.0, 40.0, 72.0)
button.focus_mode = Control.FOCUS_NONE
return button
func _sample_button(sample: String, caption: String) -> Button:
var button: Button = Button.new()
button.text = sample + "\n" + caption
button.custom_minimum_size = Vector2(82.0, 64.0)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_NONE
return button
func _new_bubble(visual_defaults: Dictionary = {}) -> Dictionary:
var visual: Dictionary = {
"bubble_style": "talk",
"font_preset": SPEECH_PAYLOAD.BASE_FONT_PRESET,
"size_preset": "medium",
"color": SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR,
"effect": "none",
"reveal": String(_draft.get("reveal", "instant")),
"pop_in": bool(_draft.get("pop_in", false)),
"word_styles": [],
}
for key: String in ["bubble_style", "font_preset", "size_preset", "color", "effect"]:
if visual_defaults.has(key):
visual[key] = visual_defaults[key]
return {"text": "", "visual": visual}
func _prepare_editor_state() -> void:
var bubbles: Array = _draft.get("bubbles", [])
while _caret_by_bubble.size() < bubbles.size():
_caret_by_bubble.append(Vector2i.ZERO)
_scroll_by_bubble.append(0.0)
_last_text_by_bubble.append(String((bubbles[_last_text_by_bubble.size()] as Dictionary).get("text", "")) if bubbles[_last_text_by_bubble.size()] is Dictionary else "")
while _caret_by_bubble.size() > bubbles.size():
_caret_by_bubble.pop_back()
_scroll_by_bubble.pop_back()
_last_text_by_bubble.pop_back()
for index: int in bubbles.size():
if bubbles[index] is Dictionary:
_last_text_by_bubble[index] = String((bubbles[index] as Dictionary).get("text", ""))
func _sync_editor() -> void:
if _active_editor == null:
return
_prepare_editor_state()
var bubbles: Array = _draft.get("bubbles", [])
if bubbles.is_empty():
return
var selected_text: String = String((bubbles[_selected_bubble] as Dictionary).get("text", ""))
_loading_editor = true
_active_editor.text = selected_text
var caret: Vector2i = _caret_by_bubble[_selected_bubble]
var line: int = clampi(caret.x, 0, maxi(0, _active_editor.get_line_count() - 1))
_active_editor.set_caret_line(line, false)
_active_editor.set_caret_column(clampi(caret.y, 0, _active_editor.get_line(line).length()), false)
_active_editor.scroll_vertical = _scroll_by_bubble[_selected_bubble]
_loading_editor = false
func _save_active_editor_state() -> void:
if _active_editor == null or not is_instance_valid(_active_editor) or _loading_editor:
return
var bubbles: Array = _draft.get("bubbles", [])
if _selected_bubble < 0 or _selected_bubble >= bubbles.size():
return
var bubble: Dictionary = bubbles[_selected_bubble]
bubble["text"] = _active_editor.text
bubbles[_selected_bubble] = bubble
_draft["bubbles"] = bubbles
_last_text_by_bubble[_selected_bubble] = _active_editor.text
_caret_by_bubble[_selected_bubble] = Vector2i(_active_editor.get_caret_line(), _active_editor.get_caret_column())
_scroll_by_bubble[_selected_bubble] = _active_editor.scroll_vertical
func _remember_active_editor_state() -> void:
if _loading_editor:
return
_save_active_editor_state()
func _update_bubble_count_hint() -> void:
var bubbles: Array = _draft.get("bubbles", [])
if bubbles.size() >= SPEECH_PAYLOAD.MAX_BUBBLES:
_sequence_hint = MAX_BUBBLE_HINT
elif bubbles.size() >= 5:
if not _five_bubble_hint_shown:
_five_bubble_hint_shown = true
_sequence_hint = FIVE_BUBBLE_HINT
elif bubbles.size() < SPEECH_PAYLOAD.MAX_BUBBLES and _sequence_hint == MAX_BUBBLE_HINT:
_sequence_hint = ""
elif _sequence_hint == FIVE_BUBBLE_HINT or _sequence_hint == MAX_BUBBLE_HINT:
_sequence_hint = ""
func add_bubble_after_selected() -> void:
_save_active_editor_state()
var bubbles: Array = _draft.get("bubbles", [])
if bubbles.size() >= SPEECH_PAYLOAD.MAX_BUBBLES:
_sequence_hint = MAX_BUBBLE_HINT
_emit_style_context()
return
_undo_removed_bubble.clear()
var selected: Dictionary = _selected_bubble_data()
var selected_visual: Dictionary = _new_bubble_visual_defaults(selected)
var insertion_index: int = clampi(_selected_bubble + 1, 0, bubbles.size())
bubbles.insert(insertion_index, _new_bubble(selected_visual))
_draft["bubbles"] = bubbles
_caret_by_bubble.insert(insertion_index, Vector2i.ZERO)
_scroll_by_bubble.insert(insertion_index, 0.0)
_last_text_by_bubble.insert(insertion_index, "")
_prepare_editor_state()
_selected_bubble = insertion_index
_target.clear()
_style_category = &""
_update_bubble_count_hint()
_set_stage_focus(_selected_bubble)
_sync_editor()
_sync_controls()
_emit_style_context()
_emit_draft()
call_deferred("open_message_dialog")
func _new_bubble_visual_defaults(source_bubble: Dictionary) -> Dictionary:
var visual: Dictionary = source_bubble.get("visual", {}) if source_bubble.get("visual", {}) is Dictionary else {}
var defaults: Dictionary = visual.duplicate(true)
var tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(String(source_bubble.get("text", "")))
if tokens.is_empty():
return defaults
var last_token: Vector2i = tokens.back()
var last_word: Vector2i = Vector2i(-1, -1)
var word_regex := RegEx.new()
if word_regex.compile(SPEECH_PAYLOAD.WORD_REGEX) == OK:
for match_result: RegExMatch in word_regex.search_all(String(source_bubble.get("text", ""))):
last_word = Vector2i(match_result.get_start(), match_result.get_end())
if last_word.x >= 0:
last_token = last_word
for raw_style: Variant in visual.get("word_styles", []):
if not raw_style is Dictionary:
continue
var word_style: Dictionary = raw_style as Dictionary
if int(word_style.get("start", -1)) != last_token.x or int(word_style.get("end", -1)) != last_token.y:
continue
for property: String in ["font_preset", "size_preset", "color", "effect"]:
if word_style.has(property):
defaults[property] = word_style[property]
break
return defaults
func remove_selected_bubble() -> void:
_save_active_editor_state()
var bubbles: Array = _draft.get("bubbles", [])
if bubbles.size() <= 1:
return
_undo_removed_bubble = {
"index": _selected_bubble,
"bubble": (bubbles[_selected_bubble] as Dictionary).duplicate(true),
"caret": _caret_by_bubble[_selected_bubble],
"scroll": _scroll_by_bubble[_selected_bubble],
"text": _last_text_by_bubble[_selected_bubble],
}
bubbles.remove_at(_selected_bubble)
_caret_by_bubble.remove_at(_selected_bubble)
_scroll_by_bubble.remove_at(_selected_bubble)
_last_text_by_bubble.remove_at(_selected_bubble)
_draft["bubbles"] = bubbles
_update_bubble_count_hint()
_selected_bubble = mini(_selected_bubble, bubbles.size() - 1)
_set_stage_focus(_selected_bubble)
_target.clear()
_sync_editor()
_sync_controls()
_emit_style_context()
_emit_draft()
func undo_remove_bubble() -> void:
if _undo_removed_bubble.is_empty():
return
var bubbles: Array = _draft.get("bubbles", [])
var index: int = clampi(int(_undo_removed_bubble.get("index", bubbles.size())), 0, bubbles.size())
bubbles.insert(index, (_undo_removed_bubble.get("bubble", {}) as Dictionary).duplicate(true))
_caret_by_bubble.insert(index, _undo_removed_bubble.get("caret", Vector2i.ZERO))
_scroll_by_bubble.insert(index, float(_undo_removed_bubble.get("scroll", 0.0)))
_last_text_by_bubble.insert(index, String(_undo_removed_bubble.get("text", "")))
_draft["bubbles"] = bubbles
_update_bubble_count_hint()
_selected_bubble = index
_undo_removed_bubble.clear()
_target.clear()
_set_stage_focus(index)
_sync_editor()
_sync_controls()
_emit_style_context()
_emit_draft()
func move_selected_bubble(offset: int) -> void:
_save_active_editor_state()
var bubbles: Array = _draft.get("bubbles", [])
var destination: int = _selected_bubble + offset
if destination < 0 or destination >= bubbles.size():
return
_undo_removed_bubble.clear()
var bubble: Variant = bubbles.pop_at(_selected_bubble)
bubbles.insert(destination, bubble)
var caret: Vector2i = _caret_by_bubble.pop_at(_selected_bubble)
_caret_by_bubble.insert(destination, caret)
var scroll: float = _scroll_by_bubble.pop_at(_selected_bubble)
_scroll_by_bubble.insert(destination, scroll)
var text: String = _last_text_by_bubble.pop_at(_selected_bubble)
_last_text_by_bubble.insert(destination, text)
_selected_bubble = destination
_set_stage_focus(destination)
_draft["bubbles"] = bubbles
_update_bubble_count_hint()
_target.clear()
_sync_editor()
_sync_controls()
_emit_style_context()
_emit_draft()
func _on_active_text_changed() -> void:
if _loading_editor:
return
_stop_editor_effect_demo()
var bubbles: Array = _draft.get("bubbles", [])
if _selected_bubble < 0 or _selected_bubble >= bubbles.size():
return
var bubble: Dictionary = bubbles[_selected_bubble]
var old_text: String = _last_text_by_bubble[_selected_bubble]
var new_text: String = _active_editor.text
_adjust_word_styles(bubble, old_text, new_text)
bubble["text"] = new_text
bubbles[_selected_bubble] = bubble
_draft["bubbles"] = bubbles
_last_text_by_bubble[_selected_bubble] = new_text
_caret_by_bubble[_selected_bubble] = Vector2i(_active_editor.get_caret_line(), _active_editor.get_caret_column())
_scroll_by_bubble[_selected_bubble] = _active_editor.scroll_vertical
if not _target.is_empty() and int(_target.get("bubble_index", -1)) == _selected_bubble:
_clear_word_target(false)
_emit_style_context()
_emit_draft()
func _adjust_word_styles(bubble: Dictionary, old_text: String, new_text: String) -> void:
var visual: Dictionary = bubble.get("visual", {})
var styles: Array = visual.get("word_styles", [])
var prefix: int = 0
while prefix < old_text.length() and prefix < new_text.length() and old_text[prefix] == new_text[prefix]:
prefix += 1
var old_suffix: int = old_text.length()
var new_suffix: int = new_text.length()
while old_suffix > prefix and new_suffix > prefix and old_text[old_suffix - 1] == new_text[new_suffix - 1]:
old_suffix -= 1
new_suffix -= 1
var shift: int = new_suffix - old_suffix
var source_styles: Array[Dictionary] = []
for raw: Variant in styles:
if raw is Dictionary:
source_styles.append((raw as Dictionary).duplicate(true))
source_styles.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("start", -1)) < int(b.get("start", -1)))
var updated: Array[Dictionary] = []
var new_tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(new_text)
for style: Dictionary in source_styles:
var start: int = int(style.get("start", -1))
var end: int = int(style.get("end", -1))
var mapped_start: int = start
var mapped_end: int = end
if end <= prefix:
pass
elif start >= old_suffix:
mapped_start += shift
mapped_end += shift
else:
mapped_start = start if start < prefix else prefix
mapped_end = end + shift if end > old_suffix else new_suffix
if mapped_end <= mapped_start:
continue
for token: Vector2i in new_tokens:
if token.x >= mapped_end or token.y <= mapped_start:
continue
_append_style_for_token(updated, token, style)
if old_suffix == prefix and new_suffix > prefix:
var inherited_style: Dictionary = _style_for_inserted_text(old_text, styles, prefix)
if not inherited_style.is_empty():
for token: Vector2i in new_tokens:
if token.x >= new_suffix or token.y <= prefix or _has_style_for_token(updated, token):
continue
_append_style_for_token(updated, token, inherited_style)
visual["word_styles"] = updated
bubble["visual"] = visual
func _append_style_for_token(styles: Array[Dictionary], token: Vector2i, source: Dictionary) -> void:
if _has_style_for_token(styles, token):
return
var copied: Dictionary = {"start": token.x, "end": token.y}
for property: String in ["font_preset", "size_preset", "color", "effect"]:
if source.has(property):
copied[property] = source[property]
if copied.size() > 2:
styles.append(copied)
func _has_style_for_token(styles: Array[Dictionary], token: Vector2i) -> bool:
for style: Dictionary in styles:
if int(style.get("start", -1)) == token.x and int(style.get("end", -1)) == token.y:
return true
return false
func _style_for_inserted_text(old_text: String, styles: Array, insertion_offset: int) -> Dictionary:
var tokens: Array[Vector2i] = SPEECH_PAYLOAD.token_spans(old_text)
var previous: Vector2i = Vector2i(-1, -1)
var following: Vector2i = Vector2i(-1, -1)
for span: Vector2i in tokens:
if span.x < insertion_offset and insertion_offset < span.y:
previous = span
break
if span.y <= insertion_offset:
previous = span
elif span.x >= insertion_offset and following.x < 0:
following = span
var source_span: Vector2i = previous if previous.x >= 0 else following
if source_span.x < 0:
return {}
for raw: Variant in styles:
if raw is Dictionary and int((raw as Dictionary).get("start", -1)) == source_span.x and int((raw as Dictionary).get("end", -1)) == source_span.y:
return (raw as Dictionary).duplicate(true)
return {}
func _insert_emoji(emoji: String) -> void:
if _active_editor == null:
return
_active_editor.grab_focus()
_active_editor.insert_text_at_caret(emoji)
func _on_replay_pressed() -> void:
replay_preview_once()
func replay_preview_once() -> void:
_stop_editor_effect_demo()
_replay_clock = 0.0
_blank_hold_clock = 0.0
_replay_running = true
_preview_state = PreviewState.PLAYING
_emit_preview_state()
replay_requested.emit()
func _sync_controls() -> void:
if _draft.is_empty() or not is_instance_valid(_active_editor):
return
func _selected_bubble_data() -> Dictionary:
var bubbles: Array = _draft.get("bubbles", [])
return bubbles[_selected_bubble] if _selected_bubble >= 0 and _selected_bubble < bubbles.size() and bubbles[_selected_bubble] is Dictionary else _new_bubble()
func _store_selected_bubble(bubble: Dictionary) -> void:
var bubbles: Array = _draft.get("bubbles", [])
if _selected_bubble >= 0 and _selected_bubble < bubbles.size():
bubbles[_selected_bubble] = bubble
_draft["bubbles"] = bubbles
func _clear_word_target(update_controls: bool = true) -> void:
_target.clear()
if update_controls and _active_editor != null and is_instance_valid(_active_editor):
_sync_controls()
_emit_style_context()
func _stop_replay_to_static() -> void:
_replay_running = false
_preview_state = PreviewState.STATIC
_blank_hold_clock = 0.0
_replay_clock = SPEECH_PAYLOAD.duration_for(_draft)
_emit_preview_state()
func _emit_preview_state() -> void:
preview_state_changed.emit(preview_state_name(), preview_elapsed(), preview_visible())
func _emit_draft(stop_replay: bool = true) -> void:
if _draft.is_empty():
return
_save_active_editor_state()
var bubbles: Array = _draft.get("bubbles", [])
_draft["text"] = String((bubbles[0] as Dictionary).get("text", "")) if not bubbles.is_empty() and bubbles[0] is Dictionary else ""
_draft["duration"] = SPEECH_PAYLOAD.duration_for(_draft)
if stop_replay:
_stop_replay_to_static()
draft_changed.emit(_draft.duplicate(true))