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
@@ -3,12 +3,16 @@ extends Node2D
var original_entry: Dictionary = {}
var missing_stk_path: String = ""
var stage_object_id: String = ""
var stage_name: String = ""
const MARKER_SIZE := Vector2(260.0, 52.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_stk_path = String(entry.get("stk_path", ""))
stage_object_id = String(entry.get("stage_object_id", ""))
stage_name = String(entry.get("stage_name", ""))
queue_redraw()
func get_area_rect() -> Rect2:
@@ -226,6 +226,10 @@ var action_queue: Array[Dictionary]:
## Original .stk asset path, assigned by StageSpawner for scene persistence.
var source_stk_path: String = ""
## Persistent per-stage-instance identity and display name. Reusable .stk
## assets never serialize these fields.
var stage_object_id: String = ""
var stage_name: String = ""
## Authored nonvisual subsystem children. They establish explicit ownership
## boundaries while this facade preserves the long-standing public API.
+426 -63
View File
@@ -34,6 +34,8 @@ const STAGE_SPATIAL_INDEX := preload("res://scripts/stage/stage_spatial_index.gd
const STAGE_TERRAIN_PAINTER := preload("res://scripts/editors/stage/stage_terrain_painter.gd")
const STAGE_CHROME_SCRIPT := preload("res://scripts/editors/stage/stage_chrome.gd")
const STAGE_PROP_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_prop_settings_view.gd")
const STAGE_CHARACTER_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/stage/stage_character_settings_view.gd")
const STAGE_OBJECT_IDENTITY_SCRIPT := preload("res://scripts/stage/stage_object_identity.gd")
const STAGE_USE_SPOT_OVERLAY_SCRIPT := preload("res://scripts/editors/stage/stage_use_spot_overlay.gd")
const STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT := preload("res://scripts/editors/stage/stage_use_target_capture_bar.gd")
const STAGE_SCENE_ADAPTER_SCRIPT := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
@@ -82,8 +84,6 @@ const RULE_EDITOR := preload("res://scenes/editors/stage/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/stage/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -257,6 +257,7 @@ var _suppressed_contact_pairs: Dictionary:
return {} if _rule_engine == null else _rule_engine.get_suppressed_contact_pairs()
var _spawner: StageSpawner
var _object_identity: StageObjectIdentity
var _selection: StageSelection
var _gizmos: StageGizmos
var _stage_input_ready: bool = false
@@ -344,12 +345,25 @@ var _asset_browser: StageAssetBrowser
var _modal_views: StageModalViews
var _chrome: StageChrome
var _prop_settings_view: StagePropSettingsView
var _character_settings_view: StageCharacterSettingsView
var _placement_name_prompt: Label
var _placement_name_prompt_panel: PanelContainer
var _objects_here_candidates: Array[Node2D] = []
var _objects_here_context: StringName = &""
var _objects_here_hit_position: Vector2 = Vector2.ZERO
var _placement_feedback_label: Label
var _placement_feedback_panel: PanelContainer
var _placement_feedback_node: Node2D
var _placement_feedback_timer: float = 0.0
var _last_pointer_viewport_position: Vector2 = Vector2.ZERO
var _edit_wobble_preview_prop: PropBlock
var _use_spot_overlay: StageUseSpotOverlay
var _use_target_capture_bar: StageUseTargetCaptureBar
var _placement_overlay = null # StagePlacementOverlay (preloaded)
var _placement_id: String = ""
var _placement_name_cache_key: String = ""
var _placement_name_cache_value: String = ""
var _stickman_library: StickmanLibrary:
get:
@@ -403,9 +417,6 @@ var _palette_dock: PanelContainer:
var _prop_settings_card: Control:
get:
return null if _prop_settings_view == null else _prop_settings_view.get_card()
var _prop_settings_reopen_button: Button:
get:
return null if _prop_settings_view == null else _prop_settings_view.get_reopen_button()
var _prop_settings_fields: VBoxContainer:
get:
return null if _prop_settings_view == null else _prop_settings_view.get_fields()
@@ -421,12 +432,6 @@ var _prop_settings_motion_group: Control:
var _prop_settings_controls: Dictionary:
get:
return {} if _prop_settings_view == null else _prop_settings_view.get_controls()
var _prop_settings_active_key: String:
get:
return "" if _prop_settings_view == null else _prop_settings_view.get_active_selection_key()
var _prop_settings_dismissed_key: String:
get:
return "" if _prop_settings_view == null else _prop_settings_view.get_dismissed_selection_key()
var _objects_here_popup: PopupPanel:
get:
return null if _modal_views == null else _modal_views.get_objects_here_popup()
@@ -539,6 +544,11 @@ var _pan_last: Vector2 = Vector2.ZERO
var _space_key_down: bool = false
var _space_press_owned: bool = false
var _pending_overlap_candidates: Array[Node2D] = []
var _pending_overlap_drag_target: Node2D = null
var _pending_overlap_world_position: Vector2 = Vector2.ZERO
var _pending_overlap_screen_position: Vector2 = Vector2.ZERO
var _box_selecting: bool = false
var _box_start: Vector2 = Vector2.ZERO
var _box_rect: Rect2 = Rect2()
@@ -548,6 +558,8 @@ var _ghost_holder: Node2D = null
## Phase 4b terrain drag-painting state.
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
## An overlap press becomes a selected-object drag after this screen-space move.
const OVERLAP_SELECT_DRAG_THRESHOLD_PX: float = 8.0
## Compatibility views retained for existing stage tools and tests.
var _terrain_dragging: bool:
@@ -643,7 +655,6 @@ var _direct_move_target: Vector2:
var _direct_move_last_style: Dictionary:
get:
return {} if _director_controller == null else _director_controller.get_direct_move_last_style()
var _next_stage_prop_serial: int = 0
var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null
var _speak_stop_current_check: CheckBox = null
@@ -1327,6 +1338,7 @@ func _ready() -> void:
_load_settings()
_spawner = STAGE_SPAWNER.new(_world)
_object_identity = STAGE_OBJECT_IDENTITY_SCRIPT.new() as StageObjectIdentity
_spatial_index = STAGE_SPATIAL_INDEX.new()
_asset_browser = STAGE_ASSET_BROWSER_SCRIPT.new() as StageAssetBrowser
add_child(_asset_browser)
@@ -1379,6 +1391,7 @@ func _ready() -> void:
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())
_check_speech_recovery()
_last_pointer_viewport_position = get_viewport().get_mouse_position()
_stage_input_ready = true
@@ -1400,6 +1413,7 @@ func _process(delta: float) -> void:
_use_spot_overlay.show_edit(_spot_edit_prop, _spot_edit_ghost_world, "Click a place on this prop" if _spot_edit_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id)
elif _ghost != null and is_instance_valid(_ghost):
_update_ghost_position()
_update_placement_feedback(delta)
_update_cursor_coords()
_update_action_overlay()
_sync_awaiting_cursor()
@@ -1453,6 +1467,28 @@ func _update_throw_preview_cache() -> void:
func _input(event: InputEvent) -> void:
if _hosted_prop_editor != null or not _stage_input_ready:
return
if event is InputEventMouseMotion:
_last_pointer_viewport_position = (event as InputEventMouseMotion).position
elif event is InputEventMouseButton:
_last_pointer_viewport_position = (event as InputEventMouseButton).position
if _objects_here_popup != null and is_instance_valid(_objects_here_popup) and _objects_here_popup.visible:
if event is InputEventMouseButton:
var chooser_click: InputEventMouseButton = event as InputEventMouseButton
var popup_rect: Rect2 = Rect2(Vector2(_objects_here_popup.position), Vector2(_objects_here_popup.size))
if chooser_click.pressed and not popup_rect.has_point(chooser_click.position):
_modal_views.dismiss_objects_here()
get_viewport().set_input_as_handled()
return
elif event is InputEventKey:
var chooser_key: InputEventKey = event as InputEventKey
if chooser_key.pressed and not chooser_key.echo and chooser_key.keycode == KEY_ESCAPE:
_modal_views.dismiss_objects_here()
get_viewport().set_input_as_handled()
return
if event is InputEventKey and (event as InputEventKey).pressed and not (event as InputEventKey).echo and (event as InputEventKey).keycode == KEY_ESCAPE and not _pending_overlap_candidates.is_empty():
_clear_pending_overlap_gesture()
get_viewport().set_input_as_handled()
return
if _speech_session != null:
_speech_session.set_camera_panning(_panning)
if _speech_session.handle_input(event):
@@ -1651,6 +1687,8 @@ func _unhandled_key_input(event: InputEvent) -> void:
_action_editor.hide()
elif _rule_editor != null and _rule_editor.visible:
_rule_editor.hide()
else:
set_mode(StageMode.EDIT)
elif _placement_id != "":
set_placement_mode("")
elif not _selection.get_selected().is_empty():
@@ -1827,8 +1865,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
else:
_place_at(world_pos)
else:
_begin_click_select(world_pos, mb.shift_pressed)
_begin_click_select(world_pos, mb.shift_pressed, mb.position)
else:
if _resolve_pending_overlap_click():
return
if _gizmos.is_dragging():
_gizmos.end_drag()
elif _box_selecting:
@@ -1851,7 +1891,9 @@ func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _scenery_edit_mode:
_handle_scenery_edit_motion(world_pos)
return
if _gizmos.is_dragging():
if _try_begin_pending_overlap_drag(mm.position, world_pos):
_director_visuals.mark_dirty()
elif _gizmos.is_dragging():
_gizmos.drag_to(world_pos)
_director_visuals.mark_dirty()
elif _terrain_dragging:
@@ -1883,6 +1925,7 @@ func set_mode(mode: StageMode) -> void:
return
if _plain_color_draft_active:
_close_selector()
_clear_pending_overlap_gesture()
_cancel_surface_color_preview()
if _scenery_controller != null:
if _scenery_controller.is_group_preview_pending():
@@ -1891,6 +1934,7 @@ func set_mode(mode: StageMode) -> void:
_scenery_controller.exit_tool()
if _scenery_edit_mode:
_exit_scenery_edit_mode()
_dismiss_active_objects_here()
_hide_scenery_capture_bar()
var previous_mode: StageMode = current_mode
_cancel_edit_wobble_preview()
@@ -1914,6 +1958,7 @@ func set_mode(mode: StageMode) -> void:
_sync_run_controls()
_apply_cursor()
_refresh_prop_settings_card()
_refresh_character_settings_card()
mode_changed.emit(int(mode))
_refresh_status()
@@ -2164,6 +2209,10 @@ func _place_at(world_pos: Vector2) -> void:
if node == null:
return
_finalize_placed_node(node)
var placed_name: String = _object_identity.get_name(node)
if not placed_name.is_empty():
_show_toast("%s placed" % placed_name)
_show_placement_feedback(node, placed_name)
if node is TerrainBlock:
_nav_dirty = true
_rebuild_grid_cells()
@@ -2176,10 +2225,12 @@ func _place_at(world_pos: Vector2) -> void:
## Common post-spawn wiring: freeze kinematic props in EDIT, save authored state,
## and connect the Phase 3a/4 signals for content/stickmen/props.
func _finalize_placed_node(node: Node2D) -> void:
if node is PropBlock:
_object_identity.assign_new(node, StageObjectIdentity.asset_base_name("prop", String((node as PropBlock).spawn_id)), _world)
elif node is STICKMAN_RIG:
_object_identity.assign_new(node, StageObjectIdentity.asset_base_name("stickman", String((node as StickmanRig).source_stk_path)), _world)
if node is PropBlock:
var placed_prop := node as PropBlock
if placed_prop.stage_object_id.is_empty():
placed_prop.stage_object_id = _new_stage_prop_id()
placed_prop.ensure_default_grip()
if placed_prop.stage_roles.is_empty():
placed_prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(placed_prop.has_usable_grip()))
@@ -2216,24 +2267,6 @@ func _finalize_placed_node(node: Node2D) -> void:
prop.separated.connect(_on_prop_separated.bind(prop))
func _new_stage_prop_id() -> String:
while true:
_next_stage_prop_serial += 1
var candidate := "prop_%d_%d_%d" % [Time.get_ticks_usec(), randi(), _next_stage_prop_serial]
if not _stage_prop_id_in_use(candidate):
return candidate
return ""
func _stage_prop_id_in_use(stage_id: String) -> bool:
for child: Node in _world.get_children():
if child is PropBlock and (child as PropBlock).stage_object_id == stage_id:
return true
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == stage_id:
return true
return false
func _resolve_prop_by_stage_id(stage_id: String) -> PropBlock:
if stage_id.is_empty():
return null
@@ -2293,6 +2326,7 @@ func _use_failure_message(reason_code: String) -> String:
func _spawn_ghost() -> void:
_free_ghost()
if _placement_id == "":
_update_placement_name_prompt(Vector2.ZERO)
return
var ghost := _spawner.spawn(_placement_id, Vector2.ZERO)
if ghost == null:
@@ -2337,17 +2371,116 @@ func _free_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.queue_free()
_ghost = null
if _placement_id.is_empty() or not _is_named_placement():
_update_placement_name_prompt(Vector2.ZERO)
func _update_ghost_position() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
var canvas_transform: Transform2D = get_viewport().get_canvas_transform()
var pointer_world_position: Vector2 = canvas_transform.affine_inverse() * _last_pointer_viewport_position
var pos: Vector2 = pointer_world_position + _spawner.get_spawn_offset(_placement_id)
if _is_terrain_placement():
pos = _snap_to_grid(pos) if _snap_enabled else pos
elif _snap_enabled:
pos = _snap_to_grid(pos)
_ghost.position = pos
_update_placement_name_prompt(_last_pointer_viewport_position)
func _is_named_placement() -> bool:
return _placement_id == "prop" or _placement_id == "stickman"
func _placement_base_name() -> String:
var asset_key: String = ""
if _placement_id == "prop":
asset_key = "prop:" + _spawner.get_selected_prop_id()
elif _placement_id == "stickman":
asset_key = "stickman:" + _spawner.get_selected_stickman_path()
if asset_key.is_empty():
_placement_name_cache_key = ""
_placement_name_cache_value = ""
return ""
if asset_key == _placement_name_cache_key:
return _placement_name_cache_value
_placement_name_cache_key = asset_key
var object_type: String = "prop" if _placement_id == "prop" else "stickman"
var asset_path: String = _spawner.get_selected_prop_id() if object_type == "prop" else _spawner.get_selected_stickman_path()
_placement_name_cache_value = StageObjectIdentity.asset_base_name(object_type, asset_path)
return _placement_name_cache_value
func _update_placement_name_prompt(screen_position: Vector2) -> void:
if _placement_name_prompt == null or _placement_name_prompt_panel == null:
return
if current_mode != StageMode.EDIT or _ghost == null or not is_instance_valid(_ghost) or not _is_named_placement():
_placement_name_prompt_panel.visible = false
return
var next_name: String = _object_identity.next_free_name(_placement_base_name(), _world, _ghost)
_placement_name_prompt.text = "Click to place %s" % next_name
_placement_name_prompt_panel.size = _placement_name_prompt_panel.get_combined_minimum_size()
var viewport: Rect2 = get_viewport_rect()
var position: Vector2 = screen_position + Vector2(16.0, 18.0)
position.x = clampf(position.x, 8.0, maxf(8.0, viewport.size.x - _placement_name_prompt_panel.size.x - 8.0))
position.y = clampf(position.y, 8.0, maxf(8.0, viewport.size.y - _placement_name_prompt_panel.size.y - 8.0))
_placement_name_prompt_panel.position = position
_placement_name_prompt_panel.visible = true
func _show_placement_feedback(node: Node2D, stage_name: String) -> void:
if _placement_feedback_label == null or node == null:
return
_placement_feedback_node = node
_placement_feedback_label.text = "%s placed" % stage_name
_placement_feedback_timer = 1.6
_placement_feedback_panel.visible = true
_position_placement_feedback()
func _update_placement_feedback(delta: float) -> void:
if _placement_feedback_timer <= 0.0:
return
_placement_feedback_timer = maxf(0.0, _placement_feedback_timer - delta)
if _placement_feedback_node == null or not is_instance_valid(_placement_feedback_node) or _placement_feedback_node.is_queued_for_deletion():
_placement_feedback_timer = 0.0
if _placement_feedback_timer <= 0.0:
_placement_feedback_panel.visible = false
_placement_feedback_node = null
else:
_position_placement_feedback()
func _position_placement_feedback() -> void:
if _placement_feedback_label == null or _placement_feedback_node == null or not is_instance_valid(_placement_feedback_node):
return
var viewport: Rect2 = get_viewport_rect()
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(_placement_feedback_node)
var screen_bounds: Rect2 = _world_rect_to_viewport_rect(bounds)
_placement_feedback_panel.size = _placement_feedback_panel.get_combined_minimum_size()
var position: Vector2 = Vector2(
screen_bounds.get_center().x - _placement_feedback_panel.size.x * 0.5,
screen_bounds.position.y - _placement_feedback_panel.size.y - 12.0
)
position.x = clampf(position.x, 8.0, maxf(8.0, viewport.size.x - _placement_feedback_panel.size.x - 8.0))
position.y = clampf(position.y, 8.0, maxf(8.0, viewport.size.y - _placement_feedback_panel.size.y - 8.0))
_placement_feedback_panel.position = position
func _world_rect_to_viewport_rect(world_rect: Rect2) -> Rect2:
var transform: Transform2D = get_viewport().get_canvas_transform()
var corners: 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 first_point: Vector2 = transform * corners[0]
var screen_rect: Rect2 = Rect2(first_point, Vector2.ZERO)
for corner_index: int in range(1, corners.size()):
screen_rect = screen_rect.expand(transform * corners[corner_index])
return screen_rect
# ---------------------------------------------------------------------------
# Terrain drag-painting (Phase 4b)
@@ -2469,7 +2602,8 @@ func _add_node_to_grid_cells(node: Node2D) -> void:
# Selection
# ---------------------------------------------------------------------------
func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
func _begin_click_select(world_pos: Vector2, shift: bool, screen_pos: Vector2) -> void:
_clear_pending_overlap_gesture()
var candidates: Array[Node2D] = _selection.hit_test_all(world_pos)
var hit: Node2D = candidates[0] if not candidates.is_empty() else null
if hit != null:
@@ -2478,8 +2612,10 @@ func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
if _selection.is_selected(hit):
_gizmos.begin_translate_drag(hit, world_pos)
elif candidates.size() > 1:
_selection.select_only(hit)
_show_objects_here_popup(candidates)
_pending_overlap_candidates = candidates.duplicate()
_pending_overlap_drag_target = _selected_overlap_drag_target(candidates)
_pending_overlap_world_position = world_pos
_pending_overlap_screen_position = screen_pos
elif _selection.is_selected(hit):
# Already selected: keep the whole selection and drag it together.
_gizmos.begin_translate_drag(hit, world_pos)
@@ -2493,6 +2629,58 @@ func _begin_click_select(world_pos: Vector2, shift: bool) -> void:
_gizmos.set_box_rect(_box_rect)
func _selected_overlap_drag_target(candidates: Array[Node2D]) -> Node2D:
if _selection == null:
return null
var primary: Node2D = _selection.get_primary()
if primary != null and candidates.has(primary):
return primary
for selected: Node2D in _selection.get_selected():
if candidates.has(selected):
return selected
return null
func _try_begin_pending_overlap_drag(screen_pos: Vector2, world_pos: Vector2) -> bool:
if _pending_overlap_candidates.is_empty() or _pending_overlap_drag_target == null:
return false
if not is_instance_valid(_pending_overlap_drag_target) or _pending_overlap_drag_target.is_queued_for_deletion():
_pending_overlap_drag_target = null
return false
if not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
return false
if screen_pos.distance_to(_pending_overlap_screen_position) < OVERLAP_SELECT_DRAG_THRESHOLD_PX:
return false
var drag_target: Node2D = _pending_overlap_drag_target
var drag_origin: Vector2 = _pending_overlap_world_position
_clear_pending_overlap_gesture()
_gizmos.begin_translate_drag(drag_target, drag_origin)
_gizmos.drag_to(world_pos)
return true
func _resolve_pending_overlap_click() -> bool:
if _pending_overlap_candidates.is_empty():
return false
var world_position: Vector2 = _pending_overlap_world_position
var candidates: Array[Node2D] = _selection.hit_test_all(world_position)
_clear_pending_overlap_gesture()
if candidates.size() > 1:
_show_objects_here_popup(candidates, &"edit_select", world_position)
elif candidates.size() == 1:
var hit: Node2D = candidates[0]
if not _selection.is_selected(hit):
_selection.select_only(hit)
return true
func _clear_pending_overlap_gesture() -> void:
_pending_overlap_candidates.clear()
_pending_overlap_drag_target = null
_pending_overlap_world_position = Vector2.ZERO
_pending_overlap_screen_position = Vector2.ZERO
func _finish_box_select(shift: bool) -> void:
_box_selecting = false
_gizmos.set_box_rect(Rect2())
@@ -3040,7 +3228,8 @@ func _refresh_status() -> void:
if sel.is_empty():
sel_text = "none"
elif sel.size() == 1:
sel_text = String(sel[0].name)
var selected_stage_name: String = "" if _object_identity == null else _object_identity.get_name(sel[0])
sel_text = selected_stage_name if not selected_stage_name.is_empty() else "Stage object"
else:
sel_text = "%d objects" % sel.size()
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
@@ -3195,7 +3384,11 @@ func _build_ui() -> void:
_modal_views = STAGE_MODAL_VIEWS_SCRIPT.new() as StageModalViews
_modal_views.configure(_sandbox_theme as SandboxTheme, _ui_font)
_modal_views.object_selected.connect(_on_objects_here_selected)
_modal_views.candidate_preview_changed.connect(_on_objects_here_candidate_preview_changed)
_modal_views.objects_here_dismissed.connect(_on_objects_here_dismissed)
_build_prop_settings_view(ui)
_build_character_settings_view(ui)
_build_placement_name_prompt(ui)
_build_scenery_inspector(ui)
_build_set_down_cancel_button(ui)
_build_use_target_capture_bar(ui)
@@ -3357,11 +3550,68 @@ func _build_prop_settings_view(ui: CanvasLayer) -> void:
_prop_settings_view.spot_rename_requested.connect(_on_spot_rename_requested)
_prop_settings_view.spot_erase_requested.connect(_on_spot_erase_requested)
_prop_settings_view.spot_selected.connect(_on_spot_selected)
_prop_settings_view.close_requested.connect(_on_prop_settings_close_requested)
_prop_settings_view.rename_requested.connect(_on_prop_rename_requested)
ui.add_child(_prop_settings_view)
_prop_settings_view.update_layout(_prop_settings_safe_rect())
func _build_character_settings_view(ui: CanvasLayer) -> void:
_character_settings_view = STAGE_CHARACTER_SETTINGS_VIEW_SCRIPT.new() as StageCharacterSettingsView
_character_settings_view.configure(_sandbox_theme as SandboxTheme)
_character_settings_view.rename_requested.connect(_on_character_rename_requested)
ui.add_child(_character_settings_view)
_character_settings_view.update_layout(_prop_settings_safe_rect())
func _build_placement_name_prompt(ui: CanvasLayer) -> void:
_placement_name_prompt_panel = _make_placement_feedback_panel("PlacementNamePromptPanel")
_placement_name_prompt_panel.visible = false
ui.add_child(_placement_name_prompt_panel)
_placement_name_prompt = Label.new()
_placement_name_prompt.name = "PlacementNamePrompt"
_placement_name_prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE
_placement_name_prompt.add_theme_font_size_override("font_size", 18)
_placement_name_prompt.add_theme_color_override("font_color", Color.WHITE)
_placement_name_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
if _ui_font != null:
_placement_name_prompt.add_theme_font_override("font", _ui_font)
_placement_name_prompt_panel.add_child(_placement_name_prompt)
_placement_feedback_panel = _make_placement_feedback_panel("PlacementFeedbackPanel")
_placement_feedback_panel.visible = false
ui.add_child(_placement_feedback_panel)
_placement_feedback_label = Label.new()
_placement_feedback_label.name = "PlacementFeedback"
_placement_feedback_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_placement_feedback_label.add_theme_font_size_override("font_size", 18)
_placement_feedback_label.add_theme_color_override("font_color", Color.WHITE)
_placement_feedback_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
if _ui_font != null:
_placement_feedback_label.add_theme_font_override("font", _ui_font)
_placement_feedback_panel.add_child(_placement_feedback_label)
func _make_placement_feedback_panel(panel_name: String) -> PanelContainer:
var panel: PanelContainer = PanelContainer.new()
panel.name = panel_name
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var accent: Color = Color("#22C6FF")
var surface: Color = Color("#182432F2")
if _sandbox_theme != null:
accent = _sandbox_theme.color("mode_colors.edit", accent)
surface = _sandbox_theme.color("stage_panels.shell.surface_color", surface)
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = surface
style.border_color = accent
style.set_border_width_all(2)
style.set_corner_radius_all(10)
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 6.0
style.content_margin_bottom = 6.0
panel.add_theme_stylebox_override("panel", style)
return panel
func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_inspector = STAGE_INSPECTOR_PANEL_SCRIPT.new() as StageInspectorPanel
_scenery_inspector.name = "SceneryRepairCard"
@@ -4157,7 +4407,7 @@ func _handle_use_target_click(world_position: Vector2, screen_position: Vector2)
_show_toast("Click a prop to use")
return
if prop_candidates.size() > 1:
_show_objects_here_popup(prop_candidates)
_show_objects_here_popup(prop_candidates, &"direct_use_prop", world_position)
return
_select_use_target_prop(prop_candidates[0] as PropBlock, screen_position)
return
@@ -4560,9 +4810,7 @@ func _refresh_prop_settings_card() -> void:
return
var prop: PropBlock = selected[0] as PropBlock
var roles: Dictionary = PROP_STAGE_ROLES.normalize(prop.stage_roles)
var display_name: String = prop.spawn_id if not prop.spawn_id.is_empty() else "Prop"
var entry: Dictionary = PROP_LIBRARY.get_entry(prop.spawn_id)
display_name = String(entry.get("name", entry.get("label", display_name)))
var display_name: String = prop.stage_name if not prop.stage_name.is_empty() else "Prop"
_prop_settings_view.show_prop(prop, display_name, roles, safe_rect, selected, _spot_selected_id)
if not _spot_edit_active and not _use_target_active and _use_spot_overlay != null:
_use_spot_overlay.show_edit(prop, Vector2.INF, "", _spot_selected_id)
@@ -4571,11 +4819,45 @@ func _refresh_prop_settings_card() -> void:
func _prop_settings_safe_rect() -> Rect2:
var viewport: Rect2 = get_viewport_rect()
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
var top_offset: float = minf(TOP_BAR_HEIGHT + 116.0, maxf(12.0, viewport.size.y - 220.0))
var top_offset: float = minf(TOP_BAR_HEIGHT + 120.0, maxf(12.0, viewport.size.y - 220.0))
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - STATUS_BAR_HEIGHT - 12.0)
return Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
func _refresh_character_settings_card() -> void:
if _character_settings_view == null or _selection == null:
return
var selected: Array[Node2D] = _selection.get_selected()
if current_mode != StageMode.EDIT or selected.size() != 1 or not selected[0] is STICKMAN_RIG:
_character_settings_view.hide_card(true)
return
_character_settings_view.show_character(selected[0] as StickmanRig, _prop_settings_safe_rect())
func _on_prop_rename_requested(node: Node2D, requested_name: String) -> void:
if current_mode != StageMode.EDIT or _selection == null or _selection.get_selected().size() != 1 or _selection.get_selected()[0] != node:
return
var previous_name: String = String(node.get("stage_name"))
var result: Dictionary = _object_identity.rename(node, requested_name, _world)
_prop_settings_view.show_name_result(result)
if bool(result.get("ok", false)):
if previous_name != String(result.get("name", "")):
_mark_scene_dirty()
_refresh_prop_settings_card()
func _on_character_rename_requested(node: Node2D, requested_name: String) -> void:
if current_mode != StageMode.EDIT or _selection == null or _selection.get_selected().size() != 1 or _selection.get_selected()[0] != node:
return
var previous_name: String = String(node.get("stage_name"))
var result: Dictionary = _object_identity.rename(node, requested_name, _world)
_character_settings_view.show_name_result(result)
if bool(result.get("ok", false)):
if previous_name != String(result.get("name", "")):
_mark_scene_dirty()
_refresh_character_settings_card()
func _position_cancel_button_after_prop_choice(after_choice: bool) -> void:
if _set_down_cancel_button == null:
return
@@ -4584,15 +4866,6 @@ func _position_cancel_button_after_prop_choice(after_choice: bool) -> void:
_set_down_cancel_button.offset_right = left + 132.0
func _on_prop_settings_close_requested() -> void:
if _prop_settings_view == null or _prop_settings_active_key.is_empty():
return
_prop_settings_view.set_dismissed(true)
_refresh_prop_settings_card()
if _prop_settings_reopen_button != null and _prop_settings_reopen_button.visible:
_prop_settings_reopen_button.grab_focus()
func _on_prop_settings_choice_selected(id: StringName, key: String) -> void:
var value: String = String(id)
if key == "depth":
@@ -5864,8 +6137,7 @@ func _handle_scenery_edit_click(event: InputEventMouseButton, world_position: Ve
_gizmos.set_box_rect(_box_rect)
return
if candidates.size() > 1 and not event.shift_pressed and not _scenery_multi_select:
_selection.select_only(hit)
_show_objects_here_popup(candidates)
_show_objects_here_popup(candidates, &"scenery_select", world_position)
return
if event.shift_pressed or _scenery_multi_select:
_selection.toggle_selection(hit)
@@ -5962,6 +6234,10 @@ func _on_grid_size_changed(value: float) -> void:
func _on_selection_changed(nodes: Array[Node2D]) -> void:
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_dismiss_active_objects_here()
else:
_gizmos.set_candidate_preview(null)
_cancel_edit_wobble_preview()
if _scenery_controller != null and _scenery_controller.is_single_transform_active() and not nodes.has(_scenery_controller.get_single_transform_node()):
_scenery_controller.cancel_single_transform()
@@ -5975,6 +6251,7 @@ func _on_selection_changed(nodes: Array[Node2D]) -> void:
object_selected.emit(nodes)
_refresh_status()
_refresh_prop_settings_card()
_refresh_character_settings_card()
if _scenery_edit_mode:
_refresh_scenery_inspector()
_refresh_scenery_capture_bar()
@@ -6016,20 +6293,106 @@ func _remove_prop_contact_exceptions(prop: PropBlock) -> void:
(node as PropBlock).remove_collision_exception_with(prop)
func _show_objects_here_popup(candidates: Array[Node2D]) -> void:
if _modal_views == null:
func _show_objects_here_popup(candidates: Array[Node2D], context: StringName, world_position: Vector2) -> void:
if _modal_views == null or candidates.size() < 2:
return
var ui_layer: CanvasLayer = get_node_or_null("UI") as CanvasLayer
if ui_layer == null:
return
_modal_views.show_objects_here(ui_layer, candidates, _selection.get_selected(), get_viewport_rect().size, get_viewport().get_mouse_position())
_dismiss_active_objects_here()
if _placement_feedback_panel != null:
_placement_feedback_panel.visible = false
_placement_feedback_timer = 0.0
_placement_feedback_node = null
_objects_here_candidates = candidates.duplicate()
_objects_here_context = context
_objects_here_hit_position = world_position
_gizmos.set_candidate_preview(null)
var click_screen_position: Vector2 = get_viewport().get_canvas_transform() * world_position
var avoid_rects: Array[Rect2] = []
var viewport_size: Vector2 = get_viewport_rect().size
avoid_rects.append(Rect2(Vector2.ZERO, Vector2(viewport_size.x, TOP_BAR_HEIGHT + 8.0)))
avoid_rects.append(Rect2(Vector2(0.0, viewport_size.y - STATUS_BAR_HEIGHT - 4.0), Vector2(viewport_size.x, STATUS_BAR_HEIGHT + 4.0)))
avoid_rects.append(Rect2(Vector2.ZERO, Vector2(120.0, viewport_size.y)))
if _mode_badge != null and _mode_badge.visible:
avoid_rects.append(_mode_badge.get_global_rect().grow(8.0))
if _palette_dock != null and _palette_dock.visible:
avoid_rects.append(_palette_dock.get_global_rect().grow(8.0))
if _placement_name_prompt_panel != null and _placement_name_prompt_panel.visible:
avoid_rects.append(_placement_name_prompt_panel.get_global_rect().grow(8.0))
if context == &"direct_use_prop" and _use_target_capture_bar != null and _use_target_capture_bar.visible:
avoid_rects.append(_use_target_capture_bar.get_global_rect().grow(8.0))
if _prop_settings_view != null:
var prop_card: Control = _prop_settings_view.get_card()
if prop_card != null and prop_card.visible:
avoid_rects.append(prop_card.get_global_rect().grow(8.0))
if _character_settings_view != null:
var character_card: Control = _character_settings_view.get_card()
if character_card != null and character_card.visible:
avoid_rects.append(character_card.get_global_rect().grow(8.0))
for candidate: Node2D in _objects_here_candidates:
var candidate_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(candidate)
if candidate_bounds.has_area():
avoid_rects.append(_world_rect_to_viewport_rect(candidate_bounds).grow(8.0))
_modal_views.show_objects_here(
ui_layer,
_objects_here_candidates,
_selection.get_selected().duplicate(),
get_viewport_rect().size,
click_screen_position,
avoid_rects
)
var popup_rect: Rect2 = _modal_views.get_objects_here_screen_rect()
if popup_rect.has_area():
avoid_rects.append(popup_rect.grow(8.0))
_gizmos.set_candidate_preview_obstacles(avoid_rects)
func _on_objects_here_selected(node: Node2D) -> void:
if node != null and is_instance_valid(node) and node.get_parent() == _world:
_selection.select_only(node)
if _use_target_active and _use_target_step == 0 and node is PropBlock:
call_deferred("_select_use_target_prop", node, get_viewport().get_mouse_position())
var context: StringName = _objects_here_context
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion() or node.get_parent() != _world or not _objects_here_candidates.has(node):
_modal_views.dismiss_objects_here()
return
var still_hit: bool = _selection.hit_test_all(_objects_here_hit_position).has(node)
if not still_hit:
_modal_views.dismiss_objects_here()
return
match context:
&"edit_select":
_selection.select_only(node)
_show_toast("Drag to move · Click to choose")
&"scenery_select":
_selection.select_only(node)
&"direct_use_prop":
if current_mode == StageMode.DIRECT and _use_target_active and _use_target_step == 0 and node is PropBlock:
_select_use_target_prop(node as PropBlock, get_viewport().get_mouse_position())
else:
_modal_views.dismiss_objects_here()
_:
_modal_views.dismiss_objects_here()
func _on_objects_here_candidate_preview_changed(node: Node2D) -> void:
if node != null and (not is_instance_valid(node) or not _objects_here_candidates.has(node)):
node = null
_gizmos.set_candidate_preview(node)
func _on_objects_here_dismissed() -> void:
_gizmos.set_candidate_preview(null)
_gizmos.set_candidate_preview_obstacles([])
_objects_here_candidates.clear()
_objects_here_context = &""
_objects_here_hit_position = Vector2.ZERO
func _dismiss_active_objects_here() -> void:
if _modal_views == null:
return
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_modal_views.dismiss_objects_here()
else:
_on_objects_here_dismissed()
func _select_use_target_prop(prop: PropBlock, _screen_position: Vector2) -> void:
@@ -0,0 +1,225 @@
class_name StageCharacterSettingsView
extends Control
## Presentation-only selected-character name card for the Sandbox Stage.
const STAGE_INSPECTOR_PANEL := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd")
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
signal rename_requested(node: Node2D, requested_name: String)
var _stage_theme: SandboxTheme
var _card: StageInspectorPanel
var _name_section: VBoxContainer
var _name_edit: LineEdit
var _done_button: Button
var _cancel_button: Button
var _feedback: Label
var _character: StickmanRig
var _active_selection_key: String = ""
var _editing: bool = false
func configure(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_build()
if not get_viewport().size_changed.is_connected(_on_viewport_resized):
get_viewport().size_changed.connect(_on_viewport_resized)
_on_viewport_resized()
func show_character(rig: StickmanRig, safe_rect: Rect2) -> void:
if rig == null or not is_instance_valid(rig):
hide_card()
return
var selection_key: String = str(rig.get_instance_id())
if selection_key != _active_selection_key:
_active_selection_key = selection_key
_editing = false
_feedback.visible = false
_character = rig
_name_edit.text = rig.stage_name
_name_section.visible = false
_card.set_body_visible(false)
_card.visible = true
_character = rig
_card.set_safe_rect(safe_rect)
_card.set_header(rig.stage_name, "🧍")
_card.set_rename_enabled(true)
_card.set_close_visible(false)
if not _editing:
_name_edit.text = rig.stage_name
_card.request_fit()
func show_name_result(result: Dictionary) -> void:
if bool(result.get("ok", false)):
var accepted_name: String = String(result.get("name", ""))
_name_edit.text = accepted_name
_feedback.visible = false
if _character != null and is_instance_valid(_character):
_card.set_header(accepted_name, "🧍")
_set_editing(false)
else:
var reason: StringName = StringName(String(result.get("reason", "")))
var suggestion: String = String(result.get("suggestion", ""))
if reason == &"blank":
_feedback.text = "Give this one a name. Try %s." % suggestion
else:
_feedback.text = "%s is already here. Try %s." % [String(result.get("name", "")), suggestion]
_feedback.visible = true
_set_editing(true)
_card.request_fit()
if not bool(result.get("ok", false)):
_name_edit.call_deferred("grab_focus")
_name_edit.call_deferred("select_all")
func hide_card(reset_selection: bool = false) -> void:
if reset_selection:
_active_selection_key = ""
_set_editing(false)
_feedback.visible = false
_character = null
if _card != null:
_card.visible = false
func get_card() -> Control:
return _card
func focus_card() -> void:
if _card != null and _card.visible:
_card.focus_first_control()
func update_layout(safe_rect: Rect2) -> void:
if _card != null:
_card.set_safe_rect(safe_rect)
func _build() -> void:
_card = STAGE_INSPECTOR_PANEL.new() as StageInspectorPanel
_card.name = "CharacterSettingsCard"
_card.visible = false
add_child(_card)
_card.apply_theme(_stage_theme)
_card.set_rename_enabled(true)
_card.set_close_visible(false)
_card.set_body_visible(false)
_card.set_title_single_line(true)
_card.rename_requested.connect(_start_editing)
var body: VBoxContainer = _card.body()
body.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
_name_section = VBoxContainer.new()
_name_section.name = "StageNameEditor"
_name_section.add_theme_constant_override("separation", 6)
_name_section.visible = false
body.add_child(_name_section)
_name_edit = LineEdit.new()
_name_edit.name = "StageNameEdit"
_name_edit.placeholder_text = "Character name"
_name_edit.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_name_edit.visible = false
_name_edit.text_submitted.connect(func(_value: String) -> void: _submit_name())
_name_edit.gui_input.connect(_on_name_edit_input)
_apply_font(_name_edit)
_name_section.add_child(_name_edit)
_feedback = Label.new()
_feedback.name = "StageNameFeedback"
_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_feedback.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
_feedback.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_feedback.visible = false
_apply_font(_feedback)
_name_section.add_child(_feedback)
var actions: HBoxContainer = HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
_name_section.add_child(actions)
_done_button = Button.new()
_done_button.name = "DoneStageName"
_done_button.text = "Done"
_done_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_done_button.visible = false
_done_button.pressed.connect(_submit_name)
_card.style_primary_action_button(_done_button)
actions.add_child(_done_button)
_cancel_button = Button.new()
_cancel_button.name = "CancelStageName"
_cancel_button.text = "Cancel"
_cancel_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_cancel_button.visible = false
_cancel_button.pressed.connect(_cancel_editing)
_card.style_action_button(_cancel_button, false)
actions.add_child(_cancel_button)
func _start_editing() -> void:
if _character == null or not is_instance_valid(_character):
return
_name_edit.text = _character.stage_name
_feedback.visible = false
_set_editing(true)
_name_edit.call_deferred("grab_focus")
_name_edit.call_deferred("select_all")
_card.request_fit()
func _set_editing(editing: bool) -> void:
_editing = editing
if _name_section == null:
return
_name_section.visible = editing
_card.set_body_visible(editing)
_name_edit.visible = editing
_done_button.visible = editing
_cancel_button.visible = editing
func _submit_name() -> void:
if _character != null and is_instance_valid(_character):
rename_requested.emit(_character, _name_edit.text)
func _cancel_editing() -> void:
if _character != null and is_instance_valid(_character):
_name_edit.text = _character.stage_name
_feedback.visible = false
_set_editing(false)
_card.request_fit()
func _on_name_edit_input(event: InputEvent) -> void:
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
_cancel_editing()
get_viewport().set_input_as_handled()
func _on_viewport_resized() -> void:
var viewport: Rect2 = get_viewport_rect()
var top_offset: float = minf(64.0 + 120.0, maxf(12.0, viewport.size.y - 220.0))
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - 38.0 - 12.0)
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
update_layout(Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0)))
func _stage_panel_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer("stage_panels." + key, fallback, minimum, maximum)
func _stage_panel_color(key: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color("stage_panels." + key, fallback)
func _apply_font(control: Control) -> void:
if _stage_theme == null:
return
var font: Font = _stage_theme.font()
if font != null:
control.add_theme_font_override("font", font)
@@ -0,0 +1 @@
uid://fqbgu2edqtpd
+373 -24
View File
@@ -9,11 +9,13 @@ const SPEECH_COMPOSER_SCENE := preload("res://scenes/editors/stage/speech_compos
const SPEECH_STYLE_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_style_toolbar.gd")
const SPEECH_SHOW_TOOLBAR_SCRIPT := preload("res://scripts/speech/speech_show_toolbar.gd")
const WAYPOINT_CONTEXT_SCRIPT := preload("res://scripts/stage/waypoint_context.gd")
const PROP_LIBRARY_SCRIPT := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const MISSING_PROP_PLACEHOLDER_SCRIPT := preload("res://scripts/props/missing_prop_placeholder.gd")
const MISSING_STICKMAN_PLACEHOLDER_SCRIPT := preload("res://scripts/characters/stickman/missing_stickman_placeholder.gd")
const DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
signal object_selected(node: Node2D)
signal candidate_preview_changed(node: Node2D)
signal objects_here_dismissed
var speak_dialog: AcceptDialog
var speak_edit: LineEdit
@@ -41,6 +43,8 @@ var speech_show_toolbar: Control
var speech_recovery_dialog: ConfirmationDialog
var speech_destination_dialog: ConfirmationDialog
var _objects_here_popup: PopupPanel
var _objects_here_guard: Control
var _objects_here_preview: Node2D
var _speak_box: VBoxContainer
var _sandbox_theme: SandboxTheme
var _ui_font: Font
@@ -237,12 +241,43 @@ func show_objects_here(
candidates: Array[Node2D],
selected_nodes: Array[Node2D],
viewport_size: Vector2,
pointer_position: Vector2
pointer_position: Vector2,
avoid_rects: Array[Rect2] = []
) -> void:
if parent == null or candidates.size() < 2:
return
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_objects_here_popup.queue_free()
_objects_here_popup.hide()
var content_inset: int = _objects_here_integer("content_inset", 12, 8, 24)
var edge_inset: int = _objects_here_integer("edge_inset", 8, 0, 32)
var safe_top: int = _objects_here_integer("safe_top", 120, 0, 240)
var safe_bottom: int = _objects_here_integer("safe_bottom", 48, 0, 160)
var preferred_width: int = _objects_here_integer("preferred_width", 360, 220, 720)
var popup_width: int = mini(preferred_width, maxi(1, int(viewport_size.x) - edge_inset * 2))
var content_width: int = maxi(1, popup_width - content_inset * 2)
var row_height: int = _objects_here_integer("row_height", 56, 44, 96)
var row_gap: int = _objects_here_integer("row_gap", 8, 2, 24)
var header_gap: int = _objects_here_integer("header_gap", 8, 4, 24)
var footer_gap: int = _objects_here_integer("footer_gap", 8, 4, 24)
var cancel_height: int = _objects_here_integer("cancel_height", 48, 44, 96)
var heading_font_size: int = _objects_here_integer("heading_font_size", 20, 16, 32)
var fixed_content_height: float = float(heading_font_size + header_gap + cancel_height + footer_gap + content_inset * 2)
var row_content_height: float = float(candidates.size() * row_height + maxi(0, candidates.size() - 1) * row_gap)
var safe_popup_height: float = maxf(180.0, viewport_size.y - float(safe_top + safe_bottom))
var minimum_rows_height: float = float(_objects_here_integer("minimum_rows_height", 116, 56, 240))
var max_rows_height: float = minf(
float(_objects_here_integer("max_rows_height", 320, 116, 640)),
maxf(minimum_rows_height, safe_popup_height - fixed_content_height)
)
var rows_height: float = minf(max_rows_height, row_content_height)
var popup_height: float = minf(safe_popup_height, fixed_content_height + rows_height)
var guard: Control = Control.new()
guard.name = "ObjectsHereInputGuard"
guard.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
guard.mouse_filter = Control.MOUSE_FILTER_STOP
guard.gui_input.connect(_on_objects_here_guard_input.bind(guard))
parent.add_child(guard)
_objects_here_guard = guard
var popup: PopupPanel = PopupPanel.new()
popup.name = "ObjectsHere"
popup.exclusive = false
@@ -250,67 +285,334 @@ func show_objects_here(
popup.unfocusable = false
if _sandbox_theme != null:
popup.theme = _sandbox_theme.control_theme()
var shell: StyleBoxFlat = _sandbox_theme.stylebox("popups.anchored_chooser") if _sandbox_theme != null else StyleBoxFlat.new()
shell.set_corner_radius_all(_objects_here_integer("corner_radius", 14, 8, 24))
shell.set_border_width_all(_objects_here_integer("border_width", 2, 1, 4))
shell.bg_color = _objects_here_color("popups.anchored_chooser.bg_color", Color("#202633FA"))
shell.border_color = _objects_here_color("popups.anchored_chooser.border_color", Color("#667386"))
shell.shadow_size = _objects_here_integer("shadow_size", 6, 0, 24)
shell.shadow_color = _objects_here_color("popups.anchored_chooser.shadow_color", Color("#00000080"))
shell.shadow_offset = _objects_here_vector2("shadow_offset", Vector2(0.0, 4.0))
# The child MarginContainer owns the content inset so the shell adds one clean gutter.
shell.content_margin_left = 0.0
shell.content_margin_right = 0.0
shell.content_margin_top = 0.0
shell.content_margin_bottom = 0.0
popup.add_theme_stylebox_override("panel", shell)
parent.add_child(popup)
var margin: MarginContainer = MarginContainer.new()
margin.name = "ObjectsHereMargin"
for side: String in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 8)
margin.add_theme_constant_override("margin_" + side, content_inset)
popup.add_child(margin)
var column: VBoxContainer = VBoxContainer.new()
column.add_theme_constant_override("separation", 4)
column.name = "ObjectsHereColumn"
column.add_theme_constant_override("separation", 0)
margin.add_child(column)
var heading: Label = Label.new()
heading.text = "Objects here · Choose an object"
heading.name = "ObjectsHereHeading"
heading.text = "Objects here · Choose one"
if _ui_font != null:
heading.add_theme_font_override("font", _ui_font)
heading.add_theme_font_size_override("font_size", heading_font_size)
heading.add_theme_color_override("font_color", _objects_here_color("global.text_color", Color("#F5F7FA")))
column.add_child(heading)
var header_spacer: Control = Control.new()
header_spacer.custom_minimum_size.y = float(header_gap)
header_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
column.add_child(header_spacer)
var scroll: ScrollContainer = ScrollContainer.new()
scroll.name = "ObjectsHereScroll"
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.custom_minimum_size = Vector2(300.0, minf(360.0, float(candidates.size()) * 48.0))
scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED if row_content_height <= rows_height else ScrollContainer.SCROLL_MODE_AUTO
scroll.custom_minimum_size = Vector2(float(content_width), rows_height)
column.add_child(scroll)
var rows: VBoxContainer = VBoxContainer.new()
rows.name = "ObjectsHereRows"
rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
rows.add_theme_constant_override("separation", row_gap)
scroll.add_child(rows)
for node: Node2D in candidates:
for candidate_index: int in candidates.size():
var node: Node2D = candidates[candidate_index]
if node == null or not is_instance_valid(node):
continue
var button: Button = Button.new()
var selected_mark: String = "✓ " if selected_nodes.has(node) else ""
button.text = selected_mark + _object_here_label(node)
button.custom_minimum_size = Vector2(300.0, 44.0)
button.name = "ObjectCandidate_%d" % candidate_index
button.focus_mode = Control.FOCUS_ALL
button.custom_minimum_size = Vector2(float(content_width), float(row_height))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_style_objects_here_candidate(button, selected_nodes.has(node))
var row_content: HBoxContainer = HBoxContainer.new()
row_content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var row_padding_x: float = float(_objects_here_integer("row_padding_x", 12, 4, 24))
var row_padding_y: float = float(_objects_here_integer("row_padding_y", 6, 0, 20))
row_content.offset_left = row_padding_x
row_content.offset_top = row_padding_y
row_content.offset_right = -row_padding_x
row_content.offset_bottom = -row_padding_y
row_content.add_theme_constant_override("separation", 8)
row_content.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(row_content)
var name_label: Label = Label.new()
name_label.text = _object_here_label(node)
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
if _ui_font != null:
name_label.add_theme_font_override("font", _ui_font)
name_label.add_theme_font_size_override("font_size", _objects_here_font_size())
row_content.add_child(name_label)
if selected_nodes.has(node):
row_content.add_child(_make_selected_candidate_badge())
button.pressed.connect(_on_objects_here_selected.bind(node))
button.mouse_entered.connect(_set_candidate_preview.bind(node))
button.mouse_exited.connect(_clear_row_preview.bind(button, node))
button.focus_entered.connect(_set_candidate_preview.bind(node))
button.focus_exited.connect(_clear_row_preview.bind(button, node))
rows.add_child(button)
var popup_size: Vector2i = Vector2i(
mini(360, maxi(260, int(viewport_size.x) - 16)),
mini(420, maxi(120, int(viewport_size.y) - 16))
var footer_spacer: Control = Control.new()
footer_spacer.custom_minimum_size.y = float(footer_gap)
footer_spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE
column.add_child(footer_spacer)
var cancel: Button = Button.new()
cancel.name = "CancelObjectsHere"
cancel.text = "Cancel"
cancel.custom_minimum_size = Vector2(
minf(float(content_width), float(_objects_here_integer("cancel_width", 120, 88, 240))),
float(cancel_height)
)
cancel.size_flags_horizontal = Control.SIZE_SHRINK_END
_style_objects_here_cancel(cancel)
cancel.pressed.connect(_dismiss_objects_here)
column.add_child(cancel)
var popup_size: Vector2i = Vector2i(popup_width, int(round(popup_height)))
popup.size = popup_size
var position: Vector2i = Vector2i(pointer_position + Vector2(12.0, 12.0))
position.x = clampi(position.x, 8, maxi(8, int(viewport_size.x) - popup_size.x - 8))
position.y = clampi(position.y, 8, maxi(8, int(viewport_size.y) - popup_size.y - 8))
var position: Vector2i = _objects_here_popup_position(pointer_position, viewport_size, popup_size, avoid_rects, edge_inset, safe_top, safe_bottom)
_objects_here_popup = popup
popup.popup_hide.connect(_on_objects_here_popup_closed.bind(popup))
popup.close_requested.connect(_dismiss_objects_here)
popup.window_input.connect(_on_objects_here_window_input.bind(popup))
popup.position = position
popup.popup()
cancel.call_deferred("grab_focus")
func _objects_here_popup_position(
pointer_position: Vector2,
viewport_size: Vector2,
popup_size: Vector2i,
avoid_rects: Array[Rect2],
edge_inset: int = 8,
safe_top: int = 120,
safe_bottom: int = 48
) -> Vector2i:
const POINTER_GAP: float = 12.0
var maximum_x: int = maxi(edge_inset, int(viewport_size.x) - popup_size.x - edge_inset)
var minimum_y: int = mini(safe_top, maxi(edge_inset, int(viewport_size.y) - popup_size.y - safe_bottom))
var maximum_y: int = maxi(minimum_y, int(viewport_size.y) - popup_size.y - safe_bottom)
var candidate_positions: Array[Vector2] = [
pointer_position + Vector2(POINTER_GAP, POINTER_GAP),
Vector2(pointer_position.x - float(popup_size.x) - POINTER_GAP, pointer_position.y + POINTER_GAP),
Vector2(pointer_position.x + POINTER_GAP, pointer_position.y - float(popup_size.y) - POINTER_GAP),
pointer_position - Vector2(float(popup_size.x) + POINTER_GAP, float(popup_size.y) + POINTER_GAP),
]
var x_edges: Array[float] = [float(edge_inset), float(maximum_x), pointer_position.x - float(popup_size.x) - POINTER_GAP, pointer_position.x + POINTER_GAP]
var y_edges: Array[float] = [float(minimum_y), float(maximum_y), pointer_position.y - float(popup_size.y) - POINTER_GAP, pointer_position.y + POINTER_GAP]
for avoid_rect: Rect2 in avoid_rects:
if not avoid_rect.has_area():
continue
var centered_y: float = pointer_position.y - float(popup_size.y) * 0.5
var centered_x: float = pointer_position.x - float(popup_size.x) * 0.5
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, centered_y))
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, centered_y))
candidate_positions.append(Vector2(centered_x, avoid_rect.position.y - float(popup_size.y) - POINTER_GAP))
candidate_positions.append(Vector2(centered_x, avoid_rect.end.y + POINTER_GAP))
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, safe_top))
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, safe_top))
candidate_positions.append(Vector2(avoid_rect.end.x + POINTER_GAP, float(maximum_y)))
candidate_positions.append(Vector2(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP, float(maximum_y)))
x_edges.append(avoid_rect.position.x - float(popup_size.x) - POINTER_GAP)
x_edges.append(avoid_rect.end.x + POINTER_GAP)
y_edges.append(avoid_rect.position.y - float(popup_size.y) - POINTER_GAP)
y_edges.append(avoid_rect.end.y + POINTER_GAP)
for candidate_x: float in x_edges:
for candidate_y: float in y_edges:
candidate_positions.append(Vector2(candidate_x, candidate_y))
var pointer_safety_rect: Rect2 = Rect2(pointer_position - Vector2(28.0, 28.0), Vector2(56.0, 56.0))
var best_position: Vector2i = Vector2i(edge_inset, edge_inset)
var best_overlap_score: float = INF
for candidate: Vector2 in candidate_positions:
var clamped_position: Vector2i = Vector2i(
clampf(candidate.x, float(edge_inset), float(maximum_x)),
clampf(candidate.y, float(minimum_y), float(maximum_y))
)
var popup_rect: Rect2 = Rect2(Vector2(clamped_position), Vector2(popup_size))
var pointer_overlap_area: float = popup_rect.intersection(pointer_safety_rect).get_area()
var avoid_overlap_area: float = 0.0
for avoid_rect: Rect2 in avoid_rects:
if avoid_rect.has_area():
avoid_overlap_area += popup_rect.intersection(avoid_rect).get_area()
var nearest_point: Vector2 = Vector2(
clampf(pointer_position.x, popup_rect.position.x, popup_rect.end.x),
clampf(pointer_position.y, popup_rect.position.y, popup_rect.end.y)
)
var overlap_score: float = pointer_overlap_area * 1000000.0 + avoid_overlap_area * 1000000.0 + nearest_point.distance_to(pointer_position)
if overlap_score < best_overlap_score:
best_overlap_score = overlap_score
best_position = clamped_position
return best_position
func _style_objects_here_candidate(button: Button, selected: bool) -> void:
if button == null:
return
var accent: Color = _objects_here_color("accent_color", Color("#22C6FF"))
var surface: Color = _objects_here_color("row_surface", Color("#171B24"))
var selected_surface: Color = _objects_here_color("selected_surface", Color("#12394A"))
var hover_surface: Color = _objects_here_color("hover_surface", Color("#203849"))
if _sandbox_theme != null:
accent = _sandbox_theme.color("mode_colors.edit", accent)
button.add_theme_stylebox_override("normal", _objects_here_button_style(
selected_surface if selected else surface,
accent if selected else _objects_here_color("row_border_color", Color("#FFFFFF55")),
_objects_here_integer("selected_border_width", 2, 1, 4) if selected else _objects_here_integer("row_border_width", 1, 0, 4),
_objects_here_integer("row_corner_radius", 12, 4, 24)
))
button.add_theme_stylebox_override("hover", _objects_here_button_style(hover_surface, accent, _objects_here_integer("hover_border_width", 2, 1, 4), _objects_here_integer("row_corner_radius", 12, 4, 24)))
button.add_theme_stylebox_override("pressed", _objects_here_button_style(selected_surface, accent, _objects_here_integer("selected_border_width", 2, 1, 4), _objects_here_integer("row_corner_radius", 12, 4, 24)))
button.add_theme_stylebox_override("focus", _objects_here_button_style(Color(0.0, 0.0, 0.0, 0.0), _objects_here_color("focus_color", accent), _objects_here_integer("focus_border_width", 3, 2, 5), _objects_here_integer("row_corner_radius", 12, 4, 24)))
var text_color: Color = _objects_here_color("row_text_color", Color.WHITE)
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", text_color)
button.add_theme_color_override("font_pressed_color", text_color)
button.add_theme_color_override("font_focus_color", text_color)
button.add_theme_font_size_override("font_size", _objects_here_font_size())
if _ui_font != null:
button.add_theme_font_override("font", _ui_font)
func _objects_here_button_style(background: Color, border: Color, border_width: int, corner_radius: int) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(corner_radius)
style.content_margin_left = float(_objects_here_integer("row_padding_x", 12, 4, 24))
style.content_margin_right = float(_objects_here_integer("row_padding_x", 12, 4, 24))
style.content_margin_top = float(_objects_here_integer("row_padding_y", 6, 0, 20))
style.content_margin_bottom = float(_objects_here_integer("row_padding_y", 6, 0, 20))
return style
func _style_objects_here_cancel(button: Button) -> void:
if button == null:
return
var normal: Color = _objects_here_color("cancel_surface", Color("#293242"))
var hover: Color = _objects_here_color("cancel_hover_surface", Color("#354154"))
var border: Color = _objects_here_color("cancel_border_color", Color("#718096"))
var text_color: Color = _objects_here_color("cancel_text_color", Color("#F5F7FA"))
var radius: int = _objects_here_integer("cancel_corner_radius", 10, 4, 24)
var border_width: int = _objects_here_integer("cancel_border_width", 2, 1, 4)
button.add_theme_stylebox_override("normal", _objects_here_button_style(normal, border, border_width, radius))
button.add_theme_stylebox_override("hover", _objects_here_button_style(hover, _objects_here_color("focus_color", border), border_width, radius))
button.add_theme_stylebox_override("pressed", _objects_here_button_style(hover, border, border_width, radius))
button.add_theme_stylebox_override("focus", _objects_here_button_style(normal, _objects_here_color("focus_color", border), _objects_here_integer("focus_border_width", 3, 2, 5), radius))
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", text_color)
button.add_theme_color_override("font_pressed_color", text_color)
button.add_theme_color_override("font_focus_color", text_color)
button.add_theme_font_size_override("font_size", _objects_here_integer("cancel_font_size", 20, 16, 32))
if _ui_font != null:
button.add_theme_font_override("font", _ui_font)
func _objects_here_integer(key: String, fallback: int, minimum: int, maximum: int) -> int:
if _sandbox_theme == null:
return clampi(fallback, minimum, maximum)
return _sandbox_theme.integer("popups.anchored_chooser." + key, fallback, minimum, maximum)
func _objects_here_color(path: String, fallback: Color) -> Color:
if _sandbox_theme == null:
return fallback
var resolved_path: String = path if path.contains(".") else "popups.anchored_chooser." + path
return _sandbox_theme.color(resolved_path, fallback)
func _objects_here_vector2(key: String, fallback: Vector2) -> Vector2:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value("popups.anchored_chooser." + key, null)
if raw is Array and (raw as Array).size() == 2:
return Vector2(float((raw as Array)[0]), float((raw as Array)[1]))
return fallback
func _make_selected_candidate_badge() -> PanelContainer:
var badge: PanelContainer = PanelContainer.new()
badge.name = "SelectedCandidateBadge"
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
var accent: Color = _objects_here_color("accent_color", Color("#22C6FF"))
if _sandbox_theme != null:
accent = _sandbox_theme.color("mode_colors.edit", accent)
var badge_style: StyleBoxFlat = StyleBoxFlat.new()
badge_style.bg_color = _objects_here_color("badge_surface", Color("#12394A"))
badge_style.border_color = accent
badge_style.set_border_width_all(1)
badge_style.set_corner_radius_all(_objects_here_integer("badge_corner_radius", 8, 4, 20))
badge_style.content_margin_left = float(_objects_here_integer("badge_padding_x", 8, 2, 20))
badge_style.content_margin_right = float(_objects_here_integer("badge_padding_x", 8, 2, 20))
badge_style.content_margin_top = float(_objects_here_integer("badge_padding_y", 2, 0, 12))
badge_style.content_margin_bottom = float(_objects_here_integer("badge_padding_y", 2, 0, 12))
badge.add_theme_stylebox_override("panel", badge_style)
var label: Label = Label.new()
label.text = "Selected"
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", _objects_here_integer("badge_font_size", 16, 12, 24))
label.add_theme_color_override("font_color", _objects_here_color("badge_text_color", Color.WHITE))
if _ui_font != null:
label.add_theme_font_override("font", _ui_font)
badge.add_child(label)
return badge
func _objects_here_font_size() -> int:
return _objects_here_integer("row_font_size", 20, 14, 40)
func get_objects_here_popup() -> PopupPanel:
return _objects_here_popup
func get_objects_here_screen_rect() -> Rect2:
if _objects_here_popup == null or not is_instance_valid(_objects_here_popup) or not _objects_here_popup.visible:
return Rect2()
return Rect2(Vector2(_objects_here_popup.position), Vector2(_objects_here_popup.size))
func dismiss_objects_here() -> void:
_dismiss_objects_here()
func _object_here_label(node: Node2D) -> String:
if node is PropBlock:
var prop: PropBlock = node as PropBlock
var depth: String = "Behind characters" if String(prop.stage_roles.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else "In front of characters"
var entry: Dictionary = PROP_LIBRARY_SCRIPT.get_entry(prop.spawn_id)
var prop_name: String = String(entry.get("name", entry.get("label", prop.spawn_id)))
return "📦 %s · %s" % [prop_name, depth]
return "📦 %s" % String(node.get("stage_name"))
if node is TerrainBlock:
return "🧱 %s · Terrain" % String(node.get("spawn_id"))
if node is StickmanRig:
return "🧍 %s · Stickman" % node.name
return "🧍 %s" % String(node.get("stage_name"))
if node.get_script() == MISSING_PROP_PLACEHOLDER_SCRIPT:
return "📦 %s" % String(node.get("stage_name"))
if node.get_script() == MISSING_STICKMAN_PLACEHOLDER_SCRIPT:
return "🧍 %s" % String(node.get("stage_name"))
return "🎯 %s" % node.name
func _on_objects_here_selected(node: Node2D) -> void:
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
_dismiss_objects_here()
return
object_selected.emit(node)
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_objects_here_popup.hide()
@@ -319,10 +621,57 @@ func _on_objects_here_selected(node: Node2D) -> void:
func _on_objects_here_popup_closed(popup: PopupPanel) -> void:
if _objects_here_popup == popup:
_objects_here_popup = null
candidate_preview_changed.emit(null)
_objects_here_preview = null
if _objects_here_guard != null and is_instance_valid(_objects_here_guard):
_objects_here_guard.queue_free()
_objects_here_guard = null
objects_here_dismissed.emit()
if is_instance_valid(popup):
popup.queue_free()
func _set_candidate_preview(node: Node2D) -> void:
if node == null or not is_instance_valid(node) or node.is_queued_for_deletion():
return
_objects_here_preview = node
candidate_preview_changed.emit(node)
func _clear_row_preview(button: Button, node: Node2D) -> void:
if button != null and is_instance_valid(button) and button.has_focus():
return
if _objects_here_preview == node:
_objects_here_preview = null
candidate_preview_changed.emit(null)
func _dismiss_objects_here() -> void:
if _objects_here_popup != null and is_instance_valid(_objects_here_popup):
_objects_here_popup.hide()
else:
_on_objects_here_popup_closed(null)
func _on_objects_here_guard_input(event: InputEvent, guard: Control) -> void:
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
guard.get_viewport().set_input_as_handled()
_dismiss_objects_here()
elif event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
guard.get_viewport().set_input_as_handled()
_dismiss_objects_here()
elif guard != null and is_instance_valid(guard):
guard.get_viewport().set_input_as_handled()
func _on_objects_here_window_input(event: InputEvent, _popup: PopupPanel) -> void:
if not (event is InputEventKey) or not (event as InputEventKey).pressed or (event as InputEventKey).keycode != KEY_ESCAPE:
return
if _objects_here_guard != null and is_instance_valid(_objects_here_guard):
_objects_here_guard.get_viewport().set_input_as_handled()
_dismiss_objects_here()
func theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
if _sandbox_theme == null:
return fallback
+149 -63
View File
@@ -16,13 +16,11 @@ signal spot_move_requested(spot_id: String)
signal spot_rename_requested(spot_id: String, name: String)
signal spot_erase_requested(spot_id: String)
signal spot_selected(spot_id: String)
signal close_requested
signal rename_requested(node: Node2D, requested_name: String)
var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
var _status_bar_height: float = 38.0
var _card: StageInspectorPanel
var _reopen_button: Button
var _fields: VBoxContainer
var _notice: Label
var _depth_group: StagePanelChoiceGroup
@@ -32,7 +30,14 @@ var _controls: Dictionary = {}
var _spot_rows: VBoxContainer
var _spot_empty_label: Label
var _active_selection_key: String = ""
var _dismissed_selection_key: String = ""
var _name_node: Node2D
var _name_section: VBoxContainer
var _name_edit: LineEdit
var _name_done_button: Button
var _name_cancel_button: Button
var _name_feedback: Label
var _name_editing: bool = false
var _restore_collapsed_after_edit: bool = false
func configure(stage_theme: SandboxTheme, top_bar_height: float, status_bar_height: float) -> void:
@@ -62,27 +67,28 @@ func show_prop(
hide_card()
return
_update_selection_key(selection)
var dismissed: bool = _active_selection_key == _dismissed_selection_key
_card.visible = not dismissed
_reopen_button.visible = dismissed
if dismissed:
return
_card.visible = true
_card.set_safe_rect(safe_rect)
_notice.visible = false
_fields.visible = true
_card.set_header(title, "📦")
_card.set_rename_enabled(true)
if _name_node != prop:
_name_node = prop
_set_name_editing(false)
_name_edit.text = prop.stage_name
_name_feedback.visible = false
_sync_roles(prop, roles)
_sync_spots(prop.get_use_spots(), selected_spot_id)
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
_set_name_editing(false)
_name_feedback.visible = false
_update_selection_key(selection)
var dismissed: bool = _active_selection_key == _dismissed_selection_key
_card.visible = not dismissed
_reopen_button.visible = dismissed
if dismissed:
return
_card.visible = true
_card.set_safe_rect(safe_rect)
_card.set_rename_enabled(false)
_notice.visible = true
_fields.visible = false
_card.set_header("Props", "📦")
@@ -90,43 +96,25 @@ func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
func hide_card(reset_selection: bool = false) -> void:
if reset_selection:
_set_name_editing(false)
_name_feedback.visible = false
if reset_selection and not _active_selection_key.is_empty():
_update_selection_key([])
if _card != null:
_card.visible = false
if _reopen_button != null:
_reopen_button.visible = false
func set_dismissed(dismissed: bool) -> void:
if _active_selection_key.is_empty():
return
_dismissed_selection_key = _active_selection_key if dismissed else ""
func update_layout(safe_rect: Rect2) -> void:
if _card == null:
return
_card.set_safe_rect(safe_rect)
if _reopen_button == null:
return
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
var button_width: float = minf(148.0, maxf(1.0, safe_rect.size.x - inset * 2.0))
var button_height: float = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
_reopen_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_reopen_button.custom_minimum_size.x = button_width
_reopen_button.position = Vector2(safe_rect.end.x - inset - button_width, safe_rect.position.y + inset)
_reopen_button.size = Vector2(button_width, button_height)
func get_card() -> Control:
return _card
func get_reopen_button() -> Button:
return _reopen_button
func get_fields() -> VBoxContainer:
return _fields
@@ -151,10 +139,6 @@ func get_active_selection_key() -> String:
return _active_selection_key
func get_dismissed_selection_key() -> String:
return _dismissed_selection_key
func focus_card() -> void:
_focus_card()
@@ -165,12 +149,15 @@ func _build() -> void:
_card.visible = false
add_child(_card)
_card.apply_theme(_stage_theme)
_card.close_requested.connect(_on_card_close_requested)
_card.set_collapsible(true)
_card.set_rename_enabled(false)
_card.rename_requested.connect(_start_name_edit)
_card.collapsed_changed.connect(_on_card_collapsed_changed)
var body: VBoxContainer = _card.body()
body.add_theme_constant_override("separation", 10)
_notice = Label.new()
_notice.text = "Select one prop to change its settings."
_notice.text = "Select one character or prop to change its name."
_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_notice.add_theme_font_size_override("font_size", _stage_panel_integer("type.body_size", 20, 14, 40))
_notice.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
@@ -182,6 +169,7 @@ func _build() -> void:
_fields.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32))
_fields.size_flags_horizontal = Control.SIZE_EXPAND_FILL
body.add_child(_fields)
_build_name_section(_fields)
_add_heading(_fields, "Where it appears")
_depth_group = _make_choice_group("depth")
_fields.add_child(_depth_group)
@@ -198,15 +186,114 @@ func _build() -> void:
_add_wobble_section()
_add_use_spots_section()
_reopen_button = Button.new()
_reopen_button.name = "ReopenPropSettings"
_reopen_button.text = "Settings"
_reopen_button.custom_minimum_size = Vector2(148.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_reopen_button.focus_mode = Control.FOCUS_ALL
_reopen_button.visible = false
_reopen_button.pressed.connect(_on_reopen_pressed)
_card.style_action_button(_reopen_button, true)
add_child(_reopen_button)
func _build_name_section(parent: VBoxContainer) -> void:
_name_section = VBoxContainer.new()
_name_section.name = "StageNameEditor"
_name_section.add_theme_constant_override("separation", 6)
_name_section.visible = false
parent.add_child(_name_section)
_name_edit = LineEdit.new()
_name_edit.name = "StageNameEdit"
_name_edit.placeholder_text = "Character or prop name"
_name_edit.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_name_edit.visible = false
_name_edit.text_submitted.connect(func(_value: String) -> void: _submit_name_edit())
_name_edit.gui_input.connect(_on_name_edit_gui_input)
_apply_ui_font(_name_edit)
_name_section.add_child(_name_edit)
_name_feedback = Label.new()
_name_feedback.name = "StageNameFeedback"
_name_feedback.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_name_feedback.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
_name_feedback.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_name_feedback.visible = false
_apply_ui_font(_name_feedback)
_name_section.add_child(_name_feedback)
var actions: HBoxContainer = HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
_name_section.add_child(actions)
_name_done_button = Button.new()
_name_done_button.name = "DoneStageName"
_name_done_button.text = "Done"
_name_done_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_name_done_button.visible = false
_name_done_button.pressed.connect(_submit_name_edit)
_card.style_primary_action_button(_name_done_button)
actions.add_child(_name_done_button)
_name_cancel_button = Button.new()
_name_cancel_button.name = "CancelStageName"
_name_cancel_button.text = "Cancel"
_name_cancel_button.custom_minimum_size = Vector2(96.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_name_cancel_button.visible = false
_name_cancel_button.pressed.connect(_cancel_name_edit)
_card.style_action_button(_name_cancel_button, false)
actions.add_child(_name_cancel_button)
func show_name_result(result: Dictionary) -> void:
if bool(result.get("ok", false)):
_name_edit.text = String(result.get("name", ""))
_name_feedback.visible = false
_set_name_editing(false)
else:
var reason: StringName = StringName(String(result.get("reason", "")))
var suggestion: String = String(result.get("suggestion", ""))
if reason == &"blank":
_name_feedback.text = "Give this one a name. Try %s." % suggestion
else:
_name_feedback.text = "%s is already here. Try %s." % [String(result.get("name", "")), suggestion]
_name_feedback.visible = true
_card.request_fit()
if not bool(result.get("ok", false)):
_name_edit.call_deferred("grab_focus")
_name_edit.call_deferred("select_all")
func _start_name_edit() -> void:
if _name_node == null or not is_instance_valid(_name_node):
return
_restore_collapsed_after_edit = _card.is_collapsed()
_card.set_collapsed(false)
_name_edit.text = String(_name_node.get("stage_name"))
_name_feedback.visible = false
_set_name_editing(true)
_name_edit.call_deferred("grab_focus")
_name_edit.call_deferred("select_all")
_card.request_fit()
func _set_name_editing(editing: bool) -> void:
_name_editing = editing
if _name_section == null:
return
_name_section.visible = editing
_name_edit.visible = editing
_name_done_button.visible = editing
_name_cancel_button.visible = editing
if not editing and _restore_collapsed_after_edit and _card != null:
_restore_collapsed_after_edit = false
_card.set_collapsed(true)
func _submit_name_edit() -> void:
if _name_node == null or not is_instance_valid(_name_node):
return
rename_requested.emit(_name_node, _name_edit.text)
func _cancel_name_edit() -> void:
if _name_node != null and is_instance_valid(_name_node):
_name_edit.text = String(_name_node.get("stage_name"))
_name_feedback.visible = false
_set_name_editing(false)
_card.request_fit()
func _on_name_edit_gui_input(event: InputEvent) -> void:
if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE:
_cancel_name_edit()
get_viewport().set_input_as_handled()
func _add_use_spots_section() -> void:
@@ -449,7 +536,12 @@ func _update_selection_key(selection: Array[Node2D]) -> void:
if selection_key == _active_selection_key:
return
_active_selection_key = selection_key
_dismissed_selection_key = ""
if _name_editing:
_name_feedback.visible = false
_restore_collapsed_after_edit = false
_set_name_editing(false)
if _card != null:
_card.set_collapsed(false, false)
_card.reset_scroll()
@@ -481,17 +573,11 @@ func _on_row_height_changed() -> void:
_card.request_fit()
func _on_card_close_requested() -> void:
close_requested.emit()
func _on_reopen_pressed() -> void:
var should_focus: bool = _reopen_button.has_focus()
set_dismissed(false)
_card.visible = true
_reopen_button.visible = false
if should_focus:
call_deferred("_focus_card")
func _on_card_collapsed_changed(collapsed: bool) -> void:
if collapsed and _name_editing:
_name_feedback.visible = false
_restore_collapsed_after_edit = false
_set_name_editing(false)
func _focus_card() -> void:
@@ -502,7 +588,7 @@ func _focus_card() -> void:
func _on_viewport_resized() -> void:
var viewport: Rect2 = get_viewport_rect()
var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48))
var top_offset: float = minf(_top_bar_height + 116.0, maxf(12.0, viewport.size.y - 220.0))
var top_offset: float = minf(_top_bar_height + 120.0, maxf(12.0, viewport.size.y - 220.0))
var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - _status_bar_height - 12.0)
var safe_rect := Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))
update_layout(safe_rect)
@@ -87,10 +87,12 @@ func collect(
entry["rotation"] = rotation_degrees
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
entry["stage_name"] = String(stage_node.get("stage_name"))
elif type == "prop":
var prop: PropBlock = stage_node as PropBlock
entry["asset_id"] = prop.spawn_id
entry["stage_object_id"] = prop.stage_object_id
entry["stage_name"] = prop.stage_name
entry["roles"] = prop.stage_roles.duplicate(true)
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
entry["next_use_spot_number"] = prop.next_use_spot_number
@@ -109,9 +111,13 @@ func collect(
entry["type"] = "stickman"
entry["position"] = STAGE_SCENE_IO.vector_to_data(position)
entry["rotation"] = rotation_degrees
entry["stage_object_id"] = String(stage_node.get("stage_object_id"))
entry["stage_name"] = String(stage_node.get("stage_name"))
else:
var rig: StickmanRig = stage_node as StickmanRig
entry["stk_path"] = rig.source_stk_path
entry["stage_object_id"] = rig.stage_object_id
entry["stage_name"] = rig.stage_name
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
@@ -248,6 +254,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.stage_object_id = String(entry.get("stage_object_id", ""))
prop.stage_name = String(entry.get("stage_name", ""))
prop.set_wobble_on_impact(bool(entry.get("wobble_on_impact", false)))
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
@@ -270,6 +277,9 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
else:
warnings.append("Invalid prop material preset")
if node is StickmanRig:
var rig: StickmanRig = node as StickmanRig
rig.stage_object_id = String(entry.get("stage_object_id", ""))
rig.stage_name = String(entry.get("stage_name", ""))
var facing_names: Array = StickmanRig.FacingProfile.keys()
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
if facing_index >= 0:
@@ -4,12 +4,14 @@ extends Node2D
var original_entry: Dictionary = {}
var missing_prop_path: String = ""
var stage_object_id: String = ""
var stage_name: String = ""
const MARKER_SIZE := Vector2(180.0, 54.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_prop_path = String(entry.get("asset_id", ""))
stage_object_id = String(entry.get("stage_object_id", ""))
stage_name = String(entry.get("stage_name", ""))
queue_redraw()
func get_area_rect() -> Rect2:
+1
View File
@@ -31,6 +31,7 @@ var spawn_id: String = ""
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
## spawn_id, this value is unique per instance and survives scene save/load.
var stage_object_id: String = ""
var stage_name: String = ""
var interaction_anchors: Dictionary = {}
var interaction_reserved_by: int = 0
var interaction_owner_id: int = 0
+132 -10
View File
@@ -3,6 +3,8 @@ extends PanelContainer
## Reusable shell for an in-stage selection inspector.
signal close_requested
signal rename_requested
signal collapsed_changed(collapsed: bool)
var _stage_theme: SandboxTheme = null
var _safe_rect := Rect2()
@@ -12,9 +14,16 @@ var _layout: VBoxContainer
var _header: HBoxContainer
var _icon: Label
var _title: Label
var _rename_button: Button
var _close_button: Button
var _scroll: ScrollContainer
var _body: VBoxContainer
var _collapsible: bool = false
var _collapsed: bool = false
var _close_visible: bool = true
var _rename_enabled: bool = false
var _body_visible: bool = true
var _single_line_title: bool = false
func _ready() -> void:
@@ -29,8 +38,13 @@ func _unhandled_key_input(event: InputEvent) -> void:
return
var key_event := event as InputEventKey
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE:
close_requested.emit()
get_viewport().set_input_as_handled()
if _collapsible:
if not _collapsed:
set_collapsed(true)
get_viewport().set_input_as_handled()
elif _close_visible:
close_requested.emit()
get_viewport().set_input_as_handled()
func apply_theme(stage_theme: SandboxTheme) -> void:
@@ -48,8 +62,9 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
_margin.add_theme_constant_override("margin_" + side, padding)
_layout.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
_header.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
_rename_button.custom_minimum_size = Vector2(92.0, float(_integer("stage_panels.action.min_target_size", 48, 44, 96)))
_close_button.custom_minimum_size = Vector2(
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
float(_integer("stage_panels.action.close_width", 88, 72, 180)) if not _collapsible else float(_integer("stage_panels.action.min_target_size", 48, 44, 96)),
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
)
_title.add_theme_font_size_override("font_size", _integer("stage_panels.type.title_size", 24, 12, 48))
@@ -58,11 +73,14 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
var ui_font := stage_theme.font()
if ui_font != null:
_title.add_theme_font_override("font", ui_font)
_rename_button.add_theme_font_override("font", ui_font)
_close_button.add_theme_font_override("font", ui_font)
var emoji_font := stage_theme.emoji_font()
if emoji_font != null:
_icon.add_theme_font_override("font", emoji_font)
_close_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_rename_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_style_action_button(_rename_button, false)
_style_action_button(_close_button, false)
request_fit()
@@ -77,6 +95,74 @@ func set_header(title: String, icon_text: String = "") -> void:
request_fit()
func set_title_single_line(enabled: bool) -> void:
_single_line_title = enabled
_apply_title_layout()
request_fit()
func set_rename_enabled(enabled: bool) -> void:
_rename_enabled = enabled
if _rename_button == null:
return
_rename_button.visible = enabled
request_fit()
func set_collapsible(enabled: bool, collapsed: bool = false) -> void:
_collapsible = enabled
if _close_button == null:
_collapsed = collapsed if enabled else false
return
_close_button.visible = enabled or _close_visible
_close_button.custom_minimum_size.x = float(_integer("stage_panels.action.close_width", 88, 72, 180)) if not enabled else float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
if enabled:
set_collapsed(collapsed, false)
else:
_collapsed = false
_close_button.text = "Close"
_close_button.tooltip_text = "Close inspector"
if _scroll != null:
_scroll.visible = _body_visible
_apply_title_layout()
request_fit()
func set_close_visible(enabled: bool) -> void:
_close_visible = enabled
if _close_button == null:
return
_close_button.visible = _collapsible or enabled
request_fit()
func set_body_visible(enabled: bool) -> void:
_body_visible = enabled
if _scroll != null:
_scroll.visible = enabled and not _collapsed
request_fit()
func set_collapsed(collapsed: bool, notify: bool = true) -> void:
if not _collapsible:
return
var changed: bool = _collapsed != collapsed
_collapsed = collapsed
if _scroll != null:
_scroll.visible = not collapsed and _body_visible
if _close_button != null:
_close_button.text = "▾" if collapsed else "▴"
_close_button.tooltip_text = "Expand settings" if collapsed else "Collapse settings"
_apply_title_layout()
request_fit()
if changed and notify:
collapsed_changed.emit(collapsed)
func is_collapsed() -> bool:
return _collapsed
func body() -> VBoxContainer:
return _body
@@ -109,7 +195,10 @@ func focus_first_control() -> void:
continue
control.grab_focus()
return
_close_button.grab_focus()
if _rename_button.visible:
_rename_button.grab_focus()
elif _close_button.visible:
_close_button.grab_focus()
func style_action_button(button: Button, prominent: bool = false) -> void:
@@ -184,27 +273,43 @@ func _build_shell() -> void:
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
_header.add_child(_icon)
_title = Label.new()
_title.name = "InspectorTitle"
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_title.max_lines_visible = 2
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_header.add_child(_title)
_apply_title_layout()
_rename_button = Button.new()
_rename_button.name = "RenameStageObject"
_rename_button.text = "Rename"
_rename_button.custom_minimum_size = Vector2(92.0, float(_integer("stage_panels.action.min_target_size", 48, 44, 96)))
_rename_button.visible = false
_rename_button.pressed.connect(func() -> void: rename_requested.emit())
_rename_button.visible = _rename_enabled
_header.add_child(_rename_button)
_close_button = Button.new()
_close_button.text = "Close"
_close_button.name = "CollapseStageInspector"
_close_button.text = "▾" if _collapsible and _collapsed else "▴" if _collapsible else "Close"
_close_button.tooltip_text = "Expand settings" if _collapsible and _collapsed else "Collapse settings" if _collapsible else "Close inspector"
_close_button.visible = _collapsible or _close_visible
_close_button.custom_minimum_size = Vector2(
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
float(_integer("stage_panels.action.close_width", 88, 72, 180)) if not _collapsible else float(_integer("stage_panels.action.min_target_size", 48, 44, 96)),
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
)
_close_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
_close_button.focus_mode = Control.FOCUS_ALL
_close_button.pressed.connect(func() -> void: close_requested.emit())
_close_button.pressed.connect(_on_close_button_pressed)
_header.add_child(_close_button)
_scroll = ScrollContainer.new()
_scroll.name = "InspectorBodyScroll"
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scroll.visible = _body_visible and not _collapsed
_layout.add_child(_scroll)
_body = VBoxContainer.new()
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -240,11 +345,12 @@ func _fit_to_content() -> void:
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
var body_height := _body.get_combined_minimum_size().y
var fixed_height := header_height + gap + padding * 2.0
var show_body: bool = not _collapsed and _body_visible
var fixed_height := header_height + (gap if show_body else 0.0) + padding * 2.0
var available_height := maxf(72.0, rect.size.y - inset * 2.0)
var natural_height := fixed_height + body_height
var natural_height := fixed_height + (body_height if show_body else 0.0)
var card_height := minf(natural_height, available_height)
var scroll_height := maxf(0.0, card_height - fixed_height)
var scroll_height := maxf(0.0, card_height - fixed_height) if show_body else 0.0
# Update the scroll minimum before shrinking the PanelContainer. Otherwise its
# previous full-content minimum can clamp the new height until another layout.
_scroll.custom_minimum_size.y = scroll_height
@@ -254,6 +360,22 @@ func _fit_to_content() -> void:
_fit_pending = false
func _on_close_button_pressed() -> void:
if _collapsible:
set_collapsed(not _collapsed)
else:
close_requested.emit()
func _apply_title_layout() -> void:
if _title == null:
return
var one_line: bool = _single_line_title or (_collapsible and _collapsed)
_title.autowrap_mode = TextServer.AUTOWRAP_OFF if one_line else TextServer.AUTOWRAP_WORD_SMART
_title.max_lines_visible = 1 if one_line else 2
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
func _style_action_button(button: Button, prominent: bool) -> void:
if button == null:
return
+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