feat: add Phase 20a stage identities and overlap selection
This commit is contained in:
@@ -125,6 +125,63 @@ func _apply_phase19_defaults() -> void:
|
||||
if not asset_selector.has(key):
|
||||
asset_selector[key] = starter_defaults[key]
|
||||
popups["asset_selector"] = asset_selector
|
||||
var anchored_chooser_defaults: Dictionary = {
|
||||
"preferred_width": 360,
|
||||
"edge_inset": 8,
|
||||
"safe_top": 120,
|
||||
"safe_bottom": 48,
|
||||
"content_inset": 12,
|
||||
"heading_font_size": 20,
|
||||
"row_height": 56,
|
||||
"row_gap": 8,
|
||||
"header_gap": 8,
|
||||
"footer_gap": 8,
|
||||
"max_rows_height": 320,
|
||||
"minimum_rows_height": 116,
|
||||
"row_font_size": 20,
|
||||
"row_text_color": "#F5F7FA",
|
||||
"row_surface": "#171B24",
|
||||
"row_border_color": "#FFFFFF55",
|
||||
"selected_surface": "#12394A",
|
||||
"hover_surface": "#203849",
|
||||
"accent_color": "#22C6FF",
|
||||
"focus_color": "#8BD9FF",
|
||||
"corner_radius": 14,
|
||||
"border_width": 2,
|
||||
"bg_color": "#202633FA",
|
||||
"border_color": "#667386",
|
||||
"padding": 0,
|
||||
"shadow_size": 6,
|
||||
"shadow_color": "#00000080",
|
||||
"shadow_offset": [0, 4],
|
||||
"row_corner_radius": 12,
|
||||
"row_border_width": 1,
|
||||
"selected_border_width": 2,
|
||||
"hover_border_width": 2,
|
||||
"focus_border_width": 3,
|
||||
"row_padding_x": 12,
|
||||
"row_padding_y": 6,
|
||||
"badge_corner_radius": 8,
|
||||
"badge_surface": "#12394A",
|
||||
"badge_text_color": "#F5F7FA",
|
||||
"badge_font_size": 16,
|
||||
"badge_padding_x": 8,
|
||||
"badge_padding_y": 2,
|
||||
"cancel_width": 120,
|
||||
"cancel_height": 48,
|
||||
"cancel_corner_radius": 10,
|
||||
"cancel_border_width": 2,
|
||||
"cancel_font_size": 20,
|
||||
"cancel_surface": "#293242",
|
||||
"cancel_hover_surface": "#354154",
|
||||
"cancel_border_color": "#718096",
|
||||
"cancel_text_color": "#F5F7FA"
|
||||
}
|
||||
var anchored_chooser: Dictionary = popups.get("anchored_chooser", {}).duplicate(true)
|
||||
for key: String in anchored_chooser_defaults:
|
||||
if not anchored_chooser.has(key):
|
||||
anchored_chooser[key] = anchored_chooser_defaults[key]
|
||||
popups["anchored_chooser"] = anchored_chooser
|
||||
var ground_finish_defaults: Dictionary = {
|
||||
"card_width": 880,
|
||||
"max_height_ratio": 0.86,
|
||||
@@ -168,6 +225,11 @@ func _apply_phase19_defaults() -> void:
|
||||
"hover_color": "#FFE66DCC",
|
||||
"rotate_color": "#4DB9FFE6",
|
||||
"backing_extra_width": 3.0,
|
||||
"candidate_name_pill_bg_color": "#182432F2",
|
||||
"candidate_name_pill_corner_radius": 8.0,
|
||||
"candidate_name_pill_border_width": 3.0,
|
||||
"candidate_name_pill_padding_x": 12.0,
|
||||
"candidate_name_pill_padding_y": 8.0,
|
||||
}
|
||||
for key: String in defaults:
|
||||
if not gizmos.has(key):
|
||||
|
||||
+199
-17
@@ -1,16 +1,19 @@
|
||||
class_name StageGizmos
|
||||
extends Node2D
|
||||
## StageGizmos - Hover highlight, selection outlines and a rotate ring for the
|
||||
## StageGizmos - Hover highlight, selection outlines and a compact rotate handle for the
|
||||
## Sandbox Stage Builder (Phase 2).
|
||||
##
|
||||
## Translation is done by dragging an object directly (no move handle); a drag
|
||||
## moves the whole multi-selection. The rotate ring only appears when exactly one
|
||||
## moves the whole multi-selection. The rotate handle only appears when exactly one
|
||||
## object is selected (rotating a multi-selection isn't useful). Pure drawing +
|
||||
## distance-based hit-testing (no Area2D). Dragging drives global_position /
|
||||
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
||||
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||
const StickmanRigScript := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||
const MissingPropScript := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
const MissingStickmanScript := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||
const StageBackgroundGroundScript := preload("res://scripts/stage/scenery/stage_background_ground.gd")
|
||||
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
||||
@@ -37,12 +40,14 @@ const SELECTION_COLOR := Color(1.0, 1.0, 1.0, 0.9)
|
||||
const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5)
|
||||
const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9)
|
||||
|
||||
const SELECTION_WIDTH := 2.0
|
||||
const SELECTION_WIDTH := 3.0
|
||||
const HOVER_WIDTH := 1.5
|
||||
const ROTATE_WIDTH := 2.0
|
||||
|
||||
const ROTATE_HANDLE_MARGIN_PX := 48.0
|
||||
const ROTATE_RING_TOLERANCE_PX := 12.0
|
||||
const ROTATE_HANDLE_OFFSET_PX := 24.0
|
||||
const ROTATE_HANDLE_HIT_HALF_SIZE_PX := 22.0
|
||||
const ROTATE_ICON_RADIUS_PX := 8.0
|
||||
const ROTATE_HANDLE_SAFE_TOP_PX := 112.0
|
||||
const SCALE_CROSS_SIZE_PX := 5.0
|
||||
const SCALE_HIT_RADIUS_PX := 12.0
|
||||
const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0
|
||||
@@ -69,6 +74,14 @@ var _enabled: bool = true
|
||||
var _transform_handles_visible: bool = true
|
||||
var _targets: Array[Node2D] = []
|
||||
var _hovered: Node2D = null
|
||||
var _candidate_preview: Node2D = null
|
||||
var _candidate_preview_obstacles: Array[Rect2] = []
|
||||
var _candidate_preview_color: Color = Color("#22C6FF")
|
||||
var _candidate_preview_name_background: Color = Color("#182432F2")
|
||||
var _candidate_preview_name_corner_radius: float = 8.0
|
||||
var _candidate_preview_name_border_width: float = 3.0
|
||||
var _candidate_preview_name_padding: Vector2 = Vector2(12.0, 8.0)
|
||||
var _candidate_preview_name_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
var _dragging: Handle = Handle.NONE
|
||||
var _drag_last: Vector2 = Vector2.ZERO
|
||||
var _drag_anchor: Node2D = null
|
||||
@@ -97,6 +110,16 @@ func set_hover(node: Node2D) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_candidate_preview(node: Node2D) -> void:
|
||||
_candidate_preview = node if node != null and is_instance_valid(node) else null
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_candidate_preview_obstacles(obstacles: Array[Rect2]) -> void:
|
||||
_candidate_preview_obstacles = obstacles.duplicate()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_enabled(enabled: bool) -> void:
|
||||
_enabled = enabled
|
||||
visible = enabled
|
||||
@@ -121,6 +144,14 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
|
||||
_hover_core_color = stage_theme.color("overlays.gizmos.hover_color", HOVER_COLOR)
|
||||
_rotate_backing_color = stage_theme.color("overlays.gizmos.backing_color", Color("#111821E8"))
|
||||
_rotate_core_color = stage_theme.color("overlays.gizmos.rotate_color", ROTATE_COLOR)
|
||||
_candidate_preview_color = stage_theme.color("mode_colors.edit", Color("#22C6FF"))
|
||||
_candidate_preview_name_background = stage_theme.color("overlays.gizmos.candidate_name_pill_bg_color", Color("#182432F2"))
|
||||
_candidate_preview_name_corner_radius = stage_theme.number("overlays.gizmos.candidate_name_pill_corner_radius", 8.0, 4.0, 24.0)
|
||||
_candidate_preview_name_border_width = stage_theme.number("overlays.gizmos.candidate_name_pill_border_width", 3.0, 1.0, 8.0)
|
||||
_candidate_preview_name_padding = Vector2(
|
||||
stage_theme.number("overlays.gizmos.candidate_name_pill_padding_x", 12.0, 4.0, 32.0),
|
||||
stage_theme.number("overlays.gizmos.candidate_name_pill_padding_y", 8.0, 2.0, 24.0)
|
||||
)
|
||||
_outline_extra_width = stage_theme.number("overlays.gizmos.backing_extra_width", 3.0, 1.0, 8.0)
|
||||
queue_redraw()
|
||||
|
||||
@@ -130,7 +161,7 @@ func set_box_rect(rect: Rect2) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
|
||||
## Returns ROTATE if the cursor is on the compact rotate handle (single selection only).
|
||||
func hit_test(world_pos: Vector2) -> Handle:
|
||||
if not _enabled or not _transform_handles_visible or _targets.size() != 1:
|
||||
return Handle.NONE
|
||||
@@ -148,9 +179,11 @@ func hit_test(world_pos: Vector2) -> Handle:
|
||||
for corner: Vector2 in _corners(rect):
|
||||
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
||||
return Handle.SCALE
|
||||
var ring := _rotate_ring_radius(rect)
|
||||
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
|
||||
return Handle.ROTATE
|
||||
if not _is_scenery_target(target):
|
||||
var rotate_handle: Vector2 = _rotate_handle_position(rect)
|
||||
var handle_offset: Vector2 = (world_pos - rotate_handle) * _zoom()
|
||||
if absf(handle_offset.x) <= ROTATE_HANDLE_HIT_HALF_SIZE_PX and absf(handle_offset.y) <= ROTATE_HANDLE_HIT_HALF_SIZE_PX:
|
||||
return Handle.ROTATE
|
||||
return Handle.NONE
|
||||
|
||||
|
||||
@@ -171,7 +204,7 @@ func get_scenery_resize_corner(world_pos: Vector2) -> Vector2:
|
||||
return closest
|
||||
|
||||
|
||||
## Begins a rotate drag (used when the ring is clicked).
|
||||
## Begins a rotate drag (used when the rotate handle is clicked).
|
||||
func begin_drag(world_pos: Vector2) -> void:
|
||||
_dragging = hit_test(world_pos)
|
||||
_drag_last = world_pos
|
||||
@@ -264,7 +297,7 @@ func is_dragging() -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _enabled and (_targets.size() > 0 or _hovered != null or _box_rect.size != Vector2.ZERO):
|
||||
if _enabled and (_targets.size() > 0 or _hovered != null or _candidate_preview != null or _box_rect.size != Vector2.ZERO):
|
||||
queue_redraw()
|
||||
|
||||
|
||||
@@ -274,17 +307,17 @@ func _draw() -> void:
|
||||
var zoom := _zoom()
|
||||
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
|
||||
_draw_halo_rect(_target_bounds(_hovered), _hover_core_color, HOVER_WIDTH / zoom, _hover_backing_color)
|
||||
if _candidate_preview != null and is_instance_valid(_candidate_preview) and not _candidate_preview.is_queued_for_deletion():
|
||||
_draw_candidate_preview(_candidate_preview, zoom)
|
||||
# A selection outline for every selected object.
|
||||
for node: Node2D in _targets:
|
||||
if node != null and is_instance_valid(node):
|
||||
_draw_halo_rect(_target_bounds(node), _selection_core_color, SELECTION_WIDTH / zoom, _selection_backing_color)
|
||||
# Rotate ring only for a single selection.
|
||||
# A clear rectangle signals selection; the compact handle keeps rotation available.
|
||||
if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var rect: Rect2 = _target_bounds(_targets[0])
|
||||
if not _is_scenery_target(_targets[0]):
|
||||
var ring_radius: float = _rotate_ring_radius(rect)
|
||||
draw_arc(rect.get_center(), ring_radius, 0.0, TAU, 64, _rotate_backing_color, ROTATE_WIDTH / zoom + _outline_extra_width / zoom, true)
|
||||
draw_arc(rect.get_center(), ring_radius, 0.0, TAU, 64, _rotate_core_color, ROTATE_WIDTH / zoom, true)
|
||||
_draw_rotate_handle(rect, zoom)
|
||||
if _is_scenery_target(_targets[0]):
|
||||
for corner: Vector2 in _corners(rect):
|
||||
draw_circle(corner, 11.0 / zoom, Color("#111821E8"))
|
||||
@@ -314,8 +347,157 @@ func _draw_halo_rect(rect: Rect2, core_color: Color, core_width: float, backing_
|
||||
draw_rect(rect, core_color, false, core_width)
|
||||
|
||||
|
||||
func _rotate_ring_radius(rect: Rect2) -> float:
|
||||
return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom()
|
||||
func _draw_candidate_preview(root: Node, zoom: float) -> void:
|
||||
if root is CanvasItem and not (root as CanvasItem).is_visible_in_tree():
|
||||
return
|
||||
if root is Polygon2D:
|
||||
var polygon: Polygon2D = root as Polygon2D
|
||||
if polygon.polygon.size() >= 2:
|
||||
var points: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in polygon.polygon:
|
||||
points.append(to_local(polygon.to_global(point)))
|
||||
points.append(points[0])
|
||||
draw_polyline(points, Color(_candidate_preview_color, 0.62), 12.0 / zoom, true)
|
||||
draw_polyline(points, _candidate_preview_color, 4.5 / zoom, true)
|
||||
elif root is Line2D:
|
||||
var line: Line2D = root as Line2D
|
||||
if line.points.size() >= 2:
|
||||
var points: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in line.points:
|
||||
points.append(to_local(line.to_global(point)))
|
||||
var segment_count: int = points.size() - 1 + (1 if line.closed else 0)
|
||||
for index: int in segment_count:
|
||||
draw_line(points[index % points.size()], points[(index + 1) % points.size()], Color(_candidate_preview_color, 0.62), 12.0 / zoom, true)
|
||||
draw_line(points[index % points.size()], points[(index + 1) % points.size()], _candidate_preview_color, 4.5 / zoom, true)
|
||||
for child: Node in root.get_children():
|
||||
_draw_candidate_preview(child, zoom)
|
||||
if root is Node2D and root == _candidate_preview:
|
||||
_draw_candidate_preview_name(root as Node2D, zoom)
|
||||
|
||||
|
||||
func _draw_candidate_preview_name(node: Node2D, zoom: float) -> void:
|
||||
var name_text: String = node.name
|
||||
if node is PropBlockScript or node is StickmanRigScript or node.get_script() == MissingPropScript or node.get_script() == MissingStickmanScript:
|
||||
name_text = String(node.get("stage_name"))
|
||||
if name_text.is_empty():
|
||||
name_text = node.name
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
if font == null:
|
||||
return
|
||||
var font_size: int = maxi(20, roundi(20.0 / zoom))
|
||||
var text_size: Vector2 = font.get_string_size(name_text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size)
|
||||
var font_ascent: float = font.get_ascent(font_size)
|
||||
var font_descent: float = font.get_descent(font_size)
|
||||
var bounds: Rect2 = _target_bounds(node)
|
||||
var canvas_transform: Transform2D = get_viewport().get_canvas_transform()
|
||||
var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse()
|
||||
var candidate_screen_bounds: Rect2 = _world_rect_to_screen_rect(bounds, canvas_transform)
|
||||
var rect_size: Vector2 = Vector2(text_size.x, font_ascent + font_descent) + _candidate_preview_name_padding * 2.0 / zoom
|
||||
var screen_size: Vector2 = rect_size * zoom
|
||||
var target_center: Vector2 = candidate_screen_bounds.get_center()
|
||||
var screen_candidates: Array[Vector2] = [
|
||||
Vector2(target_center.x - screen_size.x * 0.5, candidate_screen_bounds.position.y - screen_size.y - 10.0),
|
||||
Vector2(target_center.x - screen_size.x * 0.5, candidate_screen_bounds.end.y + 10.0),
|
||||
Vector2(candidate_screen_bounds.position.x - screen_size.x - 10.0, target_center.y - screen_size.y * 0.5),
|
||||
Vector2(candidate_screen_bounds.end.x + 10.0, target_center.y - screen_size.y * 0.5),
|
||||
]
|
||||
var viewport_rect: Rect2 = get_viewport().get_visible_rect()
|
||||
var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(maxf(0.0, viewport_rect.size.x - 16.0), maxf(0.0, viewport_rect.size.y - 120.0)))
|
||||
var max_safe_position: Vector2 = Vector2(
|
||||
maxf(safe_rect.position.x, safe_rect.end.x - screen_size.x),
|
||||
maxf(safe_rect.position.y, safe_rect.end.y - screen_size.y)
|
||||
)
|
||||
screen_candidates.append(safe_rect.position)
|
||||
screen_candidates.append(Vector2(max_safe_position.x, safe_rect.position.y))
|
||||
screen_candidates.append(Vector2(safe_rect.position.x, max_safe_position.y))
|
||||
screen_candidates.append(max_safe_position)
|
||||
for obstacle: Rect2 in _candidate_preview_obstacles:
|
||||
if not obstacle.has_area():
|
||||
continue
|
||||
screen_candidates.append(Vector2(obstacle.position.x - screen_size.x - 8.0, target_center.y - screen_size.y * 0.5))
|
||||
screen_candidates.append(Vector2(obstacle.end.x + 8.0, target_center.y - screen_size.y * 0.5))
|
||||
screen_candidates.append(Vector2(target_center.x - screen_size.x * 0.5, obstacle.position.y - screen_size.y - 8.0))
|
||||
screen_candidates.append(Vector2(target_center.x - screen_size.x * 0.5, obstacle.end.y + 8.0))
|
||||
var best_screen_position: Vector2 = safe_rect.position
|
||||
var best_score: float = INF
|
||||
for candidate_position: Vector2 in screen_candidates:
|
||||
var clamped_position: Vector2 = Vector2(
|
||||
clampf(candidate_position.x, safe_rect.position.x, max_safe_position.x),
|
||||
clampf(candidate_position.y, safe_rect.position.y, max_safe_position.y)
|
||||
)
|
||||
var candidate_rect: Rect2 = Rect2(clamped_position, screen_size)
|
||||
var overlap_area: float = candidate_rect.intersection(candidate_screen_bounds).get_area()
|
||||
for obstacle: Rect2 in _candidate_preview_obstacles:
|
||||
if obstacle.has_area():
|
||||
overlap_area += candidate_rect.intersection(obstacle).get_area()
|
||||
var score: float = overlap_area * 10000.0 + clamped_position.distance_to(target_center) * 0.02
|
||||
if score < best_score:
|
||||
best_score = score
|
||||
best_screen_position = clamped_position
|
||||
var screen_world_position: Vector2 = inverse_canvas_transform * best_screen_position
|
||||
var rect: Rect2 = Rect2(to_local(screen_world_position), rect_size)
|
||||
_candidate_preview_name_style.bg_color = _candidate_preview_name_background
|
||||
_candidate_preview_name_style.border_color = _candidate_preview_color
|
||||
_candidate_preview_name_style.set_corner_radius_all(maxi(1, roundi(_candidate_preview_name_corner_radius / zoom)))
|
||||
_candidate_preview_name_style.set_border_width_all(maxi(1, roundi(_candidate_preview_name_border_width / zoom)))
|
||||
draw_style_box(_candidate_preview_name_style, rect)
|
||||
var centered_baseline: float = rect_size.y * 0.5 + (font_ascent - font_descent) * 0.5
|
||||
draw_string(
|
||||
font,
|
||||
rect.position + Vector2(_candidate_preview_name_padding.x / zoom, centered_baseline),
|
||||
name_text,
|
||||
HORIZONTAL_ALIGNMENT_LEFT,
|
||||
-1.0,
|
||||
font_size,
|
||||
Color.WHITE
|
||||
)
|
||||
|
||||
|
||||
func _world_rect_to_screen_rect(world_rect: Rect2, canvas_transform: Transform2D) -> Rect2:
|
||||
var points: Array[Vector2] = [
|
||||
world_rect.position,
|
||||
Vector2(world_rect.end.x, world_rect.position.y),
|
||||
world_rect.end,
|
||||
Vector2(world_rect.position.x, world_rect.end.y),
|
||||
]
|
||||
var result: Rect2 = Rect2(canvas_transform * points[0], Vector2.ZERO)
|
||||
for point_index: int in range(1, points.size()):
|
||||
result = result.expand(canvas_transform * points[point_index])
|
||||
return result
|
||||
|
||||
|
||||
func _rotate_handle_position(rect: Rect2) -> Vector2:
|
||||
var zoom: float = _zoom()
|
||||
var above_position := Vector2(rect.get_center().x, rect.position.y - ROTATE_HANDLE_OFFSET_PX / zoom)
|
||||
var screen_position: Vector2 = get_viewport().get_canvas_transform() * above_position
|
||||
if screen_position.y - ROTATE_HANDLE_HIT_HALF_SIZE_PX < ROTATE_HANDLE_SAFE_TOP_PX:
|
||||
return Vector2(rect.get_center().x, rect.end.y + ROTATE_HANDLE_OFFSET_PX / zoom)
|
||||
return above_position
|
||||
|
||||
|
||||
func _draw_rotate_handle(rect: Rect2, zoom: float) -> void:
|
||||
var center: Vector2 = _rotate_handle_position(rect)
|
||||
var below_box: bool = center.y > rect.get_center().y
|
||||
var box_anchor_y: float = rect.end.y if below_box else rect.position.y
|
||||
var handle_anchor_offset: float = -14.0 / zoom if below_box else 14.0 / zoom
|
||||
var connector_start := Vector2(rect.get_center().x, box_anchor_y)
|
||||
var connector_end := center + Vector2(0.0, handle_anchor_offset)
|
||||
draw_line(connector_start, connector_end, _rotate_backing_color, 5.0 / zoom, true)
|
||||
draw_line(connector_start, connector_end, _rotate_core_color, 2.0 / zoom, true)
|
||||
draw_circle(center, 16.0 / zoom, _rotate_backing_color)
|
||||
draw_circle(center, 13.0 / zoom, Color("#252B38"))
|
||||
draw_arc(center, ROTATE_ICON_RADIUS_PX / zoom, deg_to_rad(35.0), deg_to_rad(300.0), 24, _rotate_core_color, 2.5 / zoom, true)
|
||||
var end_angle: float = deg_to_rad(300.0)
|
||||
var radial: Vector2 = Vector2(cos(end_angle), sin(end_angle))
|
||||
var tangent: Vector2 = Vector2(-radial.y, radial.x)
|
||||
var tip: Vector2 = center + radial * (ROTATE_ICON_RADIUS_PX / zoom)
|
||||
var arrow_back: Vector2 = tip - tangent * (4.0 / zoom)
|
||||
var arrow_points := PackedVector2Array([
|
||||
tip,
|
||||
arrow_back + radial * (3.5 / zoom),
|
||||
arrow_back - radial * (3.5 / zoom),
|
||||
])
|
||||
draw_colored_polygon(arrow_points, _rotate_core_color)
|
||||
|
||||
|
||||
func _snap(v: Vector2) -> Vector2:
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
class_name StageObjectIdentity
|
||||
extends RefCounted
|
||||
## Scene-scoped identity and display-name allocation for placed stage objects.
|
||||
|
||||
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
|
||||
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
||||
|
||||
var _id_serial: int = 0
|
||||
|
||||
|
||||
func get_id(node: Node2D) -> String:
|
||||
if not _is_named_object(node):
|
||||
return ""
|
||||
return String(node.get("stage_object_id"))
|
||||
|
||||
|
||||
func get_name(node: Node2D) -> String:
|
||||
if not _is_named_object(node):
|
||||
return ""
|
||||
return String(node.get("stage_name"))
|
||||
|
||||
|
||||
func assign_new(node: Node2D, base_name: String, world: Node2D) -> void:
|
||||
if not _is_named_object(node) or world == null:
|
||||
return
|
||||
var stage_id: String = get_id(node)
|
||||
if stage_id.strip_edges().is_empty() or _id_in_use(stage_id, world, node):
|
||||
node.set("stage_object_id", _new_id(world, node))
|
||||
var current_name: String = get_name(node).strip_edges()
|
||||
if current_name.is_empty() or _name_in_use(current_name, world, node):
|
||||
var naming_base: String = current_name if not current_name.is_empty() else base_name
|
||||
node.set("stage_name", next_free_name(naming_base, world, node))
|
||||
elif current_name != get_name(node):
|
||||
node.set("stage_name", current_name)
|
||||
|
||||
|
||||
func next_free_name(base_name: String, world: Node2D, exclude: Node2D = null) -> String:
|
||||
var base: String = _base_name(base_name, world, exclude)
|
||||
if not _name_in_use(base, world, exclude):
|
||||
return base
|
||||
var suffix: int = 2
|
||||
while _name_in_use("%s %d" % [base, suffix], world, exclude):
|
||||
suffix += 1
|
||||
return "%s %d" % [base, suffix]
|
||||
|
||||
|
||||
func validate_name(requested: String, world: Node2D, exclude: Node2D = null) -> Dictionary:
|
||||
var trimmed: String = requested.strip_edges()
|
||||
if trimmed.is_empty():
|
||||
return {
|
||||
"ok": false,
|
||||
"name": trimmed,
|
||||
"suggestion": next_free_name(get_name(exclude), world, exclude),
|
||||
"reason": &"blank",
|
||||
}
|
||||
if _name_in_use(trimmed, world, exclude):
|
||||
return {
|
||||
"ok": false,
|
||||
"name": trimmed,
|
||||
"suggestion": next_free_name(trimmed, world, exclude),
|
||||
"reason": &"duplicate",
|
||||
}
|
||||
return {"ok": true, "name": trimmed, "suggestion": "", "reason": &""}
|
||||
|
||||
|
||||
func rename(node: Node2D, requested: String, world: Node2D) -> Dictionary:
|
||||
if not _is_named_object(node):
|
||||
return {"ok": false, "name": "", "suggestion": "", "reason": &"invalid_object"}
|
||||
var result: Dictionary = validate_name(requested, world, node)
|
||||
if bool(result.get("ok", false)):
|
||||
node.set("stage_name", String(result["name"]))
|
||||
return result
|
||||
|
||||
|
||||
static func asset_base_name(object_type: String, asset_path: String) -> String:
|
||||
var fallback: String = asset_path.get_file().get_basename().strip_edges()
|
||||
if object_type == "stickman":
|
||||
var data: Dictionary = STICKMAN_FACTORY.load_stk(asset_path)
|
||||
var name: String = String(data.get("stickman_name", "")).strip_edges()
|
||||
return name if not name.is_empty() else fallback if not fallback.is_empty() else "Character"
|
||||
if object_type == "prop":
|
||||
var entry: Dictionary = PROP_LIBRARY.get_entry(asset_path)
|
||||
var prop_name: String = String(entry.get("name", entry.get("label", ""))).strip_edges()
|
||||
return prop_name if not prop_name.is_empty() else fallback if not fallback.is_empty() else "Prop"
|
||||
return "Character" if object_type == "stickman" else "Prop"
|
||||
|
||||
|
||||
func _base_name(requested: String, world: Node2D, node: Node2D) -> String:
|
||||
var base: String = requested.strip_edges()
|
||||
if base.is_empty():
|
||||
base = "Character" if _is_character(node) else "Prop"
|
||||
var suffix_start: int = base.rfind(" ")
|
||||
if suffix_start > 0:
|
||||
var suffix: String = base.substr(suffix_start + 1)
|
||||
var unsuffixed: String = base.substr(0, suffix_start).strip_edges()
|
||||
if suffix.is_valid_int() and int(suffix) >= 2 and _name_in_use(base, world, node) and _name_in_use(unsuffixed, world, node):
|
||||
base = unsuffixed
|
||||
return base if not base.is_empty() else ("Character" if _is_character(node) else "Prop")
|
||||
|
||||
|
||||
func _new_id(world: Node2D, exclude: Node2D) -> String:
|
||||
while true:
|
||||
_id_serial += 1
|
||||
var candidate: String = "stage_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _id_serial]
|
||||
if not _id_in_use(candidate, world, exclude):
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
func _id_in_use(stage_id: String, world: Node2D, exclude: Node2D) -> bool:
|
||||
for child: Node in world.get_children():
|
||||
var node: Node2D = child as Node2D
|
||||
if node == null or node == exclude or not _is_named_object(node):
|
||||
continue
|
||||
if get_id(node) == stage_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _name_in_use(stage_name: String, world: Node2D, exclude: Node2D) -> bool:
|
||||
var key: String = stage_name.strip_edges().to_lower()
|
||||
if key.is_empty():
|
||||
return false
|
||||
for child: Node in world.get_children():
|
||||
var node: Node2D = child as Node2D
|
||||
if node == null or node == exclude or not _is_named_object(node):
|
||||
continue
|
||||
if get_name(node).strip_edges().to_lower() == key:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _is_named_object(node: Node2D) -> bool:
|
||||
if node == null:
|
||||
return false
|
||||
return node is STICKMAN_RIG or node is PROP_BLOCK or node.get_script() == MISSING_STICKMAN or node.get_script() == MISSING_PROP
|
||||
|
||||
|
||||
func _is_character(node: Node2D) -> bool:
|
||||
if node == null:
|
||||
return false
|
||||
return node is STICKMAN_RIG or node.get_script() == MISSING_STICKMAN
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8frldld3aq26
|
||||
@@ -3,7 +3,7 @@ extends RefCounted
|
||||
## JSON boundary for Sandbox Stage documents. This class deliberately has no
|
||||
## scene-tree dependencies, which makes rejected documents non-destructive.
|
||||
|
||||
const FILE_VERSION: String = "1.7"
|
||||
const FILE_VERSION: String = "1.8"
|
||||
const DEFAULT_PLAIN_COLOR: String = "#F5EFE0"
|
||||
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
@@ -12,6 +12,7 @@ const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
||||
const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd")
|
||||
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
|
||||
const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"]
|
||||
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
|
||||
@@ -186,13 +187,14 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
prop_entry["wobble_on_impact"] = false
|
||||
_assign_legacy_depths(current.get("objects", []))
|
||||
return current
|
||||
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"]:
|
||||
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]:
|
||||
return {}
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
if version not in ["1.5", "1.6"]:
|
||||
migrated["sky_id"] = "sky_neutral"
|
||||
migrated["background_kind"] = "sky"
|
||||
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
|
||||
if version != "1.7":
|
||||
if version not in ["1.5", "1.6"]:
|
||||
migrated["sky_id"] = "sky_neutral"
|
||||
migrated["background_kind"] = "sky"
|
||||
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
|
||||
for raw: Variant in migrated.get("objects", []):
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
@@ -206,15 +208,68 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
|
||||
if not object.has("wobble_on_impact"):
|
||||
object["wobble_on_impact"] = false
|
||||
if version not in ["1.5", "1.6"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
|
||||
if version not in ["1.5", "1.6", "1.7"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
|
||||
object["finish_id"] = "finish_ground_legacy"
|
||||
if version != "1.6" and String(object.get("type", "")) == "wall_panel":
|
||||
if version not in ["1.6", "1.7"] and String(object.get("type", "")) == "wall_panel":
|
||||
_migrate_legacy_wall_tint(object)
|
||||
migrated["version"] = FILE_VERSION
|
||||
_assign_legacy_depths(migrated.get("objects", []))
|
||||
_migrate_stage_object_identity(migrated.get("objects", []))
|
||||
return migrated
|
||||
|
||||
|
||||
static func _migrate_stage_object_identity(raw_objects: Variant) -> void:
|
||||
if not raw_objects is Array:
|
||||
return
|
||||
var objects: Array = raw_objects
|
||||
var used_ids: Dictionary = {}
|
||||
var reserved_prop_ids: Dictionary = {}
|
||||
var used_names: Dictionary = {}
|
||||
for raw: Variant in objects:
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
var entry: Dictionary = raw
|
||||
if String(entry.get("type", "")) != "prop":
|
||||
continue
|
||||
var existing_prop_id: String = String(entry.get("stage_object_id", "")).strip_edges()
|
||||
if not existing_prop_id.is_empty():
|
||||
reserved_prop_ids[existing_prop_id] = true
|
||||
for raw: Variant in objects:
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
var entry: Dictionary = raw
|
||||
var object_type: String = String(entry.get("type", ""))
|
||||
if object_type not in ["stickman", "prop"]:
|
||||
continue
|
||||
var file_id: String = String(entry.get("file_id", ""))
|
||||
var stage_id: String = ""
|
||||
if object_type == "prop":
|
||||
stage_id = String(entry.get("stage_object_id", ""))
|
||||
if stage_id.strip_edges().is_empty():
|
||||
stage_id = "prop:%s" % (file_id if not file_id.is_empty() else str(used_ids.size() + 1))
|
||||
else:
|
||||
stage_id = "stickman:%s" % (file_id if not file_id.is_empty() else str(used_ids.size() + 1))
|
||||
if object_type == "stickman" or String(entry.get("stage_object_id", "")).strip_edges().is_empty():
|
||||
var id_base: String = stage_id
|
||||
var id_suffix: int = 2
|
||||
while used_ids.has(stage_id) or reserved_prop_ids.has(stage_id):
|
||||
stage_id = "%s:%d" % [id_base, id_suffix]
|
||||
id_suffix += 1
|
||||
used_ids[stage_id] = true
|
||||
entry["stage_object_id"] = stage_id
|
||||
var asset_path: String = String(entry.get("stk_path", "")) if object_type == "stickman" else String(entry.get("asset_id", ""))
|
||||
var base_name: String = STAGE_OBJECT_IDENTITY.asset_base_name(object_type, asset_path).strip_edges()
|
||||
if base_name.is_empty():
|
||||
base_name = "Character" if object_type == "stickman" else "Prop"
|
||||
var stage_name: String = base_name
|
||||
var name_suffix: int = 2
|
||||
while used_names.has(stage_name.to_lower()):
|
||||
stage_name = "%s %d" % [base_name, name_suffix]
|
||||
name_suffix += 1
|
||||
used_names[stage_name.to_lower()] = true
|
||||
entry["stage_name"] = stage_name
|
||||
|
||||
|
||||
static func _migrate_legacy_wall_tint(entry: Dictionary) -> void:
|
||||
var finish: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
|
||||
if finish not in [&"finish_wall_brick", &"finish_wall_wood"]:
|
||||
@@ -268,22 +323,35 @@ static func _validate_root(data: Dictionary) -> Dictionary:
|
||||
return {"ok": false, "error": "Scene Plain Background color is invalid."}
|
||||
var seen: Dictionary = {}
|
||||
var seen_stage_ids: Dictionary = {}
|
||||
var seen_stage_names: Dictionary = {}
|
||||
for raw: Variant in data["objects"]:
|
||||
if not raw is Dictionary: continue
|
||||
var entry: Dictionary = raw
|
||||
var file_id := String(entry.get("file_id", ""))
|
||||
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
|
||||
seen[file_id] = true
|
||||
if String(entry.get("type", "")) == "prop":
|
||||
var object_type: String = String(entry.get("type", ""))
|
||||
if object_type in ["stickman", "prop"]:
|
||||
var raw_stage_id: Variant = entry.get("stage_object_id", null)
|
||||
if not raw_stage_id is String:
|
||||
return {"ok": false, "error": "Stage object IDs must be unique and non-empty strings."}
|
||||
var stage_id: String = String(raw_stage_id)
|
||||
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
|
||||
return {"ok": false, "error": "Stage object IDs must be unique and non-empty."}
|
||||
seen_stage_ids[stage_id] = true
|
||||
var raw_stage_name: Variant = entry.get("stage_name", null)
|
||||
if not raw_stage_name is String:
|
||||
return {"ok": false, "error": "Stage object names must be trimmed, non-empty strings."}
|
||||
var stage_name: String = String(raw_stage_name)
|
||||
if stage_name.is_empty() or stage_name != stage_name.strip_edges():
|
||||
return {"ok": false, "error": "Stage object names must be trimmed and non-empty."}
|
||||
var name_key: String = stage_name.to_lower()
|
||||
if seen_stage_names.has(name_key):
|
||||
return {"ok": false, "error": "Stage object names must be unique, ignoring capitalization."}
|
||||
seen_stage_names[name_key] = true
|
||||
if object_type == "prop":
|
||||
if entry.has("wobble_on_impact") and not entry["wobble_on_impact"] is bool:
|
||||
return {"ok": false, "error": "Prop wobble_on_impact must be true or false."}
|
||||
var raw_stage_id: Variant = entry.get("stage_object_id", "")
|
||||
if not raw_stage_id is String:
|
||||
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
|
||||
var stage_id := String(raw_stage_id)
|
||||
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
|
||||
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
|
||||
seen_stage_ids[stage_id] = true
|
||||
var use_spot_error: String = PROP_USE_SPOT_DATA.validate_saved(entry.get("use_spots", []), entry.get("next_use_spot_number", 1))
|
||||
if not use_spot_error.is_empty():
|
||||
return {"ok": false, "error": use_spot_error}
|
||||
|
||||
@@ -22,8 +22,13 @@ const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||
const STAGE_BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
|
||||
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||
const MISSING_PROP := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
const MISSING_STICKMAN := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
|
||||
|
||||
enum SelectionScope { ACTION, SCENERY }
|
||||
const VISIBLE_HIT_TOLERANCE_PX: float = 7.0
|
||||
|
||||
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
|
||||
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
|
||||
@@ -120,10 +125,13 @@ func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
|
||||
var node := child as Node2D
|
||||
if node == null or not _is_selectable(node):
|
||||
continue
|
||||
if get_world_aabb(node).has_point(world_pos):
|
||||
var broad_bounds: Rect2 = get_world_aabb(node)
|
||||
if _is_named_visual_object(node):
|
||||
broad_bounds = broad_bounds.grow((VISIBLE_HIT_TOLERANCE_PX + 24.0) / maxf(_camera.zoom.x if _camera != null else 1.0, 0.0001))
|
||||
if broad_bounds.has_point(world_pos) and _visible_hit(node, world_pos, VISIBLE_HIT_TOLERANCE_PX):
|
||||
hits.append(node)
|
||||
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos) if _scope == SelectionScope.ACTION else null
|
||||
if ragdoll_hit != null and not hits.has(ragdoll_hit):
|
||||
if ragdoll_hit != null and not hits.has(ragdoll_hit) and _visible_hit(ragdoll_hit, world_pos, VISIBLE_HIT_TOLERANCE_PX):
|
||||
hits.append(ragdoll_hit)
|
||||
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
|
||||
if a.z_index != b.z_index:
|
||||
@@ -241,6 +249,78 @@ func _is_selectable(node: Node) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func _is_named_visual_object(node: Node2D) -> bool:
|
||||
return node is PROP_BLOCK or node is STICKMAN_RIG or node.get_script() == MISSING_PROP or node.get_script() == MISSING_STICKMAN
|
||||
|
||||
|
||||
func _visible_hit(node: Node2D, world_pos: Vector2, screen_tolerance_px: float) -> bool:
|
||||
if node == null or not is_instance_valid(node) or not node.is_visible_in_tree():
|
||||
return false
|
||||
if node is PROP_BLOCK or node is STICKMAN_RIG or node.get_script() == MISSING_PROP or node.get_script() == MISSING_STICKMAN:
|
||||
var inherited_opacity: float = _ancestor_opacity(node.get_parent())
|
||||
if inherited_opacity <= 0.01 or _node_opacity(node) <= 0.01:
|
||||
return false
|
||||
if node.has_method("get_area_rect"):
|
||||
return (node.global_transform * (node.call("get_area_rect") as Rect2)).has_point(world_pos)
|
||||
var tolerance_world: float = maxf(screen_tolerance_px, 0.0) / maxf(_camera.zoom.x if _camera != null else 1.0, 0.0001)
|
||||
return _visible_geometry_hit(node, world_pos, tolerance_world, inherited_opacity)
|
||||
return true
|
||||
|
||||
|
||||
func _visible_geometry_hit(root: Node, world_pos: Vector2, tolerance_world: float, inherited_opacity: float) -> bool:
|
||||
var opacity: float = inherited_opacity
|
||||
if root is CanvasItem:
|
||||
var canvas_item: CanvasItem = root as CanvasItem
|
||||
if not canvas_item.is_visible_in_tree():
|
||||
return false
|
||||
opacity *= _node_opacity(canvas_item)
|
||||
if opacity <= 0.01:
|
||||
return false
|
||||
if root is Polygon2D:
|
||||
var polygon: Polygon2D = root as Polygon2D
|
||||
if polygon.color.a * opacity > 0.01 and polygon.polygon.size() >= 3:
|
||||
var local_point: Vector2 = polygon.to_local(world_pos)
|
||||
if Geometry2D.is_point_in_polygon(local_point, polygon.polygon):
|
||||
return true
|
||||
elif root is Line2D:
|
||||
var line: Line2D = root as Line2D
|
||||
if line.default_color.a * opacity > 0.01 and line.points.size() >= 2 and _point_near_line(line, world_pos, tolerance_world):
|
||||
return true
|
||||
for child: Node in root.get_children():
|
||||
if _visible_geometry_hit(child, world_pos, tolerance_world, opacity):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _ancestor_opacity(start: Node) -> float:
|
||||
var opacity: float = 1.0
|
||||
var current: Node = start
|
||||
while current != null:
|
||||
if current is CanvasItem:
|
||||
opacity *= _node_opacity(current as CanvasItem)
|
||||
current = current.get_parent()
|
||||
return opacity
|
||||
|
||||
|
||||
func _node_opacity(item: CanvasItem) -> float:
|
||||
return clampf(item.modulate.a * item.self_modulate.a, 0.0, 1.0)
|
||||
|
||||
|
||||
func _point_near_line(line: Line2D, world_pos: Vector2, tolerance_world: float) -> bool:
|
||||
var points: PackedVector2Array = line.points
|
||||
var axis_x_scale: float = line.global_transform.basis_xform(Vector2.RIGHT).length()
|
||||
var axis_y_scale: float = line.global_transform.basis_xform(Vector2.DOWN).length()
|
||||
var half_width: float = line.width * maxf(axis_x_scale, axis_y_scale) * 0.5
|
||||
var threshold_sq: float = (half_width + tolerance_world) * (half_width + tolerance_world)
|
||||
var segment_count: int = points.size() - 1 + (1 if line.closed else 0)
|
||||
for segment_index: int in segment_count:
|
||||
var start: Vector2 = line.to_global(points[segment_index % points.size()])
|
||||
var end: Vector2 = line.to_global(points[(segment_index + 1) % points.size()])
|
||||
if Geometry2D.get_closest_point_to_segment(world_pos, start, end).distance_squared_to(world_pos) <= threshold_sq:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
|
||||
if _world == null or not is_instance_valid(_world):
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user