Files
stickman/scripts/speech/stickman_speech_bubble.gd

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