- 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.
1282 lines
45 KiB
GDScript
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))
|