Add Stage Surface Color Dialog and Background Ground Functionality

- Implemented StageSurfaceColorDialog for custom color selection with live preview.
- Created StageBackgroundGround class for non-walkable scenery behind the set.
- Introduced StageRectSurface for shared rectangular geometry and tint preview.
- Added StageSurfacePreview for rendering surface previews.
- Developed comprehensive tests for surface color selection, rendering, and serialization.
This commit is contained in:
2026-10-17 21:00:03 -04:00
parent a0510e6405
commit 26dd9c64e1
39 changed files with 3118 additions and 142 deletions
+432 -10
View File
@@ -53,9 +53,15 @@ const STAGE_SCENERY_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/st
const STAGE_SKY_VIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_sky_view.gd")
const STAGE_DRAW_ORDER_SCRIPT := preload("res://scripts/stage/stage_draw_order.gd")
const STAGE_WALL_PANEL_SCRIPT := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_BACKGROUND_GROUND_SCRIPT := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const STAGE_RECT_SURFACE_SCRIPT := preload("res://scripts/stage/scenery/stage_rect_surface.gd")
const STAGE_SCENERY_ARTWORK_SCRIPT := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STAGE_SURFACE_RENDERER_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const STAGE_INSPECTOR_PANEL_SCRIPT := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd")
const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
const STAGE_SURFACE_COLOR_CONTROLS_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const STAGE_SURFACE_COLOR_DIALOG_SCRIPT := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_SURFACE_PREVIEW_SCRIPT := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
# Phase 3c editor tools.
@@ -125,6 +131,7 @@ const SPEECH_DRAFT_DEBOUNCE: float = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_DEBOUNCE
const SPEECH_DRAFT_SCHEMA: int = STAGE_SPEECH_SESSION_SCRIPT.DRAFT_SCHEMA
const SPEECH_BUBBLE_DRAG_THRESHOLD: float = STAGE_SPEECH_SESSION_SCRIPT.BUBBLE_DRAG_THRESHOLD
const RECENT_SCENE_LIMIT: int = STAGE_DOCUMENT_CONTROLLER_SCRIPT.RECENT_LIMIT
const RECENT_SURFACE_COLOR_LIMIT: int = 8
const DEFAULT_SCENE_NAME: String = "Untitled"
const DEFAULT_CAMERA_POSITION: Vector2 = Vector2(0.0, -400.0)
const DEFAULT_CAMERA_ZOOM: float = 0.5
@@ -248,6 +255,7 @@ var _suppressed_contact_pairs: Dictionary:
var _spawner: StageSpawner
var _selection: StageSelection
var _gizmos: StageGizmos
var _stage_input_ready: bool = false
var _grid: StageGrid
var _spatial_index: StageSpatialIndex
var _terrain_painter: StageTerrainPainter
@@ -268,6 +276,13 @@ var _scenery_group_start: Vector2 = Vector2.ZERO
var _scenery_placed_count: int = 0
var _scenery_inspector: StageInspectorPanel
var _scenery_repair_message: Label
var _scenery_surface_section: VBoxContainer
var _scenery_wall_look_heading: Label
var _scenery_wall_look_group: StagePanelChoiceGroup
var _scenery_background_ground_look_heading: Label
var _scenery_background_ground_look_group: StagePanelChoiceGroup
var _scenery_surface_helper: Label
var _scenery_surface_colors: StageSurfaceColorControls
var _scenery_depth_section: VBoxContainer
var _scenery_depth_heading: Label
var _scenery_depth_group: StagePanelChoiceGroup
@@ -276,6 +291,7 @@ var _scenery_size_row: HBoxContainer
var _scenery_smaller_button: Button
var _scenery_bigger_button: Button
var _scenery_erase_wall_area_button: Button
var _scenery_erase_piece_button: Button
var _scenery_find_button: Button
var _scenery_choose_button: Button
var _scenery_keep_button: Button
@@ -284,6 +300,11 @@ var _scenery_repair_candidate: StageSceneryArtwork
var _scenery_repair_path: String = ""
var _missing_scenery_dialog: ConfirmationDialog
var _scenery_repair_confirm_dialog: ConfirmationDialog
var _scenery_erase_piece_dialog: ConfirmationDialog
var _scenery_erase_piece_targets: Array[WeakRef] = []
var _surface_color_dialog: StageSurfaceColorDialog
var _surface_color_session: Dictionary = {}
var _recent_surface_colors: Array[Color] = []
var _group_preview_save_dialog: ConfirmationDialog
var _pending_group_save_path: String = ""
var _rule_engine: StageRuleEngine
@@ -1330,6 +1351,7 @@ func _ready() -> void:
_update_scene_chrome()
_handle_cli_startup(OS.get_cmdline_user_args())
_check_speech_recovery()
_stage_input_ready = true
func _process(delta: float) -> void:
@@ -1400,7 +1422,7 @@ func _update_throw_preview_cache() -> void:
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if _hosted_prop_editor != null:
if _hosted_prop_editor != null or not _stage_input_ready:
return
if _speech_session != null:
_speech_session.set_camera_panning(_panning)
@@ -1530,6 +1552,17 @@ func _unhandled_key_input(event: InputEvent) -> void:
_exit_scenery_edit_mode()
get_viewport().set_input_as_handled()
return
if event.keycode in [KEY_DELETE, KEY_BACKSPACE] and _scenery_edit_mode and current_mode == StageMode.EDIT:
var selected_scenery: Array[Node2D] = _selection.get_selected() if _selection != null else []
var has_surface: bool = false
for selected_node: Node2D in selected_scenery:
if selected_node.get_script() == STAGE_WALL_PANEL_SCRIPT or selected_node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT:
has_surface = true
break
if has_surface:
_request_erase_selected_scenery()
get_viewport().set_input_as_handled()
return
match event.keycode:
KEY_DELETE, KEY_BACKSPACE:
if current_mode == StageMode.EDIT:
@@ -1795,6 +1828,7 @@ func set_mode(mode: StageMode) -> void:
_show_toast("Finish or cancel speech editing first.")
_sync_mode_buttons()
return
_cancel_surface_color_preview()
if _scenery_controller != null:
if _scenery_controller.is_group_preview_pending():
_scenery_controller.cancel_group()
@@ -2555,6 +2589,10 @@ func _validate_queues_for_save() -> String:
func _reset_scene_content() -> void:
set_mode(StageMode.EDIT)
_cancel_surface_color_preview()
if _scenery_erase_piece_dialog != null:
_scenery_erase_piece_dialog.hide()
_clear_erase_scenery_targets()
if _scenery_repair_confirm_dialog != null:
_scenery_repair_confirm_dialog.hide()
_cancel_scenery_repair_preview()
@@ -3033,6 +3071,8 @@ func _build_ui() -> void:
_asset_browser.make_artwork_requested.connect(_open_stage_artwork_editor)
_asset_browser.build_ui(ui, _ui_font, _emoji_font, _sandbox_theme)
_asset_browser.edit_scenery_requested.connect(_enter_scenery_edit_mode)
_asset_browser.surface_color_confirmed.connect(_record_recent_surface_color)
_asset_browser.set_surface_color_history(_recent_surface_colors)
_build_ground_finish_popup(ui)
_modal_views.build_file_dialogs(ui)
@@ -3122,6 +3162,43 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_repair_message.name = "MissingArtworkMessage"
_scenery_repair_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
body.add_child(_scenery_repair_message)
_scenery_surface_section = VBoxContainer.new()
_scenery_surface_section.name = "ScenerySurfaceSettings"
_scenery_surface_section.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 0, 32))
_scenery_wall_look_heading = _make_scenery_section_heading("Look")
_scenery_surface_section.add_child(_scenery_wall_look_heading)
_scenery_wall_look_group = STAGE_PANEL_CHOICE_GROUP_SCRIPT.new() as StagePanelChoiceGroup
_scenery_wall_look_group.name = "WallPanelLooks"
_scenery_wall_look_group.add_choice(&"finish_wall_paint", "Paint", _make_surface_choice_cue(&"wall", &"finish_wall_paint"))
_scenery_wall_look_group.add_choice(&"finish_wall_brick", "Brick", _make_surface_choice_cue(&"wall", &"finish_wall_brick"))
_scenery_wall_look_group.add_choice(&"finish_wall_wood", "Wood", _make_surface_choice_cue(&"wall", &"finish_wall_wood"))
_scenery_wall_look_group.apply_theme(_sandbox_theme as SandboxTheme)
_scenery_wall_look_group.choice_selected.connect(_on_selected_surface_look_choice)
_scenery_surface_section.add_child(_scenery_wall_look_group)
_scenery_background_ground_look_heading = _make_scenery_section_heading("Look")
_scenery_surface_section.add_child(_scenery_background_ground_look_heading)
_scenery_background_ground_look_group = STAGE_PANEL_CHOICE_GROUP_SCRIPT.new() as StagePanelChoiceGroup
_scenery_background_ground_look_group.name = "BackgroundGroundLooks"
_scenery_background_ground_look_group.add_choice(&"finish_background_grass", "Grass", _make_surface_choice_cue(&"background_ground", &"finish_background_grass"))
_scenery_background_ground_look_group.add_choice(&"finish_background_concrete", "Concrete", _make_surface_choice_cue(&"background_ground", &"finish_background_concrete"))
_scenery_background_ground_look_group.add_choice(&"finish_background_carpet", "Carpet", _make_surface_choice_cue(&"background_ground", &"finish_background_carpet"))
_scenery_background_ground_look_group.apply_theme(_sandbox_theme as SandboxTheme)
_scenery_background_ground_look_group.choice_selected.connect(_on_selected_surface_look_choice)
_scenery_surface_section.add_child(_scenery_background_ground_look_group)
_scenery_surface_helper = Label.new()
_scenery_surface_helper.name = "ScenerySurfaceHelper"
_scenery_surface_helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scenery_surface_helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
_scenery_surface_helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_apply_ui_font(_scenery_surface_helper)
_scenery_surface_section.add_child(_scenery_surface_helper)
_scenery_surface_colors = STAGE_SURFACE_COLOR_CONTROLS_SCRIPT.new() as StageSurfaceColorControls
_scenery_surface_colors.name = "ScenerySurfaceColors"
_scenery_surface_colors.configure(Color.WHITE, _recent_surface_colors, _surface_color_presets(), _sandbox_theme as SandboxTheme)
_scenery_surface_colors.color_selected.connect(_on_selected_surface_color_selected)
_scenery_surface_colors.custom_color_requested.connect(_open_selected_surface_color_dialog)
_scenery_surface_section.add_child(_scenery_surface_colors)
body.add_child(_scenery_surface_section)
_scenery_depth_section = VBoxContainer.new()
_scenery_depth_section.name = "SceneryDepthSection"
_scenery_depth_section.add_theme_constant_override("separation", 6)
@@ -3162,6 +3239,9 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_erase_wall_area_button = _make_scenery_repair_button("EraseWallArea", "Erase wall area", true)
_scenery_erase_wall_area_button.pressed.connect(_begin_selected_wall_erase)
body.add_child(_scenery_erase_wall_area_button)
_scenery_erase_piece_button = _make_scenery_repair_button("EraseSceneryPiece", "Erase wall panel", true)
_scenery_erase_piece_button.pressed.connect(_request_erase_selected_scenery)
body.add_child(_scenery_erase_piece_button)
_scenery_find_button = _make_scenery_repair_button("FindArtwork", "Find artwork…", true)
_scenery_find_button.pressed.connect(func() -> void: _begin_scenery_repair(true))
body.add_child(_scenery_find_button)
@@ -3192,10 +3272,53 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_repair_confirm_dialog.canceled.connect(_cancel_scenery_repair_preview)
ui.add_child(_scenery_repair_confirm_dialog)
_apply_dialog_theme(_scenery_repair_confirm_dialog)
_scenery_erase_piece_dialog = ConfirmationDialog.new()
_scenery_erase_piece_dialog.name = "EraseSceneryPieceDialog"
_scenery_erase_piece_dialog.title = "Erase this piece?"
_scenery_erase_piece_dialog.ok_button_text = "Erase"
_scenery_erase_piece_dialog.cancel_button_text = "Keep it"
_scenery_erase_piece_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(520, 260))
_scenery_erase_piece_dialog.confirmed.connect(_confirm_erase_selected_scenery)
_scenery_erase_piece_dialog.canceled.connect(_clear_erase_scenery_targets)
ui.add_child(_scenery_erase_piece_dialog)
_apply_dialog_theme(_scenery_erase_piece_dialog)
_surface_color_dialog = STAGE_SURFACE_COLOR_DIALOG_SCRIPT.new() as StageSurfaceColorDialog
_surface_color_dialog.name = "ScenerySurfaceColorDialog"
ui.add_child(_surface_color_dialog)
_surface_color_dialog.preview_color_changed.connect(_preview_selected_surface_color)
_surface_color_dialog.color_confirmed.connect(_confirm_selected_surface_color)
_surface_color_dialog.cancelled.connect(_on_surface_color_dialog_cancelled)
if not get_viewport().size_changed.is_connected(_refresh_scenery_inspector_layout):
get_viewport().size_changed.connect(_refresh_scenery_inspector_layout)
func _make_scenery_section_heading(label_text: String) -> Label:
var heading := Label.new()
heading.text = label_text
heading.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 12, 40))
heading.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
_apply_ui_font(heading)
return heading
func _make_surface_choice_cue(kind: StringName, finish: StringName) -> StageSurfacePreview:
var cue: StageSurfacePreview = STAGE_SURFACE_PREVIEW_SCRIPT.new() as StageSurfacePreview
cue.custom_minimum_size = Vector2(64.0, 40.0)
cue.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
cue.configure(kind, finish, STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish))
return cue
func _surface_color_presets() -> Array[Color]:
var colors: Array[Color] = []
var raw_presets: Variant = _sandbox_theme.value("widgets.swatches", []) if _sandbox_theme != null else []
if raw_presets is Array:
for raw_color: Variant in raw_presets as Array:
if raw_color is String and STAGE_SCENE_IO._valid_color(raw_color):
colors.append(STAGE_SCENE_IO.color_from_data(raw_color, Color.WHITE))
return colors
func _make_scenery_repair_button(node_name: String, label: String, prominent: bool) -> Button:
var button: Button = Button.new()
button.name = node_name
@@ -4221,6 +4344,8 @@ func _open_selector(kind: String) -> void:
_scenery_group_mode = &""
for pid: String in _palette_buttons:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
if kind == "scenery":
_asset_browser.set_surface_color_history(_recent_surface_colors)
var selector_state: Dictionary = _asset_browser.open_selector(kind, _spawner.get_selected_stickman_path(), _sky_id)
if kind == "stickman":
var selected_stickman_path: String = String(selector_state.get("selected_stickman_path", _spawner.get_selected_stickman_path()))
@@ -4260,12 +4385,25 @@ func _on_asset_selected(entry: Dictionary) -> void:
if background_kind == "wall":
_close_selector()
if _scenery_controller != null:
var finish_id: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
var tint_value: Variant = entry.get("color", STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id))
var tint: Color = tint_value if tint_value is Color else STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id)
_scenery_controller.begin_wall(
StringName(String(entry.get("finish_id", "finish_wall_paint"))),
_viewport_to_world(get_viewport().get_mouse_position())
finish_id,
_viewport_to_world(get_viewport().get_mouse_position()),
tint
)
_refresh_scenery_capture_bar()
return
if background_kind == "background_ground":
_close_selector()
if _scenery_controller != null:
var finish_id: StringName = StringName(String(entry.get("finish_id", "finish_background_grass")))
var tint_value: Variant = entry.get("color", STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id))
var tint: Color = tint_value if tint_value is Color else STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish_id)
_scenery_controller.begin_background_ground(finish_id, _viewport_to_world(get_viewport().get_mouse_position()), tint)
_refresh_scenery_capture_bar()
return
var art_path: String = String(entry.get("path", entry.get("id", "")))
_close_selector()
if _scenery_controller == null or not _scenery_controller.begin_artwork(art_path, &"near"):
@@ -4362,8 +4500,13 @@ func _refresh_scenery_inspector() -> void:
_scenery_depth_section.visible = false
if _scenery_depth_group != null:
_scenery_depth_group.clear_no_signal()
if _scenery_wall_look_group != null:
_scenery_wall_look_group.clear_no_signal()
if _scenery_background_ground_look_group != null:
_scenery_background_ground_look_group.clear_no_signal()
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if not _scenery_edit_mode or selected.size() != 1 or not _is_scenery_node(selected[0]):
_cancel_surface_color_preview()
if _scenery_repair_candidate != null:
_cancel_scenery_repair_preview()
_scenery_repair_target = null
@@ -4371,15 +4514,45 @@ func _refresh_scenery_inspector() -> void:
return
var node: Node2D = selected[0]
var is_artwork: bool = node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT
var is_wall: bool = node.get_script() == STAGE_WALL_PANEL_SCRIPT
var is_background_ground: bool = node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT
var surface: StageRectSurface = node as StageRectSurface if is_wall or is_background_ground else null
var artwork: StageSceneryArtwork = node as StageSceneryArtwork if is_artwork else null
var is_missing: bool = artwork != null and artwork.missing
var surface_is_selected_dialog_target: bool = _surface_color_session_targets(surface)
if not _surface_color_session.is_empty() and not surface_is_selected_dialog_target:
_cancel_surface_color_preview()
_scenery_surface_section.visible = surface != null
_scenery_wall_look_heading.visible = is_wall
_scenery_wall_look_group.visible = is_wall
_scenery_background_ground_look_heading.visible = is_background_ground
_scenery_background_ground_look_group.visible = is_background_ground
_scenery_surface_helper.visible = surface != null
_scenery_surface_colors.visible = false
if surface != null:
if is_wall:
_scenery_wall_look_group.select_no_signal(surface.finish_id)
_scenery_surface_helper.text = "Looks like a wall · Stickmen can pass through."
else:
_scenery_background_ground_look_group.select_no_signal(surface.finish_id)
_scenery_surface_helper.text = "Decorative only · Stickmen don’t walk here."
var color_editable: bool = is_wall or surface.finish_id == &"finish_background_carpet"
_scenery_surface_colors.visible = color_editable
if color_editable:
_scenery_surface_colors.set_selected_no_signal(surface.tint)
_scenery_surface_colors.set_recent_colors(_recent_surface_colors)
_scenery_depth_section.visible = is_artwork
if artwork != null:
_scenery_depth_group.select_no_signal(artwork.depth)
_scenery_size_row.visible = not is_missing or artwork.has_known_bounds
_scenery_smaller_button.disabled = is_missing and not artwork.has_known_bounds
_scenery_bigger_button.disabled = is_missing and not artwork.has_known_bounds
_scenery_erase_wall_area_button.visible = not is_artwork
_scenery_erase_wall_area_button.visible = is_wall
_scenery_erase_piece_button.visible = surface != null
if is_background_ground:
_scenery_erase_piece_button.text = "Erase background ground"
else:
_scenery_erase_piece_button.text = "Erase wall panel"
_scenery_find_button.visible = is_missing
_scenery_choose_button.visible = is_missing
_scenery_keep_button.visible = is_missing
@@ -4390,7 +4563,7 @@ func _refresh_scenery_inspector() -> void:
_scenery_repair_message.text = "“%s” is missing its picture file. Choose artwork to put it back in this spot." % artwork.display_name
else:
_scenery_repair_target = null
var scenery_name: String = (artwork.display_name if artwork != null else "Wall panel")
var scenery_name: String = "Wall panel" if is_wall else "Background ground" if is_background_ground else artwork.display_name
_scenery_inspector.set_header(scenery_name, "🖼️" if artwork != null else "🧱")
_scenery_repair_message.text = "Make this scenery smaller or bigger. The other scenery stays where it is."
_scenery_inspector.set_safe_rect(_prop_settings_safe_rect())
@@ -4419,7 +4592,184 @@ func _on_selected_scenery_depth_choice(id: StringName) -> void:
func _is_scenery_node(node: Node2D) -> bool:
return node != null and (node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT or node.get_script() == STAGE_WALL_PANEL_SCRIPT)
return node != null and (node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT or node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT)
func _on_selected_surface_look_choice(finish: StringName) -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null:
_refresh_scenery_inspector()
return
var is_wall: bool = surface.get_script() == STAGE_WALL_PANEL_SCRIPT
var valid_finish: bool = STAGE_SURFACE_RENDERER_SCRIPT.is_wall_finish(finish) if is_wall else STAGE_SURFACE_RENDERER_SCRIPT.is_background_finish(finish)
if not valid_finish:
_refresh_scenery_inspector()
return
if surface.finish_id != finish:
surface.configure(surface.panel_size, finish, surface.tint)
_mark_scene_dirty()
_refresh_scenery_inspector()
func _selected_rect_surface() -> StageRectSurface:
if not _scenery_edit_mode or current_mode != StageMode.EDIT:
return null
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if selected.size() != 1 or not (selected[0].get_script() == STAGE_WALL_PANEL_SCRIPT or selected[0].get_script() == STAGE_BACKGROUND_GROUND_SCRIPT):
return null
return selected[0] as StageRectSurface
func _on_selected_surface_color_selected(color: Color) -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null:
_refresh_scenery_inspector()
return
var color_changed: bool = not surface.tint.is_equal_approx(color)
if color_changed:
surface.clear_preview_tint()
surface.configure(surface.panel_size, surface.finish_id, color)
_mark_scene_dirty()
_record_recent_surface_color(color)
_scenery_surface_colors.set_selected_no_signal(surface.tint)
func _open_selected_surface_color_dialog() -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null or _surface_color_dialog == null:
return
if surface.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT and surface.finish_id != &"finish_background_carpet":
return
_cancel_surface_color_preview()
var kind: StringName = &"background_ground" if surface.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT else &"wall"
_surface_color_session = {
"target": weakref(surface),
"original_color": surface.tint,
"tentative_color": surface.tint,
"surface_kind": kind,
"finish_id": surface.finish_id
}
_surface_color_dialog.configure(kind, surface.finish_id, surface.tint, _sandbox_theme as SandboxTheme)
_surface_color_dialog.open_dialog()
func _surface_color_session_targets(surface: StageRectSurface) -> bool:
if _surface_color_session.is_empty() or surface == null:
return false
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
return target_ref != null and target_ref.get_ref() == surface
func _preview_selected_surface_color(color: Color) -> void:
if _surface_color_session.is_empty():
return
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
var surface: StageRectSurface = target_ref.get_ref() as StageRectSurface if target_ref != null else null
if surface == null or not _is_surface_live_world_child(surface):
_cancel_surface_color_preview()
return
_surface_color_session["tentative_color"] = color
surface.set_preview_tint(color)
func _confirm_selected_surface_color(color: Color) -> void:
if _surface_color_session.is_empty():
return
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
var surface: StageRectSurface = target_ref.get_ref() as StageRectSurface if target_ref != null else null
if surface == null or not _is_surface_live_world_child(surface):
_cancel_surface_color_preview()
return
var color_changed: bool = not surface.tint.is_equal_approx(color)
surface.clear_preview_tint()
if color_changed:
surface.configure(surface.panel_size, surface.finish_id, color)
_surface_color_session.clear()
_record_recent_surface_color(color)
if color_changed:
_mark_scene_dirty()
_refresh_scenery_inspector()
call_deferred("_focus_surface_color_control")
func _on_surface_color_dialog_cancelled() -> void:
_cancel_surface_color_preview()
call_deferred("_focus_surface_color_control")
func _focus_surface_color_control() -> void:
if _scenery_inspector != null and _scenery_inspector.visible and _scenery_surface_colors != null and _scenery_surface_colors.visible:
_scenery_surface_colors.focus_custom_button()
func _cancel_surface_color_preview() -> void:
if _surface_color_session.is_empty():
if _surface_color_dialog != null and _surface_color_dialog.visible:
_surface_color_dialog.hide()
return
var target_ref: WeakRef = _surface_color_session.get("target") as WeakRef
var surface: StageRectSurface = target_ref.get_ref() as StageRectSurface if target_ref != null else null
if surface != null and is_instance_valid(surface):
surface.clear_preview_tint()
_surface_color_session.clear()
if _surface_color_dialog != null and _surface_color_dialog.visible:
_surface_color_dialog.hide()
if _scenery_inspector != null and is_instance_valid(_scenery_inspector):
_scenery_inspector.queue_sort()
func _is_surface_live_world_child(surface: StageRectSurface) -> bool:
return surface != null and is_instance_valid(surface) and _world != null and is_instance_valid(_world) and surface.get_parent() == _world and _scenery_edit_mode and current_mode == StageMode.EDIT
func _request_erase_selected_scenery() -> void:
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if selected.is_empty() or not _scenery_edit_mode or current_mode != StageMode.EDIT:
return
var targets: Array[Node2D] = []
for node: Node2D in selected:
if _is_scenery_node(node) and node.get_parent() == _world:
targets.append(node)
if targets.is_empty():
return
_clear_erase_scenery_targets()
for node: Node2D in targets:
_scenery_erase_piece_targets.append(weakref(node))
var count: int = targets.size()
if count == 1:
_scenery_erase_piece_dialog.dialog_text = "Erase this piece? You can’t undo this."
else:
_scenery_erase_piece_dialog.dialog_text = "Erase these %d scenery pieces? You can’t undo this." % count
_scenery_erase_piece_dialog.popup_centered()
func _confirm_erase_selected_scenery() -> void:
var erased: Array[Node2D] = []
if not _scenery_edit_mode or current_mode != StageMode.EDIT or _world == null or not is_instance_valid(_world):
_clear_erase_scenery_targets()
return
for target_ref: WeakRef in _scenery_erase_piece_targets:
var node: Node2D = target_ref.get_ref() as Node2D if target_ref != null else null
if node == null or not is_instance_valid(node) or node.get_parent() != _world or not _is_scenery_node(node):
continue
_clear_object_state(node)
node.queue_free()
erased.append(node)
_clear_erase_scenery_targets()
if erased.is_empty():
return
if _selection != null:
_selection.clear_selection()
_selection.clear_hover()
_director_visuals.mark_dirty()
object_deleted.emit(erased)
_mark_scene_dirty()
_refresh_scenery_inspector()
_refresh_status()
func _clear_erase_scenery_targets() -> void:
_scenery_erase_piece_targets.clear()
func _step_selected_scenery_size(factor: float) -> void:
@@ -4546,6 +4896,10 @@ func _enter_scenery_edit_mode() -> void:
func _exit_scenery_edit_mode() -> void:
_cancel_surface_color_preview()
if _scenery_erase_piece_dialog != null:
_scenery_erase_piece_dialog.hide()
_clear_erase_scenery_targets()
if _scenery_repair_confirm_dialog != null:
_scenery_repair_confirm_dialog.hide()
_cancel_scenery_repair_preview()
@@ -4605,7 +4959,16 @@ func _refresh_scenery_capture_bar() -> void:
else:
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "cancel_tool", "label": "✕ Cancel"})
_use_target_capture_bar.set_capture_actions("Drag to draw wall · Stickmen can pass through", actions)
if _scenery_controller.get_active_tool() == &"background_ground":
var ground_prompt: String = "Draw background ground · No walking"
if _scenery_placed_count > 0:
ground_prompt = "Draw another · placed pieces stay"
_use_target_capture_bar.set_capture_actions(ground_prompt, actions)
else:
var wall_prompt: String = "Draw wall panel · Stickmen pass through"
if _scenery_placed_count > 0:
wall_prompt = "Draw another · placed pieces stay"
_use_target_capture_bar.set_capture_actions(wall_prompt, actions)
return
if _scenery_controller != null and _scenery_controller.is_group_preview_pending():
_use_target_capture_bar.clear_horizontal_safe_bounds()
@@ -4622,6 +4985,8 @@ func _refresh_scenery_capture_bar() -> void:
var target_name: String = "scenery"
if target != null and target.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT:
target_name = (target as StageSceneryArtwork).display_name
elif target != null and target.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT:
target_name = "background ground"
elif target != null:
target_name = "wall panel"
var is_move: bool = _scenery_controller.get_single_transform_kind() == &"move"
@@ -4737,7 +5102,7 @@ func _select_all_scenery() -> void:
_selection.clear_selection()
for child: Node in _world.get_children():
var node: Node2D = child as Node2D
if node != null and (node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT):
if node != null and (node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT):
_selection.add_to_selection(node)
_refresh_scenery_capture_bar()
@@ -6920,7 +7285,7 @@ func _world_children_selectable() -> Array[Node2D]:
continue
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
continue
if node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT:
if node.get_script() == STAGE_WALL_PANEL_SCRIPT or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT or node.get_script() == STAGE_SCENERY_ARTWORK_SCRIPT:
continue
result.append(node)
return result
@@ -6984,8 +7349,24 @@ func _load_settings() -> void:
_grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
_snap_enabled = bool(d.get("snap_to_grid", false))
_show_grid = bool(d.get("show_grid", true))
var loaded_recent_scenes: Array[String] = []
var settings_migrated: bool = false
_recent_surface_colors.clear()
var raw_surface_colors: Variant = d.get("recent_surface_colors", [])
var normalized_surface_colors: Array[String] = []
if raw_surface_colors is Array:
for raw_color: Variant in raw_surface_colors as Array:
if not raw_color is String or not STAGE_SCENE_IO._valid_color(raw_color):
continue
var parsed_color: Color = STAGE_SCENE_IO.color_from_data(raw_color, Color.WHITE)
var canonical_color: String = _canonical_surface_color(parsed_color)
if not normalized_surface_colors.has(canonical_color):
normalized_surface_colors.append(canonical_color)
_recent_surface_colors.append(Color.from_string(canonical_color, Color.WHITE))
if normalized_surface_colors.size() >= RECENT_SURFACE_COLOR_LIMIT:
break
if normalized_surface_colors != (raw_surface_colors as Array):
settings_migrated = true
var loaded_recent_scenes: Array[String] = []
var raw_recents: Variant = d.get("recent_scenes", [])
if raw_recents is Array:
for raw: Variant in raw_recents:
@@ -7009,8 +7390,49 @@ func _save_settings() -> void:
"snap_to_grid": _snap_enabled,
"show_grid": _show_grid,
"recent_scenes": _recent_scenes,
"recent_surface_colors": _surface_color_history_strings(),
}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data, "\t", false))
file.close()
func _canonical_surface_color(color: Color) -> String:
var opaque: Color = Color(color.r, color.g, color.b, 1.0)
return "#" + opaque.to_html(false).to_upper()
func _surface_color_history_strings() -> Array[String]:
var result: Array[String] = []
for color: Color in _recent_surface_colors:
result.append(_canonical_surface_color(color))
return result
func _record_recent_surface_color(color: Color) -> void:
var opaque: Color = Color(color.r, color.g, color.b, 1.0)
var canonical: String = _canonical_surface_color(opaque)
var retained: Array[Color] = []
retained.append(opaque)
for existing: Color in _recent_surface_colors:
if _canonical_surface_color(existing) == canonical:
continue
retained.append(existing)
if retained.size() >= RECENT_SURFACE_COLOR_LIMIT:
break
if _surface_color_history_strings() == _colors_to_strings(retained):
return
_recent_surface_colors = retained
_save_settings()
if _asset_browser != null:
_asset_browser.set_surface_color_history(_recent_surface_colors)
if _scenery_surface_colors != null:
_scenery_surface_colors.set_recent_colors(_recent_surface_colors)
func _colors_to_strings(colors: Array[Color]) -> Array[String]:
var result: Array[String] = []
for color: Color in colors:
result.append(_canonical_surface_color(color))
return result
+9 -3
View File
@@ -8,6 +8,7 @@ signal selector_cancel_requested
signal refresh_requested
signal make_artwork_requested
signal edit_scenery_requested
signal surface_color_confirmed(color: Color)
const ASSET_SELECTOR_SCENE := preload("res://scenes/stage/asset_selector.tscn")
const STICKMAN_LIBRARY_SCRIPT := preload("res://scripts/characters/stickman/stickman_library.gd")
@@ -59,6 +60,7 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
selector.refresh_requested.connect(func() -> void: refresh_requested.emit())
selector.make_artwork_requested.connect(func() -> void: make_artwork_requested.emit())
selector.edit_scenery_requested.connect(func() -> void: edit_scenery_requested.emit())
selector.surface_color_confirmed.connect(func(color: Color) -> void: surface_color_confirmed.emit(color))
ui_layer.add_child(selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
selector.apply_font(ui_font, emoji_font)
@@ -84,6 +86,11 @@ func scan_entries(kind: String) -> Array[Dictionary]:
return PROP_LIBRARY_SCRIPT.scan()
func set_surface_color_history(colors: Array[Color]) -> void:
if selector != null:
selector.set_surface_color_history(colors)
func _starter_scene_entries() -> Array[Dictionary]:
return [
{
@@ -148,9 +155,8 @@ func _scenery_background_entries() -> Array[Dictionary]:
{"id": "sky_cloudy_day", "name": "Cloudy Day", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "☁️"},
{"id": "sky_sunset", "name": "Sunset", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌅"},
{"id": "sky_starry_night", "name": "Starry Night", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌙"},
{"id": "finish_wall_paint", "name": "Wall panel · Paint", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_paint", "icon": "🟩"},
{"id": "finish_wall_brick", "name": "Wall panel · Brick", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_brick", "icon": "🧱"},
{"id": "finish_wall_wood", "name": "Wall panel · Wood", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_wood", "icon": "🪵"}
{"id": "wall_panel_setup", "name": "Wall panel", "category": "Scenery", "gallery_tab": "backgrounds", "background_kind": "wall_setup", "icon": "🧱"},
{"id": "background_ground_setup", "name": "Background ground", "category": "Scenery", "description": "Decorative · No walking", "gallery_tab": "backgrounds", "background_kind": "background_ground_setup", "icon": "🌿"}
]
+53
View File
@@ -27,6 +27,7 @@ signal reset_requested
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const COMPACT_TOP_BAR_WIDTH: float = 900.0
var _stage_theme: SandboxTheme
var _current_mode: int = 0
@@ -43,6 +44,8 @@ var _mode_buttons: Dictionary = {}
var _palette_buttons: Dictionary = {}
var _palette_dock: PanelContainer
var _file_menu: MenuButton
var _mode_center: CenterContainer
var _right_controls: HBoxContainer
var _status_label: Label
var _status_cursor_coords: Label
var _direct_hint_label: Label
@@ -405,10 +408,13 @@ func _build_top_bar() -> void:
right_group.offset_bottom = TOP_BAR_HEIGHT - 8.0
right_group.alignment = BoxContainer.ALIGNMENT_END
right_group.add_theme_constant_override("separation", 8)
_right_controls = right_group
chrome.add_child(right_group)
_build_file_menu(left_group)
_build_mode_switcher(chrome)
_build_grid_controls(right_group)
get_viewport().size_changed.connect(_apply_responsive_top_bar_layout)
_apply_responsive_top_bar_layout()
func _build_file_menu(parent: HBoxContainer) -> void:
@@ -442,6 +448,7 @@ func _build_mode_switcher(parent: Control) -> void:
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
center.offset_bottom = TOP_BAR_HEIGHT
_mode_center = center
parent.add_child(center)
var switcher := HBoxContainer.new()
switcher.name = "ModeSwitcher"
@@ -492,6 +499,52 @@ func _build_grid_controls(parent: HBoxContainer) -> void:
parent.add_child(_grid_size_spin)
func _apply_responsive_top_bar_layout() -> void:
if _file_menu == null or _mode_center == null or _right_controls == null:
return
var viewport_width: float = get_viewport().get_visible_rect().size.x
var compact: bool = viewport_width <= COMPACT_TOP_BAR_WIDTH
if compact:
_file_menu.custom_minimum_size = Vector2(64.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", 18)
_mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_center.offset_left = 76.0
_mode_center.offset_top = 8.0
_mode_center.offset_right = 342.0
_mode_center.offset_bottom = TOP_BAR_HEIGHT - 8.0
_right_controls.add_theme_constant_override("separation", 4)
_grid_check.add_theme_font_size_override("font_size", 16)
_snap_check.add_theme_font_size_override("font_size", 16)
_grid_size_label.visible = false
_grid_size_spin.custom_minimum_size = Vector2(56.0, 0.0)
_grid_size_spin.add_theme_font_size_override("font_size", 16)
else:
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
_file_menu.add_theme_font_size_override(
"font_size", _theme_integer("fonts.sizes.file_menu", 22, 12, 48)
)
_mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
_mode_center.offset_bottom = TOP_BAR_HEIGHT
_right_controls.add_theme_constant_override("separation", 8)
_grid_check.remove_theme_font_size_override("font_size")
_snap_check.remove_theme_font_size_override("font_size")
_grid_size_label.visible = true
_grid_size_spin.custom_minimum_size = Vector2.ZERO
_grid_size_spin.remove_theme_font_size_override("font_size")
for mode: int in _mode_buttons:
var button: Button = _mode_buttons[mode] as Button
if compact:
button.custom_minimum_size = Vector2(86.0, 48.0)
button.add_theme_font_size_override("font_size", 18)
else:
button.custom_minimum_size = Vector2(
_theme_number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0), 48.0
)
button.add_theme_font_size_override(
"font_size", _theme_integer("fonts.sizes.mode_switcher", 24, 12, 48)
)
func _build_palette(entries: Array[Dictionary]) -> void:
_palette_dock = PanelContainer.new()
_palette_dock.name = "PaletteDock"
+19 -1
View File
@@ -11,6 +11,7 @@ const PROP_LIBRARY := preload("res://scripts/props/prop_library.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_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 STAGE_DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
@@ -24,7 +25,7 @@ func collect(
) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0, "wall_panel": 0, "scenery_artwork": 0}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0, "wall_panel": 0, "background_ground": 0, "scenery_artwork": 0}
for node: Node in world.get_children():
if not node is Node2D:
continue
@@ -54,6 +55,11 @@ func collect(
if stage_node is TerrainBlock and String(stage_node.get("spawn_id")) == "ground":
var ground_finish: StringName = (stage_node as TerrainBlock).finish_id
entry["finish_id"] = String(ground_finish) if not ground_finish.is_empty() else "finish_ground_legacy"
elif stage_node.get_script() == STAGE_BACKGROUND_GROUND:
var background: StageBackgroundGround = stage_node as StageBackgroundGround
entry["size"] = STAGE_SCENE_IO.vector_to_data(background.panel_size)
entry["finish_id"] = String(background.finish_id)
entry["color"] = "#" + background.tint.to_html(true)
elif stage_node.get_script() == STAGE_WALL_PANEL:
var wall: StageWallPanel = stage_node as StageWallPanel
entry["size"] = STAGE_SCENE_IO.vector_to_data(wall.panel_size)
@@ -168,6 +174,16 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
wall.motif_origin_offset = STAGE_SCENE_IO.data_to_vector(entry.get("motif_origin_offset"), Vector2.ZERO)
world.add_child(wall)
node = wall
"background_ground":
var background := STAGE_BACKGROUND_GROUND.new() as StageBackgroundGround
background.position = position
background.configure(
STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2(800.0, 300.0)),
StringName(String(entry.get("finish_id", "finish_background_grass"))),
STAGE_SCENE_IO.color_from_data(entry.get("color", "#72A84CFF"), Color("#72A84C"))
)
world.add_child(background)
node = background
"scenery_artwork":
var artwork := STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
var raw_bounds: Variant = entry.get("last_known_local_bounds", {})
@@ -285,6 +301,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
func _object_type(node: Node2D) -> String:
if node.get_script() == STAGE_WALL_PANEL:
return "wall_panel"
if node.get_script() == STAGE_BACKGROUND_GROUND:
return "background_ground"
if node.get_script() == STAGE_SCENERY_ARTWORK:
return "scenery_artwork"
if node is TerrainBlock:
@@ -8,6 +8,8 @@ signal preview_state_changed(active: bool)
signal single_gesture_changed(active: bool)
const WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const RECT_SURFACE := preload("res://scripts/stage/scenery/stage_rect_surface.gd")
const SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
@@ -15,6 +17,7 @@ const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const MIN_PANEL_SIZE: float = 24.0
const MIN_WALL_ERASE_SIZE: float = 8.0
const DEFAULT_PANEL_SIZE: Vector2 = Vector2(240.0, 280.0)
const DEFAULT_BACKGROUND_SIZE: Vector2 = Vector2(800.0, 320.0)
const WALL_ERASER_CURSOR_SIZE: Vector2 = Vector2(120.0, 120.0)
const MIN_ART_SCALE: float = 0.08
const MAX_ART_SCALE: float = 12.0
@@ -112,6 +115,12 @@ func get_active_artwork_name() -> String:
return _asset_path.get_file().get_basename()
func get_active_surface_label() -> String:
if _active_tool == &"background_ground":
return "background ground"
return "wall"
func handle_key(event: InputEventKey) -> bool:
if is_active() and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
exit_tool()
@@ -144,17 +153,31 @@ func begin_artwork(path: String, depth: StringName = &"near") -> bool:
func begin_wall(finish: StringName, initial_world_position: Vector2, tint: Color = Color("#5A9D70")) -> void:
_begin_rect_surface(&"wall", finish, initial_world_position, tint)
func begin_background_ground(finish: StringName, initial_world_position: Vector2, tint: Color) -> void:
_begin_rect_surface(&"background_ground", finish, initial_world_position, tint)
func _begin_rect_surface(tool: StringName, finish: StringName, initial_world_position: Vector2, tint: Color) -> void:
if _world == null or _ghost_holder == null:
return
exit_tool()
_active_tool = &"wall"
_active_tool = tool
_wall_finish = finish
_wall_tint = tint
_wall_anchor = Vector2.INF
_wall_dragging = false
_wall_anchor_active = false
var panel := WALL_PANEL.new() as StageWallPanel
panel.configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
var panel: StageRectSurface
var initial_size: Vector2 = DEFAULT_PANEL_SIZE
if tool == &"background_ground":
panel = BACKGROUND_GROUND.new() as StageBackgroundGround
initial_size = DEFAULT_BACKGROUND_SIZE
else:
panel = WALL_PANEL.new() as StageWallPanel
panel.configure(initial_size, _wall_finish, _wall_tint)
panel.modulate = Color(1.0, 1.0, 1.0, 0.45)
_ghost_holder.add_child(panel)
_ghost = panel
@@ -204,14 +227,14 @@ func update_pointer(world_position: Vector2) -> void:
if not is_active() or _ghost == null or not is_instance_valid(_ghost):
return
var snapped_position: Vector2 = _snap(world_position)
if _active_tool == &"wall" and _wall_dragging:
if _is_rect_placement_tool() and _wall_dragging:
var size: Vector2 = Vector2(absf(snapped_position.x - _wall_drag_start.x), absf(snapped_position.y - _wall_drag_start.y))
size.x = maxf(MIN_PANEL_SIZE, size.x)
size.y = maxf(MIN_PANEL_SIZE, size.y)
(_ghost as StageWallPanel).configure(size, _wall_finish, _wall_tint)
(_ghost as StageRectSurface).configure(size, _wall_finish, _wall_tint)
var top_left: Vector2 = Vector2(minf(_wall_drag_start.x, snapped_position.x), minf(_wall_drag_start.y, snapped_position.y))
_ghost.global_position = top_left + size * 0.5
elif _active_tool == &"wall":
elif _is_rect_placement_tool():
var preview_top_left: Vector2 = _snap(world_position) if _snap_enabled else world_position
_ghost.global_position = _move_node_visible_top_left(_ghost, preview_top_left)
elif _active_tool == &"wall_eraser":
@@ -234,7 +257,7 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
exit_tool()
return true
var completes_drag: bool = not event.pressed and event.button_index == MOUSE_BUTTON_LEFT and (
(_active_tool == &"wall" and _wall_dragging)
(_is_rect_placement_tool() and _wall_dragging)
or (_active_tool == &"wall_eraser" and _wall_erase_dragging)
)
if (over_ui and not completes_drag) or event.button_index != MOUSE_BUTTON_LEFT:
@@ -243,7 +266,7 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
if event.pressed:
_commit_artwork(world_position)
return true
if _active_tool == &"wall":
if _is_rect_placement_tool():
if event.pressed:
# Refresh from the press coordinates in case no mouse-motion event was
# delivered after the palette closed. The visible ghost corner is the
@@ -262,12 +285,12 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
if size.x < MIN_PANEL_SIZE or size.y < MIN_PANEL_SIZE:
_wall_anchor_active = false
_wall_anchor = Vector2.INF
_restore_wall_ghost(world_position)
_restore_rect_surface_ghost(world_position)
return true
_commit_wall(_wall_drag_start, end)
_commit_rect_surface(_wall_drag_start, end)
_wall_anchor = Vector2.INF
_wall_anchor_active = false
_restore_wall_ghost(world_position)
_restore_rect_surface_ghost(world_position)
return true
if _active_tool == &"wall_eraser":
if event.pressed:
@@ -304,8 +327,8 @@ func cancel_active_gesture() -> void:
_wall_anchor_active = false
_wall_erase_dragging = false
_wall_erase_start = Vector2.INF
if _active_tool == &"wall" and _ghost != null and is_instance_valid(_ghost):
(_ghost as StageWallPanel).configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
if _is_rect_placement_tool() and _ghost != null and is_instance_valid(_ghost):
(_ghost as StageRectSurface).configure(_default_size_for_active_surface(), _wall_finish, _wall_tint)
elif _active_tool == &"wall_eraser" and _ghost != null and is_instance_valid(_ghost):
_set_wall_eraser_preview_rect(Rect2(_wall_erase_pointer, WALL_ERASER_CURSOR_SIZE))
@@ -334,10 +357,10 @@ func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool:
return false
var snapshot: Dictionary = {"node": member, "position": member.global_position, "rotation": member.global_rotation, "scale": member.scale}
var bounds: Rect2
if member.get_script() == WALL_PANEL:
var wall: StageWallPanel = member as StageWallPanel
snapshot["panel_size"] = wall.panel_size
bounds = member.global_transform * wall.get_local_bounds()
if member is StageRectSurface:
var surface: StageRectSurface = member as StageRectSurface
snapshot["panel_size"] = surface.panel_size
bounds = member.global_transform * surface.get_local_bounds()
elif member.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = member as StageSceneryArtwork
bounds = STAGE_SELECTION.get_world_aabb(member)
@@ -369,7 +392,7 @@ func preview_group(delta: Vector2, factor: float) -> bool:
if member == null or not is_instance_valid(member):
return false
if _group_kind == &"resize":
if member.get_script() == WALL_PANEL:
if member is StageRectSurface:
var next_size: Vector2 = (snapshot["panel_size"] as Vector2) * factor
if next_size.x < MIN_PANEL_SIZE or next_size.y < MIN_PANEL_SIZE:
return false
@@ -389,9 +412,9 @@ func preview_group(delta: Vector2, factor: float) -> bool:
var new_scale: Vector2 = art_scale * factor
member.global_position = anchor + ((snapshot["position"] as Vector2) - anchor) * factor
member.global_rotation = float(snapshot["rotation"])
if member.get_script() == WALL_PANEL:
var wall: StageWallPanel = member as StageWallPanel
wall.configure((snapshot["panel_size"] as Vector2) * factor, wall.finish_id, wall.tint)
if member is StageRectSurface:
var surface: StageRectSurface = member as StageRectSurface
surface.configure((snapshot["panel_size"] as Vector2) * factor, surface.finish_id, surface.tint)
else:
member.scale = new_scale
return true
@@ -410,7 +433,7 @@ func commit_group() -> void:
func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dragged_corner: Vector2 = Vector2.INF) -> bool:
if _single_active or _group_pending or node == null or not is_instance_valid(node) or kind not in [&"move", &"resize"]:
return false
if node.get_script() != WALL_PANEL and node.get_script() != SCENERY_ARTWORK:
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return false
if not pointer.is_finite():
return false
@@ -434,8 +457,8 @@ func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dr
"dragged_corner": corners[corner_index],
"pivot": _opposite_corner(bounds, corner_index),
}
if node.get_script() == WALL_PANEL:
snapshot["panel_size"] = (node as StageWallPanel).panel_size
if node is StageRectSurface:
snapshot["panel_size"] = (node as StageRectSurface).panel_size
else:
var initial_scale: Vector2 = node.scale
if not initial_scale.is_finite() or initial_scale.x <= 0.0 or initial_scale.y <= 0.0:
@@ -502,7 +525,7 @@ func cancel_single_transform() -> void:
func step_single_size(node: Node2D, factor: float) -> bool:
if node == null or not is_instance_valid(node) or not is_finite(factor) or factor <= 0.0 or _single_active or _group_pending:
return false
if node.get_script() != WALL_PANEL and node.get_script() != SCENERY_ARTWORK:
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return false
var bounds: Rect2 = _world_bounds(node)
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
@@ -518,7 +541,7 @@ func step_single_size(node: Node2D, factor: float) -> bool:
"scale": node.scale,
"bounds": bounds,
"pivot": bounds.position,
"panel_size": (node as StageWallPanel).panel_size if node.get_script() == WALL_PANEL else Vector2.ZERO,
"panel_size": (node as StageRectSurface).panel_size if node is StageRectSurface else Vector2.ZERO,
}
var pivot: Vector2 = bounds.position
if not _can_apply_single_scale(snapshot, factor):
@@ -540,9 +563,9 @@ func cancel_group() -> void:
member.global_position = snapshot["position"] as Vector2
member.global_rotation = float(snapshot["rotation"])
member.scale = snapshot["scale"] as Vector2
if member.get_script() == WALL_PANEL and snapshot.has("panel_size"):
var wall: StageWallPanel = member as StageWallPanel
wall.configure(snapshot["panel_size"] as Vector2, wall.finish_id, wall.tint)
if member is StageRectSurface and snapshot.has("panel_size"):
var surface: StageRectSurface = member as StageRectSurface
surface.configure(snapshot["panel_size"] as Vector2, surface.finish_id, surface.tint)
_group_pending = false
_group_snapshots.clear()
_group_kind = &""
@@ -553,7 +576,7 @@ static func bounds_for(members: Array[Node2D]) -> Rect2:
var result: Rect2 = Rect2()
var has_rect: bool = false
for member: Node2D in members:
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(member) if member.get_script() == SCENERY_ARTWORK else (member.global_transform * (member as StageWallPanel).get_local_bounds())
var rect: Rect2 = (member as StageRectSurface).global_transform * (member as StageRectSurface).get_local_bounds() if member is StageRectSurface else STAGE_SELECTION.get_world_aabb(member)
result = rect if not has_rect else result.merge(rect)
has_rect = true
return result
@@ -572,9 +595,13 @@ func _commit_artwork(position: Vector2) -> void:
document_change_requested.emit()
func _commit_wall(first: Vector2, second: Vector2) -> void:
func _commit_rect_surface(first: Vector2, second: Vector2) -> void:
var size: Vector2 = Vector2(maxf(MIN_PANEL_SIZE, absf(second.x - first.x)), maxf(MIN_PANEL_SIZE, absf(second.y - first.y)))
var panel := WALL_PANEL.new() as StageWallPanel
var panel: StageRectSurface
if _active_tool == &"background_ground":
panel = BACKGROUND_GROUND.new() as StageBackgroundGround
else:
panel = WALL_PANEL.new() as StageWallPanel
panel.configure(size, _wall_finish, _wall_tint)
panel.global_position = (first + second) * 0.5
_world.add_child(panel)
@@ -582,13 +609,21 @@ func _commit_wall(first: Vector2, second: Vector2) -> void:
document_change_requested.emit()
func _restore_wall_ghost(world_position: Vector2) -> void:
if _ghost == null or not is_instance_valid(_ghost) or _ghost.get_script() != WALL_PANEL:
func _restore_rect_surface_ghost(world_position: Vector2) -> void:
if _ghost == null or not is_instance_valid(_ghost) or not (_ghost is StageRectSurface):
return
(_ghost as StageWallPanel).configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
(_ghost as StageRectSurface).configure(_default_size_for_active_surface(), _wall_finish, _wall_tint)
update_pointer(world_position)
func _is_rect_placement_tool() -> bool:
return _active_tool in [&"wall", &"background_ground"]
func _default_size_for_active_surface() -> Vector2:
return DEFAULT_BACKGROUND_SIZE if _active_tool == &"background_ground" else DEFAULT_PANEL_SIZE
func _erase_wall_region(world_rect: Rect2) -> int:
if _world == null or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
return 0
@@ -695,7 +730,7 @@ func _can_apply_single_scale(snapshot: Dictionary, factor: float) -> bool:
var next_position: Vector2 = (snapshot["pivot"] as Vector2) + ((snapshot["position"] as Vector2) - (snapshot["pivot"] as Vector2)) * factor
if not next_position.is_finite():
return false
if node.get_script() == WALL_PANEL:
if node is StageRectSurface:
var next_size: Vector2 = (snapshot["panel_size"] as Vector2) * factor
return next_size.is_finite() and next_size.x >= MIN_PANEL_SIZE and next_size.y >= MIN_PANEL_SIZE
var start_scale: Vector2 = snapshot["scale"] as Vector2
@@ -707,10 +742,10 @@ func _apply_single_scale_to_snapshot(snapshot: Dictionary, factor: float, pivot:
var node: Node2D = snapshot["node"] as Node2D
node.global_position = pivot + ((snapshot["position"] as Vector2) - pivot) * factor
node.global_rotation = float(snapshot["rotation"])
if node.get_script() == WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall.scale = snapshot["scale"] as Vector2
wall.configure((snapshot["panel_size"] as Vector2) * factor, wall.finish_id, wall.tint)
if node is StageRectSurface:
var surface: StageRectSurface = node as StageRectSurface
surface.scale = snapshot["scale"] as Vector2
surface.configure((snapshot["panel_size"] as Vector2) * factor, surface.finish_id, surface.tint)
else:
node.scale = (snapshot["scale"] as Vector2) * factor
@@ -722,9 +757,9 @@ func _restore_single_snapshot() -> void:
node.global_position = _single_snapshot["position"] as Vector2
node.global_rotation = float(_single_snapshot["rotation"])
node.scale = _single_snapshot["scale"] as Vector2
if node.get_script() == WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall.configure(_single_snapshot["panel_size"] as Vector2, wall.finish_id, wall.tint)
if node is StageRectSurface:
var surface: StageRectSurface = node as StageRectSurface
surface.configure(_single_snapshot["panel_size"] as Vector2, surface.finish_id, surface.tint)
func _single_transform_changed() -> bool:
@@ -739,14 +774,14 @@ func _snapshot_matches_current(snapshot: Dictionary) -> bool:
return false
if not is_equal_approx(node.global_rotation, float(snapshot["rotation"])) or not node.scale.is_equal_approx(snapshot["scale"] as Vector2):
return false
if node.get_script() == WALL_PANEL:
return (node as StageWallPanel).panel_size.is_equal_approx(snapshot["panel_size"] as Vector2)
if node is StageRectSurface:
return (node as StageRectSurface).panel_size.is_equal_approx(snapshot["panel_size"] as Vector2)
return true
func _world_bounds(node: Node2D) -> Rect2:
if node.get_script() == WALL_PANEL:
return node.global_transform * (node as StageWallPanel).get_local_bounds()
if node is StageRectSurface:
return node.global_transform * (node as StageRectSurface).get_local_bounds()
if node.get_script() == SCENERY_ARTWORK:
return (node as StageSceneryArtwork).get_visual_world_bounds()
return STAGE_SELECTION.get_world_aabb(node)
@@ -789,7 +824,7 @@ func _snap_visual_center(node: Node2D, desired_center: Vector2) -> Vector2:
func _move_node_visible_top_left(node: Node2D, desired_top_left: Vector2) -> Vector2:
if node.get_script() not in [WALL_PANEL, SCENERY_ARTWORK]:
if not (node is StageRectSurface) and node.get_script() != SCENERY_ARTWORK:
return desired_top_left
var bounds: Rect2 = _world_bounds(node)
return node.global_position + desired_top_left - bounds.position
@@ -12,9 +12,11 @@ const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0
const ACTION_ROW_MIN_WIDTH: float = 480.0
const ACTION_BUTTON_WIDTH: float = 200.0
const DEFAULT_BUTTON_HEIGHT: float = 56.0
const NARROW_COMPACT_WIDTH: float = 244.0
var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
var _layout_stash: Node
var _horizontal_layout: HBoxContainer
var _vertical_layout: VBoxContainer
var _wrapped_layout: VBoxContainer
@@ -76,6 +78,7 @@ func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
var button := Button.new()
button.name = "Action_%s" % String(action_id)
button.text = String(action.get("label", action_id))
button.tooltip_text = button.text
button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
button.focus_mode = Control.FOCUS_ALL
button.pressed.connect(func() -> void: capture_action_requested.emit(action_id))
@@ -154,15 +157,22 @@ func apply_theme() -> void:
func _build_controls() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
_layout_stash = Node.new()
_layout_stash.name = "LayoutStash"
add_child(_layout_stash)
_horizontal_layout = HBoxContainer.new()
_horizontal_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_horizontal_layout.add_theme_constant_override("separation", 20)
_vertical_layout = VBoxContainer.new()
_vertical_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_vertical_layout.add_theme_constant_override("separation", 12)
_vertical_layout.visible = false
_wrapped_layout = VBoxContainer.new()
_wrapped_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_wrapped_layout.add_theme_constant_override("separation", 10)
_wrapped_layout.visible = false
_layout_stash.add_child(_vertical_layout)
_layout_stash.add_child(_wrapped_layout)
_active_layout = _horizontal_layout
add_child(_active_layout)
_prompt = Label.new()
@@ -178,8 +188,11 @@ func _build_controls() -> void:
_actions_row.alignment = BoxContainer.ALIGNMENT_END
_actions_column = VBoxContainer.new()
_actions_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_actions_column.visible = false
_layout_stash.add_child(_actions_column)
_actions_parent = _actions_row
_active_layout.add_child(_actions_parent)
_actions_row.visible = true
_change_prop_button = Button.new()
_change_prop_button.name = "ChangeProp"
_change_prop_button.text = "Change prop"
@@ -250,8 +263,13 @@ func _layout_capture_bar() -> void:
safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset))
var available_width: float = maxf(1.0, safe_right - safe_left)
var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
var narrow_compact: bool = width < NARROW_COMPACT_WIDTH
custom_minimum_size.x = width
size.x = width
clip_contents = true
_prompt.add_theme_font_size_override("font_size", 18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34))
_prompt.max_lines_visible = 2 if narrow_compact else 3
_set_compact_panel_style(narrow_compact)
var left: float = safe_left + (available_width - width) * 0.5 if not _has_horizontal_safe_bounds else safe_left
position = Vector2(left, _top_bar_height + 56.0)
var many_actions: bool = _custom_action_buttons.size() > 3
@@ -259,6 +277,7 @@ func _layout_capture_bar() -> void:
var wrapped: bool = (_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions)
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped, stack_actions, wrapped)
_fit_custom_action_widths(width, wrapped)
_apply_compact_action_labels(narrow_compact)
call_deferred("_fit_height_to_content")
@@ -273,8 +292,12 @@ func _set_layout(compact: bool, stack_actions: bool, wrapped: bool = false) -> v
previous_actions.remove_child(_change_prop_button)
previous_actions.remove_child(_cancel_button)
_active_layout.remove_child(previous_actions)
previous_actions.visible = false
_layout_stash.add_child(previous_actions)
_actions_parent = target_actions
_layout_stash.remove_child(_actions_parent)
_active_layout.add_child(_actions_parent)
_actions_parent.visible = true
_actions_parent.add_child(_change_prop_button)
_actions_parent.add_child(_cancel_button)
for button: Button in _custom_action_buttons:
@@ -282,9 +305,13 @@ func _set_layout(compact: bool, stack_actions: bool, wrapped: bool = false) -> v
if _active_layout != target_layout:
_active_layout.remove_child(_prompt)
_active_layout.remove_child(_actions_parent)
_active_layout.visible = false
remove_child(_active_layout)
_layout_stash.add_child(_active_layout)
_active_layout = target_layout
_layout_stash.remove_child(_active_layout)
add_child(_active_layout)
_active_layout.visible = true
_active_layout.add_child(_prompt)
_active_layout.add_child(_actions_parent)
_change_prop_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH
@@ -297,18 +324,60 @@ func _fit_custom_action_widths(width: float, wrapped: bool) -> void:
var button_width: float = ACTION_BUTTON_WIDTH
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
var action_font_size: int = maxi(18, body_size - (4 if wrapped else 0))
if width < NARROW_COMPACT_WIDTH:
button_width = 0.0
action_font_size = 16
if wrapped and not _custom_action_buttons.is_empty():
var gaps: float = float(maxi(0, _custom_action_buttons.size() - 1) * 12)
button_width = minf(ACTION_BUTTON_WIDTH, maxf(144.0, (width - 44.0 - gaps) / float(_custom_action_buttons.size())))
for button: Button in _custom_action_buttons:
button.custom_minimum_size.x = button_width
button.add_theme_font_size_override("font_size", action_font_size)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width < NARROW_COMPACT_WIDTH else Control.SIZE_FILL
func _apply_compact_action_labels(compact: bool) -> void:
var compact_labels: Dictionary = {
"select_several": "Multi",
"select_all": "All",
"clear_selection": "Clear",
"done_edit": "Done",
"move_group": "Move",
"resize_group": "Size",
"keep_group": "Keep",
"cancel_group": "Cancel",
"done_tool": "Done",
"cancel_tool": "Cancel",
"cancel_single": "Cancel",
"depth_far": "Far",
"depth_near": "Near",
"depth_floor": "Floor",
}
for button: Button in _custom_action_buttons:
var action_id: String = button.name.trim_prefix("Action_")
var full_label: String = button.tooltip_text
button.text = String(compact_labels.get(action_id, full_label)) if compact else full_label
func _set_compact_panel_style(compact: bool) -> void:
var panel_style: StyleBoxFlat = get_theme_stylebox("panel") as StyleBoxFlat
if panel_style == null:
return
panel_style.border_width_left = 4 if compact else 8
panel_style.content_margin_left = 8.0 if compact else 20.0
panel_style.content_margin_right = 8.0 if compact else 18.0
panel_style.content_margin_top = 8.0 if compact else 14.0
panel_style.content_margin_bottom = 8.0 if compact else 14.0
func _fit_height_to_content() -> void:
if not is_inside_tree() or not visible:
return
custom_minimum_size.y = get_combined_minimum_size().y
var target_height: float = _active_layout.get_combined_minimum_size().y
var panel_style: StyleBox = get_theme_stylebox("panel")
if panel_style != null:
target_height += panel_style.get_minimum_size().y
custom_minimum_size.y = target_height
size.y = custom_minimum_size.y