feat: add Phase 20a stage identities and overlap selection

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