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