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_payload.gd") const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd") const SPEECH_WORD_EFFECT := preload("res://scripts/speech_word_effect.gd") const STAGE_SPAWNER := preload("res://scripts/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