1875 lines
85 KiB
GDScript
1875 lines
85 KiB
GDScript
class_name SpeechBubble
|
|
extends Node2D
|
|
## Shared deterministic speech renderer used by stage preview and playback.
|
|
|
|
class BubbleFrameLayer extends Node2D:
|
|
var renderer: Node2D
|
|
var bubble_index: int = -1
|
|
|
|
func _draw() -> void:
|
|
if renderer != null and is_instance_valid(renderer):
|
|
renderer.call("_draw_bubble_frame_layer", self, bubble_index)
|
|
|
|
|
|
class EditorOverlayLayer extends Node2D:
|
|
var renderer: Node2D
|
|
|
|
func _draw() -> void:
|
|
if renderer != null and is_instance_valid(renderer):
|
|
renderer.call("_draw_editor_overlay_layer", self)
|
|
|
|
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
|
const SANDBOX_THEME := preload("res://scripts/stage/sandbox_theme.gd")
|
|
const SPEECH_WORD_EFFECT := preload("res://scripts/speech/speech_word_effect.gd")
|
|
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
|
|
const AUTO_HEAD_GAP: float = 48.0
|
|
const MAX_FRAME_WIDTH: float = 420.0
|
|
const MIN_FRAME_WIDTH: float = 108.0
|
|
const PADDING: Vector2 = Vector2(24.0, 18.0)
|
|
const SHOUT_PADDING: Vector2 = Vector2(30.0, 22.0)
|
|
const SHAPE_TEXT_SAFETY: Vector2 = Vector2(8.0, 6.0)
|
|
const FONT_LINE_HEIGHT_SAFETY: float = 1.15
|
|
const STACK_GAP: float = 12.0
|
|
const ORDER_BADGE_RADIUS: float = 21.0
|
|
const ORDER_BADGE_HIT_RADIUS: float = 28.0
|
|
const ORDER_BADGE_MIN_SPACING: float = 58.0
|
|
|
|
var _action: Dictionary = {}
|
|
var _elapsed: float = 0.0
|
|
var _suppressed: bool = false
|
|
var _preview: bool = false
|
|
var _static_editor_preview: bool = false
|
|
var _placement_ghost: bool = false
|
|
var _mirrored: bool = false
|
|
var _screen_scale: float = 1.0
|
|
var _fit_scale: float = 1.0
|
|
var _display_offset: Vector2 = Vector2.ZERO
|
|
var _display_overflow: bool = false
|
|
var _last_viewport_size: Vector2 = Vector2(-1.0, -1.0)
|
|
var _last_canvas_transform: Transform2D = Transform2D.IDENTITY
|
|
var _last_rendered_token: Vector3i = Vector3i(-1, -1, -1)
|
|
var _last_layout_action: Dictionary = {}
|
|
var _last_layout_elapsed: float = -1.0
|
|
var _head_center_rig: Vector2 = Vector2(0.0, -310.0)
|
|
var _head_radius_rig: float = 100.0
|
|
var _selected_preview_bubble: int = -1
|
|
var _selected_token: Vector3i = Vector3i(-1, -1, -1)
|
|
var _editor_effect_preview: Dictionary = {}
|
|
var _last_editor_effect_preview: Dictionary = {}
|
|
var _drag_bubble_index: int = -1
|
|
var _drag_pointer_offset: Vector2 = Vector2.ZERO
|
|
var _labels: Array[RichTextLabel] = []
|
|
var _frame_layers: Array[BubbleFrameLayer] = []
|
|
var _layouts: Array[Dictionary] = []
|
|
var _editor_overlay_layer: EditorOverlayLayer
|
|
var _label_meta_clicked: Dictionary = {}
|
|
var _theme: SandboxTheme
|
|
var _word_effect: RichTextEffect
|
|
|
|
signal bubble_position_changed(bubble_index: int, center_rig: Vector2)
|
|
signal stage_word_selected(bubble_index: int, start: int, end: int)
|
|
signal stage_token_selected(bubble_index: int, start: int, end: int)
|
|
signal stage_line_tokens_requested(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2)
|
|
signal stage_text_tap_detected(bubble_index: int)
|
|
|
|
|
|
func _ready() -> void:
|
|
_theme = SANDBOX_THEME.new()
|
|
_theme.load_from()
|
|
_word_effect = SPEECH_WORD_EFFECT.new() as RichTextEffect
|
|
_editor_overlay_layer = EditorOverlayLayer.new()
|
|
_editor_overlay_layer.name = "SpeechEditorOverlay"
|
|
_editor_overlay_layer.renderer = self
|
|
_editor_overlay_layer.z_index = 64
|
|
add_child(_editor_overlay_layer)
|
|
visible = false
|
|
|
|
|
|
func render_action(action: Dictionary, elapsed: float, preview: bool = false, static_editor_preview: bool = false) -> void:
|
|
var normalized_action: Dictionary = SPEECH_PAYLOAD.normalize(action)
|
|
var next_elapsed: float = maxf(0.0, elapsed)
|
|
var viewport: Viewport = get_viewport()
|
|
var viewport_size: Vector2 = viewport.get_visible_rect().size if viewport != null else Vector2.ZERO
|
|
var canvas_transform: Transform2D = viewport.get_canvas_transform() if viewport != null else Transform2D.IDENTITY
|
|
var previous_head: Vector2 = _head_center_rig
|
|
var previous_radius: float = _head_radius_rig
|
|
var previous_position: Vector2 = position
|
|
var previous_fit_scale: float = _fit_scale
|
|
var previous_display_offset: Vector2 = _display_offset
|
|
var previous_display_overflow: bool = _display_overflow
|
|
_action = normalized_action
|
|
_elapsed = next_elapsed
|
|
_preview = preview
|
|
_static_editor_preview = static_editor_preview
|
|
_fit_scale = 1.0
|
|
_display_offset = Vector2.ZERO
|
|
_display_overflow = false
|
|
_resolve_live_head_geometry()
|
|
_update_screen_scale()
|
|
visible = not _suppressed and not _action.is_empty()
|
|
if not visible:
|
|
return
|
|
var layout_changed: bool = normalized_action != _last_layout_action or next_elapsed != _last_layout_elapsed or _last_viewport_size != viewport_size or _last_canvas_transform != canvas_transform or previous_head != _head_center_rig or previous_radius != _head_radius_rig or _last_rendered_token != _selected_token or _last_editor_effect_preview != _editor_effect_preview
|
|
if layout_changed or _layouts.is_empty():
|
|
# Effect clock/style changes redraw text in the existing frames; only geometry changes remeasure the stack.
|
|
if _can_refresh_editor_content_in_place(viewport_size, canvas_transform, previous_head, previous_radius, next_elapsed):
|
|
position = previous_position
|
|
_fit_scale = previous_fit_scale
|
|
_display_offset = previous_display_offset
|
|
_display_overflow = previous_display_overflow
|
|
_update_node_scale()
|
|
_refresh_editor_content_in_place(next_elapsed)
|
|
else:
|
|
_rebuild_layout()
|
|
_last_layout_action = normalized_action.duplicate(true)
|
|
_last_layout_elapsed = next_elapsed
|
|
_last_rendered_token = _selected_token
|
|
_last_editor_effect_preview = _editor_effect_preview.duplicate(true)
|
|
else:
|
|
_fit_display_layout()
|
|
_last_viewport_size = viewport_size
|
|
_last_canvas_transform = canvas_transform
|
|
_queue_redraw_all()
|
|
|
|
|
|
func show_text(text: String) -> void:
|
|
# Legacy callers retain their authored fixed duration in the runner.
|
|
render_action({"type": "speak", "text": text, "duration": 2.0}, 0.0, false)
|
|
|
|
|
|
func hide_bubble() -> void:
|
|
visible = false
|
|
_editor_effect_preview.clear()
|
|
|
|
|
|
func set_suppressed(value: bool) -> void:
|
|
_suppressed = value
|
|
if value:
|
|
_editor_effect_preview.clear()
|
|
visible = not _suppressed and not _action.is_empty()
|
|
_queue_redraw_all()
|
|
|
|
|
|
func set_placement_ghost(value: bool) -> void:
|
|
_placement_ghost = value
|
|
_queue_redraw_all()
|
|
|
|
|
|
func set_mirrored(value: bool) -> void:
|
|
_mirrored = value
|
|
_update_node_scale()
|
|
_queue_redraw_all()
|
|
|
|
|
|
func has_display_overflow() -> bool:
|
|
return _display_overflow
|
|
|
|
|
|
func set_selected_bubble_index(index: int) -> void:
|
|
if _selected_preview_bubble == index:
|
|
return
|
|
_selected_preview_bubble = index
|
|
for layout: Dictionary in _layouts:
|
|
var guide_only: bool = _preview and _static_editor_preview and index >= 0 and int(layout.get("index", -1)) != index
|
|
layout["guide_only"] = guide_only
|
|
var label: RichTextLabel = layout.get("label") as RichTextLabel
|
|
if label != null and is_instance_valid(label):
|
|
label.modulate.a = 0.5 if guide_only else 1.0
|
|
_sync_preview_layer_order()
|
|
_queue_redraw_all()
|
|
|
|
|
|
func _sync_preview_layer_order() -> void:
|
|
var selected_frame_z: int = SPEECH_PAYLOAD.MAX_BUBBLES * 2 + 3
|
|
for layout: Dictionary in _layouts:
|
|
var index: int = int(layout.get("index", -1))
|
|
var label: RichTextLabel = layout.get("label") as RichTextLabel
|
|
if label != null and is_instance_valid(label):
|
|
label.z_index = SPEECH_PAYLOAD.MAX_BUBBLES * 2 + 4 if index == _selected_preview_bubble else index * 2 + 2
|
|
if index >= 0 and index < _frame_layers.size():
|
|
_frame_layers[index].z_index = selected_frame_z if index == _selected_preview_bubble else index * 2 + 1
|
|
|
|
|
|
func set_selected_token(index: int, start: int, end: int) -> void:
|
|
var next_token: Vector3i = Vector3i(index, start, end) if index >= 0 and start >= 0 and end > start else Vector3i(-1, -1, -1)
|
|
if _selected_token == next_token:
|
|
return
|
|
_selected_token = next_token
|
|
_queue_redraw_all()
|
|
|
|
|
|
func _queue_redraw_all() -> void:
|
|
queue_redraw()
|
|
for layer: BubbleFrameLayer in _frame_layers:
|
|
if is_instance_valid(layer):
|
|
layer.queue_redraw()
|
|
if _editor_overlay_layer != null and is_instance_valid(_editor_overlay_layer):
|
|
_editor_overlay_layer.queue_redraw()
|
|
|
|
|
|
func set_editor_effect_preview(preview: Dictionary) -> void:
|
|
_editor_effect_preview = preview.duplicate(true)
|
|
|
|
|
|
func _can_refresh_editor_content_in_place(viewport_size: Vector2, canvas_transform: Transform2D, previous_head: Vector2, previous_radius: float, next_elapsed: float) -> bool:
|
|
if not _preview or not _static_editor_preview or _layouts.is_empty() or _last_layout_action.is_empty():
|
|
return false
|
|
if _last_viewport_size != viewport_size or _last_canvas_transform != canvas_transform or previous_head != _head_center_rig or previous_radius != _head_radius_rig:
|
|
return false
|
|
if next_elapsed != _last_layout_elapsed:
|
|
return false
|
|
return _speech_geometry_signature(_last_layout_action) == _speech_geometry_signature(_action)
|
|
|
|
|
|
func _speech_geometry_signature(action: Dictionary) -> Dictionary:
|
|
var bubble_signatures: Array[Dictionary] = []
|
|
for raw_bubble: Variant in action.get("bubbles", []):
|
|
if not raw_bubble is Dictionary:
|
|
continue
|
|
var bubble: Dictionary = raw_bubble as Dictionary
|
|
var visual: Dictionary = bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {}
|
|
var typography_overrides: 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
|
|
if style.has("font_preset") or style.has("size_preset"):
|
|
typography_overrides.append({
|
|
"start": style.get("start", -1),
|
|
"end": style.get("end", -1),
|
|
"font_preset": style.get("font_preset", ""),
|
|
"size_preset": style.get("size_preset", ""),
|
|
})
|
|
bubble_signatures.append({
|
|
"text": String(bubble.get("text", "")),
|
|
"bubble_style": String(visual.get("bubble_style", "talk")),
|
|
"font_preset": String(visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)),
|
|
"size_preset": String(visual.get("size_preset", "medium")),
|
|
"pop_in": bool(visual.get("pop_in", false)),
|
|
"word_styles": typography_overrides,
|
|
"placement": bubble.get("placement", null),
|
|
})
|
|
return {
|
|
"bubbles": bubble_signatures,
|
|
"placement": action.get("placement", null),
|
|
"arrangement": String(action.get("arrangement", "together")),
|
|
}
|
|
|
|
|
|
func _refresh_editor_content_in_place(elapsed: float) -> void:
|
|
var guide_only_bubble: int = _selected_preview_bubble
|
|
for layout: Dictionary in _layouts:
|
|
var bubble_index: int = int(layout.get("index", -1))
|
|
var bubble: Dictionary = _bubble_data(bubble_index)
|
|
if bubble.is_empty():
|
|
continue
|
|
var visual: Dictionary = bubble.get("visual", {}) if bubble.get("visual", {}) is Dictionary else {}
|
|
var text: String = String(bubble.get("text", ""))
|
|
var label: RichTextLabel = layout.get("label") as RichTextLabel
|
|
layout["visual"] = visual
|
|
layout["elapsed"] = elapsed
|
|
layout["guide_only"] = guide_only_bubble >= 0 and bubble_index != guide_only_bubble
|
|
if label == null or not is_instance_valid(label):
|
|
continue
|
|
label.modulate.a = 0.5 if bool(layout["guide_only"]) else 1.0
|
|
_render_label_text(label, text, visual, elapsed, bubble_index)
|
|
label.visible_characters = _visible_character_count(text, visual, elapsed, float(_interval_for(bubble_index)))
|
|
|
|
|
|
func selected_frame_viewport_rect(index: int) -> Rect2:
|
|
if not _preview or not _static_editor_preview:
|
|
return Rect2()
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) == index:
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
frame.position += _display_offset
|
|
return _transform_rect(get_global_transform_with_canvas(), frame)
|
|
return Rect2()
|
|
|
|
|
|
func group_frames_viewport_rect() -> Rect2:
|
|
if not _preview or not _static_editor_preview or _layouts.is_empty():
|
|
return Rect2()
|
|
var group_rect: Rect2 = Rect2()
|
|
var has_frame: bool = false
|
|
for layout: Dictionary in _layouts:
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
frame.position += _display_offset
|
|
var viewport_frame: Rect2 = _transform_rect(get_global_transform_with_canvas(), frame)
|
|
group_rect = viewport_frame if not has_frame else group_rect.merge(viewport_frame)
|
|
has_frame = true
|
|
return group_rect if has_frame else Rect2()
|
|
|
|
|
|
func bubble_layout_centers(preset: StringName) -> Array[Vector2]:
|
|
var centers: Array[Vector2] = []
|
|
var bubbles: Array = _action.get("bubbles", [])
|
|
if not _preview or not _static_editor_preview or bubbles.is_empty() or _layouts.size() != bubbles.size():
|
|
return centers
|
|
if preset != &"stack" and preset != &"same_spot":
|
|
return centers
|
|
var frame_sizes: Array[Vector2] = []
|
|
var found: PackedByteArray = PackedByteArray()
|
|
centers.resize(bubbles.size())
|
|
frame_sizes.resize(bubbles.size())
|
|
found.resize(bubbles.size())
|
|
for layout: Dictionary in _layouts:
|
|
var index: int = int(layout.get("index", -1))
|
|
if index < 0 or index >= bubbles.size():
|
|
continue
|
|
var frame: Rect2 = layout.get("frame", Rect2())
|
|
centers[index] = position + frame.get_center()
|
|
frame_sizes[index] = frame.size
|
|
found[index] = 1
|
|
for index: int in found.size():
|
|
if found[index] == 0:
|
|
return []
|
|
var first_center: Vector2 = centers[0]
|
|
if preset == &"same_spot":
|
|
for index: int in centers.size():
|
|
centers[index] = first_center
|
|
return centers
|
|
var previous_center: Vector2 = first_center
|
|
var previous_height: float = frame_sizes[0].y
|
|
for index: int in range(1, centers.size()):
|
|
var side_step: float = 22.0 if index % 2 == 1 else -22.0
|
|
var next_center := Vector2(
|
|
first_center.x + side_step,
|
|
previous_center.y - (previous_height + frame_sizes[index].y) * 0.5 - STACK_GAP
|
|
)
|
|
centers[index] = next_center
|
|
previous_center = next_center
|
|
previous_height = frame_sizes[index].y
|
|
return centers
|
|
|
|
|
|
func suggest_added_bubble_center(index: int) -> Dictionary:
|
|
var added_layout: Dictionary = {}
|
|
var below_layout: Dictionary = {}
|
|
for layout: Dictionary in _layouts:
|
|
var layout_index: int = int(layout.get("index", -1))
|
|
if layout_index == index:
|
|
added_layout = layout
|
|
elif layout_index == index + 1:
|
|
below_layout = layout
|
|
if added_layout.is_empty() or below_layout.is_empty():
|
|
return {}
|
|
var added_rect: Rect2 = _layout_frame(added_layout)
|
|
var below_rect: Rect2 = _layout_frame(below_layout)
|
|
if not added_rect.grow(5.0).intersects(below_rect.grow(5.0)):
|
|
return {}
|
|
|
|
var overlap_y: float = minf(added_rect.end.y, below_rect.end.y) - maxf(added_rect.position.y, below_rect.position.y)
|
|
var shifted_rect: Rect2 = added_rect
|
|
shifted_rect.position.y += maxf(10.0, minf(22.0, overlap_y * 0.4))
|
|
var blocking_left: float = INF
|
|
var blocking_right: float = -INF
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) == index:
|
|
continue
|
|
var other_rect: Rect2 = _layout_frame(layout)
|
|
if shifted_rect.position.y >= other_rect.end.y or shifted_rect.end.y <= other_rect.position.y:
|
|
continue
|
|
blocking_left = minf(blocking_left, other_rect.position.x)
|
|
blocking_right = maxf(blocking_right, other_rect.end.x)
|
|
if not is_finite(blocking_left) or not is_finite(blocking_right):
|
|
return {}
|
|
|
|
var horizontal_gap: float = 16.0
|
|
var vertical_center: float = shifted_rect.get_center().y
|
|
var left_center: Vector2 = Vector2(blocking_left - horizontal_gap - added_rect.size.x * 0.5, vertical_center)
|
|
var right_center: Vector2 = Vector2(blocking_right + horizontal_gap + added_rect.size.x * 0.5, vertical_center)
|
|
var screen_transform: Transform2D = get_global_transform_with_canvas()
|
|
var viewport_size: Vector2 = get_viewport_rect().size
|
|
var screen_width: float = added_rect.size.x * screen_transform.x.length()
|
|
var left_screen_x: float = (screen_transform * (left_center + _display_offset)).x
|
|
var right_screen_x: float = (screen_transform * (right_center + _display_offset)).x
|
|
var left_room: float = left_screen_x - screen_width * 0.5 - 14.0
|
|
var right_room: float = viewport_size.x - right_screen_x - screen_width * 0.5 - 14.0
|
|
var choose_left: bool
|
|
if left_room >= 0.0 and right_room < 0.0:
|
|
choose_left = true
|
|
elif right_room >= 0.0 and left_room < 0.0:
|
|
choose_left = false
|
|
elif left_room >= 0.0 and right_room >= 0.0:
|
|
choose_left = absf(left_center.x - added_rect.get_center().x) <= absf(right_center.x - added_rect.get_center().x)
|
|
else:
|
|
choose_left = left_room >= right_room
|
|
var resolved_center: Vector2 = left_center if choose_left else right_center
|
|
return {"collided": true, "center": position + resolved_center}
|
|
|
|
|
|
func text_line_tokens_at_viewport(index: int, viewport_position: Vector2) -> Array[Vector2i]:
|
|
var result: Array[Vector2i] = []
|
|
var bubble: Dictionary = _bubble_data(index)
|
|
if bubble.is_empty():
|
|
return result
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) != index:
|
|
continue
|
|
var label: RichTextLabel = layout.get("label") as RichTextLabel
|
|
if label == null or not is_instance_valid(label) or not label.get_global_rect().has_point(viewport_position):
|
|
return result
|
|
var local_point: Vector2 = label.get_global_transform_with_canvas().affine_inverse() * viewport_position
|
|
var nearest_line: int = -1
|
|
var nearest_distance: float = INF
|
|
for line_index: int in label.get_line_count():
|
|
var line_top: float = label.get_line_offset(line_index)
|
|
var line_bottom: float = line_top + float(label.get_line_height(line_index))
|
|
var distance: float = maxf(maxf(line_top - local_point.y, 0.0), local_point.y - line_bottom)
|
|
if distance < nearest_distance:
|
|
nearest_distance = distance
|
|
nearest_line = line_index
|
|
if nearest_line < 0 or nearest_distance > 36.0:
|
|
return result
|
|
var text: String = String(bubble.get("text", ""))
|
|
for span: Vector2i in SPEECH_PAYLOAD.token_spans(text):
|
|
if label.get_character_line(span.x) == nearest_line:
|
|
result.append(span)
|
|
return result
|
|
return result
|
|
|
|
|
|
func hit_test_bubbles(world_position: Vector2) -> Array[int]:
|
|
var hits: Array[int] = []
|
|
if not _preview or not _static_editor_preview or not visible:
|
|
return hits
|
|
var local_position: Vector2 = to_local(world_position) - _display_offset
|
|
for layout_index: int in range(_layouts.size() - 1, -1, -1):
|
|
var layout: Dictionary = _layouts[layout_index]
|
|
if _layout_frame(layout).has_point(local_position):
|
|
hits.append(int(layout.get("index", -1)))
|
|
return hits
|
|
|
|
|
|
func hit_test_bubble_for_drag(world_position: Vector2) -> int:
|
|
if not _preview or not _static_editor_preview or not visible:
|
|
return -1
|
|
var local_position: Vector2 = to_local(world_position) - _display_offset
|
|
var selected_index: int = -1
|
|
var selected_area: float = INF
|
|
# Iterate front to back so equal-sized bubbles keep the existing topmost tie-break.
|
|
for layout_index: int in range(_layouts.size() - 1, -1, -1):
|
|
var layout: Dictionary = _layouts[layout_index]
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
if not frame.has_point(local_position):
|
|
continue
|
|
var frame_area: float = frame.get_area()
|
|
if frame_area < selected_area:
|
|
selected_area = frame_area
|
|
selected_index = int(layout.get("index", -1))
|
|
return selected_index
|
|
|
|
|
|
func hit_test_order_badge(world_position: Vector2) -> int:
|
|
if not _preview or not _static_editor_preview or not visible:
|
|
return -1
|
|
var scale: float = _badge_canvas_scale()
|
|
var local_position: Vector2 = to_local(world_position)
|
|
var badge_centers: Dictionary = _order_badge_centers()
|
|
for layout_index: int in range(_layouts.size() - 1, -1, -1):
|
|
var index: int = int(_layouts[layout_index].get("index", -1))
|
|
if not badge_centers.has(index):
|
|
continue
|
|
var center: Vector2 = badge_centers[index]
|
|
if local_position.distance_to(center) <= ORDER_BADGE_HIT_RADIUS / scale:
|
|
return index
|
|
return -1
|
|
|
|
|
|
func order_badge_viewport_rects() -> Array[Rect2]:
|
|
var result: Array[Rect2] = []
|
|
if not _preview or not _static_editor_preview or not visible:
|
|
return result
|
|
var canvas_transform: Transform2D = get_global_transform_with_canvas()
|
|
var badge_centers: Dictionary = _order_badge_centers()
|
|
var reserve_radius: float = ORDER_BADGE_RADIUS + 5.0
|
|
for layout: Dictionary in _layouts:
|
|
var index: int = int(layout.get("index", -1))
|
|
if not badge_centers.has(index):
|
|
continue
|
|
var center: Vector2 = canvas_transform * Vector2(badge_centers[index])
|
|
result.append(Rect2(center - Vector2.ONE * reserve_radius, Vector2.ONE * reserve_radius * 2.0))
|
|
return result
|
|
|
|
|
|
func _order_badge_centers() -> Dictionary:
|
|
var centers: Dictionary = {}
|
|
if _layouts.is_empty():
|
|
return centers
|
|
var canvas_transform: Transform2D = get_global_transform_with_canvas()
|
|
var inverse_transform: Transform2D = canvas_transform.affine_inverse()
|
|
var spacing: float = ORDER_BADGE_MIN_SPACING
|
|
var desired_screen: Dictionary = {}
|
|
var frame_rects_screen: Dictionary = {}
|
|
var pending: Array[int] = []
|
|
for layout: Dictionary in _layouts:
|
|
var index: int = int(layout.get("index", -1))
|
|
if index < 0:
|
|
continue
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
frame.position += _display_offset
|
|
var screen_frame: Rect2 = _transform_rect(canvas_transform, frame)
|
|
frame_rects_screen[index] = screen_frame
|
|
desired_screen[index] = canvas_transform * frame.position
|
|
pending.append(index)
|
|
if pending.is_empty():
|
|
return centers
|
|
|
|
var groups: Array = []
|
|
while not pending.is_empty():
|
|
var group: Array[int] = [pending.pop_front()]
|
|
var expanded: bool = true
|
|
while expanded:
|
|
expanded = false
|
|
for candidate_index: int in pending.duplicate():
|
|
var candidate_frame: Rect2 = frame_rects_screen[candidate_index]
|
|
for grouped_index: int in group:
|
|
var grouped_frame: Rect2 = frame_rects_screen[grouped_index]
|
|
var badges_are_crowded: bool = Vector2(desired_screen[candidate_index]).distance_to(Vector2(desired_screen[grouped_index])) < spacing
|
|
if badges_are_crowded or candidate_frame.intersects(grouped_frame):
|
|
group.append(candidate_index)
|
|
pending.erase(candidate_index)
|
|
expanded = true
|
|
break
|
|
group.sort()
|
|
groups.append(group)
|
|
|
|
var placed_screen: Array[Vector2] = []
|
|
for group_variant: Variant in groups:
|
|
var group: Array = group_variant
|
|
var group_rect: Rect2 = Rect2(Vector2.ZERO, Vector2.ZERO)
|
|
var has_rect: bool = false
|
|
for group_index_variant: Variant in group:
|
|
var group_index: int = int(group_index_variant)
|
|
var frame_rect: Rect2 = frame_rects_screen[group_index]
|
|
group_rect = frame_rect if not has_rect else group_rect.merge(frame_rect)
|
|
has_rect = true
|
|
if not has_rect:
|
|
continue
|
|
var overlapping_frames: bool = false
|
|
var largest_frame: Rect2 = group_rect
|
|
var largest_frame_area: float = -1.0
|
|
for group_position: int in range(group.size()):
|
|
var group_index: int = int(group[group_position])
|
|
var frame_rect: Rect2 = frame_rects_screen[group_index]
|
|
var frame_area: float = frame_rect.get_area()
|
|
if frame_area > largest_frame_area:
|
|
largest_frame = frame_rect
|
|
largest_frame_area = frame_area
|
|
for other_position: int in range(group_position + 1, group.size()):
|
|
var other_index: int = int(group[other_position])
|
|
if frame_rect.intersects(frame_rects_screen[other_index]):
|
|
overlapping_frames = true
|
|
break
|
|
var badge_cluster_anchor: Rect2 = largest_frame if overlapping_frames else group_rect
|
|
|
|
var candidate_layouts: Array = []
|
|
if group.size() == 1:
|
|
var desired: Vector2 = desired_screen[int(group[0])]
|
|
if _badge_candidate_fits(desired, placed_screen, spacing):
|
|
candidate_layouts.append([desired])
|
|
if candidate_layouts.is_empty():
|
|
# Keep a cluster together. Use the familiar row above the bubbles first,
|
|
# then a side rail, and only use the lower row when the stage is tight.
|
|
candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"above"))
|
|
candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"right"))
|
|
candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"left"))
|
|
candidate_layouts.append(_badge_cluster_positions(badge_cluster_anchor, group.size(), &"below"))
|
|
|
|
var chosen_positions: Array = []
|
|
for candidate_layout_index: int in range(candidate_layouts.size()):
|
|
var candidate_positions_variant: Variant = candidate_layouts[candidate_layout_index]
|
|
var candidate_positions: Array = candidate_positions_variant
|
|
var allow_top_margin: bool = overlapping_frames and candidate_layout_index == 0
|
|
if _badge_cluster_fits(candidate_positions, placed_screen, spacing, allow_top_margin):
|
|
chosen_positions = candidate_positions
|
|
break
|
|
if chosen_positions.is_empty():
|
|
# Last-resort placement still avoids toolbars and already placed badges.
|
|
for group_index_variant: Variant in group:
|
|
var group_index: int = int(group_index_variant)
|
|
var fallback: Vector2 = _find_badge_fallback_position(Vector2(desired_screen[group_index]), placed_screen, spacing)
|
|
chosen_positions.append(fallback)
|
|
placed_screen.append(fallback)
|
|
else:
|
|
for position_variant: Variant in chosen_positions:
|
|
placed_screen.append(Vector2(position_variant))
|
|
for position_index: int in range(mini(group.size(), chosen_positions.size())):
|
|
centers[int(group[position_index])] = inverse_transform * Vector2(chosen_positions[position_index])
|
|
return centers
|
|
|
|
|
|
func _badge_cluster_positions(group_rect: Rect2, count: int, arrangement: StringName) -> Array[Vector2]:
|
|
var result: Array[Vector2] = []
|
|
if count <= 0:
|
|
return result
|
|
var spacing: float = ORDER_BADGE_MIN_SPACING
|
|
if arrangement == &"left" or arrangement == &"right":
|
|
var x: float = group_rect.position.x - ORDER_BADGE_RADIUS - 8.0 if arrangement == &"left" else group_rect.end.x + ORDER_BADGE_RADIUS + 8.0
|
|
var first_y: float = group_rect.get_center().y - float(count - 1) * spacing * 0.5
|
|
for item_index: int in range(count):
|
|
result.append(Vector2(x, first_y + float(item_index) * spacing))
|
|
return result
|
|
var y: float = group_rect.position.y - ORDER_BADGE_RADIUS - 8.0 if arrangement == &"above" else group_rect.end.y + ORDER_BADGE_RADIUS + 8.0
|
|
var first_x: float = group_rect.get_center().x - float(count - 1) * spacing * 0.5
|
|
for item_index: int in range(count):
|
|
result.append(Vector2(first_x + float(item_index) * spacing, y))
|
|
return result
|
|
|
|
|
|
func _badge_candidate_fits(candidate: Vector2, placed: Array[Vector2], spacing: float, allow_top_margin: bool = false) -> bool:
|
|
if _badge_position_is_crowded(candidate, placed, spacing):
|
|
return false
|
|
var viewport_size: Vector2 = get_viewport_rect().size
|
|
var top_margin: float = ORDER_BADGE_RADIUS + 12.0 if allow_top_margin else 72.0
|
|
var bottom_margin: float = 48.0
|
|
var stage_rect := Rect2(Vector2(12.0, top_margin), Vector2(maxf(1.0, viewport_size.x - 24.0), maxf(1.0, viewport_size.y - top_margin - bottom_margin)))
|
|
var half_size: float = ORDER_BADGE_RADIUS + 3.0
|
|
var badge_rect := Rect2(candidate - Vector2.ONE * half_size, Vector2.ONE * half_size * 2.0)
|
|
return stage_rect.encloses(badge_rect)
|
|
|
|
|
|
func _badge_cluster_fits(candidates: Array, placed: Array[Vector2], spacing: float, allow_top_margin: bool = false) -> bool:
|
|
var local_positions: Array[Vector2] = []
|
|
for candidate_variant: Variant in candidates:
|
|
var candidate: Vector2 = Vector2(candidate_variant)
|
|
if not _badge_candidate_fits(candidate, placed, spacing, allow_top_margin) or _badge_position_is_crowded(candidate, local_positions, spacing):
|
|
return false
|
|
local_positions.append(candidate)
|
|
return not local_positions.is_empty()
|
|
|
|
|
|
func _find_badge_fallback_position(origin: Vector2, placed: Array[Vector2], spacing: float) -> Vector2:
|
|
var directions: Array[Vector2] = [
|
|
Vector2(0.0, -1.0), Vector2(1.0, 0.0), Vector2(-1.0, 0.0), Vector2(0.0, 1.0),
|
|
Vector2(1.0, -1.0).normalized(), Vector2(-1.0, -1.0).normalized(),
|
|
Vector2(1.0, 1.0).normalized(), Vector2(-1.0, 1.0).normalized(),
|
|
]
|
|
if _badge_candidate_fits(origin, placed, spacing):
|
|
return origin
|
|
for ring: int in range(1, SPEECH_PAYLOAD.MAX_BUBBLES * 3 + 1):
|
|
for direction: Vector2 in directions:
|
|
var candidate: Vector2 = origin + direction * spacing * float(ring)
|
|
if _badge_candidate_fits(candidate, placed, spacing):
|
|
return candidate
|
|
return origin
|
|
|
|
|
|
func _badge_position_is_crowded(candidate: Vector2, placed: Array[Vector2], spacing: float) -> bool:
|
|
for previous: Vector2 in placed:
|
|
if candidate.distance_to(previous) < spacing:
|
|
return true
|
|
return false
|
|
|
|
|
|
func _badge_canvas_scale() -> float:
|
|
var canvas_transform: Transform2D = get_global_transform_with_canvas()
|
|
return maxf(0.001, (canvas_transform.x.length() + canvas_transform.y.length()) * 0.5)
|
|
|
|
|
|
func begin_bubble_drag(world_position: Vector2, selected_index: int) -> bool:
|
|
if not _preview or not _static_editor_preview or not visible:
|
|
return false
|
|
var local_position: Vector2 = to_local(world_position) - _display_offset
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) != selected_index:
|
|
continue
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
if frame.has_point(local_position):
|
|
_drag_bubble_index = selected_index
|
|
_drag_pointer_offset = local_position - frame.get_center()
|
|
return true
|
|
return false
|
|
|
|
|
|
func update_bubble_drag(world_position: Vector2) -> void:
|
|
if _drag_bubble_index < 0:
|
|
return
|
|
var local_position: Vector2 = to_local(world_position) - _display_offset - _drag_pointer_offset
|
|
bubble_position_changed.emit(_drag_bubble_index, position + local_position)
|
|
|
|
|
|
func end_bubble_drag() -> void:
|
|
_drag_bubble_index = -1
|
|
|
|
|
|
func _update_screen_scale() -> void:
|
|
var viewport: Viewport = get_viewport()
|
|
var camera: Camera2D = viewport.get_camera_2d() if viewport != null else null
|
|
_screen_scale = clampf(1.0 / maxf(camera.zoom.x, 0.001), 0.65, 3.0) if camera != null else 1.0
|
|
_update_node_scale()
|
|
|
|
|
|
func _update_node_scale() -> void:
|
|
var amount: float = _screen_scale * _fit_scale
|
|
# The rig mirrors its root when facing left, so counter-mirror this child tree.
|
|
scale = Vector2(-amount if _mirrored else amount, amount)
|
|
|
|
|
|
func _resolve_live_head_geometry() -> void:
|
|
var rig: Node = get_parent()
|
|
if rig == null or not rig.has_method("speech_head_geometry"):
|
|
return
|
|
var raw: Variant = rig.call("speech_head_geometry")
|
|
if not raw is Dictionary:
|
|
return
|
|
var geometry: Dictionary = raw as Dictionary
|
|
var center: Variant = geometry.get("center", null)
|
|
var radius: Variant = geometry.get("radius", null)
|
|
if center is Vector2 and radius is float or center is Vector2 and radius is int:
|
|
if (center as Vector2).is_finite() and is_finite(float(radius)) and float(radius) > 0.0:
|
|
_head_center_rig = center as Vector2
|
|
_head_radius_rig = clampf(float(radius), 1.0, 10000.0)
|
|
|
|
|
|
func _visible_cards() -> Array[Dictionary]:
|
|
var bubbles: Array = _action.get("bubbles", [])
|
|
var visible_cards: Array[Dictionary] = []
|
|
if bubbles.is_empty():
|
|
return visible_cards
|
|
var arrangement: String = String(_action.get("arrangement", "together"))
|
|
if _static_editor_preview or arrangement == "together":
|
|
for i: int in bubbles.size():
|
|
if bubbles[i] is Dictionary:
|
|
visible_cards.append({"index": i, "elapsed": _elapsed})
|
|
return visible_cards
|
|
var intervals: PackedFloat32Array = SPEECH_PAYLOAD.bubble_intervals(_action)
|
|
var clock: float = _elapsed
|
|
if arrangement == "take_turns" or arrangement == "comic_chain":
|
|
var active_index: int = intervals.size() - 1
|
|
var active_elapsed: float = 0.0
|
|
for i: int in intervals.size():
|
|
if clock < intervals[i] or i == intervals.size() - 1:
|
|
active_index = i
|
|
active_elapsed = minf(clock, intervals[i])
|
|
break
|
|
clock -= intervals[i]
|
|
var keep_previous_visible: bool = bool(_action.get("connect_bubbles", false)) or arrangement == "comic_chain"
|
|
var first_visible: int = 0 if keep_previous_visible else active_index
|
|
for i: int in range(first_visible, active_index + 1):
|
|
if i >= bubbles.size() or not bubbles[i] is Dictionary:
|
|
continue
|
|
var elapsed: float = active_elapsed if i == active_index else intervals[i]
|
|
visible_cards.append({"index": i, "elapsed": elapsed})
|
|
return visible_cards
|
|
clock = _elapsed
|
|
for i: int in intervals.size():
|
|
if clock < intervals[i] or i == intervals.size() - 1:
|
|
visible_cards.append({"index": i, "elapsed": minf(clock, intervals[i])})
|
|
break
|
|
clock -= intervals[i]
|
|
return visible_cards
|
|
|
|
|
|
func _rebuild_layout() -> void:
|
|
var cards: Array[Dictionary] = _visible_cards()
|
|
_layouts.clear()
|
|
_ensure_labels(cards.size())
|
|
var bubbles: Array = _action.get("bubbles", [])
|
|
_ensure_frame_layers(bubbles.size())
|
|
for frame_layer: BubbleFrameLayer in _frame_layers:
|
|
frame_layer.visible = false
|
|
var frame_sizes: Array[Vector2] = []
|
|
var frame_paddings: Array[Vector2] = []
|
|
var total_height: float = 0.0
|
|
for raw_bubble: Variant in bubbles:
|
|
if not raw_bubble is Dictionary:
|
|
frame_sizes.append(Vector2(MIN_FRAME_WIDTH, 58.0))
|
|
frame_paddings.append(PADDING)
|
|
continue
|
|
var bubble: Dictionary = raw_bubble as Dictionary
|
|
var visual: Dictionary = bubble.get("visual", {})
|
|
var measurement: Dictionary = _measure_frame_layout(String(bubble.get("text", "")), visual)
|
|
var size: Vector2 = measurement["size"]
|
|
frame_sizes.append(size)
|
|
frame_paddings.append(measurement["padding"])
|
|
total_height += size.y
|
|
if bubbles.size() > 1:
|
|
total_height += float(bubbles.size() - 1) * STACK_GAP
|
|
var stack_origin: Vector2 = _head_center_rig + Vector2(0.0, -_head_radius_rig - total_height * 0.5 - AUTO_HEAD_GAP)
|
|
var group_placement: Dictionary = _action.get("placement", {"mode": "auto"})
|
|
if String(group_placement.get("mode", "auto")) == "manual":
|
|
var group_center: Variant = group_placement.get("center", Vector2.ZERO)
|
|
position = group_center if group_center is Vector2 else Vector2.ZERO
|
|
else:
|
|
position = stack_origin
|
|
var stack_centers: Array[float] = []
|
|
var cursor_y: float = -total_height * 0.5
|
|
for bubble_index: int in frame_sizes.size():
|
|
var size: Vector2 = frame_sizes[bubble_index]
|
|
stack_centers.append(cursor_y + size.y * 0.5)
|
|
cursor_y += size.y + STACK_GAP
|
|
for card_index: int in cards.size():
|
|
var index: int = int(cards[card_index]["index"])
|
|
var bubble: Dictionary = bubbles[index]
|
|
var visual: Dictionary = bubble.get("visual", {})
|
|
var frame_size: Vector2 = frame_sizes[index]
|
|
var label_padding: Vector2 = frame_paddings[index]
|
|
var placement_value: Variant = bubble.get("placement", null)
|
|
var stagger: float = -18.0 if index % 2 == 0 else 18.0
|
|
var frame_center: Vector2 = Vector2(stagger, stack_centers[index])
|
|
var authored_placement: bool = placement_value is Dictionary
|
|
var manual_bubble_placement: bool = false
|
|
if placement_value is Dictionary:
|
|
var bubble_placement: Dictionary = placement_value as Dictionary
|
|
if String(bubble_placement.get("mode", "auto")) == "manual":
|
|
var manual_center: Variant = bubble_placement.get("center", Vector2.ZERO)
|
|
if manual_center is Vector2:
|
|
frame_center = (manual_center as Vector2) - position
|
|
manual_bubble_placement = true
|
|
var frame := Rect2(frame_center - frame_size * 0.5, frame_size)
|
|
var label: RichTextLabel = _labels[card_index]
|
|
label.z_index = index * 2 + 2
|
|
label.position = frame.position + label_padding
|
|
label.size = frame.size - label_padding * 2.0
|
|
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
|
var local_elapsed: float = float(cards[card_index]["elapsed"])
|
|
var entrance_scale: float = _entrance_scale(visual, local_elapsed)
|
|
label.pivot_offset = label.size * 0.5
|
|
label.scale = Vector2.ONE * entrance_scale
|
|
label.mouse_filter = Control.MOUSE_FILTER_STOP if _preview and _static_editor_preview else Control.MOUSE_FILTER_IGNORE
|
|
label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND if _preview and _static_editor_preview else Control.CURSOR_ARROW
|
|
_render_label_text(label, String(bubble.get("text", "")), visual, local_elapsed, index)
|
|
label.visible_characters = _visible_character_count(String(bubble.get("text", "")), visual, local_elapsed, float(_interval_for(index)))
|
|
var guide_only: bool = _preview and _static_editor_preview and _selected_preview_bubble >= 0 and index != _selected_preview_bubble
|
|
label.modulate.a = 0.5 if guide_only else 1.0
|
|
var frame_layer: BubbleFrameLayer = _frame_layers[index]
|
|
frame_layer.renderer = self
|
|
frame_layer.bubble_index = index
|
|
frame_layer.z_index = index * 2 + 1
|
|
frame_layer.visible = true
|
|
_layouts.append({"index": index, "frame": frame, "visual": visual, "elapsed": local_elapsed, "entrance_scale": entrance_scale, "label": label, "padding": label_padding, "authored_placement": authored_placement, "independent_placement": manual_bubble_placement, "guide_only": guide_only, "show_tail": manual_bubble_placement or card_index == cards.size() - 1})
|
|
_fit_display_layout()
|
|
_sync_preview_layer_order()
|
|
visible = not _suppressed and not _layouts.is_empty()
|
|
|
|
|
|
func _fit_display_layout() -> void:
|
|
if _layouts.is_empty():
|
|
return
|
|
var local_bounds: Rect2 = _layout_frame(_layouts[0])
|
|
for layout: Dictionary in _layouts.slice(1):
|
|
local_bounds = local_bounds.merge(_layout_frame(layout))
|
|
var viewport: Viewport = get_viewport()
|
|
if viewport == null:
|
|
return
|
|
var viewport_size: Vector2 = viewport.get_visible_rect().size
|
|
var left: float = 16.0
|
|
var right: float = viewport_size.x - 16.0
|
|
var top: float = 72.0
|
|
var bottom: float = viewport_size.y - 48.0
|
|
var available: Vector2 = Vector2(maxf(1.0, right - left), maxf(1.0, bottom - top))
|
|
var screen_bounds: Rect2 = _transform_rect(get_global_transform_with_canvas(), local_bounds)
|
|
var fit: float = minf(available.x / maxf(screen_bounds.size.x, 1.0), available.y / maxf(screen_bounds.size.y, 1.0))
|
|
if fit < 1.0:
|
|
_fit_scale = maxf(0.6, fit)
|
|
_display_overflow = fit < 0.6
|
|
_update_node_scale()
|
|
screen_bounds = _transform_rect(get_global_transform_with_canvas(), local_bounds)
|
|
var screen_delta := Vector2.ZERO
|
|
if screen_bounds.position.x < left:
|
|
screen_delta.x = left - screen_bounds.position.x
|
|
elif screen_bounds.end.x > right:
|
|
screen_delta.x = right - screen_bounds.end.x
|
|
var has_manual_bubble: bool = false
|
|
for raw_bubble: Variant in _action.get("bubbles", []):
|
|
if not raw_bubble is Dictionary:
|
|
continue
|
|
var bubble_placement: Variant = (raw_bubble as Dictionary).get("placement", null)
|
|
if bubble_placement is Dictionary and String((bubble_placement as Dictionary).get("mode", "auto")) == "manual":
|
|
has_manual_bubble = true
|
|
break
|
|
var automatic_group: bool = String(_action.get("placement", {}).get("mode", "auto")) != "manual" and not has_manual_bubble
|
|
if automatic_group and screen_bounds.position.y < top:
|
|
var rig: Node2D = get_parent() as Node2D
|
|
var rig_bounds_world: Rect2 = STAGE_SPAWNER.get_world_aabb(rig) if rig != null else Rect2()
|
|
var figure_bounds: Rect2 = _transform_rect(get_viewport().get_canvas_transform(), rig_bounds_world)
|
|
if figure_bounds.size.x <= 0.0 or figure_bounds.size.y <= 0.0:
|
|
var transform: Transform2D = get_global_transform_with_canvas()
|
|
var head_screen: Vector2 = transform * _head_point_in_bubble()
|
|
figure_bounds = Rect2(head_screen - Vector2(_head_radius_rig * _screen_scale, _head_radius_rig * _screen_scale), Vector2.ONE * _head_radius_rig * 2.0 * _screen_scale)
|
|
var side_gap: float = 18.0
|
|
var side_right: float = figure_bounds.end.x + screen_bounds.size.x * 0.5 + side_gap
|
|
var side_left: float = figure_bounds.position.x - screen_bounds.size.x * 0.5 - side_gap
|
|
var right_fits: bool = side_right + screen_bounds.size.x * 0.5 <= right
|
|
var left_fits: bool = side_left - screen_bounds.size.x * 0.5 >= left
|
|
var choose_right: bool = right_fits or (not left_fits and right - figure_bounds.end.x >= figure_bounds.position.x - left)
|
|
var desired_center_x: float = side_right if choose_right else side_left
|
|
screen_delta.x = desired_center_x - screen_bounds.get_center().x
|
|
var min_center_y: float = top + screen_bounds.size.y * 0.5
|
|
var max_center_y: float = maxf(min_center_y, bottom - screen_bounds.size.y * 0.5)
|
|
var side_center_y: float = clampf(figure_bounds.get_center().y, min_center_y, max_center_y)
|
|
screen_delta.y = side_center_y - screen_bounds.get_center().y
|
|
if not right_fits and not left_fits:
|
|
_display_overflow = true
|
|
elif screen_bounds.position.y < top:
|
|
_display_overflow = true
|
|
elif screen_bounds.end.y > bottom:
|
|
screen_delta.y = bottom - screen_bounds.end.y
|
|
_display_offset = get_global_transform_with_canvas().basis_xform_inv(screen_delta)
|
|
for layout: Dictionary in _layouts:
|
|
var frame: Rect2 = layout["frame"]
|
|
var label: RichTextLabel = layout["label"]
|
|
var label_padding: Vector2 = layout.get("padding", PADDING)
|
|
label.position = frame.position + label_padding + _display_offset
|
|
|
|
|
|
func _layout_frame(layout: Dictionary) -> Rect2:
|
|
var frame: Rect2 = layout["frame"]
|
|
var factor: float = float(layout.get("entrance_scale", 1.0))
|
|
var size: Vector2 = frame.size * factor
|
|
return Rect2(frame.get_center() - size * 0.5, size)
|
|
|
|
|
|
func _entrance_scale(visual: Dictionary, elapsed: float) -> float:
|
|
if not bool(visual.get("pop_in", false)):
|
|
return 1.0
|
|
var duration: float = _theme.speech_number("animation.pop_duration", 0.26, 0.1, 0.8) if _theme != null else 0.26
|
|
var overshoot_end: float = duration * 0.58
|
|
if elapsed < overshoot_end:
|
|
return lerpf(0.52, 1.16, clampf(elapsed / maxf(overshoot_end, 0.001), 0.0, 1.0))
|
|
return lerpf(1.16, 1.0, clampf((elapsed - overshoot_end) / maxf(duration - overshoot_end, 0.001), 0.0, 1.0))
|
|
|
|
|
|
func _transform_rect(transform: Transform2D, rect: Rect2) -> Rect2:
|
|
var a: Vector2 = transform * rect.position
|
|
var b: Vector2 = transform * Vector2(rect.end.x, rect.position.y)
|
|
var c: Vector2 = transform * rect.end
|
|
var d: Vector2 = transform * Vector2(rect.position.x, rect.end.y)
|
|
var result := Rect2(a, Vector2.ZERO)
|
|
result = result.expand(b).expand(c).expand(d)
|
|
return result
|
|
|
|
|
|
func _measure_frame(text: String, visual: Dictionary) -> Vector2:
|
|
return _measure_frame_layout(text, visual)["size"]
|
|
|
|
|
|
func _measure_frame_layout(text: String, visual: Dictionary) -> Dictionary:
|
|
var label_padding: Vector2 = _padding_for_visual(visual)
|
|
var base_font_id: String = String(visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))
|
|
var font_size: int = _font_size(String(visual.get("size_preset", "medium")), base_font_id)
|
|
var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, _typed_word_styles(visual.get("word_styles", [])))
|
|
var line_widths: Array[float] = [0.0]
|
|
var base_font: Font = _theme.speech_font(base_font_id)
|
|
var line_heights: Array[float] = [float(base_font.get_height(font_size)) * FONT_LINE_HEIGHT_SAFETY]
|
|
for run: Dictionary in runs:
|
|
var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)))
|
|
var run_size: int = _font_size(String(run.get("size_preset", visual.get("size_preset", "medium"))), font_id)
|
|
var font: Font = _theme.speech_font(font_id)
|
|
var segments: PackedStringArray = String(run.get("text", "")).split("\n", true)
|
|
for segment_index: int in segments.size():
|
|
line_widths[line_widths.size() - 1] += font.get_string_size(segments[segment_index], HORIZONTAL_ALIGNMENT_LEFT, -1.0, run_size).x
|
|
line_heights[line_heights.size() - 1] = maxf(line_heights.back(), float(font.get_height(run_size)) * FONT_LINE_HEIGHT_SAFETY)
|
|
if segment_index < segments.size() - 1:
|
|
line_widths.append(0.0)
|
|
line_heights.append(float(font.get_height(run_size)) * FONT_LINE_HEIGHT_SAFETY)
|
|
var max_width: float = MAX_FRAME_WIDTH - label_padding.x * 2.0
|
|
var content_width: float = minf(max_width, maxf(80.0, line_widths.max()))
|
|
var measured_height: float = 0.0
|
|
for line_index: int in line_widths.size():
|
|
var wraps: int = maxi(1, int(ceil(line_widths[line_index] / maxf(content_width, 1.0))))
|
|
measured_height += line_heights[line_index] * float(wraps)
|
|
var content_size := Vector2(content_width, measured_height)
|
|
var style: String = String(visual.get("bubble_style", "talk"))
|
|
if style in ["shout", "think"]:
|
|
label_padding = _padding_to_fit_shape(style, content_size, label_padding)
|
|
var width: float = maxf(MIN_FRAME_WIDTH, content_size.x + label_padding.x * 2.0)
|
|
var height: float = maxf(58.0, content_size.y + label_padding.y * 2.0)
|
|
return {"size": Vector2(width, height), "padding": label_padding}
|
|
|
|
|
|
func _padding_to_fit_shape(style: String, content_size: Vector2, initial_padding: Vector2) -> Vector2:
|
|
var padding: Vector2 = initial_padding
|
|
# Burst and cloud outlines dip inward, so fit the full text box to their actual contours.
|
|
for _attempt: int in 64:
|
|
var frame_size := Vector2(
|
|
maxf(MIN_FRAME_WIDTH, content_size.x + padding.x * 2.0),
|
|
maxf(58.0, content_size.y + padding.y * 2.0)
|
|
)
|
|
if _shape_contains_text_rect(style, frame_size, content_size + SHAPE_TEXT_SAFETY * 2.0):
|
|
return padding
|
|
padding += Vector2(3.0, 3.0)
|
|
return padding
|
|
|
|
|
|
func _shape_contains_text_rect(style: String, frame_size: Vector2, text_rect_size: Vector2) -> bool:
|
|
var frame := Rect2(-frame_size * 0.5, frame_size)
|
|
var outline: PackedVector2Array = _frame_outline_points(style, frame)
|
|
var half_text: Vector2 = text_rect_size * 0.5
|
|
var text_rect := Rect2(-half_text, text_rect_size)
|
|
var corners: Array[Vector2] = [
|
|
Vector2(-half_text.x, -half_text.y),
|
|
Vector2(half_text.x, -half_text.y),
|
|
Vector2(half_text.x, half_text.y),
|
|
Vector2(-half_text.x, half_text.y),
|
|
]
|
|
# Shout bursts and thought clouds are concave: keep the outline itself out
|
|
# of the text area as well as checking the text rectangle's perimeter.
|
|
for outline_index: int in outline.size():
|
|
var outline_start: Vector2 = outline[outline_index]
|
|
var outline_end: Vector2 = outline[(outline_index + 1) % outline.size()]
|
|
if text_rect.has_point(outline_start):
|
|
return false
|
|
for rect_edge: int in corners.size():
|
|
var intersection: Variant = Geometry2D.segment_intersects_segment(
|
|
outline_start,
|
|
outline_end,
|
|
corners[rect_edge],
|
|
corners[(rect_edge + 1) % corners.size()]
|
|
)
|
|
if intersection != null:
|
|
return false
|
|
const EDGE_SAMPLES: int = 8
|
|
for edge_index: int in corners.size():
|
|
var start: Vector2 = corners[edge_index]
|
|
var end: Vector2 = corners[(edge_index + 1) % corners.size()]
|
|
for sample_index: int in range(EDGE_SAMPLES + 1):
|
|
var point: Vector2 = start.lerp(end, float(sample_index) / float(EDGE_SAMPLES))
|
|
if not Geometry2D.is_point_in_polygon(point, outline):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _padding_for_visual(visual: Dictionary) -> Vector2:
|
|
return SHOUT_PADDING if String(visual.get("bubble_style", "talk")) == "shout" else PADDING
|
|
|
|
|
|
func _ensure_labels(count: int) -> void:
|
|
while _labels.size() < count:
|
|
var label := RichTextLabel.new()
|
|
label.bbcode_enabled = false
|
|
label.fit_content = false
|
|
label.scroll_active = false
|
|
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
label.meta_underlined = false
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
label.add_theme_color_override("default_color", Color("#202020"))
|
|
label.add_theme_font_override("normal_font", _theme.font())
|
|
label.install_effect(_word_effect)
|
|
label.meta_clicked.connect(_on_label_meta_clicked)
|
|
label.gui_input.connect(_on_label_gui_input.bind(label))
|
|
add_child(label)
|
|
_labels.append(label)
|
|
for i: int in _labels.size():
|
|
_labels[i].visible = i < count
|
|
|
|
|
|
func _ensure_frame_layers(count: int) -> void:
|
|
while _frame_layers.size() < count:
|
|
var index: int = _frame_layers.size()
|
|
var frame_layer := BubbleFrameLayer.new()
|
|
frame_layer.name = "SpeechBubbleFrame%d" % (index + 1)
|
|
frame_layer.renderer = self
|
|
frame_layer.bubble_index = index
|
|
add_child(frame_layer)
|
|
_frame_layers.append(frame_layer)
|
|
|
|
|
|
func _draw() -> void:
|
|
if _suppressed:
|
|
return
|
|
if _comic_chain_links_visible():
|
|
for layout: Dictionary in _layouts:
|
|
var bubble_index: int = int(layout.get("index", -1))
|
|
if bubble_index < 0:
|
|
continue
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
frame.position += _display_offset
|
|
var visual: Dictionary = layout.get("visual", {})
|
|
var style: String = String(visual.get("bubble_style", "talk"))
|
|
var colors: Dictionary = _style_colors(style)
|
|
var guide_only: bool = bool(layout.get("guide_only", false))
|
|
var ghost: bool = _placement_ghost or guide_only
|
|
var ghost_fill_alpha: float = 1.0 if guide_only and not _placement_ghost else 0.15
|
|
var target: Vector2 = _comic_chain_connector_target(bubble_index, frame)
|
|
if style in ["talk", "shout", "whisper"]:
|
|
draw_speech_balloon_to_point(self, style, frame, colors["fill"], colors["outline"], target, ghost, 3, bubble_index > 0, ghost_fill_alpha)
|
|
elif style == "think":
|
|
_draw_thought_connector(self, frame, target, colors["fill"], ghost)
|
|
return
|
|
func _draw_bubble_frame_layer(canvas: CanvasItem, bubble_index: int) -> void:
|
|
if _suppressed:
|
|
return
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) != bubble_index:
|
|
continue
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
frame.position += _display_offset
|
|
var visual: Dictionary = layout["visual"]
|
|
var style: String = String(visual.get("bubble_style", "talk"))
|
|
var colors: Dictionary = _style_colors(style)
|
|
var guide_only: bool = bool(layout.get("guide_only", false))
|
|
var ghost: bool = _placement_ghost or guide_only
|
|
var ghost_fill_alpha: float = 1.0 if guide_only and not _placement_ghost else 0.15
|
|
var chain_connector: bool = _comic_chain_links_visible()
|
|
var show_tail: bool = bool(layout.get("show_tail", false)) and not bool(layout.get("guide_only", false)) and not chain_connector
|
|
if show_tail and style in ["talk", "shout", "whisper"]:
|
|
draw_speech_balloon(canvas, style, frame, colors["fill"], colors["outline"], _head_point_in_bubble(), _local_head_radius(), ghost, 3, ghost_fill_alpha)
|
|
else:
|
|
draw_style_shape(canvas, style, frame, colors["fill"], colors["outline"], ghost, 3, ghost_fill_alpha)
|
|
if show_tail and style == "think":
|
|
var head_point: Vector2 = _head_point_in_bubble()
|
|
var rim: Vector2 = _head_rim_point(frame, head_point, _local_head_radius())
|
|
_draw_thought_connector(canvas, frame, rim, colors["fill"], ghost)
|
|
return
|
|
|
|
|
|
func _comic_chain_links_visible() -> bool:
|
|
return bool(_action.get("connect_bubbles", false)) or String(_action.get("arrangement", "together")) == "comic_chain"
|
|
|
|
|
|
func _comic_chain_connector_target(bubble_index: int, current_frame: Rect2) -> Vector2:
|
|
var head_point: Vector2 = _head_point_in_bubble()
|
|
if not _comic_chain_links_visible() or bubble_index <= 0:
|
|
return _head_rim_point(current_frame, head_point, _local_head_radius())
|
|
for previous_layout: Dictionary in _layouts:
|
|
if int(previous_layout.get("index", -1)) != bubble_index - 1:
|
|
continue
|
|
var previous_frame: Rect2 = _layout_frame(previous_layout)
|
|
previous_frame.position += _display_offset
|
|
var previous_visual: Dictionary = previous_layout.get("visual", {})
|
|
var previous_style: String = String(previous_visual.get("bubble_style", "talk"))
|
|
var boundary: Vector2 = _style_boundary_point(previous_style, previous_frame, current_frame.get_center())
|
|
var inward: Vector2 = (previous_frame.get_center() - current_frame.get_center()).normalized()
|
|
return boundary + inward * 10.0
|
|
return _head_rim_point(current_frame, head_point, _local_head_radius())
|
|
|
|
|
|
func _style_boundary_point(style: String, frame: Rect2, target: Vector2) -> Vector2:
|
|
var contour: PackedVector2Array = _frame_outline_points(style, frame)
|
|
var boundary_hit: Dictionary = _ray_polygon_boundary(contour, frame.get_center(), target)
|
|
if not boundary_hit.is_empty():
|
|
return boundary_hit["point"]
|
|
return _ray_rect_boundary(frame, target)
|
|
|
|
|
|
func _draw_thought_connector(canvas: CanvasItem, frame: Rect2, target: Vector2, fill: Color, ghost: bool = false) -> void:
|
|
var start: Vector2 = _style_boundary_point("think", frame, target)
|
|
var gap: Vector2 = target - start
|
|
if gap.length() < 8.0:
|
|
return
|
|
var draw_fill: Color = fill
|
|
if ghost:
|
|
draw_fill.a = 0.15
|
|
var ratios: Array[float] = [0.12, 0.49, 0.84]
|
|
var radii: Array[float] = [7.0, 5.0, 3.5]
|
|
for index: int in ratios.size():
|
|
canvas.draw_circle(start + gap * ratios[index], radii[index], draw_fill)
|
|
|
|
|
|
func _draw_thought_connector_halo(canvas: CanvasItem, frame: Rect2, target: Vector2) -> void:
|
|
var start: Vector2 = _style_boundary_point("think", frame, target)
|
|
var gap: Vector2 = target - start
|
|
if gap.length() < 8.0:
|
|
return
|
|
var ratios: Array[float] = [0.12, 0.49, 0.84]
|
|
var radii: Array[float] = [7.0, 5.0, 3.5]
|
|
for index: int in ratios.size():
|
|
var center: Vector2 = start + gap * ratios[index]
|
|
canvas.draw_arc(center, radii[index] + 4.0, 0.0, TAU, 24, Color("#171B24"), 5.0, true)
|
|
canvas.draw_arc(center, radii[index] + 2.0, 0.0, TAU, 24, Color("#FFD84D"), 3.0, true)
|
|
|
|
|
|
func _draw_editor_overlay_layer(canvas: CanvasItem) -> void:
|
|
if _suppressed:
|
|
return
|
|
_draw_selected_token_marker(canvas)
|
|
if _preview and _static_editor_preview and _selected_preview_bubble >= 0:
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) != _selected_preview_bubble:
|
|
continue
|
|
var selected_frame: Rect2 = _layout_frame(layout)
|
|
selected_frame.position += _display_offset
|
|
var selected_visual: Dictionary = layout.get("visual", {})
|
|
var selected_style: String = String(selected_visual.get("bubble_style", "talk"))
|
|
var selected_chain_connector: bool = _comic_chain_links_visible() and selected_style in ["talk", "shout", "whisper"]
|
|
var selected_has_tail: bool = (bool(layout.get("show_tail", false)) or selected_chain_connector) and selected_style in ["talk", "shout", "whisper"]
|
|
if selected_has_tail:
|
|
if selected_chain_connector:
|
|
var chain_target: Vector2 = _comic_chain_connector_target(_selected_preview_bubble, selected_frame)
|
|
draw_speech_balloon_to_point(canvas, selected_style, selected_frame.grow(5.0), Color(0.0, 0.0, 0.0, 0.0), Color("#171B24"), chain_target, false, 9, _selected_preview_bubble > 0)
|
|
draw_speech_balloon_to_point(canvas, selected_style, selected_frame.grow(3.0), Color(0.0, 0.0, 0.0, 0.0), Color("#FFD84D"), chain_target, false, 4, _selected_preview_bubble > 0)
|
|
else:
|
|
draw_speech_balloon(canvas, selected_style, selected_frame.grow(5.0), Color(0.0, 0.0, 0.0, 0.0), Color("#171B24"), _head_point_in_bubble(), _local_head_radius(), false, 9)
|
|
draw_speech_balloon(canvas, selected_style, selected_frame.grow(3.0), Color(0.0, 0.0, 0.0, 0.0), Color("#FFD84D"), _head_point_in_bubble(), _local_head_radius(), false, 4)
|
|
else:
|
|
draw_style_shape(canvas, selected_style, selected_frame.grow(5.0), Color(0.0, 0.0, 0.0, 0.0), Color("#171B24"), false, 9)
|
|
draw_style_shape(canvas, selected_style, selected_frame.grow(3.0), Color(0.0, 0.0, 0.0, 0.0), Color("#FFD84D"), false, 4)
|
|
if _comic_chain_links_visible() and selected_style == "think":
|
|
var thought_target: Vector2 = _comic_chain_connector_target(_selected_preview_bubble, selected_frame)
|
|
_draw_thought_connector_halo(canvas, selected_frame, thought_target)
|
|
break
|
|
if _preview and _static_editor_preview:
|
|
_draw_order_badges(canvas)
|
|
|
|
|
|
func _draw_order_badges(canvas: CanvasItem) -> void:
|
|
var font: Font = _theme.font() if _theme != null else ThemeDB.fallback_font
|
|
var badge_scale: float = _badge_canvas_scale()
|
|
var radius: float = ORDER_BADGE_RADIUS / badge_scale
|
|
var font_size: int = maxi(1, roundi(30.0 / badge_scale))
|
|
var badge_centers: Dictionary = _order_badge_centers()
|
|
for layout: Dictionary in _layouts:
|
|
var index: int = int(layout.get("index", -1))
|
|
if index < 0:
|
|
continue
|
|
var frame: Rect2 = _layout_frame(layout)
|
|
var center: Vector2 = badge_centers.get(index, frame.position + _display_offset)
|
|
var is_selected: bool = index == _selected_preview_bubble
|
|
var badge_fill: Color = Color("#FFD84D") if is_selected else Color("#17202B")
|
|
var badge_ring: Color = Color("#17202B") if is_selected else Color("#FFD84D")
|
|
var badge_text: Color = Color("#17202B") if is_selected else Color.WHITE
|
|
canvas.draw_circle(center, radius, badge_fill)
|
|
canvas.draw_arc(center, radius, 0.0, TAU, 32, badge_ring, 3.5 / badge_scale, true)
|
|
var number: String = str(index + 1)
|
|
var text_width: float = font.get_string_size(number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x
|
|
var ascent: float = font.get_ascent(font_size)
|
|
canvas.draw_string(font, center + Vector2(-text_width * 0.5, ascent * 0.34), number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, badge_text)
|
|
|
|
|
|
func _draw_selected_token_marker(canvas: CanvasItem) -> void:
|
|
if not _preview or not _static_editor_preview or _selected_token.x < 0 or _selected_token.y < 0 or _selected_token.z <= _selected_token.y:
|
|
return
|
|
for layout: Dictionary in _layouts:
|
|
if int(layout.get("index", -1)) != _selected_token.x:
|
|
continue
|
|
var label: RichTextLabel = layout.get("label") as RichTextLabel
|
|
if label == null or not is_instance_valid(label):
|
|
return
|
|
var label_to_bubble: Transform2D = get_global_transform_with_canvas().affine_inverse() * label.get_global_transform_with_canvas()
|
|
var bubble: Dictionary = _bubble_data(_selected_token.x)
|
|
var text: String = String(bubble.get("text", ""))
|
|
var visual: Dictionary = layout.get("visual", {})
|
|
for label_rect: Rect2 in _selected_token_line_rects(label, text, visual, _selected_token.y, _selected_token.z):
|
|
var top_left: Vector2 = label_to_bubble * label_rect.position
|
|
var top_right: Vector2 = label_to_bubble * Vector2(label_rect.end.x, label_rect.position.y)
|
|
var bottom_left: Vector2 = label_to_bubble * Vector2(label_rect.position.x, label_rect.end.y)
|
|
var bottom_right: Vector2 = label_to_bubble * label_rect.end
|
|
var bubble_rect: Rect2 = Rect2(top_left, Vector2.ZERO).expand(top_right).expand(bottom_left).expand(bottom_right)
|
|
var marker_rect: Rect2 = bubble_rect
|
|
marker_rect.position -= Vector2(4.0, 5.0)
|
|
marker_rect.size += Vector2(8.0, 10.0)
|
|
var halo: StyleBoxFlat = StyleBoxFlat.new()
|
|
halo.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
|
halo.border_color = Color("#17202B")
|
|
halo.set_border_width_all(4)
|
|
halo.set_corner_radius_all(7)
|
|
canvas.draw_style_box(halo, marker_rect.grow(1.0))
|
|
var marker: StyleBoxFlat = StyleBoxFlat.new()
|
|
marker.bg_color = Color("#FFD84D")
|
|
marker.bg_color.a = 0.16
|
|
marker.border_color = Color("#FFD84D")
|
|
marker.set_border_width_all(2)
|
|
marker.set_corner_radius_all(6)
|
|
canvas.draw_style_box(marker, marker_rect)
|
|
return
|
|
|
|
|
|
func _selected_token_line_rects(label: RichTextLabel, text: String, visual: Dictionary, start: int, end: int) -> Array[Rect2]:
|
|
var result: Array[Rect2] = []
|
|
var line_spans: Dictionary = {}
|
|
var upper: int = mini(end, mini(text.length(), label.get_total_character_count()))
|
|
for character_index: int in range(maxi(0, start), upper):
|
|
var line_index: int = label.get_character_line(character_index)
|
|
if line_spans.has(line_index):
|
|
var current_span: Vector2i = line_spans[line_index]
|
|
current_span.x = mini(current_span.x, character_index)
|
|
current_span.y = maxi(current_span.y, character_index + 1)
|
|
line_spans[line_index] = current_span
|
|
else:
|
|
line_spans[line_index] = Vector2i(character_index, character_index + 1)
|
|
for line_key: Variant in line_spans:
|
|
var line_index: int = int(line_key)
|
|
var visible_range: Vector2i = label.get_line_range(line_index)
|
|
var selected_range: Vector2i = line_spans[line_key]
|
|
var line_start: int = clampi(visible_range.x, 0, text.length())
|
|
var selected_start: int = maxi(line_start, selected_range.x)
|
|
var selected_end: int = mini(text.length(), selected_range.y)
|
|
if selected_end <= selected_start:
|
|
continue
|
|
var line_center_offset: float = maxf(0.0, (label.size.x - float(label.get_line_width(line_index))) * 0.5)
|
|
var x: float = line_center_offset + _measure_styled_text_range(text, visual, line_start, selected_start)
|
|
var width: float = _measure_styled_text_range(text, visual, selected_start, selected_end)
|
|
if width <= 0.0:
|
|
continue
|
|
var line_height: float = float(label.get_line_height(line_index))
|
|
var selected_text_height: float = _text_height_for_range(text, visual, selected_start, selected_end)
|
|
var marker_height: float = minf(line_height, maxf(16.0, selected_text_height + 4.0))
|
|
# RichTextLabel applies its vertical-alignment offset while drawing, but
|
|
# get_line_offset() returns the line's unaligned content offset. Mirror the
|
|
# centering here so the editor marker follows the text when the bubble has
|
|
# enough spare height to vertically center multiple lines.
|
|
var text_vertical_offset: float = _label_text_vertical_offset(label)
|
|
var marker_y: float = text_vertical_offset + label.get_line_offset(line_index) + line_height - marker_height
|
|
result.append(Rect2(Vector2(x, marker_y), Vector2(width, marker_height)))
|
|
return result
|
|
|
|
|
|
func _label_text_vertical_offset(label: RichTextLabel) -> float:
|
|
var text_top: float = 0.0
|
|
var text_height: float = label.size.y
|
|
var normal_style: StyleBox = label.get_theme_stylebox("normal")
|
|
if normal_style != null:
|
|
text_top = normal_style.get_offset().y
|
|
text_height -= normal_style.get_minimum_size().y
|
|
var content_height: float = float(label.get_content_height())
|
|
var alignment_offset: float = 0.0
|
|
if label.vertical_alignment == VERTICAL_ALIGNMENT_CENTER and text_height > content_height:
|
|
alignment_offset = (text_height - content_height) * 0.5
|
|
return text_top + alignment_offset
|
|
|
|
|
|
func _text_height_for_range(text: String, visual: Dictionary, start: int, end: int) -> float:
|
|
var styles: Array[Dictionary] = _typed_word_styles(visual.get("word_styles", []))
|
|
var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, styles)
|
|
var result: float = 0.0
|
|
for run: Dictionary in runs:
|
|
var run_start: int = int(run.get("start", 0))
|
|
var run_end: int = int(run.get("end", 0))
|
|
var fragment_start: int = maxi(start, run_start)
|
|
var fragment_end: int = mini(end, run_end)
|
|
if fragment_end <= fragment_start:
|
|
continue
|
|
var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)))
|
|
var size_id: String = String(run.get("size_preset", visual.get("size_preset", "medium")))
|
|
var font: Font = _theme.speech_font(font_id)
|
|
var font_size: int = _font_size(size_id, font_id)
|
|
var fragment: String = text.substr(fragment_start, fragment_end - fragment_start)
|
|
var measured_height: float = font.get_string_size(fragment, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).y
|
|
result = maxf(result, measured_height)
|
|
if result > 0:
|
|
return result
|
|
var fallback_font_id: String = String(visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET))
|
|
var fallback_size: int = _font_size(String(visual.get("size_preset", "medium")), fallback_font_id)
|
|
return _theme.speech_font(fallback_font_id).get_height(fallback_size)
|
|
|
|
|
|
func _measure_styled_text_range(text: String, visual: Dictionary, start: int, end: int) -> float:
|
|
if end <= start:
|
|
return 0.0
|
|
var styles: Array[Dictionary] = _typed_word_styles(visual.get("word_styles", []))
|
|
var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, styles)
|
|
var width: float = 0.0
|
|
for run: Dictionary in runs:
|
|
var run_start: int = int(run.get("start", 0))
|
|
var run_end: int = int(run.get("end", 0))
|
|
var fragment_start: int = maxi(start, run_start)
|
|
var fragment_end: int = mini(end, run_end)
|
|
if fragment_end <= fragment_start:
|
|
continue
|
|
var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)))
|
|
var size_id: String = String(run.get("size_preset", visual.get("size_preset", "medium")))
|
|
var font: Font = _theme.speech_font(font_id)
|
|
var fragment: String = text.substr(fragment_start, fragment_end - fragment_start)
|
|
width += font.get_string_size(fragment, HORIZONTAL_ALIGNMENT_LEFT, -1.0, _font_size(size_id, font_id)).x
|
|
return width
|
|
|
|
|
|
static func draw_style_shape(canvas: CanvasItem, style: String, frame: Rect2, fill: Color, outline: Color, ghost: bool = false, border_width: int = 3, ghost_fill_alpha: float = 0.15) -> void:
|
|
var draw_fill: Color = fill
|
|
var draw_outline: Color = outline
|
|
if ghost:
|
|
draw_fill.a = ghost_fill_alpha
|
|
draw_outline.a = 0.95
|
|
if style == "whisper":
|
|
draw_fill.a *= 0.78
|
|
if style == "shout":
|
|
var burst: PackedVector2Array = _burst_points(frame)
|
|
canvas.draw_colored_polygon(burst, draw_fill)
|
|
var closed_burst: PackedVector2Array = burst.duplicate()
|
|
closed_burst.append(burst[0])
|
|
canvas.draw_polyline(closed_burst, draw_outline, float(border_width), true)
|
|
elif style == "think":
|
|
var cloud: PackedVector2Array = _cloud_points(frame)
|
|
canvas.draw_colored_polygon(cloud, draw_fill)
|
|
var closed_cloud: PackedVector2Array = cloud.duplicate()
|
|
closed_cloud.append(cloud[0])
|
|
canvas.draw_polyline(closed_cloud, draw_outline, float(border_width), true)
|
|
else:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = draw_fill
|
|
box.border_color = draw_outline
|
|
box.set_border_width_all(4 if style == "sfx" else border_width)
|
|
box.set_corner_radius_all(16 if style == "talk" else 4)
|
|
canvas.draw_style_box(box, frame)
|
|
if style == "whisper":
|
|
_draw_dashed_rect_on(canvas, frame.grow(-2.0), draw_outline, float(border_width), 8.0)
|
|
if ghost:
|
|
_draw_dashed_rect_on(canvas, frame, draw_outline, 2.0, 8.0)
|
|
|
|
|
|
static func draw_speech_balloon(canvas: CanvasItem, style: String, frame: Rect2, fill: Color, outline: Color, head_center: Vector2, head_radius: float, ghost: bool = false, border_width: int = 3, ghost_fill_alpha: float = 0.15) -> void:
|
|
var direction: Vector2 = (head_center - frame.get_center()).normalized()
|
|
if direction.is_zero_approx():
|
|
direction = Vector2.DOWN
|
|
var tail_tip: Vector2 = head_center - direction * (head_radius + 5.0)
|
|
draw_speech_balloon_to_point(canvas, style, frame, fill, outline, tail_tip, ghost, border_width, false, ghost_fill_alpha)
|
|
|
|
|
|
static func draw_speech_balloon_to_point(canvas: CanvasItem, style: String, frame: Rect2, fill: Color, outline: Color, tail_tip: Vector2, ghost: bool = false, border_width: int = 3, blunt_tip: bool = false, ghost_fill_alpha: float = 0.15) -> void:
|
|
var body_contour: PackedVector2Array = _frame_outline_points(style, frame)
|
|
var tail: Dictionary = _speaker_tail_geometry(body_contour, frame.get_center(), tail_tip, style == "shout", 8.0 if blunt_tip else 0.0)
|
|
var contour: PackedVector2Array = tail["contour"]
|
|
if contour.size() < 3:
|
|
draw_style_shape(canvas, style, frame, fill, outline, ghost, border_width, ghost_fill_alpha)
|
|
return
|
|
var draw_fill: Color = fill
|
|
var draw_outline: Color = outline
|
|
if ghost:
|
|
draw_fill.a = ghost_fill_alpha
|
|
draw_outline.a = 0.95
|
|
if style == "whisper":
|
|
draw_fill.a *= 0.78
|
|
_draw_speaker_tail_fill(canvas, tail.get("left_edge", PackedVector2Array()), tail.get("right_edge", PackedVector2Array()), draw_fill)
|
|
_draw_style_fill(canvas, style, frame, draw_fill)
|
|
var closed_contour: PackedVector2Array = contour.duplicate()
|
|
closed_contour.append(contour[0])
|
|
canvas.draw_polyline(closed_contour, draw_outline, float(border_width), true)
|
|
if style == "whisper":
|
|
_draw_dashed_rect_on(canvas, frame.grow(-4.0), draw_outline, float(border_width), 8.0)
|
|
if ghost:
|
|
_draw_dashed_polygon_on(canvas, contour, draw_outline, 2.0, 8.0)
|
|
|
|
|
|
static func _draw_style_fill(canvas: CanvasItem, style: String, frame: Rect2, fill: Color) -> void:
|
|
if style == "shout":
|
|
canvas.draw_colored_polygon(_burst_points(frame), fill)
|
|
elif style == "think":
|
|
canvas.draw_colored_polygon(_cloud_points(frame), fill)
|
|
else:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = fill
|
|
box.border_color = fill
|
|
box.set_border_width_all(0)
|
|
box.set_corner_radius_all(16 if style == "talk" else 4)
|
|
canvas.draw_style_box(box, frame)
|
|
|
|
|
|
static func _draw_speaker_tail_fill(canvas: CanvasItem, left_edge: PackedVector2Array, right_edge: PackedVector2Array, fill: Color) -> void:
|
|
if left_edge.size() < 2 or right_edge.size() != left_edge.size():
|
|
return
|
|
for index: int in range(left_edge.size() - 1):
|
|
var left_start: Vector2 = left_edge[index]
|
|
var left_end: Vector2 = left_edge[index + 1]
|
|
var right_start: Vector2 = right_edge[index]
|
|
var right_end: Vector2 = right_edge[index + 1]
|
|
_draw_fill_triangle(canvas, left_start, left_end, right_end, fill)
|
|
_draw_fill_triangle(canvas, left_start, right_end, right_start, fill)
|
|
|
|
|
|
static func _draw_fill_triangle(canvas: CanvasItem, a: Vector2, b: Vector2, c: Vector2, fill: Color) -> void:
|
|
if absf((b - a).cross(c - a)) < 0.01:
|
|
return
|
|
canvas.draw_colored_polygon(PackedVector2Array([a, b, c]), fill)
|
|
|
|
|
|
static func _frame_outline_points(style: String, frame: Rect2) -> PackedVector2Array:
|
|
if style == "shout":
|
|
return _burst_points(frame)
|
|
if style == "think":
|
|
return _cloud_points(frame)
|
|
var corner_radius: float = 16.0 if style == "talk" else 4.0
|
|
return _rounded_rect_points(frame, corner_radius, 5)
|
|
|
|
|
|
static func _rounded_rect_points(frame: Rect2, requested_radius: float, samples_per_corner: int) -> PackedVector2Array:
|
|
var points := PackedVector2Array()
|
|
var radius: float = clampf(requested_radius, 0.0, minf(frame.size.x, frame.size.y) * 0.5)
|
|
var centers: Array[Vector2] = [
|
|
Vector2(frame.end.x - radius, frame.position.y + radius),
|
|
Vector2(frame.end.x - radius, frame.end.y - radius),
|
|
Vector2(frame.position.x + radius, frame.end.y - radius),
|
|
Vector2(frame.position.x + radius, frame.position.y + radius),
|
|
]
|
|
var starts: Array[float] = [-PI * 0.5, 0.0, PI * 0.5, PI]
|
|
var samples: int = maxi(2, samples_per_corner)
|
|
for corner_index: int in range(4):
|
|
for sample_index: int in range(samples + 1):
|
|
var angle: float = starts[corner_index] + PI * 0.5 * float(sample_index) / float(samples)
|
|
points.append(centers[corner_index] + Vector2(cos(angle), sin(angle)) * radius)
|
|
return points
|
|
|
|
|
|
static func _speaker_tail_geometry(contour: PackedVector2Array, origin: Vector2, tip: Vector2, shout: bool, tip_half_width: float = 0.0) -> Dictionary:
|
|
var no_tail: Dictionary = {"contour": contour, "left_edge": PackedVector2Array(), "right_edge": PackedVector2Array()}
|
|
if contour.size() < 3 or origin.distance_to(tip) < 12.0:
|
|
return no_tail
|
|
var boundary_hit: Dictionary = _ray_polygon_boundary(contour, origin, tip)
|
|
if boundary_hit.is_empty():
|
|
return no_tail
|
|
var boundary: Vector2 = boundary_hit["point"]
|
|
if boundary.distance_to(tip) < 12.0:
|
|
return no_tail
|
|
var cumulative: Array[float] = [0.0]
|
|
for index: int in range(contour.size()):
|
|
cumulative.append(cumulative.back() + contour[index].distance_to(contour[(index + 1) % contour.size()]))
|
|
var perimeter: float = cumulative.back()
|
|
if perimeter <= 0.001:
|
|
return no_tail
|
|
var edge_index: int = int(boundary_hit["edge_index"])
|
|
var edge_length: float = cumulative[edge_index + 1] - cumulative[edge_index]
|
|
var hit_distance: float = cumulative[edge_index] + edge_length * float(boundary_hit["edge_t"])
|
|
var half_width: float = 12.0 if shout else 14.0
|
|
var base_start_distance: float = hit_distance - half_width
|
|
var base_end_distance: float = hit_distance + half_width
|
|
var base_start: Vector2 = _polygon_point_at_distance(contour, cumulative, base_start_distance)
|
|
var base_end: Vector2 = _polygon_point_at_distance(contour, cumulative, base_end_distance)
|
|
var base_tangent: Vector2 = (base_end - base_start).normalized()
|
|
if base_tangent.is_zero_approx():
|
|
return no_tail
|
|
var outward: Vector2 = base_tangent.rotated(-PI * 0.5)
|
|
var tail_direction: Vector2 = (tip - boundary).normalized()
|
|
var tail_length: float = boundary.distance_to(tip)
|
|
var control_one: Vector2 = boundary + outward * minf(tail_length * 0.44, 52.0)
|
|
var control_two: Vector2 = tip - tail_direction * minf(tail_length * 0.22, 30.0)
|
|
var sample_count: int = 6 if shout else 10
|
|
var left_edge := PackedVector2Array([base_start])
|
|
var right_edge := PackedVector2Array([base_end])
|
|
for sample_index: int in range(1, sample_count + 1):
|
|
var t: float = float(sample_index) / float(sample_count)
|
|
left_edge.append(_tail_edge_point(boundary, tip, control_one, control_two, half_width, t, -1.0, shout, sample_index, sample_count, tip_half_width))
|
|
right_edge.append(_tail_edge_point(boundary, tip, control_one, control_two, half_width, t, 1.0, false, sample_index, sample_count, tip_half_width))
|
|
var result := PackedVector2Array([base_end])
|
|
var start_distance: float = base_end_distance
|
|
var end_distance: float = base_start_distance + perimeter
|
|
if start_distance >= perimeter:
|
|
start_distance -= perimeter
|
|
end_distance -= perimeter
|
|
for cycle: int in range(2):
|
|
for vertex_index: int in range(contour.size()):
|
|
var vertex_distance: float = cumulative[vertex_index] + float(cycle) * perimeter
|
|
if vertex_distance > start_distance + 0.001 and vertex_distance < end_distance - 0.001:
|
|
result.append(contour[vertex_index])
|
|
result.append(base_start)
|
|
for sample_index: int in range(1, sample_count + 1):
|
|
result.append(left_edge[sample_index])
|
|
for sample_index: int in range(sample_count, -1, -1):
|
|
result.append(right_edge[sample_index])
|
|
return {"contour": result, "left_edge": left_edge, "right_edge": right_edge}
|
|
|
|
|
|
static func _tail_edge_point(start: Vector2, end: Vector2, control_one: Vector2, control_two: Vector2, half_width: float, t: float, side: float, shout_kink: bool, sample_index: int, sample_count: int, tip_half_width: float = 0.0) -> Vector2:
|
|
var center: Vector2 = _cubic_bezier_point(start, control_one, control_two, end, t)
|
|
var tangent: Vector2 = _cubic_bezier_tangent(start, control_one, control_two, end, t).normalized()
|
|
if tangent.is_zero_approx():
|
|
tangent = (end - start).normalized()
|
|
var normal: Vector2 = tangent.rotated(PI * 0.5)
|
|
var tapered_width: float = half_width * pow(maxf(0.0, 1.0 - t), 1.15)
|
|
var half_at_t: float = lerpf(tapered_width, tip_half_width, pow(t, 4.0))
|
|
var result: Vector2 = center + normal * half_at_t * side
|
|
if shout_kink and sample_index == maxi(1, roundi(float(sample_count) * 0.42)):
|
|
result += normal * 4.0
|
|
return result
|
|
|
|
|
|
static func _cubic_bezier_point(start: Vector2, control_one: Vector2, control_two: Vector2, end: Vector2, t: float) -> Vector2:
|
|
var inverse_t: float = 1.0 - t
|
|
return start * inverse_t * inverse_t * inverse_t + control_one * 3.0 * inverse_t * inverse_t * t + control_two * 3.0 * inverse_t * t * t + end * t * t * t
|
|
|
|
|
|
static func _cubic_bezier_tangent(start: Vector2, control_one: Vector2, control_two: Vector2, end: Vector2, t: float) -> Vector2:
|
|
var inverse_t: float = 1.0 - t
|
|
return (control_one - start) * 3.0 * inverse_t * inverse_t + (control_two - control_one) * 6.0 * inverse_t * t + (end - control_two) * 3.0 * t * t
|
|
|
|
|
|
static func _ray_polygon_boundary(contour: PackedVector2Array, origin: Vector2, target: Vector2) -> Dictionary:
|
|
var direction: Vector2 = (target - origin).normalized()
|
|
if direction.is_zero_approx():
|
|
return {}
|
|
var nearest_distance: float = INF
|
|
var nearest_edge: int = -1
|
|
var nearest_t: float = 0.0
|
|
for edge_index: int in range(contour.size()):
|
|
var edge_start: Vector2 = contour[edge_index]
|
|
var edge_end: Vector2 = contour[(edge_index + 1) % contour.size()]
|
|
var edge: Vector2 = edge_end - edge_start
|
|
var denominator: float = direction.x * edge.y - direction.y * edge.x
|
|
if absf(denominator) < 0.00001:
|
|
continue
|
|
var delta: Vector2 = edge_start - origin
|
|
var ray_distance: float = (delta.x * edge.y - delta.y * edge.x) / denominator
|
|
var edge_t: float = (delta.x * direction.y - delta.y * direction.x) / denominator
|
|
if ray_distance >= 0.0 and edge_t >= 0.0 and edge_t <= 1.0 and ray_distance < nearest_distance:
|
|
nearest_distance = ray_distance
|
|
nearest_edge = edge_index
|
|
nearest_t = edge_t
|
|
if nearest_edge < 0:
|
|
return {}
|
|
return {
|
|
"point": origin + direction * nearest_distance,
|
|
"edge_index": nearest_edge,
|
|
"edge_t": nearest_t,
|
|
}
|
|
|
|
|
|
static func _polygon_point_at_distance(contour: PackedVector2Array, cumulative: Array[float], distance: float) -> Vector2:
|
|
var perimeter: float = cumulative.back()
|
|
if perimeter <= 0.001:
|
|
return contour[0] if not contour.is_empty() else Vector2.ZERO
|
|
var wrapped_distance: float = fposmod(distance, perimeter)
|
|
for edge_index: int in range(contour.size()):
|
|
var edge_start_distance: float = cumulative[edge_index]
|
|
var edge_end_distance: float = cumulative[edge_index + 1]
|
|
if wrapped_distance > edge_end_distance and edge_index < contour.size() - 1:
|
|
continue
|
|
var edge_length: float = edge_end_distance - edge_start_distance
|
|
var ratio: float = clampf((wrapped_distance - edge_start_distance) / maxf(edge_length, 0.001), 0.0, 1.0)
|
|
return contour[edge_index].lerp(contour[(edge_index + 1) % contour.size()], ratio)
|
|
return contour[0]
|
|
|
|
|
|
static func _draw_dashed_polygon_on(canvas: CanvasItem, contour: PackedVector2Array, color: Color, width: float, dash: float) -> void:
|
|
for index: int in range(contour.size()):
|
|
canvas.draw_dashed_line(contour[index], contour[(index + 1) % contour.size()], color, width, dash)
|
|
|
|
|
|
static func _ray_rect_boundary(rect: Rect2, target: Vector2) -> Vector2:
|
|
var center: Vector2 = rect.get_center()
|
|
var direction: Vector2 = (target - center).normalized()
|
|
if direction.is_zero_approx():
|
|
return Vector2(center.x, rect.end.y)
|
|
var half: Vector2 = rect.size * 0.5
|
|
var x_scale: float = half.x / maxf(absf(direction.x), 0.0001)
|
|
var y_scale: float = half.y / maxf(absf(direction.y), 0.0001)
|
|
return center + direction * minf(x_scale, y_scale)
|
|
|
|
|
|
static func _burst_points(frame: Rect2) -> PackedVector2Array:
|
|
var points := PackedVector2Array()
|
|
var center: Vector2 = frame.get_center()
|
|
for index: int in 24:
|
|
var angle: float = TAU * float(index) / 24.0 - PI * 0.5
|
|
var radius: float = 1.0 if index % 2 == 0 else 0.88
|
|
points.append(center + Vector2(cos(angle) * frame.size.x * 0.5 * radius, sin(angle) * frame.size.y * 0.5 * radius))
|
|
return points
|
|
|
|
|
|
static func _cloud_points(frame: Rect2) -> PackedVector2Array:
|
|
var points := PackedVector2Array()
|
|
var center: Vector2 = frame.get_center()
|
|
for index: int in 40:
|
|
var angle: float = TAU * float(index) / 40.0
|
|
var scallop: float = 1.0 + 0.08 * cos(angle * 8.0)
|
|
points.append(center + Vector2(cos(angle) * frame.size.x * 0.5 * scallop, sin(angle) * frame.size.y * 0.5 * scallop))
|
|
return points
|
|
|
|
|
|
static func _draw_dashed_rect_on(canvas: CanvasItem, rect: Rect2, color: Color, width: float, dash: float) -> void:
|
|
for side: int in 4:
|
|
var start: Vector2 = rect.position
|
|
var end: Vector2 = rect.end
|
|
match side:
|
|
0: end = Vector2(rect.end.x, rect.position.y)
|
|
1: start = Vector2(rect.end.x, rect.position.y); end = rect.end
|
|
2: start = rect.end; end = Vector2(rect.position.x, rect.end.y)
|
|
3: start = Vector2(rect.position.x, rect.end.y); end = rect.position
|
|
canvas.draw_dashed_line(start, end, color, width, dash)
|
|
|
|
|
|
func _head_point_in_bubble() -> Vector2:
|
|
var rig: Node2D = get_parent() as Node2D
|
|
return to_local(rig.to_global(_head_center_rig)) if rig != null else -position
|
|
|
|
|
|
func _local_head_radius() -> float:
|
|
return _head_radius_rig / maxf(absf(scale.x), 0.001)
|
|
|
|
|
|
func _head_rim_point(frame: Rect2, head_center: Vector2, head_radius: float) -> Vector2:
|
|
var direction: Vector2 = (frame.get_center() - head_center).normalized()
|
|
if direction.is_zero_approx():
|
|
direction = Vector2.UP
|
|
return head_center + direction * (head_radius + 5.0)
|
|
|
|
func _draw_burst(frame: Rect2, fill: Color, outline: Color) -> void:
|
|
var points := PackedVector2Array()
|
|
var center: Vector2 = frame.get_center()
|
|
var radius_x: float = frame.size.x * 0.5
|
|
var radius_y: float = frame.size.y * 0.5
|
|
for i: int in 24:
|
|
var angle: float = TAU * float(i) / 24.0 - PI * 0.5
|
|
var radius: float = 1.0 if i % 2 == 0 else 0.88
|
|
points.append(center + Vector2(cos(angle) * radius_x * radius, sin(angle) * radius_y * radius))
|
|
draw_colored_polygon(points, fill)
|
|
var closed := points.duplicate()
|
|
closed.append(points[0])
|
|
draw_polyline(closed, outline, 4.0, true)
|
|
|
|
|
|
func _draw_cloud(frame: Rect2, fill: Color, outline: Color) -> void:
|
|
var points := PackedVector2Array()
|
|
var center: Vector2 = frame.get_center()
|
|
for i: int in 40:
|
|
var angle: float = TAU * float(i) / 40.0
|
|
var scallop: float = 1.0 + 0.08 * cos(angle * 8.0)
|
|
points.append(center + Vector2(cos(angle) * frame.size.x * 0.5 * scallop, sin(angle) * frame.size.y * 0.5 * scallop))
|
|
draw_colored_polygon(points, fill)
|
|
var closed := points.duplicate()
|
|
closed.append(points[0])
|
|
draw_polyline(closed, outline, 3.0, true)
|
|
|
|
|
|
func _draw_dashed_rect(rect: Rect2, color: Color, width: float, dash: float) -> void:
|
|
for side: int in 4:
|
|
var start: Vector2 = rect.position
|
|
var end: Vector2 = rect.end
|
|
match side:
|
|
0: end = Vector2(rect.end.x, rect.position.y)
|
|
1: start = Vector2(rect.end.x, rect.position.y); end = rect.end
|
|
2: start = rect.end; end = Vector2(rect.position.x, rect.end.y)
|
|
3: start = Vector2(rect.position.x, rect.end.y); end = rect.position
|
|
draw_dashed_line(start, end, color, width, dash)
|
|
|
|
|
|
func _style_colors(style: String) -> Dictionary:
|
|
var fallback: Dictionary = {
|
|
"talk": {"fill": Color("#FFFDF6"), "outline": Color("#202020")},
|
|
"shout": {"fill": Color("#FFD642"), "outline": Color("#D92F26")},
|
|
"think": {"fill": Color("#E9F5FF"), "outline": Color("#37698B")},
|
|
"whisper": {"fill": Color("#F2F2F2"), "outline": Color("#6D7885")},
|
|
"sfx": {"fill": Color("#FFDD39"), "outline": Color("#D92F26")},
|
|
}
|
|
var fill: Color = fallback.get(style, fallback["talk"])["fill"]
|
|
var outline: Color = fallback.get(style, fallback["talk"])["outline"]
|
|
if _theme != null:
|
|
fill = _theme.color("speech.styles.%s.fill" % style, fill)
|
|
outline = _theme.color("speech.styles.%s.outline" % style, outline)
|
|
return {"fill": fill, "outline": outline}
|
|
|
|
|
|
func _render_label_text(label: RichTextLabel, text: String, visual: Dictionary, elapsed: float, bubble_index: int) -> void:
|
|
label.clear()
|
|
var runs: Array[Dictionary] = SPEECH_PAYLOAD.rich_text_runs(text, _typed_word_styles(visual.get("word_styles", [])))
|
|
for run: Dictionary in runs:
|
|
var run_start: int = int(run.get("start", -1))
|
|
var run_end: int = int(run.get("end", -1))
|
|
var token_span: Vector2i = SPEECH_PAYLOAD.token_span_at(text, run_start)
|
|
var is_token: bool = token_span == Vector2i(run_start, run_end)
|
|
if is_token:
|
|
label.push_meta({"bubble_index": bubble_index, "start": run_start, "end": run_end}, RichTextLabel.META_UNDERLINE_NEVER)
|
|
var font_id: String = String(run.get("font_preset", visual.get("font_preset", SPEECH_PAYLOAD.BASE_FONT_PRESET)))
|
|
var size_id: String = String(run.get("size_preset", visual.get("size_preset", "medium")))
|
|
var color: String = String(run.get("color", visual.get("color", SPEECH_PAYLOAD.DEFAULT_TEXT_COLOR)))
|
|
var effect: String = String(run.get("effect", visual.get("effect", "none")))
|
|
var effect_clock: float = elapsed
|
|
var demo_matches_bubble: bool = _preview and _static_editor_preview and int(_editor_effect_preview.get("bubble_index", -1)) == bubble_index
|
|
var demo_all_tokens: bool = demo_matches_bubble and bool(_editor_effect_preview.get("all_tokens", false))
|
|
var demo_matches_token: bool = demo_matches_bubble and int(_editor_effect_preview.get("start", -1)) == run_start and int(_editor_effect_preview.get("end", -1)) == run_end
|
|
var demo_applies: bool = demo_all_tokens or demo_matches_token
|
|
if demo_applies:
|
|
effect = String(_editor_effect_preview.get("effect", ""))
|
|
effect_clock = float(_editor_effect_preview.get("elapsed", 0.0))
|
|
label.push_font(_theme.speech_font(font_id), _font_size(size_id, font_id))
|
|
if SPEECH_PAYLOAD._valid_color(color):
|
|
label.push_color(Color(color))
|
|
var animate_effect: bool = effect in ["shake", "wave", "bounce"] and (not (_preview and _static_editor_preview) or demo_applies)
|
|
if animate_effect:
|
|
label.push_customfx(_word_effect, {"kind": effect, "clock": effect_clock})
|
|
label.add_text(String(run.get("text", "")))
|
|
if animate_effect:
|
|
label.pop()
|
|
if SPEECH_PAYLOAD._valid_color(color):
|
|
label.pop()
|
|
label.pop()
|
|
if is_token:
|
|
label.pop()
|
|
|
|
|
|
func _on_label_meta_clicked(meta: Variant) -> void:
|
|
if not _preview or not _static_editor_preview or not meta is Dictionary:
|
|
return
|
|
var value: Dictionary = meta as Dictionary
|
|
var bubble_index: int = int(value.get("bubble_index", -1))
|
|
var start: int = int(value.get("start", -1))
|
|
var end: int = int(value.get("end", -1))
|
|
if bubble_index < 0 or start < 0 or end <= start:
|
|
return
|
|
stage_token_selected.emit(bubble_index, start, end)
|
|
stage_word_selected.emit(bubble_index, start, end)
|
|
_label_meta_clicked[bubble_index] = true
|
|
|
|
|
|
func _on_label_gui_input(event: InputEvent, label: RichTextLabel) -> void:
|
|
if not _preview or not _static_editor_preview or not event is InputEventMouseButton:
|
|
return
|
|
var mouse_event: InputEventMouseButton = event as InputEventMouseButton
|
|
if mouse_event.button_index != MOUSE_BUTTON_LEFT:
|
|
return
|
|
var bubble_index: int = -1
|
|
for layout: Dictionary in _layouts:
|
|
if layout.get("label") == label:
|
|
bubble_index = int(layout.get("index", -1))
|
|
break
|
|
if bubble_index < 0:
|
|
return
|
|
if mouse_event.pressed:
|
|
_label_meta_clicked[bubble_index] = false
|
|
return
|
|
stage_text_tap_detected.emit(bubble_index)
|
|
var viewport_position: Vector2 = label.get_global_transform_with_canvas() * label.get_local_mouse_position()
|
|
var spans: Array[Vector2i] = text_line_tokens_at_viewport(bubble_index, viewport_position)
|
|
if not spans.is_empty():
|
|
call_deferred("_emit_line_token_picker_if_needed", bubble_index, spans, viewport_position)
|
|
|
|
|
|
func _emit_line_token_picker_if_needed(bubble_index: int, spans: Array[Vector2i], viewport_position: Vector2) -> void:
|
|
if bool(_label_meta_clicked.get(bubble_index, false)):
|
|
_label_meta_clicked[bubble_index] = false
|
|
return
|
|
stage_line_tokens_requested.emit(bubble_index, spans, viewport_position)
|
|
|
|
|
|
func _typed_word_styles(raw: Variant) -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
if raw is Array:
|
|
for item: Variant in raw:
|
|
if item is Dictionary:
|
|
result.append(item as Dictionary)
|
|
return result
|
|
|
|
|
|
func _bubble_data(index: int) -> Dictionary:
|
|
var bubbles: Array = _action.get("bubbles", [])
|
|
return bubbles[index] as Dictionary if index >= 0 and index < bubbles.size() and bubbles[index] is Dictionary else {}
|
|
|
|
|
|
func _escape_bbcode(value: String) -> String:
|
|
return value.replace("[", "[[")
|
|
|
|
|
|
func _font_size(size_preset: String, font_preset: String = SPEECH_PAYLOAD.BASE_FONT_PRESET) -> int:
|
|
if _theme != null:
|
|
return _theme.speech_font_size(font_preset, size_preset)
|
|
match size_preset:
|
|
"small": return 20
|
|
"huge": return 40
|
|
_: return 28
|
|
|
|
|
|
func _interval_for(index: int) -> float:
|
|
var intervals: PackedFloat32Array = SPEECH_PAYLOAD.bubble_intervals(_action)
|
|
return intervals[index] if index >= 0 and index < intervals.size() else maxf(1.5, float(_action.get("duration", 2.0)))
|
|
|
|
|
|
func _visible_character_count(text: String, visual: Dictionary, elapsed: float, interval: float) -> int:
|
|
var reveal: String = String(visual.get("reveal", "instant"))
|
|
if reveal == "instant":
|
|
return -1
|
|
var words: Array = []
|
|
var regex := RegEx.new()
|
|
if regex.compile(SPEECH_PAYLOAD.WORD_REGEX) == OK:
|
|
words = regex.search_all(text)
|
|
var reveal_budget: float = minf((0.045 * float(text.length())) if reveal == "letter" else (0.20 * float(words.size())), interval * 0.55)
|
|
if reveal_budget <= 0.0:
|
|
return -1
|
|
var fraction: float = clampf(elapsed / reveal_budget, 0.0, 1.0)
|
|
if reveal == "letter":
|
|
return int(floor(float(text.length()) * fraction))
|
|
var allowed_words: int = int(floor(float(words.size()) * fraction))
|
|
if allowed_words >= words.size():
|
|
return -1
|
|
return int(words[allowed_words].get_end()) if allowed_words > 0 else 0
|