feat: add Phase 20a stage identities and overlap selection

This commit is contained in:
2026-10-26 15:10:28 -04:00
parent 0cc882ef4b
commit 35eb0e2503
74 changed files with 3854 additions and 217 deletions
+62
View File
@@ -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
View File
@@ -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:
+146
View File
@@ -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
+84 -16
View File
@@ -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}
+82 -2
View File
@@ -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