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
@@ -166,8 +166,8 @@ func _build_shell() -> void:
func _fit_to_content() -> void:
_fit_pending = false
if not is_inside_tree() or _body == null:
_fit_pending = false
return
var rect := _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
@@ -179,7 +179,16 @@ func _fit_to_content() -> void:
size.x = card_width
await get_tree().process_frame
if not is_inside_tree():
_fit_pending = false
return
# A resize can arrive while the first layout pass is waiting for the body
# minimum size. Re-read the safe rectangle so this pass uses the new bounds.
rect = _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
rect = get_viewport_rect()
card_width = minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
custom_minimum_size.x = card_width
size.x = card_width
var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28))
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
@@ -189,10 +198,13 @@ func _fit_to_content() -> void:
var natural_height := fixed_height + body_height
var card_height := minf(natural_height, available_height)
var scroll_height := maxf(0.0, card_height - fixed_height)
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
# Update the scroll minimum before shrinking the PanelContainer. Otherwise its
# previous full-content minimum can clamp the new height until another layout.
_scroll.custom_minimum_size.y = scroll_height
_scroll.update_minimum_size()
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
_fit_pending = false
func _style_action_button(button: Button, prominent: bool) -> void:
@@ -0,0 +1,147 @@
class_name StageSurfaceColorControls
extends VBoxContainer
## Shared wrapping Color history and preset controls for scenery surfaces.
signal color_selected(color: Color)
signal custom_color_requested
const COLOR_SWATCH_SELECTOR := preload("res://scripts/shared/ui/color_swatch_selector.gd")
var _stage_theme: SandboxTheme
var _selected: Color = Color.WHITE
var _recents: Array[Color] = []
var _presets: Array[Color] = []
var _built: bool = false
var _current_chip: Button
var _current_value: Label
var _recent_empty_label: Label
var _recent_swatches: ColorSwatchSelector
var _preset_swatches: ColorSwatchSelector
var _more_button: Button
func configure(selected: Color, recents: Array[Color], presets: Array[Color], stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
_selected = selected
_recents = recents.duplicate()
_presets = presets.duplicate()
if not _built:
_build()
_refresh_visuals()
func set_selected_no_signal(color: Color) -> void:
_selected = color
_refresh_current_color()
_recent_swatches.set_selected(color)
_preset_swatches.set_selected(color)
func set_recent_colors(colors: Array[Color]) -> void:
if _recents == colors:
return
_recents = colors.duplicate()
_recent_empty_label.visible = _recents.is_empty()
_recent_swatches.visible = not _recents.is_empty()
_recent_swatches.configure(_recents, _selected, _stage_theme)
func focus_custom_button() -> void:
if _more_button != null:
_more_button.grab_focus()
func _build() -> void:
_built = true
add_theme_constant_override("separation", _integer("stage_surface_colors.section_gap", 8, 0, 28))
var current_heading: Label = _make_heading("Color")
add_child(current_heading)
var current_row := HBoxContainer.new()
current_row.add_theme_constant_override("separation", _integer("stage_surface_colors.section_gap", 8, 0, 28))
add_child(current_row)
_current_chip = Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48, 44, 96))
_current_chip.custom_minimum_size = Vector2(swatch_size, swatch_size)
_current_chip.focus_mode = Control.FOCUS_NONE
_current_chip.mouse_filter = Control.MOUSE_FILTER_IGNORE
current_row.add_child(_current_chip)
_current_value = Label.new()
_current_value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_current_value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
current_row.add_child(_current_value)
add_child(_make_heading("Recently used"))
_recent_empty_label = Label.new()
_recent_empty_label.text = "Colors you use will appear here"
_recent_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_recent_empty_label.add_theme_color_override("font_color", _color("global.muted_text_color", Color("#AAB2C0")))
add_child(_recent_empty_label)
_recent_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_recent_swatches.color_selected.connect(_on_swatch_selected)
add_child(_recent_swatches)
add_child(_make_heading("Colors"))
_preset_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_preset_swatches.color_selected.connect(_on_swatch_selected)
add_child(_preset_swatches)
_more_button = Button.new()
_more_button.text = "More colors…"
_more_button.custom_minimum_size.y = float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
_more_button.focus_mode = Control.FOCUS_ALL
_more_button.pressed.connect(func() -> void: custom_color_requested.emit())
add_child(_more_button)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
_current_value.add_theme_font_override("font", font)
_more_button.add_theme_font_override("font", font)
_current_value.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_more_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_more_button.add_theme_color_override("font_color", _color("global.text_color", Color("#F5F7FA")))
_more_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
_more_button.add_theme_stylebox_override("hover", _stage_theme.stylebox("stage_panels.action").duplicate())
func _refresh_visuals() -> void:
if not _built:
return
_refresh_current_color()
_recent_empty_label.visible = _recents.is_empty()
_recent_swatches.visible = not _recents.is_empty()
_recent_swatches.configure(_recents, _selected, _stage_theme)
_preset_swatches.configure(_presets, _selected, _stage_theme)
func _refresh_current_color() -> void:
_current_value.text = "#%s" % _selected.to_html(false).to_upper()
var chip_style := StyleBoxFlat.new()
chip_style.bg_color = _selected
chip_style.border_color = _color("stage_panels.choice.border_color", Color("#FFFFFFAA"))
chip_style.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2, 0, 8))
chip_style.set_corner_radius_all(_integer("stage_surface_colors.swatch_corner_radius", 8, 0, 24))
_current_chip.add_theme_stylebox_override("normal", chip_style)
_current_chip.add_theme_stylebox_override("hover", chip_style.duplicate())
func _on_swatch_selected(color: Color) -> void:
_selected = color
_refresh_current_color()
_recent_swatches.set_selected(color)
_preset_swatches.set_selected(color)
color_selected.emit(color)
func _make_heading(text_value: String) -> Label:
var heading := Label.new()
heading.text = text_value
heading.add_theme_font_size_override("font_size", _integer("stage_panels.type.section_size", 20, 12, 40))
heading.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if _stage_theme != null and _stage_theme.font() != null:
heading.add_theme_font_override("font", _stage_theme.font())
return heading
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://d37coqfbju18u
@@ -0,0 +1,160 @@
class_name StageSurfaceColorDialog
extends Window
## Bounded custom-color dialog with a live Stage surface preview.
signal preview_color_changed(color: Color)
signal color_confirmed(color: Color)
signal cancelled
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
var _surface_kind: StringName = &"wall"
var _finish_id: StringName = &"finish_wall_paint"
var _initial_color: Color = Color.WHITE
var _stage_theme: SandboxTheme
var _color_picker: ColorPicker
var _surface_preview: StageSurfacePreview
var _built: bool = false
func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void:
_surface_kind = surface_kind
_finish_id = finish
_initial_color = color
_stage_theme = stage_theme
if not _built:
_build()
_color_picker.color = color
_update_preview(color)
title = "Choose a color"
exclusive = true
transient = true
theme = stage_theme.control_theme() if stage_theme != null else null
func open_dialog() -> void:
var max_size: Vector2i = _usable_window_size()
var preferred_size := Vector2i(560, 640)
var raw_size: Variant = _stage_theme.value("popups.dialog.surface_color_size", [560, 640]) if _stage_theme != null else [560, 640]
if raw_size is Array and (raw_size as Array).size() == 2:
preferred_size = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1]))
var dialog_size := Vector2i(mini(preferred_size.x, max_size.x), mini(preferred_size.y, max_size.y))
dialog_size.x = maxi(1, dialog_size.x)
dialog_size.y = maxi(1, dialog_size.y)
size = dialog_size
popup_centered(dialog_size)
_color_picker.grab_focus()
func _unhandled_key_input(event: InputEvent) -> void:
if visible and event is InputEventKey and (event as InputEventKey).pressed and not (event as InputEventKey).echo and (event as InputEventKey).keycode == KEY_ESCAPE:
_cancel()
get_viewport().set_input_as_handled()
func _build() -> void:
_built = true
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
for side: String in ["left", "right", "top", "bottom"]:
margin.add_theme_constant_override("margin_" + side, 12)
add_child(margin)
var layout := VBoxContainer.new()
layout.add_theme_constant_override("separation", 8)
margin.add_child(layout)
var preview_heading := Label.new()
preview_heading.text = "Preview"
layout.add_child(preview_heading)
_surface_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
var preview_size := Vector2(420.0, 140.0)
var raw_preview: Variant = _stage_theme.value("popups.dialog.surface_color_preview_size", [420, 140]) if _stage_theme != null else [420, 140]
if raw_preview is Array and (raw_preview as Array).size() == 2:
preview_size = Vector2(float((raw_preview as Array)[0]), float((raw_preview as Array)[1]))
preview_size.x = minf(preview_size.x, maxf(1.0, float(_usable_window_size().x - 56)))
_surface_preview.custom_minimum_size = preview_size
_surface_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_surface_preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
layout.add_child(_surface_preview)
var picker_scroll := ScrollContainer.new()
picker_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
picker_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
picker_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
layout.add_child(picker_scroll)
_color_picker = ColorPicker.new()
_color_picker.name = "ColorPickerControl"
var picker_min_size := Vector2(320.0, 340.0)
var raw_picker_size: Variant = _stage_theme.value("popups.dialog.surface_color_picker_min_size", [320, 340]) if _stage_theme != null else [320, 340]
if raw_picker_size is Array and (raw_picker_size as Array).size() == 2:
picker_min_size = Vector2(float((raw_picker_size as Array)[0]), float((raw_picker_size as Array)[1]))
picker_min_size.x = minf(picker_min_size.x, maxf(1.0, float(_usable_window_size().x - 56)))
_color_picker.custom_minimum_size = picker_min_size
_color_picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_color_picker.focus_mode = Control.FOCUS_ALL
_color_picker.color_changed.connect(_on_picker_color_changed)
picker_scroll.add_child(_color_picker)
var actions := HBoxContainer.new()
actions.alignment = BoxContainer.ALIGNMENT_END
actions.add_theme_constant_override("separation", 8)
layout.add_child(actions)
var use_button := Button.new()
use_button.text = "Use color"
var action_width: float = maxf(44.0, minf(120.0, (float(_usable_window_size().x) - 40.0) * 0.5))
use_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)))
use_button.focus_mode = Control.FOCUS_ALL
use_button.pressed.connect(_confirm_color)
actions.add_child(use_button)
var cancel_button := Button.new()
cancel_button.text = "✕ Cancel"
cancel_button.custom_minimum_size = Vector2(action_width, float(_integer("popups.dialog.action_min_target_size", 48, 44, 96)))
cancel_button.focus_mode = Control.FOCUS_ALL
cancel_button.pressed.connect(_cancel)
actions.add_child(cancel_button)
close_requested.connect(_cancel)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
preview_heading.add_theme_font_override("font", font)
use_button.add_theme_font_override("font", font)
cancel_button.add_theme_font_override("font", font)
preview_heading.add_theme_font_size_override("font_size", _stage_theme.integer("stage_panels.type.section_size", 20, 12, 40))
use_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
cancel_button.add_theme_stylebox_override("normal", _stage_theme.stylebox("stage_panels.action"))
use_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA")))
cancel_button.add_theme_color_override("font_color", _stage_theme.color("global.text_color", Color("#F5F7FA")))
func _on_picker_color_changed(color: Color) -> void:
_update_preview(color)
preview_color_changed.emit(color)
func _update_preview(color: Color) -> void:
if _surface_preview != null:
_surface_preview.configure(_surface_kind, _finish_id, color)
func _confirm_color() -> void:
var confirmed_color: Color = _color_picker.color
hide()
color_confirmed.emit(confirmed_color)
func _cancel() -> void:
_update_preview(_initial_color)
hide()
cancelled.emit()
func _usable_window_size() -> Vector2i:
var available: Vector2i = Vector2i(get_tree().root.size)
var usable: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
if usable.size.x > 0 and usable.size.y > 0:
available.x = mini(available.x, usable.size.x)
available.y = mini(available.y, usable.size.y)
available.x = maxi(1, available.x - 32)
available.y = maxi(1, available.y - 32)
return available
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
@@ -0,0 +1 @@
uid://c56y4dpi7w2pu
+1 -1
View File
@@ -50,7 +50,7 @@ func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Varia
if not description.is_empty():
_description = Label.new()
_description.text = description
_description.custom_minimum_size = Vector2(0.0, 34.0)
_description.custom_minimum_size = Vector2(0.0, float(_payload.get("description_min_height", 34.0)))
_description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_description.vertical_alignment = VERTICAL_ALIGNMENT_TOP
_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
+354 -10
View File
@@ -7,16 +7,27 @@ signal browse_requested()
signal refresh_requested()
signal make_artwork_requested()
signal edit_scenery_requested()
signal surface_color_confirmed(color: Color)
const COLUMNS := 4
const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS
const GRID_H_SEPARATION: float = 8.0
const GRID_V_SEPARATION: float = 8.0
const POPUP_SIDE_PADDING: float = 40.0
const POPUP_VERTICAL_PADDING: float = 32.0
const POPUP_SAFE_SCREEN_MARGIN: float = 24.0
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
const THUMB_SIZE := Vector2(156.0, 156.0)
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
const BACKGROUND_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_background_card_preview.gd")
const STARTER_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_starter_card_preview.gd")
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const SURFACE_COLOR_CONTROLS := preload("res://scripts/shared/stage_ui/stage_surface_color_controls.gd")
const SURFACE_COLOR_DIALOG := preload("res://scripts/shared/stage_ui/stage_surface_color_dialog.gd")
const STAGE_PANEL_CHOICE_GROUP := preload("res://scripts/shared/stage_ui/stage_panel_choice_group.gd")
var kind: String = ""
@@ -32,9 +43,24 @@ var _gallery_tab: String = "backgrounds"
var _tag_id: String = "all"
var _current_sky_id: String = ""
var _sky_card_group: ButtonGroup
var _active_columns: int = COLUMNS
var _page_capacity: int = PAGE_SIZE
var _effective_cell_size: Vector2 = CELL_MIN_SIZE
var _surface_color_history: Array[Color] = []
var _setup_view: VBoxContainer
var _setup_kind: StringName = &""
var _setup_finish: StringName = &""
var _setup_color: Color = Color.WHITE
var _setup_original_color: Color = Color.WHITE
var _setup_color_chosen: bool = false
var _setup_preview: StageSurfacePreview
var _setup_color_controls: StageSurfaceColorControls
var _setup_color_dialog: StageSurfaceColorDialog
@onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer
@onready var _content_stack: VBoxContainer = %ContentStack
@onready var _grid_scroll: ScrollContainer = %GridScroll
@onready var _empty_label: Label = %EmptyLabel
@onready var _page_label: Label = %PageLabel
@onready var _prev_button: Button = %PrevButton
@@ -48,10 +74,13 @@ var _sky_card_group: ButtonGroup
@onready var _tag_filter: OptionButton = %TagFilter
@onready var _make_artwork_button: Button = %MakeArtworkButton
@onready var _edit_scenery_button: Button = %EditSceneryButton
@onready var _title_bar: Control = $MarginContainer/VBoxContainer/TitleBar
@onready var _footer: Control = $MarginContainer/VBoxContainer/Footer
func _ready() -> void:
exclusive = true
_build_surface_setup_view()
if _tag_filter.item_count == 0:
for label: String in ["All artwork", "Nature", "Buildings", "Walls", "Roads", "Objects"]:
_tag_filter.add_item(label)
@@ -70,11 +99,15 @@ func _ready() -> void:
func _on_root_size_changed() -> void:
if visible:
_update_popup_layout()
_rebuild()
popup_centered()
func open(p_kind: String, entries: Array[Dictionary], current_sky_id: String = "") -> void:
kind = p_kind
_setup_kind = &""
_setup_view.visible = false
_current_sky_id = current_sky_id if kind == "scenery" else ""
_title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
_tabs.visible = kind == "scenery"
@@ -100,9 +133,16 @@ func set_entries(entries: Array[Dictionary]) -> void:
_thumbnails.clear()
_cell_texrects.clear()
_page = 0
_update_popup_layout()
_rebuild()
func set_surface_color_history(colors: Array[Color]) -> void:
_surface_color_history = colors.duplicate()
if _setup_color_controls != null and is_instance_valid(_setup_color_controls):
_setup_color_controls.set_recent_colors(_surface_color_history)
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
var key := _entry_key(entry)
_thumbnails[key] = tex
@@ -144,6 +184,221 @@ func apply_theme(theme: Variant) -> void:
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
var wall_choices: StagePanelChoiceGroup = get_meta("wall_look_group", null) as StagePanelChoiceGroup
var ground_choices: StagePanelChoiceGroup = get_meta("ground_look_group", null) as StagePanelChoiceGroup
if wall_choices != null:
wall_choices.apply_theme(theme as SandboxTheme)
if ground_choices != null:
ground_choices.apply_theme(theme as SandboxTheme)
if _setup_color_controls != null:
_setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), theme as SandboxTheme)
func _build_surface_setup_view() -> void:
_setup_view = VBoxContainer.new()
_setup_view.name = "SurfaceSetupView"
_setup_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_setup_view.add_theme_constant_override("separation", _theme_integer("popups.asset_selector.setup_gap", 10, 0, 32))
_setup_view.visible = false
_content_stack.add_child(_setup_view)
var heading_row := HBoxContainer.new()
heading_row.add_theme_constant_override("separation", 8)
_setup_view.add_child(heading_row)
var back_button := Button.new()
back_button.text = "‹ Back"
back_button.custom_minimum_size = Vector2(float(_theme_integer("stage_panels.action.close_width", 92, 72, 180)), float(_theme_integer("stage_panels.action.min_target_size", 48, 44, 96)))
back_button.focus_mode = Control.FOCUS_ALL
back_button.pressed.connect(_back_from_surface_setup)
heading_row.add_child(back_button)
var heading := Label.new()
heading.name = "SetupHeading"
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
heading.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.title_size", 24, 12, 48))
heading_row.add_child(heading)
var look_heading := Label.new()
look_heading.text = "Look"
look_heading.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.section_size", 20, 12, 40))
_setup_view.add_child(look_heading)
var wall_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
wall_choices.name = "WallLookChoices"
wall_choices.add_choice(&"finish_wall_paint", "Paint", _make_look_cue(&"wall", &"finish_wall_paint"))
wall_choices.add_choice(&"finish_wall_brick", "Brick", _make_look_cue(&"wall", &"finish_wall_brick"))
wall_choices.add_choice(&"finish_wall_wood", "Wood", _make_look_cue(&"wall", &"finish_wall_wood"))
wall_choices.choice_selected.connect(_on_setup_look_choice)
_setup_view.add_child(wall_choices)
var ground_choices: StagePanelChoiceGroup = STAGE_PANEL_CHOICE_GROUP.new() as StagePanelChoiceGroup
ground_choices.name = "BackgroundGroundLookChoices"
ground_choices.add_choice(&"finish_background_grass", "Grass", _make_look_cue(&"background_ground", &"finish_background_grass"))
ground_choices.add_choice(&"finish_background_concrete", "Concrete", _make_look_cue(&"background_ground", &"finish_background_concrete"))
ground_choices.add_choice(&"finish_background_carpet", "Carpet", _make_look_cue(&"background_ground", &"finish_background_carpet"))
ground_choices.choice_selected.connect(_on_setup_look_choice)
_setup_view.add_child(ground_choices)
set_meta("wall_look_group", wall_choices)
set_meta("ground_look_group", ground_choices)
var preview_frame := PanelContainer.new()
preview_frame.name = "SurfacePreviewFrame"
var preview_size: Vector2 = _theme_vector2("popups.asset_selector.surface_preview_size", Vector2(420.0, 132.0))
preview_frame.custom_minimum_size.y = preview_size.y + 10.0
preview_frame.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_setup_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
_setup_preview.custom_minimum_size = preview_size
_setup_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
preview_frame.add_child(_setup_preview)
_setup_view.add_child(preview_frame)
_setup_color_controls = SURFACE_COLOR_CONTROLS.new() as StageSurfaceColorControls
_setup_color_controls.name = "SurfaceColorControls"
_setup_color_controls.color_selected.connect(_on_setup_color_selected)
_setup_color_controls.custom_color_requested.connect(_open_setup_color_dialog)
_setup_view.add_child(_setup_color_controls)
var helper := Label.new()
helper.name = "SurfaceRoleHelper"
helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
helper.add_theme_color_override("font_color", _theme_color("global.muted_text_color", Color("#AAB2C0")))
_setup_view.add_child(helper)
var draw_button := Button.new()
draw_button.name = "DrawSurfaceButton"
draw_button.custom_minimum_size.y = float(_theme_integer("stage_panels.choice.minimum_height", 56, 44, 112))
draw_button.focus_mode = Control.FOCUS_ALL
draw_button.pressed.connect(_draw_selected_surface)
_setup_view.add_child(draw_button)
set_meta("setup_heading", heading)
set_meta("setup_helper", helper)
set_meta("setup_draw_button", draw_button)
_apply_font_to(back_button)
_apply_font_to(heading)
_apply_font_to(look_heading)
_apply_font_to(helper)
_apply_font_to(draw_button)
if _sandbox_theme != null:
draw_button.add_theme_stylebox_override("normal", _sandbox_theme.stylebox("stage_panels.action"))
draw_button.add_theme_stylebox_override("hover", _sandbox_theme.stylebox("stage_panels.action"))
draw_button.add_theme_color_override("font_color", _theme_color("stage_panels.type.text_color", Color("#F5F7FA")))
draw_button.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.body_size", 20, 14, 40))
for choice: StagePanelChoiceGroup in [wall_choices, ground_choices]:
choice.apply_theme(_sandbox_theme as SandboxTheme)
_apply_font_to(choice)
func _make_look_cue(kind: StringName, finish: StringName) -> Control:
var preview: StageSurfacePreview = SURFACE_PREVIEW.new() as StageSurfacePreview
preview.custom_minimum_size = _theme_vector2("popups.asset_selector.look_cue_size", Vector2(80.0, 42.0))
preview.configure(kind, finish, SURFACE_RENDERER.default_tint(finish))
return preview
func _open_surface_setup(kind: StringName) -> void:
_setup_kind = kind
_setup_color_chosen = false
_setup_finish = &"finish_wall_paint" if kind == &"wall" else &"finish_background_grass"
_setup_color = SURFACE_RENDERER.default_tint(_setup_finish)
_setup_original_color = _setup_color
var wall_choices: StagePanelChoiceGroup = get_meta("wall_look_group") as StagePanelChoiceGroup
var ground_choices: StagePanelChoiceGroup = get_meta("ground_look_group") as StagePanelChoiceGroup
wall_choices.visible = kind == &"wall"
ground_choices.visible = kind == &"background_ground"
wall_choices.select_no_signal(_setup_finish)
ground_choices.select_no_signal(_setup_finish)
(get_meta("setup_heading") as Label).text = "Wall panel" if kind == &"wall" else "Background ground"
(get_meta("setup_helper") as Label).text = "Looks like a wall · Stickmen can pass through." if kind == &"wall" else "Decorative only · Stickmen don’t walk here."
(get_meta("setup_draw_button") as Button).text = "Draw wall panel" if kind == &"wall" else "Draw background ground"
_setup_color_controls.visible = _supports_setup_color()
_setup_color_controls.configure(_setup_color, _surface_color_history, _preset_surface_colors(), _sandbox_theme as SandboxTheme)
_update_setup_preview()
_page = 0
_update_popup_layout()
_rebuild()
back_button_focus()
func back_button_focus() -> void:
var back_button: Button = _setup_view.get_child(0).get_child(0) as Button
if back_button != null:
back_button.grab_focus()
func _back_from_surface_setup() -> void:
_setup_kind = &""
_setup_view.visible = false
_update_popup_layout()
_rebuild()
_close_button.grab_focus()
func _on_setup_look_choice(id: StringName) -> void:
var supported: bool = SURFACE_RENDERER.is_wall_finish(id) if _setup_kind == &"wall" else SURFACE_RENDERER.is_background_finish(id)
if not supported:
return
_setup_finish = id
if not _setup_color_chosen:
_setup_color = SURFACE_RENDERER.default_tint(_setup_finish)
_setup_color_controls.visible = _supports_setup_color()
_setup_color_controls.set_selected_no_signal(_setup_color)
_update_setup_preview()
_update_popup_layout()
func _supports_setup_color() -> bool:
return _setup_kind == &"wall" or _setup_finish == &"finish_background_carpet"
func _on_setup_color_selected(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_setup_color_chosen = true
_update_setup_preview()
surface_color_confirmed.emit(_setup_color)
func _update_setup_preview() -> void:
if _setup_preview == null:
return
_setup_preview.configure(_setup_kind, _setup_finish, _setup_color)
_setup_color_controls.set_selected_no_signal(_setup_color)
func _preset_surface_colors() -> Array[Color]:
if _sandbox_theme == null:
return [Color.WHITE, Color.BLACK, Color("#4C9AFF"), Color("#29A36A"), Color("#FFD84D"), Color("#D94F70")]
return (_sandbox_theme as SandboxTheme).swatches()
func _open_setup_color_dialog() -> void:
if _setup_color_dialog == null or not is_instance_valid(_setup_color_dialog):
_setup_color_dialog = SURFACE_COLOR_DIALOG.new() as StageSurfaceColorDialog
add_child(_setup_color_dialog)
_setup_color_dialog.preview_color_changed.connect(_on_setup_color_preview)
_setup_color_dialog.color_confirmed.connect(_on_setup_color_confirmed)
_setup_color_dialog.cancelled.connect(_on_setup_color_cancelled)
_setup_original_color = _setup_color
_setup_color_dialog.configure(_setup_kind, _setup_finish, _setup_original_color, _sandbox_theme as SandboxTheme)
_setup_color_dialog.open_dialog()
func _on_setup_color_preview(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_update_setup_preview()
func _on_setup_color_confirmed(color: Color) -> void:
_setup_color = Color(color.r, color.g, color.b, 1.0)
_setup_color_chosen = true
_update_setup_preview()
surface_color_confirmed.emit(_setup_color)
_setup_color_controls.focus_custom_button()
func _on_setup_color_cancelled() -> void:
_setup_color = _setup_original_color
_setup_color_controls.set_selected_no_signal(_setup_color)
_update_setup_preview()
_setup_color_controls.focus_custom_button()
func _draw_selected_surface() -> void:
if _setup_kind.is_empty():
return
var background_kind: String = "wall" if _setup_kind == &"wall" else "background_ground"
item_selected.emit({"background_kind": background_kind, "finish_id": String(_setup_finish), "color": _setup_color})
var _cell_min_size: Vector2 = CELL_MIN_SIZE
var _thumbnail_size: Vector2 = THUMB_SIZE
@@ -176,10 +431,13 @@ func _on_tag_filter_selected(index: int) -> void:
var ids: Array[String] = ["all", "nature", "buildings", "walls", "roads", "objects"]
_tag_id = ids[clampi(index, 0, ids.size() - 1)]
_page = 0
_update_popup_layout()
_rebuild()
func _set_gallery_tab(tab: String) -> void:
_setup_kind = &""
_setup_view.visible = false
_gallery_tab = tab
_backgrounds_tab.set_pressed_no_signal(tab == "backgrounds")
_artwork_tab.set_pressed_no_signal(tab == "artwork")
@@ -190,6 +448,7 @@ func _set_gallery_tab(tab: String) -> void:
_refresh_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork")
_empty_label.text = "No artwork in this group yet." if tab == "artwork" else "Choose a Sky look or wall finish."
_page = 0
_update_popup_layout()
_rebuild()
@@ -211,12 +470,14 @@ func _filtered_entries() -> Array[Dictionary]:
func _on_prev() -> void:
if _page > 0:
_page -= 1
_update_popup_layout()
_rebuild()
func _on_next() -> void:
if (_page + 1) * PAGE_SIZE < _filtered_entries().size():
if (_page + 1) * _page_capacity < _filtered_entries().size():
_page += 1
_update_popup_layout()
_rebuild()
@@ -224,7 +485,55 @@ func _page_count() -> int:
var entries: Array[Dictionary] = _filtered_entries()
if entries.is_empty():
return 1
return int(ceil(float(entries.size()) / float(PAGE_SIZE)))
return int(ceil(float(entries.size()) / float(_page_capacity)))
func _usable_screen_size() -> Vector2:
var root_window: Window = get_tree().root
var available: Vector2 = Vector2(root_window.size)
var screen_index: int = DisplayServer.window_get_current_screen()
var usable_rect: Rect2i = DisplayServer.screen_get_usable_rect(screen_index)
if usable_rect.size.x > 0 and usable_rect.size.y > 0:
available.x = minf(available.x, float(usable_rect.size.x))
available.y = minf(available.y, float(usable_rect.size.y))
return available
func _update_popup_layout() -> void:
var available: Vector2 = _usable_screen_size()
var max_width: float = maxf(1.0, minf(available.x * 0.94, available.x - POPUP_SAFE_SCREEN_MARGIN))
var max_height: float = maxf(1.0, minf(available.y * 0.90, available.y - POPUP_SAFE_SCREEN_MARGIN))
var preferred_width: float = CELL_MIN_SIZE.x * COLUMNS + GRID_H_SEPARATION * (COLUMNS - 1) + POPUP_SIDE_PADDING
var popup_width: float = minf(preferred_width, max_width)
var grid_width: float = maxf(1.0, popup_width - POPUP_SIDE_PADDING)
_active_columns = clampi(floori((grid_width + GRID_H_SEPARATION) / (CELL_MIN_SIZE.x + GRID_H_SEPARATION)), 1, COLUMNS)
_page_capacity = _active_columns * ROWS
_grid.columns = _active_columns
var card_width: float = minf(CELL_MIN_SIZE.x, (grid_width - GRID_H_SEPARATION * (_active_columns - 1)) / float(_active_columns))
var card_height_limit: float = 248.0 if kind == "starters" else CELL_MIN_SIZE.y
var card_height: float = minf(card_height_limit, card_width + (58.0 if kind == "starters" else 30.0))
_effective_cell_size = Vector2(maxf(96.0, card_width), maxf(180.0, card_height))
var filtered_count: int = _filtered_entries().size()
var page_count: int = maxi(1, int(ceil(float(filtered_count) / float(_page_capacity))))
_page = clampi(_page, 0, page_count - 1)
var page_start: int = _page * _page_capacity
var page_items: int = clampi(filtered_count - page_start, 0, _page_capacity)
var grid_rows: int = int(ceil(float(page_items) / float(_active_columns))) if page_items > 0 else 0
var body_height: float
if not _setup_kind.is_empty():
body_height = maxf(180.0, _setup_view.get_combined_minimum_size().y)
else:
body_height = maxf(120.0, float(grid_rows) * _effective_cell_size.y + float(maxi(0, grid_rows - 1)) * GRID_V_SEPARATION)
if page_items == 0:
body_height = maxf(body_height, _empty_label.get_combined_minimum_size().y)
var fixed_height: float = _title_bar.get_combined_minimum_size().y + _footer.get_combined_minimum_size().y + POPUP_VERTICAL_PADDING
var visible_sections: int = 3
if _tabs.visible:
fixed_height += _tabs.get_combined_minimum_size().y
visible_sections += 1
fixed_height += float(visible_sections - 1) * 8.0
var popup_height: float = minf(fixed_height + body_height, max_height)
size = Vector2i(maxi(1, roundi(popup_width)), maxi(1, roundi(popup_height)))
func _rebuild() -> void:
@@ -233,19 +542,30 @@ func _rebuild() -> void:
c.queue_free()
_cell_texrects.clear()
var filtered: Array[Dictionary] = _filtered_entries()
_empty_label.visible = filtered.is_empty()
_grid.visible = not filtered.is_empty()
var in_setup: bool = not _setup_kind.is_empty()
_setup_view.visible = in_setup
_empty_label.visible = filtered.is_empty() and not in_setup
_grid.visible = not filtered.is_empty() and not in_setup
_grid_scroll.visible = not filtered.is_empty() or in_setup
var total := _page_count()
var single := total <= 1
_prev_button.visible = not single
_next_button.visible = not single
_page_label.visible = not single
_prev_button.disabled = _page <= 0
_next_button.disabled = (_page + 1) * PAGE_SIZE >= filtered.size()
_next_button.disabled = (_page + 1) * _page_capacity >= filtered.size()
_page_label.text = "Page %d/%d" % [_page + 1, total]
if in_setup:
_prev_button.visible = false
_next_button.visible = false
_page_label.visible = false
_browse_button.visible = false
_refresh_button.visible = false
_make_artwork_button.visible = false
return
if filtered.is_empty():
return
var bounds := page_bounds(filtered.size(), _page)
var bounds := page_bounds(filtered.size(), _page, _page_capacity)
for i: int in range(int(bounds["start"]), int(bounds["end"])):
_grid.add_child(_build_cell(filtered[i]))
@@ -257,12 +577,16 @@ func _build_cell(entry: Dictionary) -> Control:
# opening a selector never implies that one asset is pre-selected.
var categories: Array = entry.get("tags", []) as Array
var category: String = ", ".join(PackedStringArray(categories)) if not categories.is_empty() else String(entry.get("category", entry.get("material_label", "")))
var card_size: Vector2 = _cell_min_size
var thumb_size: Vector2 = _thumbnail_size
var card_size: Vector2 = _effective_cell_size
var is_background_ground_setup: bool = String(entry.get("background_kind", "")) == "background_ground_setup"
var thumb_height_budget: float = card_size.y - (74.0 if is_background_ground_setup else 64.0)
var thumb_edge: float = minf(_thumbnail_size.x, minf(card_size.x - 20.0, thumb_height_budget))
var thumb_size := Vector2.ONE * maxf(72.0, thumb_edge)
if kind == "starters":
card_size.y = maxf(card_size.y, 248.0)
thumb_size = Vector2(128.0, 128.0)
thumb_size = Vector2.ONE * minf(128.0, maxf(72.0, thumb_edge))
var card_payload: Dictionary = {"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": String(entry.get("icon", "")), "category": category, "description": String(entry.get("description", "")), "thumbnail_size": thumb_size}
if is_background_ground_setup:
card_payload["description_min_height"] = 42.0
if entry.has("background_kind"):
card_payload["background_kind"] = entry["background_kind"]
card_payload["finish_id"] = entry.get("finish_id", "")
@@ -301,7 +625,27 @@ func _theme_color(path: String, fallback: Color) -> Color:
return fallback
func _theme_integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _sandbox_theme == null else _sandbox_theme.integer(path, fallback, minimum, maximum)
func _theme_vector2(path: String, fallback: Vector2) -> Vector2:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value(path, [fallback.x, fallback.y])
if not raw is Array or (raw as Array).size() != 2:
return fallback
return Vector2(float((raw as Array)[0]), float((raw as Array)[1]))
func _on_cell_pressed(entry: Dictionary) -> void:
var background_kind: String = String(entry.get("background_kind", ""))
if background_kind == "wall_setup":
_open_surface_setup(&"wall")
return
if background_kind == "background_ground_setup":
_open_surface_setup(&"background_ground")
return
item_selected.emit(entry)
+114 -9
View File
@@ -1,17 +1,122 @@
class_name ColorSwatchSelector
extends HBoxContainer
extends FlowContainer
signal color_selected(color: Color)
var _selected: Color = Color.WHITE
var _group: ButtonGroup = ButtonGroup.new()
var _buttons: Array[Button] = []
var _stage_theme: SandboxTheme
var _colors: Array[Color] = []
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_stage_theme = theme as SandboxTheme if theme is SandboxTheme else null
_selected = selected
for color: Color in colors:
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
button.set_pressed_no_signal(color.is_equal_approx(selected))
_colors = colors.duplicate()
var focus_key: String = ""
var focus_owner: Control = get_viewport().gui_get_focus_owner() if is_inside_tree() else null
if focus_owner != null and focus_owner.get_parent() == self:
var focused_color: Color = focus_owner.get_meta("surface_color", Color.WHITE)
focus_key = "#" + focused_color.to_html(false).to_upper()
_group = ButtonGroup.new()
_group.allow_unpress = true
_buttons.clear()
for child: Node in get_children():
remove_child(child)
child.queue_free()
add_theme_constant_override("h_separation", _integer("stage_surface_colors.swatch_gap", 8))
add_theme_constant_override("v_separation", _integer("stage_surface_colors.swatch_gap", 8))
var seen: Dictionary = {}
for color: Color in _colors:
var color_key: String = color.to_html(true).to_upper()
if seen.has(color_key):
continue
seen[color_key] = true
var button := Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48))
button.custom_minimum_size = Vector2(swatch_size, swatch_size)
button.toggle_mode = true
button.button_group = _group
button.focus_mode = Control.FOCUS_ALL
button.tooltip_text = "Color #%s" % color.to_html(false).to_upper()
button.set_meta("surface_color", color)
button.pressed.connect(_on_pressed.bind(button))
add_child(button)
_buttons.append(button)
_apply_button_style(button, color)
_sync_pressed_state()
if not focus_key.is_empty():
call_deferred("_restore_color_focus", focus_key)
func set_selected(color: Color, emit_signal: bool = false) -> void:
_selected = color
if emit_signal: color_selected.emit(color)
func get_selected() -> Color: return _selected
_sync_pressed_state()
if emit_signal:
color_selected.emit(color)
func get_selected() -> Color:
return _selected
func _restore_color_focus(color_key: String) -> void:
for button: Button in _buttons:
var button_color: Color = button.get_meta("surface_color", Color.WHITE)
if "#" + button_color.to_html(false).to_upper() == color_key:
button.grab_focus()
return
func _on_pressed(button: Button) -> void:
var color: Color = button.get_meta("surface_color", Color.WHITE)
set_selected(color, true)
func _sync_pressed_state() -> void:
for button: Button in _buttons:
var color: Color = button.get_meta("surface_color", Color.WHITE)
button.set_pressed_no_signal(color.to_html(true) == _selected.to_html(true))
button.text = "✓" if color.to_html(true) == _selected.to_html(true) else ""
var label_color: Color = Color("#111821") if color.get_luminance() > 0.58 else Color("#FFFFFF")
button.add_theme_color_override("font_color", label_color)
button.add_theme_color_override("font_hover_color", label_color)
button.add_theme_color_override("font_pressed_color", label_color)
func _apply_button_style(button: Button, color: Color) -> void:
var border: Color = _color("stage_surface_colors.swatch_border", Color("#FFFFFFB8"))
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var corner: int = _integer("stage_surface_colors.swatch_corner_radius", 8)
var normal := StyleBoxFlat.new()
normal.bg_color = color
normal.border_color = border
normal.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 2))
normal.set_corner_radius_all(corner)
var selected := normal.duplicate() as StyleBoxFlat
selected.border_color = accent
selected.set_border_width_all(_integer("stage_surface_colors.selected_border_width", 4))
var hover := normal.duplicate() as StyleBoxFlat
hover.border_color = Color.WHITE
hover.set_border_width_all(_integer("stage_surface_colors.focus_border_width", 3))
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", selected)
button.add_theme_stylebox_override("hover_pressed", selected.duplicate())
var focus := normal.duplicate() as StyleBoxFlat
focus.draw_center = false
focus.border_color = _color("stage_surface_colors.swatch_focus_border", Color.WHITE)
focus.set_border_width_all(_integer("stage_surface_colors.focus_border_width", 3))
button.add_theme_stylebox_override("focus", focus)
if _stage_theme != null and _stage_theme.font() != null:
button.add_theme_font_override("font", _stage_theme.font())
button.add_theme_font_size_override("font_size", _integer("stage_surface_colors.swatch_check_size", 24))
func _integer(path: String, fallback: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, 0, 64)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
+2 -1
View File
@@ -13,7 +13,7 @@ const DEFAULT_CONFIG: Dictionary = {
"effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":10}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"stage_panels": {
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
@@ -24,6 +24,7 @@ const DEFAULT_CONFIG: Dictionary = {
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"},
"spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6}
},
"stage_surface_colors": {"section_gap": 8, "swatch_gap": 8, "swatch_size": 48, "swatch_border": "#FFFFFFB8", "swatch_focus_border": "#FFFFFF", "swatch_corner_radius": 8, "swatch_border_width": 2, "swatch_check_size": 24, "selected_border_width": 4, "focus_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
@@ -37,9 +37,37 @@ func configure(entry: Dictionary) -> void:
func _draw() -> void:
if _background_kind == "wall" and SURFACE_RENDERER.is_wall_finish(_preview_id):
var wall_tint: Color = Color("#7FA36C") if _preview_id == &"finish_wall_paint" else Color.WHITE
SURFACE_RENDERER.draw_wall(self, Rect2(Vector2.ZERO, size), _preview_id, wall_tint, Vector2.ZERO)
# The preview rect is already small and clipped, so draw its full pattern directly.
SURFACE_RENDERER.draw_wall(
self,
Rect2(Vector2.ZERO, size),
_preview_id,
wall_tint,
Vector2.ZERO,
Vector2.ONE,
false
)
elif _background_kind == "wall_setup":
var finishes: Array[StringName] = [&"finish_wall_paint", &"finish_wall_brick", &"finish_wall_wood"]
_draw_finish_samples(finishes, true)
elif _background_kind == "background_ground_setup":
var finishes: Array[StringName] = [&"finish_background_grass", &"finish_background_concrete", &"finish_background_carpet"]
_draw_finish_samples(finishes, false)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
queue_redraw()
func _draw_finish_samples(finishes: Array[StringName], wall: bool) -> void:
var gap: float = 2.0
var sample_width: float = (size.x - gap * float(finishes.size() - 1)) / float(finishes.size())
for index: int in finishes.size():
var sample_rect := Rect2(Vector2(float(index) * (sample_width + gap), 0.0), Vector2(sample_width, size.y))
var finish: StringName = finishes[index]
var tint: Color = SURFACE_RENDERER.default_tint(finish)
if wall:
SURFACE_RENDERER.draw_wall(self, sample_rect, finish, tint, Vector2.ZERO, Vector2.ONE, false)
else:
SURFACE_RENDERER.draw_background(self, sample_rect, finish, tint, Vector2.ZERO, Vector2.ONE, false)
@@ -0,0 +1,15 @@
class_name StageBackgroundGround
extends "res://scripts/stage/scenery/stage_rect_surface.gd"
## Non-walkable rectangular scenery surface behind the set.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
super.configure(new_size, finish if SURFACE_RENDERER.is_background_finish(finish) else &"finish_background_grass", color)
z_index = DRAW_ORDER.BACKGROUND_GROUND
func _draw() -> void:
SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
@@ -0,0 +1 @@
uid://bl3tfgsieeq5x
@@ -0,0 +1,39 @@
class_name StageRectSurface
extends Node2D
## Shared rectangular geometry and transient tint preview for inert Stage scenery.
var panel_size: Vector2 = Vector2(320.0, 400.0)
var finish_id: StringName = &""
var tint: Color = Color.WHITE
var _preview_tint: Color = Color.WHITE
var _has_preview_tint: bool = false
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
finish_id = finish
tint = color
queue_redraw()
func get_local_bounds() -> Rect2:
return Rect2(-panel_size * 0.5, panel_size)
func set_preview_tint(color: Color) -> void:
_preview_tint = color
_has_preview_tint = true
queue_redraw()
func clear_preview_tint() -> void:
_has_preview_tint = false
queue_redraw()
func get_render_tint() -> Color:
return _preview_tint if _has_preview_tint else tint
func get_surface_draw_origin_world() -> Vector2:
return global_position
@@ -0,0 +1 @@
uid://sk8pkbk41ef0
@@ -0,0 +1,28 @@
class_name StageSurfacePreview
extends Control
## Small render-only preview that uses the Stage surface motif renderer.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
var surface_kind: StringName = &"wall"
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
func configure(kind: StringName, finish: StringName, color: Color) -> void:
surface_kind = kind
finish_id = finish
tint = color
mouse_filter = Control.MOUSE_FILTER_IGNORE
queue_redraw()
func _draw() -> void:
var rect: Rect2 = Rect2(Vector2(3.0, 3.0), size - Vector2(6.0, 6.0))
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
return
if surface_kind == &"background_ground":
SURFACE_RENDERER.draw_background(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
else:
SURFACE_RENDERER.draw_wall(self, rect, finish_id, tint, Vector2.ZERO, Vector2.ONE, false)
draw_rect(rect, Color("#F5F7FA"), false, 2.0)
@@ -0,0 +1 @@
uid://kyveykcipe5c
+589 -15
View File
@@ -3,17 +3,37 @@ extends RefCounted
## World-aligned, bounded vector motifs for finite walls and Ground.
const WALL_FINISHES: Array[StringName] = [&"finish_wall_paint", &"finish_wall_brick", &"finish_wall_wood"]
const BACKGROUND_FINISHES: Array[StringName] = [&"finish_background_grass", &"finish_background_concrete", &"finish_background_carpet"]
const GROUND_FINISHES: Array[StringName] = [&"finish_ground_paint", &"finish_ground_brick", &"finish_ground_wood", &"finish_ground_grass", &"finish_ground_dirt", &"finish_ground_sidewalk", &"finish_ground_asphalt"]
const MAX_CELLS: int = 1200
const BRICK_WIDTH: float = 82.0
const BRICK_HEIGHT: float = 34.0
const MAX_VISIBLE_BRICK_CELLS: int = 12000
const WALL_BRICK_WIDTH: float = 100.0
const WALL_BRICK_HEIGHT: float = 38.0
const MAX_WALL_BRICK_CELLS: int = 1200
const MAX_BACKGROUND_CELLS: int = 12000
static func is_wall_finish(id: StringName) -> bool:
return WALL_FINISHES.has(id)
static func is_background_finish(id: StringName) -> bool:
return BACKGROUND_FINISHES.has(id)
static func default_tint(finish: StringName) -> Color:
match finish:
&"finish_wall_paint": return Color("#5A9D70")
&"finish_wall_brick": return Color("#B95F3D")
&"finish_wall_wood": return Color("#C87137")
&"finish_background_grass": return Color("#72A84C")
&"finish_background_concrete": return Color("#A6AAA3")
&"finish_background_carpet": return Color("#8B537B")
_: return Color("#5A9D70")
static func is_ground_finish(id: StringName) -> bool:
return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy"
@@ -24,9 +44,10 @@ static func draw_wall(
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport)
static func draw_ground(
@@ -37,7 +58,19 @@ static func draw_ground(
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, false, true)
static func draw_background(
canvas: CanvasItem,
rect: Rect2,
finish: StringName,
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport)
static func _draw_surface(
@@ -47,7 +80,9 @@ static func _draw_surface(
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2,
wall: bool
wall: bool,
background: bool,
cull_to_viewport: bool
) -> void:
var normalized := rect.abs()
if normalized.size.x <= 0.0 or normalized.size.y <= 0.0:
@@ -57,11 +92,15 @@ static func _draw_surface(
if finish == &"finish_ground_legacy":
base = Color("#468B42")
elif finish.ends_with("brick"):
base = Color("#B95F3D") if wall else Color("#994D35")
base = Color("#D2CBAF") if wall else Color("#994D35")
elif finish.ends_with("wood"):
base = Color("#9A673C") if wall else Color("#B88951")
elif finish == &"finish_ground_grass":
elif finish == &"finish_ground_grass" or finish == &"finish_background_grass":
base = Color("#72A84C")
elif finish == &"finish_background_concrete":
base = Color("#A6AAA3")
elif finish == &"finish_background_carpet":
base = tint
elif finish == &"finish_ground_dirt":
base = Color("#8E633E")
elif finish == &"finish_ground_sidewalk":
@@ -70,13 +109,39 @@ static func _draw_surface(
base = Color("#505A62")
canvas.draw_rect(normalized, base, true)
if finish.ends_with("brick"):
var view_rect: Rect2 = _visible_local_rect(canvas)
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_scale: float = brick_detail_scale_for_view(view_rect.size if not view_rect.size.is_zero_approx() else normalized.size, motif_scale)
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
var detail_size: Vector2 = view_rect.size if not view_rect.size.is_zero_approx() else normalized.size
var detail_scale: float = brick_detail_scale_for_view(detail_size, motif_scale, wall)
if wall:
_draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint)
else:
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
elif finish.ends_with("wood"):
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
if wall:
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
var detail_scale: float = wood_detail_scale_for_view(pattern_rect.size, motif_scale)
_draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint)
else:
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
elif finish == &"finish_background_grass":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_grass(canvas, pattern_rect, world_origin, motif_scale)
elif finish == &"finish_background_concrete":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale)
elif finish == &"finish_background_carpet":
var view_rect: Rect2 = _visible_local_rect(canvas) if cull_to_viewport else normalized
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
_draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint)
elif finish == &"finish_ground_grass":
_draw_grass(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_dirt":
@@ -89,20 +154,529 @@ static func _draw_surface(
push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish))
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> float:
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE, wall: bool = false) -> float:
var scale: float = 1.0
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
var safe_motif_scale: Vector2 = _normalized_scale(motif_scale)
var brick_width: float = WALL_BRICK_WIDTH if wall else BRICK_WIDTH
var brick_height: float = WALL_BRICK_HEIGHT if wall else BRICK_HEIGHT
var max_cells: int = MAX_WALL_BRICK_CELLS if wall else MAX_VISIBLE_BRICK_CELLS
for _iteration: int in 8:
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (brick_width * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (brick_height * scale))) + 2
var cell_count: int = rows * columns
if cell_count <= max_cells:
return scale
scale *= sqrt(float(cell_count) / float(max_cells)) * 1.01
return scale
static func wood_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> float:
var scale: float = 1.0
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
var safe_motif_scale: Vector2 = _normalized_scale(motif_scale)
for _iteration: int in 8:
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (BRICK_WIDTH * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (BRICK_HEIGHT * scale))) + 2
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (320.0 * scale))) + 2
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (64.0 * scale))) + 2
var cell_count: int = rows * columns
if cell_count <= MAX_VISIBLE_BRICK_CELLS:
if cell_count <= MAX_CELLS:
return scale
scale *= sqrt(float(cell_count) / float(MAX_VISIBLE_BRICK_CELLS)) * 1.01
scale *= sqrt(float(cell_count) / float(MAX_CELLS)) * 1.01
return scale
static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
const BOARD_HEIGHT: float = 64.0
const PLANK_WIDTH: float = 320.0
var safe_scale: Vector2 = _normalized_scale(motif_scale)
var board_world_height: float = BOARD_HEIGHT * detail_scale
var plank_world_width: float = PLANK_WIDTH * detail_scale
var world_left: float = rect.position.x * safe_scale.x + origin.x
var world_right: float = rect.end.x * safe_scale.x + origin.x
var world_top: float = rect.position.y * safe_scale.y + origin.y
var world_bottom: float = rect.end.y * safe_scale.y + origin.y
var first_row: int = floori(world_top / board_world_height)
var last_row: int = floori((world_bottom - 0.001) / board_world_height)
var row_count: int = mini(last_row - first_row + 1, MAX_CELLS)
var natural: Color = default_tint(&"finish_wall_wood")
var seam_color: Color = _material_color(Color("#784421"), tint, natural)
var grain_color: Color = _material_color(Color("#A05A2C"), tint, natural)
var highlight_color: Color = _material_color(Color("#E39A59"), tint, natural)
for row_offset: int in row_count:
var row: int = first_row + row_offset
var board_world_top: float = float(row) * board_world_height
var board_local_top: float = (board_world_top - origin.y) / safe_scale.y
var stagger: float = plank_world_width * 0.5 if posmod(row, 2) == 1 else 0.0
var first_column: int = floori((world_left - stagger) / plank_world_width)
var last_column: int = floori((world_right - stagger - 0.001) / plank_world_width)
var column_count: int = mini(last_column - first_column + 1, MAX_CELLS)
for column_offset: int in column_count:
var column: int = first_column + column_offset
var plank_world_left: float = float(column) * plank_world_width + stagger
var plank_rect := Rect2(
Vector2((plank_world_left - origin.x) / safe_scale.x, board_local_top),
Vector2(plank_world_width / safe_scale.x, board_world_height / safe_scale.y)
)
var visible_plank: Rect2 = rect.intersection(plank_rect)
if visible_plank.size.x <= 0.0 or visible_plank.size.y <= 0.0:
continue
var tone_index: int = posmod(row + column, 3)
var plank_color: Color = _material_color(Color("#C87137"), tint, natural)
if tone_index == 1:
plank_color = plank_color.darkened(0.045)
elif tone_index == 2:
plank_color = plank_color.lightened(0.035)
canvas.draw_rect(visible_plank, plank_color, true)
var plank_local_left: float = (plank_world_left - origin.x) / safe_scale.x
_draw_clipped_line(
canvas, rect,
Vector2(plank_local_left, board_local_top),
Vector2(plank_local_left, board_local_top + board_world_height / safe_scale.y),
_color_with_alpha(seam_color, 0.88), 2.0 / safe_scale.x
)
_draw_wall_wood_grain(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, grain_color, highlight_color, detail_scale, tint, natural
)
_draw_wall_wood_knot(
canvas, rect, plank_world_left, board_world_top, plank_world_width, board_world_height,
origin, safe_scale, row, column, detail_scale, tint, natural
)
_draw_clipped_line(
canvas, rect,
Vector2(rect.position.x, board_local_top), Vector2(rect.end.x, board_local_top),
seam_color, 2.4 / safe_scale.y
)
_draw_clipped_line(
canvas, rect,
Vector2(rect.position.x, board_local_top + 2.4 / safe_scale.y),
Vector2(rect.end.x, board_local_top + 2.4 / safe_scale.y),
_color_with_alpha(highlight_color, 0.62), 1.0 / safe_scale.y
)
static func _draw_wall_wood_grain(
canvas: CanvasItem,
clip_rect: Rect2,
plank_world_left: float,
board_world_top: float,
plank_world_width: float,
board_world_height: float,
origin: Vector2,
motif_scale: Vector2,
row: int,
column: int,
grain_color: Color,
highlight_color: Color,
detail_scale: float,
tint: Color,
natural: Color
) -> void:
const TRACK_COUNT: int = 5
const SAMPLES: int = 32
var seed: int = row * 73856093 + column * 19349663
for track: int in TRACK_COUNT:
var track_seed: int = seed + track * 83492791
var lane: float = 0.16 + _wood_hash(track_seed) * 0.68
var amplitude: float = (0.55 + _wood_hash(track_seed + 11) * 2.4) * detail_scale
var cycles: float = 1.0 + _wood_hash(track_seed + 23) * 2.6
var phase: float = _wood_hash(track_seed + 37) * TAU
var points := PackedVector2Array()
for sample: int in SAMPLES + 1:
var t: float = float(sample) / float(SAMPLES)
var wave: float = sin(t * TAU * cycles + phase) * amplitude
wave += sin(t * TAU * (cycles * 0.43 + 0.4) + phase * 1.7) * amplitude * 0.32
var world_x: float = plank_world_left + t * plank_world_width
var world_y: float = board_world_top + lane * board_world_height + wave
points.append(Vector2((world_x - origin.x) / motif_scale.x, (world_y - origin.y) / motif_scale.y))
_draw_clipped_polyline(canvas, clip_rect, points, _color_with_alpha(grain_color, 0.19), 5.0 * detail_scale / motif_scale.y)
_draw_clipped_polyline(canvas, clip_rect, points, _color_with_alpha(grain_color, 0.58), 1.15 * detail_scale / motif_scale.y)
var highlight_points := PackedVector2Array()
for point: Vector2 in points:
highlight_points.append(point + Vector2(0.0, -1.0 * detail_scale / motif_scale.y))
_draw_clipped_polyline(canvas, clip_rect, highlight_points, _color_with_alpha(highlight_color, 0.32), 0.9 * detail_scale / motif_scale.y)
static func _draw_wall_wood_knot(
canvas: CanvasItem,
clip_rect: Rect2,
plank_world_left: float,
board_world_top: float,
plank_world_width: float,
board_world_height: float,
origin: Vector2,
motif_scale: Vector2,
row: int,
column: int,
detail_scale: float,
tint: Color,
natural: Color
) -> void:
var seed: int = row * 92821 + column * 68917 + 31
if _wood_hash(seed) > 0.24:
return
var center_world := Vector2(
plank_world_left + plank_world_width * (0.27 + _wood_hash(seed + 1) * 0.46),
board_world_top + board_world_height * (0.34 + _wood_hash(seed + 2) * 0.34)
)
var radius_x: float = (12.0 + _wood_hash(seed + 3) * 12.0) * detail_scale
var radius_y: float = (3.0 + _wood_hash(seed + 4) * 2.8) * detail_scale
var center := Vector2((center_world.x - origin.x) / motif_scale.x, (center_world.y - origin.y) / motif_scale.y)
for ring: int in 3:
var ring_scale: float = 1.0 - float(ring) * 0.27
var points := PackedVector2Array()
for point_index: int in 33:
var angle: float = TAU * float(point_index) / 32.0
var wobble: float = 1.0 + 0.07 * sin(angle * 3.0 + float(seed % 17))
var world_offset := Vector2(
cos(angle) * radius_x * ring_scale * wobble,
sin(angle) * radius_y * ring_scale * (1.0 + 0.08 * cos(angle * 2.0))
)
points.append(center + Vector2(world_offset.x / motif_scale.x, world_offset.y / motif_scale.y))
var ring_source: Color = Color("#784421") if ring != 1 else Color("#E39A59")
var ring_color: Color = _color_with_alpha(_material_color(ring_source, tint, natural), 0.56 if ring != 1 else 0.52)
var ring_width: float = (1.25 if ring != 1 else 0.85) * detail_scale / motif_scale.y
_draw_clipped_polyline(canvas, clip_rect, points, ring_color, ring_width)
static func _draw_clipped_polyline(canvas: CanvasItem, rect: Rect2, points: PackedVector2Array, color: Color, width: float) -> void:
if points.size() < 2:
return
var inset: float = maxf(0.0, width) * 0.5
var safe_rect := Rect2(rect.position + Vector2.ONE * inset, rect.size - Vector2.ONE * inset * 2.0)
var fully_inside: bool = safe_rect.size.x > 0.0 and safe_rect.size.y > 0.0
if fully_inside:
for point: Vector2 in points:
if not safe_rect.has_point(point):
fully_inside = false
break
if fully_inside:
canvas.draw_polyline(points, color, width, true)
return
for index: int in range(points.size() - 1):
_draw_clipped_line(canvas, rect, points[index], points[index + 1], color, width)
static func _wood_hash(seed: int) -> float:
var value: float = sin(float(seed) * 12.9898 + 78.233) * 43758.5453
return value - floorf(value)
static func _color_with_alpha(color: Color, alpha: float) -> Color:
return Color(color.r, color.g, color.b, alpha)
static func _material_color(source: Color, selected: Color, natural: Color) -> Color:
var natural_value: float = maxf(0.08, natural.v)
var source_value_ratio: float = source.v / natural_value
var value: float = clampf(selected.v * source_value_ratio, 0.035, 1.0)
var natural_saturation: float = maxf(0.08, natural.s)
var source_saturation_ratio: float = source.s / natural_saturation
var saturation: float = clampf(selected.s * source_saturation_ratio, 0.0, 1.0)
return Color.from_hsv(selected.h, saturation, value, source.a)
static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
const CELL_WIDTH: float = 44.0
const CELL_HEIGHT: float = 36.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / CELL_WIDTH) - 1
var last_column: int = floori(world_right / CELL_WIDTH) + 1
var first_row: int = floori(world_top / CELL_HEIGHT) - 1
var last_row: int = floori(world_bottom / CELL_HEIGHT) + 1
var remaining: int = MAX_BACKGROUND_CELLS
for row: int in range(first_row, last_row + 1):
for column: int in range(first_column, last_column + 1):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 73856093 + column * 19349663 + 197
var center_world := Vector2((float(column) + 0.5) * CELL_WIDTH, (float(row) + 0.5) * CELL_HEIGHT)
center_world += Vector2((_wood_hash(seed) - 0.5) * 34.0, (_wood_hash(seed + 1) - 0.5) * 26.0)
var center := Vector2((center_world.x - origin.x) / motif_scale.x, (center_world.y - origin.y) / motif_scale.y)
if not rect.grow(8.0).has_point(center):
continue
var tuft_color: Color = Color("#3A7837").lightened(_wood_hash(seed + 2) * 0.24)
var highlight: Color = Color("#C3DA74").darkened(_wood_hash(seed + 3) * 0.24)
var radius: float = 1.8 + _wood_hash(seed + 4) * 1.6
canvas.draw_circle(center, radius, _color_with_alpha(tuft_color, 0.56))
for blade: int in 4:
var blade_seed: int = seed + blade * 47 + 11
var x_offset: float = (_wood_hash(blade_seed) - 0.5) * 9.0 / motif_scale.x
var y_offset: float = (_wood_hash(blade_seed + 1) - 0.5) * 7.0 / motif_scale.y
var blade_height: float = (4.5 + _wood_hash(blade_seed + 2) * 4.0) / motif_scale.y
var start := center + Vector2(x_offset, y_offset)
var tip := start + Vector2((_wood_hash(blade_seed + 3) - 0.5) * 4.0 / motif_scale.x, -blade_height)
_draw_clipped_line(canvas, rect, start, tip, _color_with_alpha(highlight, 0.78), 1.25 / motif_scale.y)
static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
const SLAB_WIDTH: float = 280.0
const SLAB_HEIGHT: float = 190.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / SLAB_WIDTH)
var last_column: int = floori(world_right / SLAB_WIDTH)
var first_row: int = floori(world_top / SLAB_HEIGHT)
var last_row: int = floori(world_bottom / SLAB_HEIGHT)
var column_gap: float = 1.4 / motif_scale.x
var row_gap: float = 1.4 / motif_scale.y
var remaining: int = MAX_BACKGROUND_CELLS
for column: int in range(first_column, mini(last_column + 1, first_column + MAX_BACKGROUND_CELLS)):
var x: float = float(column) * SLAB_WIDTH
var local_x: float = (x - origin.x) / motif_scale.x
_draw_clipped_line(canvas, rect, Vector2(local_x, rect.position.y), Vector2(local_x, rect.end.y), Color("#68757A", 0.32), column_gap)
for row: int in range(first_row, mini(last_row + 1, first_row + MAX_BACKGROUND_CELLS)):
if remaining <= 0:
return
remaining -= 1
var seed: int = column * 92821 + row * 68917 + 51
var world_point := Vector2(x + 16.0 + _wood_hash(seed) * (SLAB_WIDTH - 32.0), float(row) * SLAB_HEIGHT + 14.0 + _wood_hash(seed + 1) * (SLAB_HEIGHT - 28.0))
var point := Vector2((world_point.x - origin.x) / motif_scale.x, (world_point.y - origin.y) / motif_scale.y)
if rect.grow(3.0).has_point(point):
var speckle: Color = Color("#E2E1D8") if _wood_hash(seed + 2) > 0.48 else Color("#677173")
canvas.draw_circle(point, (0.65 + _wood_hash(seed + 3) * 0.8) / motif_scale.y, _color_with_alpha(speckle, 0.34))
for row: int in range(first_row, mini(last_row + 1, first_row + MAX_BACKGROUND_CELLS)):
var y: float = float(row) * SLAB_HEIGHT
var local_y: float = (y - origin.y) / motif_scale.y
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, local_y), Vector2(rect.end.x, local_y), Color("#68757A", 0.32), row_gap)
static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color) -> void:
const CELL_WIDTH: float = 34.0
const CELL_HEIGHT: float = 26.0
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_column: int = floori(world_left / CELL_WIDTH)
var last_column: int = floori(world_right / CELL_WIDTH)
var first_row: int = floori(world_top / CELL_HEIGHT)
var last_row: int = floori(world_bottom / CELL_HEIGHT)
var remaining: int = MAX_BACKGROUND_CELLS
var natural: Color = default_tint(&"finish_background_carpet")
for row: int in range(first_row, last_row + 1):
for column: int in range(first_column, last_column + 1):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 92821 + column * 68917 + 71
var cell_left: float = float(column) * CELL_WIDTH
var cell_top: float = float(row) * CELL_HEIGHT
for fiber: int in 2:
var fiber_seed: int = seed + fiber * 53
var start_world := Vector2(cell_left + 3.0 + _wood_hash(fiber_seed) * (CELL_WIDTH - 6.0), cell_top + 3.0 + _wood_hash(fiber_seed + 1) * (CELL_HEIGHT - 6.0))
var end_world := start_world + Vector2(2.0 + _wood_hash(fiber_seed + 2) * 4.0, (_wood_hash(fiber_seed + 3) - 0.5) * 2.2)
var start := Vector2((start_world.x - origin.x) / motif_scale.x, (start_world.y - origin.y) / motif_scale.y)
var finish := Vector2((end_world.x - origin.x) / motif_scale.x, (end_world.y - origin.y) / motif_scale.y)
var source: Color = Color("#E7B8D2") if _wood_hash(fiber_seed + 4) > 0.52 else Color("#3E3044")
var fiber_color: Color = _material_color(source, tint, natural)
_draw_clipped_line(canvas, rect, start, finish, _color_with_alpha(fiber_color, 0.42), 0.85 / motif_scale.y)
static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
var brick_world_w: float = WALL_BRICK_WIDTH * detail_scale
var brick_world_h: float = WALL_BRICK_HEIGHT * detail_scale
var brick_w: float = brick_world_w / motif_scale.x
var brick_h: float = brick_world_h / motif_scale.y
var world_left: float = rect.position.x * motif_scale.x + origin.x
var world_right: float = rect.end.x * motif_scale.x + origin.x
var world_top: float = rect.position.y * motif_scale.y + origin.y
var world_bottom: float = rect.end.y * motif_scale.y + origin.y
var first_row: int = floori(world_top / brick_world_h)
var last_row: int = floori((world_bottom - 0.001) / brick_world_h)
var mortar_gap := Vector2(3.5 * detail_scale / motif_scale.x, 3.5 * detail_scale / motif_scale.y)
var source_palette: Array[Color] = [
Color("#B65E3E"), Color("#C16B47"), Color("#A95337"),
Color("#BD704B"), Color("#AD583A"), Color("#C47A53"),
]
var palette: Array[Color] = []
for source_color: Color in source_palette:
palette.append(_material_color(source_color, tint, default_tint(&"finish_wall_brick")))
var row_count: int = mini(last_row - first_row + 1, MAX_WALL_BRICK_CELLS)
for row_offset: int in row_count:
var row: int = first_row + row_offset
var row_world_top: float = float(row) * brick_world_h
var row_local_top: float = (row_world_top - origin.y) / motif_scale.y
var stagger_world: float = brick_world_w * 0.5 if posmod(row, 2) == 1 else 0.0
var first_column: int = floori((world_left - stagger_world) / brick_world_w) - 1
var column_count: int = int(ceil(rect.size.x * motif_scale.x / brick_world_w)) + 2
for column_offset: int in column_count:
var column: int = first_column + column_offset
var seed: int = row * 92821 + column * 68917 + 31
var brick_world_left: float = float(column) * brick_world_w + stagger_world
var brick_local_left: float = (brick_world_left - origin.x) / motif_scale.x
var width_variation: float = 0.92 + _wood_hash(seed + 1) * 0.14
var height_variation: float = 0.92 + _wood_hash(seed + 2) * 0.12
var face_size := Vector2(brick_w * width_variation, brick_h * height_variation) - mortar_gap * 2.0
var center_jitter := Vector2(
(_wood_hash(seed + 3) - 0.5) * 2.0 * mortar_gap.x * 0.3,
(_wood_hash(seed + 4) - 0.5) * 2.0 * mortar_gap.y * 0.3
)
var face_rect := Rect2(
Vector2(brick_local_left, row_local_top) + mortar_gap + center_jitter,
face_size
)
if face_rect.end.x < rect.position.x or face_rect.position.x > rect.end.x:
continue
if face_rect.end.y < rect.position.y or face_rect.position.y > rect.end.y:
continue
var roughness := Vector2(1.7 * detail_scale / motif_scale.x, 1.2 * detail_scale / motif_scale.y)
var outline: PackedVector2Array = _rough_brick_outline(face_rect, seed, roughness)
var shadow_offset := Vector2(1.3 * detail_scale / motif_scale.x, 1.8 * detail_scale / motif_scale.y)
var shadow_points: PackedVector2Array = _clip_polygon_to_rect(_offset_polygon(outline, shadow_offset), rect)
if shadow_points.size() >= 3:
canvas.draw_colored_polygon(shadow_points, _color_with_alpha(_material_color(Color("#72503E"), tint, default_tint(&"finish_wall_brick")), 0.40))
var face_points: PackedVector2Array = _clip_polygon_to_rect(outline, rect)
if face_points.size() < 3:
continue
var color_index: int = int(_wood_hash(seed + 5) * float(palette.size()))
var face_color: Color = palette[color_index]
canvas.draw_colored_polygon(face_points, face_color)
var closed_outline := face_points.duplicate()
closed_outline.append(face_points[0])
_draw_clipped_polyline(
canvas, rect, closed_outline, _color_with_alpha(_material_color(Color("#784631"), tint, default_tint(&"finish_wall_brick")), 0.30), 1.2 * detail_scale / motif_scale.y
)
var upper_edge := PackedVector2Array()
for point_index: int in mini(5, outline.size()):
upper_edge.append(outline[point_index])
_draw_clipped_polyline(
canvas, rect, upper_edge, _color_with_alpha(_material_color(Color("#F0B27C"), tint, default_tint(&"finish_wall_brick")), 0.58), 1.15 * detail_scale / motif_scale.y
)
var facet := PackedVector2Array([
Vector2(face_rect.position.x + face_rect.size.x * 0.06, face_rect.position.y + face_rect.size.y * 0.20),
Vector2(face_rect.position.x + face_rect.size.x * 0.40, face_rect.position.y + face_rect.size.y * 0.12),
Vector2(face_rect.position.x + face_rect.size.x * 0.68, face_rect.position.y + face_rect.size.y * 0.22),
Vector2(face_rect.position.x + face_rect.size.x * 0.34, face_rect.position.y + face_rect.size.y * 0.40),
])
var facet_points: PackedVector2Array = _clip_polygon_to_rect(facet, rect)
if facet_points.size() >= 3:
canvas.draw_colored_polygon(facet_points, _color_with_alpha(_material_color(Color("#E49A68"), tint, default_tint(&"finish_wall_brick")), 0.20))
_draw_wall_brick_texture(canvas, rect, face_rect, seed, detail_scale, motif_scale, tint)
static func _rough_brick_outline(rect: Rect2, seed: int, roughness: Vector2) -> PackedVector2Array:
var perimeter: Array[Vector2] = [
Vector2(0.02, 0.05), Vector2(0.22, 0.00), Vector2(0.45, 0.04), Vector2(0.68, 0.00),
Vector2(0.96, 0.03), Vector2(1.00, 0.22), Vector2(0.97, 0.48), Vector2(1.00, 0.73),
Vector2(0.97, 0.95), Vector2(0.72, 1.00), Vector2(0.48, 0.96), Vector2(0.22, 1.00),
Vector2(0.03, 0.96), Vector2(0.00, 0.73), Vector2(0.04, 0.48), Vector2(0.00, 0.22),
]
var points := PackedVector2Array()
for point_index: int in perimeter.size():
var normalized_point: Vector2 = perimeter[point_index]
var jitter := Vector2(
(_wood_hash(seed + point_index * 2 + 11) - 0.5) * 2.0 * roughness.x,
(_wood_hash(seed + point_index * 2 + 12) - 0.5) * 2.0 * roughness.y
)
points.append(rect.position + normalized_point * rect.size + jitter)
return points
static func _draw_wall_brick_texture(
canvas: CanvasItem,
clip_rect: Rect2,
face_rect: Rect2,
seed: int,
detail_scale: float,
motif_scale: Vector2,
tint: Color
) -> void:
var mark_count: int = 4 + int(_wood_hash(seed + 201) * 4.0)
var natural: Color = default_tint(&"finish_wall_brick")
var dark_ochre: Color = _material_color(Color("#74412D"), tint, natural)
var warm_clay: Color = _material_color(Color("#E09A68"), tint, natural)
for mark_index: int in mark_count:
var mark_seed: int = seed + mark_index * 37 + 211
var start_x: float = face_rect.position.x + face_rect.size.x * (0.09 + _wood_hash(mark_seed) * 0.72)
var center_y: float = face_rect.position.y + face_rect.size.y * (0.20 + _wood_hash(mark_seed + 1) * 0.63)
var length: float = face_rect.size.x * (0.08 + _wood_hash(mark_seed + 2) * 0.22)
var wave: float = (0.7 + _wood_hash(mark_seed + 3) * 1.8) * detail_scale / motif_scale.y
var points := PackedVector2Array([
Vector2(start_x, center_y),
Vector2(start_x + length * 0.48, center_y + wave),
Vector2(start_x + length, center_y - wave * 0.35),
])
var light_mark: bool = _wood_hash(mark_seed + 4) > 0.72
var mark_color: Color = warm_clay if light_mark else dark_ochre
var alpha: float = 0.20 + _wood_hash(mark_seed + 5) * 0.25
var width: float = (0.8 + _wood_hash(mark_seed + 6) * 1.6) * detail_scale / motif_scale.y
_draw_clipped_polyline(canvas, clip_rect, points, _color_with_alpha(mark_color, alpha), width)
var pore_count: int = 3 + int(_wood_hash(seed + 401) * 4.0)
for pore_index: int in pore_count:
var pore_seed: int = seed + pore_index * 53 + 419
var pore := Vector2(
face_rect.position.x + face_rect.size.x * (0.10 + _wood_hash(pore_seed) * 0.80),
face_rect.position.y + face_rect.size.y * (0.15 + _wood_hash(pore_seed + 1) * 0.70)
)
var radius: float = (0.55 + _wood_hash(pore_seed + 2) * 0.8) * detail_scale / motif_scale.y
var pore_color: Color = warm_clay if _wood_hash(pore_seed + 3) > 0.62 else dark_ochre
canvas.draw_circle(pore, radius, _color_with_alpha(pore_color, 0.38))
static func _offset_polygon(points: PackedVector2Array, offset: Vector2) -> PackedVector2Array:
var result := PackedVector2Array()
for point: Vector2 in points:
result.append(point + offset)
return result
static func _clip_polygon_to_rect(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
var clipped := points
for edge: int in 4:
clipped = _clip_polygon_to_edge(clipped, rect, edge)
if clipped.is_empty():
return clipped
return clipped
static func _clip_polygon_to_edge(points: PackedVector2Array, rect: Rect2, edge: int) -> PackedVector2Array:
var result := PackedVector2Array()
if points.is_empty():
return result
var previous: Vector2 = points[points.size() - 1]
var previous_inside: bool = _point_inside_rect_edge(previous, rect, edge)
for current: Vector2 in points:
var current_inside: bool = _point_inside_rect_edge(current, rect, edge)
if current_inside:
if not previous_inside:
result.append(_rect_edge_intersection(previous, current, rect, edge))
result.append(current)
elif previous_inside:
result.append(_rect_edge_intersection(previous, current, rect, edge))
previous = current
previous_inside = current_inside
return result
static func _point_inside_rect_edge(point: Vector2, rect: Rect2, edge: int) -> bool:
match edge:
0:
return point.x >= rect.position.x
1:
return point.x <= rect.end.x
2:
return point.y >= rect.position.y
_:
return point.y <= rect.end.y
static func _rect_edge_intersection(from: Vector2, to: Vector2, rect: Rect2, edge: int) -> Vector2:
if edge <= 1:
var boundary_x: float = rect.position.x if edge == 0 else rect.end.x
var ratio_x: float = (boundary_x - from.x) / (to.x - from.x)
return Vector2(boundary_x, lerpf(from.y, to.y, ratio_x))
var boundary_y: float = rect.position.y if edge == 2 else rect.end.y
var ratio_y: float = (boundary_y - from.y) / (to.y - from.y)
return Vector2(lerpf(from.x, to.x, ratio_y), boundary_y)
static func _draw_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, base: Color, motif_scale: Vector2, detail_scale: float = 1.0) -> void:
var brick_world_w: float = BRICK_WIDTH * detail_scale
var brick_world_h: float = BRICK_HEIGHT * detail_scale
+3 -13
View File
@@ -1,27 +1,17 @@
class_name StageWallPanel
extends Node2D
extends "res://scripts/stage/scenery/stage_rect_surface.gd"
## Finite decorative wall panel with world-scale finish motifs.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
var panel_size: Vector2 = Vector2(320.0, 400.0)
## Saved source-wall anchor offset, retained to preserve erased-fragment metadata.
## Motif drawing uses the fragment's current world origin so its brick courses stay aligned.
var motif_origin_offset: Vector2 = Vector2.ZERO
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
finish_id = finish if SURFACE_RENDERER.is_wall_finish(finish) else &"finish_wall_paint"
tint = color
super.configure(new_size, finish if SURFACE_RENDERER.is_wall_finish(finish) else &"finish_wall_paint", color)
z_index = DRAW_ORDER.WALL
queue_redraw()
func get_local_bounds() -> Rect2:
return Rect2(-panel_size * 0.5, panel_size)
func get_effective_motif_origin_world() -> Vector2:
@@ -33,4 +23,4 @@ func get_surface_draw_origin_world() -> Vector2:
func _draw() -> void:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, tint, get_surface_draw_origin_world())
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
+1
View File
@@ -3,6 +3,7 @@ extends RefCounted
## Semantic draw bands shared by scenery, terrain, Props, and characters.
const WALL: int = -40
const BACKGROUND_GROUND: int = -50
const FAR: int = -30
const NEAR: int = -20
const TERRAIN: int = -10
+4 -3
View File
@@ -12,6 +12,7 @@ extends Node2D
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const StageBackgroundGroundScript := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
# ---------------------------------------------------------------------------
@@ -300,12 +301,12 @@ func _corners(rect: Rect2) -> Array[Vector2]:
func _is_scenery_target(node: Node2D) -> bool:
return node != null and (node.get_script() == StageWallPanelScript or node.get_script() == StageSceneryArtworkScript)
return node != null and (node.get_script() == StageWallPanelScript or node.get_script() == StageBackgroundGroundScript or node.get_script() == StageSceneryArtworkScript)
func _target_bounds(node: Node2D) -> Rect2:
if node != null and node.get_script() == StageWallPanelScript:
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 != null and node.get_script() == StageSceneryArtworkScript:
return (node as StageSceneryArtwork).get_visual_world_bounds()
return STAGE_SELECTION.get_world_aabb(node)
+34 -4
View File
@@ -3,7 +3,8 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.5"
const FILE_VERSION: String = "1.6"
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
@@ -11,6 +12,7 @@ const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"]
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
const SKY_IDS: Array[String] = ["sky_clear_day", "sky_cloudy_day", "sky_sunset", "sky_starry_night", "sky_neutral"]
const SCENERY_DEPTHS: Array[String] = ["far", "near", "floor"]
@@ -183,10 +185,11 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
prop_entry["wobble_on_impact"] = false
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4"]:
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]:
return {}
var migrated: Dictionary = data.duplicate(true)
migrated["sky_id"] = "sky_neutral"
if version != "1.5":
migrated["sky_id"] = "sky_neutral"
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
@@ -200,13 +203,32 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
if not object.has("wobble_on_impact"):
object["wobble_on_impact"] = false
if String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
if version != "1.5" and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
object["finish_id"] = "finish_ground_legacy"
if String(object.get("type", "")) == "wall_panel":
_migrate_legacy_wall_tint(object)
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
static func _migrate_legacy_wall_tint(entry: Dictionary) -> void:
var finish: StringName = StringName(String(entry.get("finish_id", "finish_wall_paint")))
if finish not in [&"finish_wall_brick", &"finish_wall_wood"]:
if not entry.has("color"):
entry["color"] = "#5A9D70"
return
if not entry.has("color") or _is_legacy_green_tint(entry.get("color")):
entry["color"] = "#" + SURFACE_RENDERER.default_tint(finish).to_html(true)
static func _is_legacy_green_tint(value: Variant) -> bool:
if not value is String:
return false
var raw: String = String(value).strip_edges().trim_prefix("#").to_upper()
return raw == "5A9D70" or raw == "5A9D70FF"
static func _assign_legacy_depths(raw_objects: Variant) -> void:
if not raw_objects is Array:
return
@@ -277,6 +299,14 @@ static func _validate_root(data: Dictionary) -> Dictionary:
return {"ok": false, "error": "Wall panel color is invalid."}
if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")):
return {"ok": false, "error": "Wall panel motif origin is invalid."}
elif String(entry.get("type", "")) == "background_ground":
if not is_vector_data(entry.get("size")):
return {"ok": false, "error": "Background ground size is invalid."}
var background_size: Vector2 = data_to_vector(entry.get("size"), Vector2.ZERO)
if background_size.x <= 0.0 or background_size.y <= 0.0 or not BACKGROUND_FINISHES.has(String(entry.get("finish_id", ""))):
return {"ok": false, "error": "Background ground size or finish is invalid."}
if not _valid_color(entry.get("color", "")):
return {"ok": false, "error": "Background ground color is invalid."}
elif String(entry.get("type", "")) == "scenery_artwork":
var asset_id: Variant = entry.get("asset_id", "")
if not asset_id is String or String(asset_id).strip_edges().is_empty():
+4 -1
View File
@@ -20,6 +20,7 @@ signal selection_changed(nodes: Array[Node2D])
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_BACKGROUND_GROUND := preload("res://scripts/stage/scenery/stage_background_ground.gd")
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
enum SelectionScope { ACTION, SCENERY }
@@ -172,6 +173,8 @@ func box_select(rect: Rect2, additive: bool) -> void:
static func get_world_aabb(node: Node2D) -> Rect2:
if node == null or not is_instance_valid(node):
return Rect2()
if node is StageRectSurface:
return node.global_transform * (node as StageRectSurface).get_local_bounds()
# Duck-typed TriggerArea: expose its centered local rect through the transform.
if node.has_method("get_area_rect"):
return node.global_transform * (node.call("get_area_rect") as Rect2)
@@ -230,7 +233,7 @@ func _is_selectable(node: Node) -> bool:
return false
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
return false
var is_scenery: bool = node.get_script() == STAGE_WALL_PANEL or node.get_script() == STAGE_SCENERY_ARTWORK
var is_scenery: bool = node.get_script() == STAGE_WALL_PANEL or node.get_script() == STAGE_BACKGROUND_GROUND or node.get_script() == STAGE_SCENERY_ARTWORK
if _scope == SelectionScope.ACTION and is_scenery:
return false
if _scope == SelectionScope.SCENERY and not is_scenery: