Add tests for scenery surface caching and preview behavior

- Introduced `test_scenery_brick_preview.gd` to validate focused placement and CPU draw-cost for large brick walls.
- Added `test_scenery_surface_cache.gd` to assess texture budget, visual equivalence, and cache invalidation.
- Created `test_scenery_surface_preview.gd` to evaluate adaptive preview selection and placement behavior.
- Implemented various helper functions to measure drawing performance and validate expected behaviors during tests.
- Added unique identifiers for new test files to ensure proper tracking and management.
This commit is contained in:
2026-10-21 11:06:57 -04:00
parent 80ba83b703
commit c1758efd3b
54 changed files with 5941 additions and 526 deletions
+506 -95
View File
@@ -62,6 +62,8 @@ const STAGE_PANEL_CHOICE_GROUP_SCRIPT := preload("res://scripts/shared/stage_ui/
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 STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT := preload("res://scripts/shared/stage_ui/stage_depth_choice_preview.gd")
const PROP_FILE_SCRIPT := preload("res://scripts/editors/prop/prop_file.gd")
const PROP_EDITOR_SCENE := preload("res://scenes/editors/prop/prop_editor.tscn")
# Phase 3c editor tools.
@@ -287,10 +289,20 @@ var _scenery_depth_section: VBoxContainer
var _scenery_depth_heading: Label
var _scenery_depth_group: StagePanelChoiceGroup
var _scenery_depth_helper: Label
var _scenery_depth_choice_cues: Array[Control] = []
var _scenery_depth_preview_texture: Texture2D
var _scenery_depth_preview_path: String = ""
var _scenery_depth_preview_busy: bool = false
var _scenery_depth_preview_generation: int = 0
var _scenery_depth_preview_scheduled_generation: int = -1
var _scenery_depth_thumbnail_renderer: PropThumbnail
var _scenery_depth_cursor_badge: PanelContainer
var _scenery_depth_cursor_badge_label: Label
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_background_ground_area_button: Button
var _scenery_erase_piece_button: Button
var _scenery_find_button: Button
var _scenery_choose_button: Button
@@ -300,7 +312,13 @@ 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_dialog: Control
var _scenery_erase_piece_dialog_panel: PanelContainer
var _scenery_erase_piece_dialog_title: Label
var _scenery_erase_piece_dialog_message: Label
var _scenery_erase_piece_dialog_actions: BoxContainer
var _scenery_erase_piece_keep_button: Button
var _scenery_erase_piece_remove_button: Button
var _scenery_erase_piece_targets: Array[WeakRef] = []
var _surface_color_dialog: StageSurfaceColorDialog
var _surface_color_session: Dictionary = {}
@@ -1376,6 +1394,7 @@ func _process(delta: float) -> void:
_update_action_overlay()
_sync_awaiting_cursor()
_update_speech_badge_cursor()
_update_scenery_depth_cursor_badge()
if _toast_timer > 0.0:
_toast_timer -= delta
if _toast_timer <= 0.0:
@@ -1521,6 +1540,11 @@ func _unhandled_key_input(event: InputEvent) -> void:
return
if not (event is InputEventKey) or not event.pressed or event.echo:
return
if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible:
if event.keycode == KEY_ESCAPE:
_dismiss_scenery_erase_piece_dialog()
get_viewport().set_input_as_handled()
return
if _speech_composer_is_open():
if event.keycode == KEY_ESCAPE:
if bool(_speech_composer.call("is_message_dialog_open")):
@@ -1692,9 +1716,7 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
_refresh_scenery_inspector()
_refresh_scenery_capture_bar()
elif _scenery_controller != null and _scenery_controller.is_group_preview_pending():
_scenery_controller.cancel_group()
_scenery_group_mode = &""
_refresh_scenery_capture_bar()
_keep_scenery_group_preview()
else:
_exit_scenery_edit_mode()
get_viewport().set_input_as_handled()
@@ -2557,6 +2579,8 @@ func _save_scene_to_path(path: String, is_autosave: bool = false) -> bool:
_pending_file_action = PendingFileAction.NONE
return false
if not is_autosave:
if _scenery_controller != null:
_scenery_controller.commit_placement_session()
_paused_edit_autosave_remaining = -1.0
_set_scene_dirty(false)
_save_settings()
@@ -3211,15 +3235,21 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_depth_section.add_child(_scenery_depth_heading)
_scenery_depth_group = STAGE_PANEL_CHOICE_GROUP_SCRIPT.new() as StagePanelChoiceGroup
_scenery_depth_group.name = "SceneryDepthChoices"
_scenery_depth_group.add_choice(&"far", "Far")
_scenery_depth_group.add_choice(&"near", "Near")
_scenery_depth_group.add_choice(&"floor", "On the floor")
_scenery_depth_group.configure_layout(true, 118.0, 24, 16, 1, Color("#087DBA"))
_scenery_depth_choice_cues.clear()
var back_cue: Control = _make_scenery_depth_choice_cue(&"far", Vector2(128.0, 82.0))
var middle_cue: Control = _make_scenery_depth_choice_cue(&"near", Vector2(128.0, 82.0))
var front_cue: Control = _make_scenery_depth_choice_cue(&"floor", Vector2(128.0, 82.0))
_scenery_depth_choice_cues = [back_cue, middle_cue, front_cue]
_scenery_depth_group.add_choice(&"far", "Back", back_cue, "Behind other scenery")
_scenery_depth_group.add_choice(&"near", "Middle", middle_cue, "In front of Back · behind tiles")
_scenery_depth_group.add_choice(&"floor", "Front", front_cue, "In front of ground tiles")
_scenery_depth_group.apply_theme(_sandbox_theme as SandboxTheme)
_scenery_depth_group.choice_selected.connect(_on_selected_scenery_depth_choice)
_scenery_depth_section.add_child(_scenery_depth_group)
_scenery_depth_helper = Label.new()
_scenery_depth_helper.name = "SceneryDepthHelper"
_scenery_depth_helper.text = "Far is behind Near. On the floor is in front of the ground."
_scenery_depth_helper.text = "The pictures show what can cover the artwork."
_scenery_depth_helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scenery_depth_helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
_scenery_depth_helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
@@ -3237,9 +3267,14 @@ func _build_scenery_inspector(ui: CanvasLayer) -> void:
_scenery_size_row.add_child(_scenery_bigger_button)
body.add_child(_scenery_size_row)
_scenery_erase_wall_area_button = _make_scenery_repair_button("EraseWallArea", "Erase wall area", true)
_scenery_inspector.style_primary_action_button(_scenery_erase_wall_area_button)
_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_background_ground_area_button = _make_scenery_repair_button("EraseBackgroundGroundArea", "Erase background ground area", true)
_scenery_inspector.style_primary_action_button(_scenery_erase_background_ground_area_button)
_scenery_erase_background_ground_area_button.pressed.connect(_begin_selected_background_ground_erase)
body.add_child(_scenery_erase_background_ground_area_button)
_scenery_erase_piece_button = _make_scenery_repair_button("EraseSceneryPiece", "Remove wall panel", false, 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)
@@ -3272,16 +3307,7 @@ 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)
_build_scenery_erase_piece_dialog(ui)
_surface_color_dialog = STAGE_SURFACE_COLOR_DIALOG_SCRIPT.new() as StageSurfaceColorDialog
_surface_color_dialog.name = "ScenerySurfaceColorDialog"
ui.add_child(_surface_color_dialog)
@@ -3301,10 +3327,314 @@ func _make_scenery_section_heading(label_text: String) -> Label:
return heading
func _make_scenery_depth_choice_cue(depth: StringName, cue_size: Vector2) -> Control:
var cue: StageDepthChoicePreview = STAGE_DEPTH_CHOICE_PREVIEW_SCRIPT.new() as StageDepthChoicePreview
cue.configure(depth, _scenery_depth_preview_texture, cue_size)
return cue
func _make_scenery_depth_capture_action(action_id: StringName, depth: StringName, label: String, selected_depth: StringName) -> Dictionary:
return {
"id": action_id,
"label": label,
"choice": true,
"selected": depth == selected_depth,
"cue": _make_scenery_depth_choice_cue(depth, Vector2(38.0, 34.0)),
}
func _set_scenery_depth_preview_path(path: String) -> void:
var canonical_path: String = CONTENT_PATHS.canonicalize(path) if not path.is_empty() else ""
if canonical_path == _scenery_depth_preview_path and (_scenery_depth_preview_texture != null or _scenery_depth_preview_busy or _scenery_depth_preview_scheduled_generation == _scenery_depth_preview_generation):
return
if canonical_path != _scenery_depth_preview_path:
_scenery_depth_preview_path = canonical_path
_scenery_depth_preview_texture = null
_scenery_depth_preview_generation += 1
_update_scenery_depth_choice_cues()
if canonical_path.is_empty() or not FileAccess.file_exists(canonical_path):
return
if not _scenery_depth_preview_busy:
_scenery_depth_preview_scheduled_generation = _scenery_depth_preview_generation
call_deferred("_render_scenery_depth_preview", canonical_path, _scenery_depth_preview_generation)
func _render_scenery_depth_preview(path: String, generation: int) -> void:
if _scenery_depth_preview_busy or path.is_empty():
return
_scenery_depth_preview_scheduled_generation = -1
_scenery_depth_preview_busy = true
if _scenery_depth_thumbnail_renderer == null:
_scenery_depth_thumbnail_renderer = PROP_THUMBNAIL.new() as PropThumbnail
_scenery_depth_thumbnail_renderer.name = "SceneryDepthThumbnailRenderer"
add_child(_scenery_depth_thumbnail_renderer)
await get_tree().process_frame
var parsed: Dictionary = PROP_FILE_SCRIPT.from_file_dict(PROP_FILE_SCRIPT.load(path))
var shapes: Array[Dictionary] = parsed.get("shapes", []) as Array[Dictionary]
var texture: Texture2D
if not shapes.is_empty() and _scenery_depth_thumbnail_renderer != null:
texture = await _scenery_depth_thumbnail_renderer.render_shapes(shapes)
if generation == _scenery_depth_preview_generation and path == _scenery_depth_preview_path and texture != null:
_scenery_depth_preview_texture = texture
_update_scenery_depth_choice_cues()
if _scenery_controller != null and _scenery_controller.get_active_tool() == &"artwork":
_refresh_scenery_capture_bar()
_scenery_depth_preview_busy = false
if not _scenery_depth_preview_path.is_empty() and _scenery_depth_preview_path != path:
_scenery_depth_preview_scheduled_generation = _scenery_depth_preview_generation
call_deferred("_render_scenery_depth_preview", _scenery_depth_preview_path, _scenery_depth_preview_generation)
func _update_scenery_depth_choice_cues() -> void:
for cue_control: Control in _scenery_depth_choice_cues:
if cue_control is StageDepthChoicePreview:
(cue_control as StageDepthChoicePreview).set_artwork_texture(_scenery_depth_preview_texture)
func _build_scenery_erase_piece_dialog(ui: CanvasLayer) -> void:
_scenery_erase_piece_dialog = Control.new()
_scenery_erase_piece_dialog.name = "EraseSceneryPieceDialog"
_scenery_erase_piece_dialog.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_scenery_erase_piece_dialog.mouse_filter = Control.MOUSE_FILTER_STOP
_scenery_erase_piece_dialog.z_index = 100
_scenery_erase_piece_dialog.visible = false
ui.add_child(_scenery_erase_piece_dialog)
var backdrop: ColorRect = ColorRect.new()
backdrop.name = "DialogBackdrop"
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
backdrop.color = _confirmation_dialog_color("backdrop_color", Color("#10151D66"))
backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
_scenery_erase_piece_dialog.add_child(backdrop)
var center: CenterContainer = CenterContainer.new()
center.name = "DialogCenter"
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
_scenery_erase_piece_dialog.add_child(center)
_scenery_erase_piece_dialog_panel = PanelContainer.new()
_scenery_erase_piece_dialog_panel.name = "DialogCard"
center.add_child(_scenery_erase_piece_dialog_panel)
var margin: MarginContainer = MarginContainer.new()
margin.name = "DialogMargin"
var padding: int = _confirmation_dialog_integer("padding", 18, 12, 32)
margin.add_theme_constant_override("margin_left", padding)
margin.add_theme_constant_override("margin_right", padding)
margin.add_theme_constant_override("margin_top", padding)
margin.add_theme_constant_override("margin_bottom", padding)
_scenery_erase_piece_dialog_panel.add_child(margin)
var layout: VBoxContainer = VBoxContainer.new()
layout.name = "DialogLayout"
layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
layout.size_flags_vertical = Control.SIZE_EXPAND_FILL
layout.add_theme_constant_override("separation", _confirmation_dialog_integer("body_gap", 10, 4, 24))
margin.add_child(layout)
_scenery_erase_piece_dialog_title = Label.new()
_scenery_erase_piece_dialog_title.name = "DialogTitle"
_scenery_erase_piece_dialog_title.text = "Remove this scenery?"
_scenery_erase_piece_dialog_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scenery_erase_piece_dialog_title.add_theme_font_size_override("font_size", _confirmation_dialog_integer("title_font_size", 26, 20, 40))
_scenery_erase_piece_dialog_title.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
_apply_ui_font(_scenery_erase_piece_dialog_title)
var title_font: Font = _ui_font
if title_font != null:
var strong_title_font: FontVariation = FontVariation.new()
strong_title_font.base_font = title_font
strong_title_font.variation_embolden = 0.55
_scenery_erase_piece_dialog_title.add_theme_font_override("font", strong_title_font)
layout.add_child(_scenery_erase_piece_dialog_title)
_scenery_erase_piece_dialog_message = Label.new()
_scenery_erase_piece_dialog_message.name = "DialogMessage"
_scenery_erase_piece_dialog_message.text = "Remove this whole piece? You can’t undo this."
_scenery_erase_piece_dialog_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scenery_erase_piece_dialog_message.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scenery_erase_piece_dialog_message.add_theme_font_size_override("font_size", _confirmation_dialog_integer("message_font_size", 20, 16, 32))
_scenery_erase_piece_dialog_message.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA")))
_apply_ui_font(_scenery_erase_piece_dialog_message)
layout.add_child(_scenery_erase_piece_dialog_message)
var actions_spacer: Control = Control.new()
actions_spacer.name = "ActionsSpacer"
actions_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
layout.add_child(actions_spacer)
_scenery_erase_piece_dialog_actions = BoxContainer.new()
_scenery_erase_piece_dialog_actions.name = "DialogActions"
_scenery_erase_piece_dialog_actions.alignment = BoxContainer.ALIGNMENT_END
_scenery_erase_piece_dialog_actions.add_theme_constant_override("separation", _confirmation_dialog_integer("footer_gap", 12, 8, 24))
layout.add_child(_scenery_erase_piece_dialog_actions)
_scenery_erase_piece_keep_button = Button.new()
_scenery_erase_piece_keep_button.name = "KeepSceneryButton"
_scenery_erase_piece_keep_button.text = "Keep it"
_scenery_erase_piece_keep_button.focus_mode = Control.FOCUS_ALL
_scenery_erase_piece_keep_button.pressed.connect(_dismiss_scenery_erase_piece_dialog)
_scenery_erase_piece_dialog_actions.add_child(_scenery_erase_piece_keep_button)
_scenery_erase_piece_remove_button = Button.new()
_scenery_erase_piece_remove_button.name = "RemoveSceneryButton"
_scenery_erase_piece_remove_button.text = "Remove scenery"
_scenery_erase_piece_remove_button.focus_mode = Control.FOCUS_ALL
_scenery_erase_piece_remove_button.pressed.connect(_confirm_erase_scenery_from_dialog)
_scenery_erase_piece_dialog_actions.add_child(_scenery_erase_piece_remove_button)
_apply_scenery_erase_piece_dialog_style()
if not get_tree().root.size_changed.is_connected(_refresh_scenery_erase_piece_dialog_size):
get_tree().root.size_changed.connect(_refresh_scenery_erase_piece_dialog_size)
func _apply_scenery_erase_piece_dialog_style() -> void:
if _scenery_erase_piece_dialog == null:
return
var shell: StyleBoxFlat = StyleBoxFlat.new()
shell.bg_color = _confirmation_dialog_color("surface_color", Color("#202633FA"))
shell.border_color = _confirmation_dialog_color("border_color", Color("#AAB2C066"))
shell.set_border_width_all(_confirmation_dialog_integer("border_width", 1, 1, 4))
shell.set_corner_radius_all(_confirmation_dialog_integer("corner_radius", 16, 10, 28))
_scenery_erase_piece_dialog_panel.add_theme_stylebox_override("panel", shell)
_apply_scenery_erase_piece_action_style(_scenery_erase_piece_keep_button, true)
_apply_scenery_erase_piece_action_style(_scenery_erase_piece_remove_button, false)
func _apply_scenery_erase_piece_action_style(button: Button, primary: bool) -> void:
if button == null:
return
var accent: Color = _confirmation_dialog_color("accent_color", Color("#22C6FF"))
var blue: Color = _confirmation_dialog_color("action_color", Color("#087DBA"))
var danger: Color = _confirmation_dialog_color("danger_color", Color("#FF626B"))
var neutral: Color = _stage_panel_color("action.surface_color", Color("#171B24"))
var hover_neutral: Color = _confirmation_dialog_color("danger_hover_surface", Color("#3B252C"))
var pressed_neutral: Color = _confirmation_dialog_color("danger_pressed_surface", Color("#4A252D"))
var border_width: int = _confirmation_dialog_integer("button_border_width", 2, 1, 5)
var radius: int = _confirmation_dialog_integer("button_radius", 12, 8, 24)
var normal_color: Color = blue if primary else neutral
var hover_color: Color = blue.lightened(0.12) if primary else hover_neutral
var pressed_color: Color = blue.darkened(0.12) if primary else pressed_neutral
var edge_color: Color = accent if primary else danger
button.add_theme_stylebox_override("normal", _make_scenery_erase_button_style(normal_color, edge_color, border_width, radius))
button.add_theme_stylebox_override("hover", _make_scenery_erase_button_style(hover_color, edge_color.lightened(0.12), border_width + 1, radius))
button.add_theme_stylebox_override("pressed", _make_scenery_erase_button_style(pressed_color, edge_color, border_width + 1, radius))
button.add_theme_stylebox_override("hover_pressed", _make_scenery_erase_button_style(pressed_color.lightened(0.05), edge_color, border_width + 1, radius))
button.add_theme_stylebox_override("focus", _make_scenery_erase_button_style(normal_color, accent, border_width + 1, radius))
var label_color: Color = Color.WHITE if primary else danger
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)
button.add_theme_font_size_override("font_size", _confirmation_dialog_integer("button_font_size", 20, 16, 32))
button.custom_minimum_size = Vector2(
float(_confirmation_dialog_integer("remove_button_width", 170, 128, 240)) if not primary else float(_confirmation_dialog_integer("keep_button_width", 132, 112, 220)),
float(_confirmation_dialog_integer("button_height", 48, 44, 80))
)
var font: Font = _ui_font
if primary and font != null:
var strong_font: FontVariation = FontVariation.new()
strong_font.base_font = font
strong_font.variation_embolden = 0.55
button.add_theme_font_override("font", strong_font)
else:
_apply_ui_font(button)
func _make_scenery_erase_button_style(background: Color, border: Color, width: int, radius: int) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(width)
style.set_corner_radius_all(radius)
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 8.0
style.content_margin_bottom = 8.0
return style
func _confirmation_dialog_integer(token: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _sandbox_theme == null else _sandbox_theme.integer("popups.confirmation." + token, fallback, minimum, maximum)
func _confirmation_dialog_color(token: String, fallback: Color) -> Color:
return fallback if _sandbox_theme == null else _sandbox_theme.color("popups.confirmation." + token, fallback)
func _open_scenery_erase_piece_dialog() -> void:
if _scenery_erase_piece_dialog == null:
return
var popup_size: Vector2i = _scenery_erase_piece_dialog_size()
_apply_scenery_erase_piece_dialog_layout(popup_size.x)
if _scenery_erase_piece_dialog_panel != null:
_scenery_erase_piece_dialog_panel.custom_minimum_size = Vector2(popup_size)
_scenery_erase_piece_dialog.visible = true
call_deferred("_focus_scenery_erase_piece_keep_button")
func _scenery_erase_piece_dialog_size() -> Vector2i:
var preferred: Vector2i = Vector2i(520, 208)
var raw_size: Variant = _sandbox_theme.value("popups.confirmation.scenery_erase_size", [520, 208]) if _sandbox_theme != null else [520, 208]
if raw_size is Array and (raw_size as Array).size() == 2:
preferred = Vector2i(int((raw_size as Array)[0]), int((raw_size as Array)[1]))
var available: Vector2i = Vector2i(get_tree().root.size)
var usable_rect: Rect2i = DisplayServer.screen_get_usable_rect(DisplayServer.window_get_current_screen())
if usable_rect.size.x > 0 and usable_rect.size.y > 0:
available.x = mini(available.x, usable_rect.size.x)
available.y = mini(available.y, usable_rect.size.y)
available = Vector2i(maxi(1, available.x - 32), maxi(1, available.y - 32))
var width: int = mini(preferred.x, available.x)
var height: int = mini(preferred.y, available.y)
if width < 440:
height = mini(available.y, height + 56)
return Vector2i(width, height)
func _apply_scenery_erase_piece_dialog_layout(dialog_width: int) -> void:
if _scenery_erase_piece_dialog_actions == null or _scenery_erase_piece_keep_button == null or _scenery_erase_piece_remove_button == null:
return
var stacked: bool = dialog_width < 440
_scenery_erase_piece_dialog_actions.vertical = stacked
_scenery_erase_piece_dialog_actions.alignment = BoxContainer.ALIGNMENT_END
_scenery_erase_piece_dialog_actions.add_theme_constant_override("separation", 8 if stacked else _confirmation_dialog_integer("footer_gap", 12, 8, 24))
if stacked:
var button_width: float = maxf(1.0, float(dialog_width - _confirmation_dialog_integer("padding", 18, 12, 32) * 2))
var button_height: float = float(_confirmation_dialog_integer("button_height", 48, 44, 80))
_scenery_erase_piece_remove_button.custom_minimum_size = Vector2(button_width, button_height)
_scenery_erase_piece_keep_button.custom_minimum_size = Vector2(button_width, button_height)
else:
_scenery_erase_piece_remove_button.custom_minimum_size.x = float(_confirmation_dialog_integer("remove_button_width", 170, 128, 240))
_scenery_erase_piece_keep_button.custom_minimum_size.x = float(_confirmation_dialog_integer("keep_button_width", 132, 112, 220))
func _refresh_scenery_erase_piece_dialog_size() -> void:
if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible:
_open_scenery_erase_piece_dialog()
func _focus_scenery_erase_piece_keep_button() -> void:
if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible and _scenery_erase_piece_keep_button != null:
_scenery_erase_piece_keep_button.grab_focus()
func _dismiss_scenery_erase_piece_dialog() -> void:
if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible:
_scenery_erase_piece_dialog.hide()
_clear_erase_scenery_targets()
func _confirm_erase_scenery_from_dialog() -> void:
_confirm_erase_selected_scenery()
if _scenery_erase_piece_dialog != null and _scenery_erase_piece_dialog.visible:
_scenery_erase_piece_dialog.hide()
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.set_rounded_frame(
float(_sandbox_theme.integer("popups.asset_selector.look_corner_radius", 8, 2, 20)) if _sandbox_theme != null else 8.0,
_sandbox_theme.color("popups.style_defaults.bg_color", Color("#202633FA")) if _sandbox_theme != null else Color("#202633FA")
)
cue.configure(kind, finish, STAGE_SURFACE_RENDERER_SCRIPT.default_tint(finish))
return cue
@@ -3319,14 +3649,17 @@ func _surface_color_presets() -> Array[Color]:
return colors
func _make_scenery_repair_button(node_name: String, label: String, prominent: bool) -> Button:
func _make_scenery_repair_button(node_name: String, label: String, prominent: bool, destructive: bool = false) -> Button:
var button: Button = Button.new()
button.name = node_name
button.text = label
button.focus_mode = Control.FOCUS_ALL
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size.y = float(_stage_panel_integer("action.min_target_size", 48, 44, 96))
_scenery_inspector.style_action_button(button, prominent)
if destructive:
_scenery_inspector.style_destructive_button(button)
else:
_scenery_inspector.style_action_button(button, prominent)
return button
@@ -3365,6 +3698,65 @@ func _build_use_target_capture_bar(ui: CanvasLayer) -> void:
_use_target_capture_bar.change_prop_requested.connect(_choose_different_use_prop)
_use_target_capture_bar.capture_action_requested.connect(_on_capture_action_requested)
ui.add_child(_use_target_capture_bar)
_build_scenery_depth_cursor_badge(ui)
func _build_scenery_depth_cursor_badge(ui: CanvasLayer) -> void:
_scenery_depth_cursor_badge = PanelContainer.new()
_scenery_depth_cursor_badge.name = "SceneryDepthCursorBadge"
_scenery_depth_cursor_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_scenery_depth_cursor_badge.z_index = 2
var style := StyleBoxFlat.new()
style.bg_color = _stage_panel_color("choice.selected_surface", Color("#12394A"))
style.border_color = _sandbox_theme.color("mode_colors.edit", Color("#22C6FF")) if _sandbox_theme != null else Color("#22C6FF")
style.set_border_width_all(1)
style.set_corner_radius_all(10)
style.content_margin_left = 9.0
style.content_margin_right = 9.0
style.content_margin_top = 3.0
style.content_margin_bottom = 3.0
_scenery_depth_cursor_badge.add_theme_stylebox_override("panel", style)
_scenery_depth_cursor_badge_label = Label.new()
_scenery_depth_cursor_badge_label.name = "DepthLabel"
_scenery_depth_cursor_badge_label.text = "MIDDLE"
_scenery_depth_cursor_badge_label.add_theme_font_size_override("font_size", 13)
_scenery_depth_cursor_badge_label.add_theme_color_override("font_color", Color("#F5F7FA"))
_apply_ui_font(_scenery_depth_cursor_badge_label)
_scenery_depth_cursor_badge.add_child(_scenery_depth_cursor_badge_label)
_scenery_depth_cursor_badge.visible = false
ui.add_child(_scenery_depth_cursor_badge)
func _update_scenery_depth_cursor_badge() -> void:
if _scenery_depth_cursor_badge == null or _scenery_controller == null:
return
if current_mode != StageMode.EDIT or _scenery_controller.get_active_tool() != &"artwork":
_scenery_depth_cursor_badge.visible = false
return
var mouse_position: Vector2 = get_viewport().get_mouse_position()
if _use_target_capture_bar != null and _use_target_capture_bar.visible and _use_target_capture_bar.get_global_rect().has_point(mouse_position):
_scenery_depth_cursor_badge.visible = false
return
var ghost_bounds: Rect2 = _scenery_controller.get_active_artwork_preview_bounds()
if ghost_bounds.size.x <= 0.0 or ghost_bounds.size.y <= 0.0:
_scenery_depth_cursor_badge.visible = false
return
var depth: StringName = _scenery_controller.get_artwork_depth()
var depth_label: String = "Back" if depth == &"far" else "Front" if depth == &"floor" else "Middle"
_scenery_depth_cursor_badge_label.text = depth_label.to_upper()
var badge_size: Vector2 = _scenery_depth_cursor_badge.get_combined_minimum_size()
_scenery_depth_cursor_badge.size = badge_size
var viewport_size: Vector2 = get_viewport_rect().size
var screen_top_left: Vector2 = _world_to_screen(ghost_bounds.position)
var screen_bottom_right: Vector2 = _world_to_screen(ghost_bounds.end)
var ghost_screen_rect: Rect2 = Rect2(screen_top_left, screen_bottom_right - screen_top_left).abs()
var badge_position := Vector2(ghost_screen_rect.get_center().x - badge_size.x * 0.5, ghost_screen_rect.position.y - badge_size.y - 8.0)
if badge_position.y < 8.0:
badge_position.y = ghost_screen_rect.position.y + 8.0
badge_position.x = clampf(badge_position.x, 8.0, maxf(8.0, viewport_size.x - badge_size.x - 8.0))
badge_position.y = clampf(badge_position.y, 8.0, maxf(8.0, viewport_size.y - badge_size.y - 8.0))
_scenery_depth_cursor_badge.position = badge_position
_scenery_depth_cursor_badge.visible = true
func _build_ground_finish_popup(ui: CanvasLayer) -> void:
@@ -4527,7 +4919,7 @@ func _refresh_scenery_inspector() -> void:
_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_helper.visible = is_wall
_scenery_surface_colors.visible = false
if surface != null:
if is_wall:
@@ -4535,7 +4927,6 @@ func _refresh_scenery_inspector() -> void:
_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:
@@ -4543,16 +4934,18 @@ func _refresh_scenery_inspector() -> void:
_scenery_surface_colors.set_recent_colors(_recent_surface_colors)
_scenery_depth_section.visible = is_artwork
if artwork != null:
_set_scenery_depth_preview_path(artwork.asset_path)
_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 = is_wall
_scenery_erase_background_ground_area_button.visible = is_background_ground
_scenery_erase_piece_button.visible = surface != null
if is_background_ground:
_scenery_erase_piece_button.text = "Erase background ground"
_scenery_erase_piece_button.text = "Remove background ground"
else:
_scenery_erase_piece_button.text = "Erase wall panel"
_scenery_erase_piece_button.text = "Remove wall panel"
_scenery_find_button.visible = is_missing
_scenery_choose_button.visible = is_missing
_scenery_keep_button.visible = is_missing
@@ -4595,6 +4988,16 @@ 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 or node.get_script() == STAGE_BACKGROUND_GROUND_SCRIPT)
func _has_remaining_scenery() -> bool:
if _world == null or not is_instance_valid(_world):
return false
for child: Node in _world.get_children():
var node: Node2D = child as Node2D
if node != null and not node.is_queued_for_deletion() and _is_scenery_node(node):
return true
return false
func _on_selected_surface_look_choice(finish: StringName) -> void:
var surface: StageRectSurface = _selected_rect_surface()
if surface == null:
@@ -4737,10 +5140,10 @@ func _request_erase_selected_scenery() -> void:
_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."
_scenery_erase_piece_dialog_message.text = "Remove this whole 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()
_scenery_erase_piece_dialog_message.text = "Remove these %d scenery pieces? You can’t undo this." % count
_open_scenery_erase_piece_dialog()
func _confirm_erase_selected_scenery() -> void:
@@ -4764,7 +5167,10 @@ func _confirm_erase_selected_scenery() -> void:
_director_visuals.mark_dirty()
object_deleted.emit(erased)
_mark_scene_dirty()
_refresh_scenery_inspector()
if not _has_remaining_scenery():
_exit_scenery_edit_mode()
else:
_refresh_scenery_inspector()
_refresh_status()
@@ -4790,13 +5196,31 @@ func _begin_selected_wall_erase() -> void:
_begin_wall_erase_tool()
func _begin_selected_background_ground_erase() -> void:
var selected: Array[Node2D] = _selection.get_selected() if _selection != null else []
if selected.size() != 1 or selected[0].get_script() != STAGE_BACKGROUND_GROUND_SCRIPT or _scenery_controller == null:
return
_begin_background_ground_erase_tool()
func _begin_wall_erase_tool() -> void:
_begin_surface_erase_tool(&"wall")
func _begin_background_ground_erase_tool() -> void:
_begin_surface_erase_tool(&"background_ground")
func _begin_surface_erase_tool(surface_kind: StringName) -> void:
if _scenery_controller == null or not _scenery_edit_mode:
return
_selection.clear_selection()
_scenery_inspector.hide()
if not _scenery_controller.begin_wall_eraser(_viewport_to_world(get_viewport().get_mouse_position())):
_show_toast("Could not start the wall eraser. Try again.")
var pointer_world: Vector2 = _viewport_to_world(get_viewport().get_mouse_position())
var started: bool = _scenery_controller.begin_surface_eraser(pointer_world, surface_kind)
if not started:
var surface_label: String = "wall and background ground" if surface_kind == &"all" else ("wall" if surface_kind == &"wall" else "background ground")
_show_toast("Could not start the %s eraser. Try again." % surface_label)
_refresh_scenery_capture_bar()
@@ -4926,7 +5350,6 @@ func _on_scenery_placement_committed(node: Node2D) -> void:
if node == null or not is_instance_valid(node):
return
_scenery_placed_count += 1
_mark_scene_dirty()
_refresh_scenery_capture_bar()
@@ -4941,34 +5364,40 @@ func _refresh_scenery_capture_bar() -> void:
_use_target_capture_bar.clear_horizontal_safe_bounds()
var actions: Array[Dictionary] = []
if _scenery_controller.get_active_tool() == &"artwork":
actions.append({"id": "depth_far", "label": "Far"})
actions.append({"id": "depth_near", "label": "Near"})
actions.append({"id": "depth_floor", "label": "On the floor"})
_set_scenery_depth_preview_path(_scenery_controller.get_active_artwork_path())
var selected_depth: StringName = _scenery_controller.get_artwork_depth()
actions.append(_make_scenery_depth_capture_action(&"depth_far", &"far", "Back", selected_depth))
actions.append(_make_scenery_depth_capture_action(&"depth_near", &"near", "Middle", selected_depth))
actions.append(_make_scenery_depth_capture_action(&"depth_floor", &"floor", "Front", selected_depth))
var artwork_name: String = String(_scenery_controller.get_active_artwork_name())
var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another %s · placed pieces stay" % artwork_name
var prompt: String = "Click to place %s" % artwork_name if _scenery_placed_count == 0 else "Place another · Cancel undoes"
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "cancel_tool", "label": "✕ Cancel"})
_use_target_capture_bar.set_capture_actions(prompt, actions)
elif _scenery_controller.get_active_tool() == &"wall_eraser":
var erase_prompt: String = "Drag over the wall · release to erase"
if _scenery_controller.get_wall_areas_erased() > 0:
erase_prompt = "Erase another area · earlier openings stay"
actions.append({"id": "cancel_tool", "label": "Cancel"})
_use_target_capture_bar.set_scenery_capture_actions(prompt, actions)
elif _scenery_controller.get_active_tool() in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]:
var erase_surface: String = _scenery_controller.get_active_eraser_surface_label()
var erase_prompt: String = "Drag over the %s · release to erase" % erase_surface
if _scenery_controller.get_active_tool() == &"scenery_eraser":
erase_prompt = "Drag over wall or ground to erase"
if _scenery_controller.get_surface_areas_erased() > 0:
erase_prompt = "Erase another area · Cancel undoes"
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "cancel_tool", "label": "✕ Cancel"})
_use_target_capture_bar.set_capture_actions(erase_prompt, actions)
actions.append({"id": "cancel_tool", "label": "Cancel"})
_use_target_capture_bar.set_scenery_capture_actions(erase_prompt, actions)
else:
actions.append({"id": "choose_scenery", "label": "Choose scenery"})
actions.append({"id": "done_tool", "label": "Done", "primary": true})
actions.append({"id": "cancel_tool", "label": "✕ Cancel"})
actions.append({"id": "cancel_tool", "label": "Cancel"})
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)
ground_prompt = "Draw another · Cancel undoes"
_use_target_capture_bar.set_scenery_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)
wall_prompt = "Draw another · Cancel undoes"
_use_target_capture_bar.set_scenery_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()
@@ -4976,52 +5405,24 @@ func _refresh_scenery_capture_bar() -> void:
{"id": "keep_group", "label": "Keep changes", "primary": true},
{"id": "cancel_group", "label": "✕ Cancel"},
]
var group_prompt: String = "Move together · Ground and Props stay put" if _scenery_group_mode == &"move" else "Resize together · Ground and Props stay put"
_use_target_capture_bar.set_capture_actions(group_prompt, preview_actions)
var group_count: int = _selection.get_selected().size()
var group_verb: String = "Move" if _scenery_group_mode == &"move" else "Resize"
var group_prompt: String = "%s %d scenery pieces\nGround and Props stay put" % [group_verb, group_count]
_use_target_capture_bar.set_scenery_capture_actions(group_prompt, preview_actions)
return
if _scenery_controller != null and _scenery_controller.is_single_transform_active():
_set_scenery_selection_capture_bounds()
var target: Node2D = _scenery_controller.get_single_transform_node()
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"
var prompt_verb: String = "Move" if is_move else "Resize"
var release_verb: String = "place" if is_move else "resize"
var cancel_actions: Array[Dictionary] = [{"id": "cancel_single", "label": "✕ Cancel"}]
_use_target_capture_bar.set_capture_actions("%s %s · release to %s" % [prompt_verb, target_name, release_verb], cancel_actions)
_hide_scenery_capture_bar()
return
if _scenery_edit_mode:
var count: int = _selection.get_selected().size()
if count == 1:
_set_scenery_selection_capture_bounds()
else:
_use_target_capture_bar.clear_horizontal_safe_bounds()
var prompt: String = "%d scenery piece%s selected" % [count, "" if count == 1 else "s"]
var edit_actions: Array[Dictionary] = []
if count > 1:
edit_actions.append({"id": "move_group", "label": "Move together"})
edit_actions.append({"id": "resize_group", "label": "Resize together"})
edit_actions.append({"id": "clear_selection", "label": "Clear selection"})
else:
edit_actions.append({"id": "select_several", "label": "Select several"})
edit_actions.append({"id": "select_all", "label": "Select all scenery"})
if count > 0:
edit_actions.append({"id": "clear_selection", "label": "Clear selection"})
else:
edit_actions.append({"id": "erase_wall_area", "label": "Erase wall area"})
edit_actions.append({"id": "done_edit", "label": "Done editing", "primary": true})
_use_target_capture_bar.set_capture_actions(prompt, edit_actions)
_set_scenery_selection_capture_bounds(count == 1)
_use_target_capture_bar.set_scenery_edit_state(count)
return
_use_target_capture_bar.clear_horizontal_safe_bounds()
_use_target_capture_bar.hide_capture()
func _set_scenery_selection_capture_bounds() -> void:
func _set_scenery_selection_capture_bounds(include_inspector: bool = true) -> void:
if _use_target_capture_bar == null:
return
var viewport_width: float = get_viewport_rect().size.x
@@ -5030,7 +5431,9 @@ func _set_scenery_selection_capture_bounds() -> void:
var palette_width: float = float(palette_config.get("dock_width", 116.0))
var panel_width: float = float(_stage_panel_integer("shell.preferred_width", 380, 220, 720))
var left: float = palette_width + inset
var right: float = viewport_width - panel_width - inset * 2.0
var right: float = viewport_width - inset
if include_inspector:
right = viewport_width - panel_width - inset * 2.0
_use_target_capture_bar.set_horizontal_safe_bounds(left, right)
@@ -5042,6 +5445,8 @@ func _hide_scenery_capture_bar() -> void:
func _on_capture_action_requested(action_id: StringName) -> void:
match action_id:
&"choose_scenery":
_open_selector("scenery")
&"depth_far":
_scenery_controller.set_depth(&"far")
_refresh_scenery_capture_bar()
@@ -5051,9 +5456,12 @@ func _on_capture_action_requested(action_id: StringName) -> void:
&"depth_floor":
_scenery_controller.set_depth(&"floor")
_refresh_scenery_capture_bar()
&"done_tool", &"cancel_tool":
&"done_tool":
_scenery_controller.exit_tool()
_hide_scenery_capture_bar()
&"cancel_tool":
_scenery_controller.cancel_active_tool()
_hide_scenery_capture_bar()
&"select_several":
_scenery_multi_select = true
_show_toast("Tap scenery pieces to add them. Shift-click also works.")
@@ -5065,27 +5473,30 @@ func _on_capture_action_requested(action_id: StringName) -> void:
_refresh_scenery_capture_bar()
&"erase_wall_area":
_begin_wall_erase_tool()
&"erase_area":
_begin_surface_erase_tool(&"all")
&"move_group":
_begin_scenery_group_transform(&"move")
&"resize_group":
_begin_scenery_group_transform(&"resize")
&"keep_group":
_scenery_controller.commit_group()
_scenery_group_mode = &""
_refresh_scenery_capture_bar()
_keep_scenery_group_preview()
&"cancel_group":
_scenery_controller.cancel_group()
_scenery_group_mode = &""
_refresh_scenery_capture_bar()
&"cancel_single":
_scenery_controller.cancel_single_transform()
_scenery_single_dragging = false
_refresh_scenery_inspector()
_refresh_scenery_capture_bar()
&"done_edit":
_exit_scenery_edit_mode()
func _keep_scenery_group_preview() -> void:
if _scenery_controller == null or not _scenery_controller.is_group_preview_pending():
return
_scenery_controller.commit_group()
_scenery_group_mode = &""
_refresh_scenery_capture_bar()
func _begin_scenery_group_transform(kind: StringName) -> void:
var selected: Array[Node2D] = _selection.get_selected().duplicate()
if _scenery_controller == null or not _scenery_controller.begin_group_transform(kind, selected):
+1 -1
View File
@@ -157,7 +157,7 @@ func _scenery_background_entries() -> Array[Dictionary]:
{"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": "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": "🌿"}
{"id": "background_ground_setup", "name": "Background ground", "category": "Scenery", "gallery_tab": "backgrounds", "background_kind": "background_ground_setup", "icon": "🌿"}
]
@@ -60,6 +60,8 @@ func collect(
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)
if not background.motif_origin_offset.is_zero_approx():
entry["motif_origin_offset"] = STAGE_SCENE_IO.vector_to_data(background.motif_origin_offset)
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)
@@ -182,6 +184,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
StringName(String(entry.get("finish_id", "finish_background_grass"))),
STAGE_SCENE_IO.color_from_data(entry.get("color", "#72A84CFF"), Color("#72A84C"))
)
if entry.has("motif_origin_offset") and STAGE_SCENE_IO.is_vector_data(entry.get("motif_origin_offset")):
background.motif_origin_offset = STAGE_SCENE_IO.data_to_vector(entry.get("motif_origin_offset"), Vector2.ZERO)
world.add_child(background)
node = background
"scenery_artwork":
+248 -37
View File
@@ -1,6 +1,6 @@
class_name StageSceneryController
extends Node
## Owns inert Scenery placement drafts and snapshot-based group transforms.
## Owns scenery placement transactions, eraser transactions, and snapshot-based transforms.
signal placement_committed(node: Node2D)
signal document_change_requested
@@ -17,7 +17,6 @@ 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
@@ -28,6 +27,7 @@ var _camera: Camera2D
var _ghost: Node2D
var _active_tool: StringName = &""
var _asset_path: String = ""
var _placement_nodes: Array[Node2D] = []
var _depth: StringName = &"near"
var _wall_finish: StringName = &"finish_wall_paint"
var _wall_tint: Color = Color("#5A9D70")
@@ -38,7 +38,12 @@ var _wall_anchor_active: bool = false
var _wall_erase_start: Vector2 = Vector2.INF
var _wall_erase_dragging: bool = false
var _wall_erase_pointer: Vector2 = Vector2.ZERO
var _wall_areas_erased: int = 0
var _surface_erase_kind: StringName = &"wall"
var _surface_areas_erased: int = 0
# Keep original surfaces detached during an eraser session so Cancel can restore them.
var _surface_erase_originals: Array[Dictionary] = []
var _surface_erase_detached: Array[Node2D] = []
var _surface_erase_changed: bool = false
var _snap_enabled: bool = false
var _grid_size: float = 15.0
var _group_kind: StringName = &""
@@ -102,7 +107,21 @@ func get_active_tool() -> StringName:
func get_wall_areas_erased() -> int:
return _wall_areas_erased
return _surface_areas_erased
func get_surface_areas_erased() -> int:
return _surface_areas_erased
func is_surface_eraser_active() -> bool:
return _is_surface_eraser()
func get_active_eraser_surface_label() -> String:
if _surface_erase_kind == &"all":
return "wall or ground"
return "background ground" if _surface_erase_kind == &"background_ground" else "wall"
func get_group_bounds() -> Rect2:
@@ -115,6 +134,20 @@ func get_active_artwork_name() -> String:
return _asset_path.get_file().get_basename()
func get_active_artwork_path() -> String:
return _asset_path if _active_tool == &"artwork" else ""
func get_active_artwork_preview_bounds() -> Rect2:
if _active_tool != &"artwork" or _ghost == null or not is_instance_valid(_ghost) or _ghost.get_script() != SCENERY_ARTWORK:
return Rect2()
return (_ghost as StageSceneryArtwork).get_visual_world_bounds()
func get_artwork_depth() -> StringName:
return _depth
func get_active_surface_label() -> String:
if _active_tool == &"background_ground":
return "background ground"
@@ -123,7 +156,7 @@ func get_active_surface_label() -> String:
func handle_key(event: InputEventKey) -> bool:
if is_active() and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
exit_tool()
cancel_active_tool()
return true
if _group_pending and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
cancel_group()
@@ -171,13 +204,12 @@ func _begin_rect_surface(tool: StringName, finish: StringName, initial_world_pos
_wall_dragging = false
_wall_anchor_active = false
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.interactive_preview = true
panel.configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
panel.modulate = Color(1.0, 1.0, 1.0, 0.45)
_ghost_holder.add_child(panel)
_ghost = panel
@@ -188,16 +220,37 @@ func _begin_rect_surface(tool: StringName, finish: StringName, initial_world_pos
func begin_wall_eraser(initial_world_position: Vector2) -> bool:
return begin_surface_eraser(initial_world_position, &"wall")
func begin_background_ground_eraser(initial_world_position: Vector2) -> bool:
return begin_surface_eraser(initial_world_position, &"background_ground")
func begin_surface_eraser(initial_world_position: Vector2, surface_kind: StringName) -> bool:
if _world == null or _ghost_holder == null:
return false
if surface_kind not in [&"wall", &"background_ground", &"all"]:
return false
exit_tool()
_active_tool = &"wall_eraser"
_surface_erase_kind = surface_kind
match surface_kind:
&"background_ground":
_active_tool = &"background_ground_eraser"
&"all":
_active_tool = &"scenery_eraser"
_:
_active_tool = &"wall_eraser"
_wall_erase_start = Vector2.INF
_wall_erase_dragging = false
_wall_areas_erased = 0
_surface_areas_erased = 0
_surface_erase_originals.clear()
_surface_erase_detached.clear()
_surface_erase_changed = false
_capture_surface_eraser_snapshot(surface_kind)
_wall_erase_pointer = initial_world_position
var preview := Node2D.new()
preview.name = "WallEraserPreview"
preview.name = "BackgroundGroundEraserPreview" if surface_kind == &"background_ground" else "WallEraserPreview"
var fill := Polygon2D.new()
fill.name = "EraseArea"
fill.color = Color("#FF5B62", 0.2)
@@ -237,7 +290,7 @@ func update_pointer(world_position: Vector2) -> void:
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":
elif _is_surface_eraser():
_wall_erase_pointer = world_position
if _wall_erase_dragging:
_set_wall_eraser_preview_rect(_drag_rect(_wall_erase_start, _snap(world_position)))
@@ -252,13 +305,17 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
if not is_active():
return false
if event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
if _wall_dragging or _wall_anchor_active:
cancel_active_gesture()
exit_tool()
# Right-click finishes placement and area-erasure sessions. This commits
# completed work while dropping any unfinished preview gesture. Cancel
# and Escape remain the explicit rollback paths.
if _is_placement_tool() or _is_surface_eraser():
exit_tool()
else:
cancel_active_tool()
return true
var completes_drag: bool = not event.pressed and event.button_index == MOUSE_BUTTON_LEFT and (
(_is_rect_placement_tool() and _wall_dragging)
or (_active_tool == &"wall_eraser" and _wall_erase_dragging)
or (_is_surface_eraser() and _wall_erase_dragging)
)
if (over_ui and not completes_drag) or event.button_index != MOUSE_BUTTON_LEFT:
return false
@@ -292,7 +349,7 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
_wall_anchor_active = false
_restore_rect_surface_ghost(world_position)
return true
if _active_tool == &"wall_eraser":
if _is_surface_eraser():
if event.pressed:
_wall_erase_start = _snap(world_position) if _snap_enabled else world_position
_wall_erase_dragging = true
@@ -304,10 +361,10 @@ func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2,
_wall_erase_dragging = false
_wall_erase_start = Vector2.INF
if erase_rect.size.x >= MIN_WALL_ERASE_SIZE and erase_rect.size.y >= MIN_WALL_ERASE_SIZE:
var changed: int = _erase_wall_region(erase_rect)
var changed: int = _erase_surface_region(erase_rect, _surface_erase_kind)
if changed > 0:
_wall_areas_erased += 1
document_change_requested.emit()
_surface_areas_erased += 1
_surface_erase_changed = true
update_pointer(world_position)
return true
return false
@@ -329,13 +386,17 @@ func cancel_active_gesture() -> void:
_wall_erase_start = Vector2.INF
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):
elif _is_surface_eraser() and _ghost != null and is_instance_valid(_ghost):
_set_wall_eraser_preview_rect(Rect2(_wall_erase_pointer, WALL_ERASER_CURSOR_SIZE))
func exit_tool() -> void:
if _single_active:
cancel_single_transform()
if _is_placement_tool():
commit_placement_session()
if _is_surface_eraser():
_commit_surface_eraser()
cancel_active_gesture()
_free_ghost()
_active_tool = &""
@@ -346,6 +407,115 @@ func exit_tool() -> void:
preview_state_changed.emit(false)
func cancel_active_tool() -> void:
if _is_surface_eraser():
cancel_surface_eraser()
return
if _is_placement_tool():
_rollback_placement_session()
exit_tool()
func commit_placement_session() -> void:
if not _is_placement_tool():
return
var has_placed_nodes: bool = false
for node: Node2D in _placement_nodes:
if node != null and is_instance_valid(node) and node.get_parent() != null:
has_placed_nodes = true
_placement_nodes.clear()
if has_placed_nodes:
document_change_requested.emit()
func _rollback_placement_session() -> void:
for node: Node2D in _placement_nodes:
if node == null or not is_instance_valid(node):
continue
var parent: Node = node.get_parent()
if parent != null:
parent.remove_child(node)
node.queue_free()
_placement_nodes.clear()
func cancel_surface_eraser() -> void:
if not _is_surface_eraser():
return
if _surface_erase_changed:
_restore_surface_eraser_snapshot()
_surface_areas_erased = 0
_clear_surface_eraser_transaction()
exit_tool()
func _commit_surface_eraser() -> void:
if _surface_erase_changed:
document_change_requested.emit()
for detached: Node2D in _surface_erase_detached:
if detached != null and is_instance_valid(detached):
detached.queue_free()
_clear_surface_eraser_transaction()
func _clear_surface_eraser_transaction() -> void:
_surface_erase_originals.clear()
_surface_erase_detached.clear()
_surface_erase_changed = false
func _capture_surface_eraser_snapshot(surface_kind: StringName) -> void:
if _world == null or not is_instance_valid(_world):
return
var target_scripts: Array[Script] = _surface_erase_target_scripts(surface_kind)
for child: Node in _world.get_children():
if not target_scripts.has(child.get_script()):
continue
var surface: Node2D = child as Node2D
if surface == null:
continue
_surface_erase_originals.append({
"node": surface,
"parent": surface.get_parent(),
"index": surface.get_index(),
})
func _restore_surface_eraser_snapshot() -> void:
if _world == null or not is_instance_valid(_world):
return
var original_ids: Dictionary = {}
for snapshot: Dictionary in _surface_erase_originals:
var original: Node2D = snapshot.get("node") as Node2D
if original != null and is_instance_valid(original):
original_ids[original.get_instance_id()] = true
var target_scripts: Array[Script] = _surface_erase_target_scripts(_surface_erase_kind)
for child: Node in _world.get_children().duplicate():
if not target_scripts.has(child.get_script()):
continue
_world.remove_child(child)
if not original_ids.has(child.get_instance_id()):
child.queue_free()
for detached: Node2D in _surface_erase_detached:
if detached == null or not is_instance_valid(detached):
continue
if not original_ids.has(detached.get_instance_id()):
detached.queue_free()
var restore_order: Array[Dictionary] = _surface_erase_originals.duplicate()
restore_order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a.get("index", 0)) < int(b.get("index", 0)))
for snapshot: Dictionary in restore_order:
var original: Node2D = snapshot.get("node") as Node2D
var parent: Node = snapshot.get("parent") as Node
if original == null or not is_instance_valid(original):
continue
if parent == null or not is_instance_valid(parent):
parent = _world
if original.get_parent() != null:
original.get_parent().remove_child(original)
parent.add_child(original)
parent.move_child(original, clampi(int(snapshot.get("index", 0)), 0, parent.get_child_count() - 1))
func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool:
if _single_active or members.is_empty() or kind not in [&"move", &"resize"]:
return false
@@ -591,8 +761,8 @@ func _commit_artwork(position: Vector2) -> void:
artwork.set_depth(_depth)
artwork.global_position = _snap_visual_center(artwork, position)
_world.add_child(artwork)
_placement_nodes.append(artwork)
placement_committed.emit(artwork)
document_change_requested.emit()
func _commit_rect_surface(first: Vector2, second: Vector2) -> void:
@@ -605,8 +775,8 @@ func _commit_rect_surface(first: Vector2, second: Vector2) -> void:
panel.configure(size, _wall_finish, _wall_tint)
panel.global_position = (first + second) * 0.5
_world.add_child(panel)
_placement_nodes.append(panel)
placement_committed.emit(panel)
document_change_requested.emit()
func _restore_rect_surface_ghost(world_position: Vector2) -> void:
@@ -620,23 +790,39 @@ func _is_rect_placement_tool() -> bool:
return _active_tool in [&"wall", &"background_ground"]
func _is_placement_tool() -> bool:
return _active_tool in [&"artwork", &"wall", &"background_ground"]
func _default_size_for_active_surface() -> Vector2:
return DEFAULT_BACKGROUND_SIZE if _active_tool == &"background_ground" else DEFAULT_PANEL_SIZE
return DEFAULT_PANEL_SIZE
func _is_surface_eraser() -> bool:
return _active_tool in [&"wall_eraser", &"background_ground_eraser", &"scenery_eraser"]
func _erase_wall_region(world_rect: Rect2) -> int:
return _erase_surface_region(world_rect, &"wall")
func _erase_surface_region(world_rect: Rect2, surface_kind: StringName) -> int:
if _world == null or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
return 0
var changed_panels: int = 0
var target_scripts: Array[Script] = _surface_erase_target_scripts(surface_kind)
for child: Node in _world.get_children().duplicate():
if child.get_script() != WALL_PANEL:
if not target_scripts.has(child.get_script()):
continue
var wall: StageWallPanel = child as StageWallPanel
var panel_rect: Rect2 = wall.get_local_bounds()
var surface: StageRectSurface = child as StageRectSurface
if surface == null:
continue
var is_background_ground: bool = child.get_script() == BACKGROUND_GROUND
var panel_rect: Rect2 = surface.get_local_bounds()
var erase_corners: Array[Vector2] = [world_rect.position, Vector2(world_rect.end.x, world_rect.position.y), world_rect.end, Vector2(world_rect.position.x, world_rect.end.y)]
var local_erase := Rect2()
for corner_index: int in erase_corners.size():
var local_point: Vector2 = wall.to_local(erase_corners[corner_index])
var local_point: Vector2 = surface.to_local(erase_corners[corner_index])
if corner_index == 0:
local_erase = Rect2(local_point, Vector2.ZERO)
else:
@@ -647,27 +833,52 @@ func _erase_wall_region(world_rect: Rect2) -> int:
if cut.size.x <= 0.0 or cut.size.y <= 0.0:
continue
var fragments: Array[Rect2] = _wall_rect_fragments(panel_rect, cut)
var parent: Node = wall.get_parent()
var parent: Node = surface.get_parent()
if parent == null:
continue
var sibling_index: int = wall.get_index()
var source_transform: Transform2D = wall.transform
var source_motif_origin: Vector2 = wall.get_effective_motif_origin_world()
var sibling_index: int = surface.get_index()
var source_transform: Transform2D = surface.transform
var source_draw_origin: Vector2 = surface.get_surface_draw_origin_world()
if surface is StageWallPanel:
source_draw_origin = (surface as StageWallPanel).get_effective_motif_origin_world()
for fragment_rect: Rect2 in fragments:
var fragment := WALL_PANEL.new() as StageWallPanel
var fragment: StageRectSurface
if is_background_ground:
fragment = BACKGROUND_GROUND.new() as StageBackgroundGround
else:
fragment = WALL_PANEL.new() as StageWallPanel
fragment.transform = source_transform
fragment.position += source_transform.basis_xform(fragment_rect.get_center())
fragment.configure(fragment_rect.size, wall.finish_id, wall.tint)
fragment.configure(fragment_rect.size, surface.finish_id, surface.tint)
parent.add_child(fragment)
fragment.motif_origin_offset = source_motif_origin - fragment.global_position
if fragment is StageWallPanel:
(fragment as StageWallPanel).motif_origin_offset = source_draw_origin - fragment.global_position
elif fragment is StageBackgroundGround:
(fragment as StageBackgroundGround).motif_origin_offset = source_draw_origin - fragment.global_position
parent.move_child(fragment, sibling_index)
sibling_index += 1
parent.remove_child(wall)
wall.queue_free()
parent.remove_child(surface)
if _is_surface_eraser():
_surface_erase_detached.append(surface)
else:
surface.queue_free()
changed_panels += 1
return changed_panels
func _surface_erase_target_scripts(surface_kind: StringName) -> Array[Script]:
var target_scripts: Array[Script] = []
match surface_kind:
&"background_ground":
target_scripts.append(BACKGROUND_GROUND)
&"all":
target_scripts.append(WALL_PANEL)
target_scripts.append(BACKGROUND_GROUND)
_:
target_scripts.append(WALL_PANEL)
return target_scripts
func _wall_rect_fragments(panel_rect: Rect2, cut: Rect2) -> Array[Rect2]:
var fragments: Array[Rect2] = []
_add_wall_fragment(fragments, Rect2(panel_rect.position, Vector2(panel_rect.size.x, cut.position.y - panel_rect.position.y)))
@@ -7,12 +7,16 @@ signal change_prop_requested
signal capture_action_requested(action_id: StringName)
const MAX_BAR_WIDTH: float = 800.0
const SCENERY_CAPTURE_MAX_WIDTH: float = 900.0
const VIEWPORT_WIDTH_RATIO: float = 0.68
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
const SCENERY_EDIT_MAX_WIDTH: float = 920.0
const SCENERY_EDIT_BUTTON_HEIGHT: float = 54.0
const SCENERY_CAPTURE_BUTTON_HEIGHT: float = 56.0
var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
@@ -25,12 +29,28 @@ var _actions_row: HBoxContainer
var _actions_column: VBoxContainer
var _actions_parent: BoxContainer
var _prompt: Label
var _scenery_edit_layout: VBoxContainer
var _scenery_edit_prompt: Label
var _scenery_edit_count: Label
var _scenery_edit_count_badge: PanelContainer
var _scenery_selection_group: PanelContainer
var _scenery_edit_actions: HBoxContainer
var _scenery_selection_actions: HBoxContainer
var _scenery_select_several_button: Button
var _scenery_select_all_button: Button
var _scenery_move_group_button: Button
var _scenery_resize_group_button: Button
var _scenery_clear_selection_button: Button
var _scenery_erase_area_button: Button
var _scenery_done_button: Button
var _change_prop_button: Button
var _cancel_button: Button
var _custom_action_buttons: Array[Button] = []
var _has_horizontal_safe_bounds: bool = false
var _safe_left: float = 0.0
var _safe_right: float = 0.0
var _scenery_edit_active: bool = false
var _scenery_capture_active: bool = false
func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void:
@@ -51,6 +71,7 @@ func _ready() -> void:
func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void:
if _prompt == null:
return
_show_standard_capture_layout()
_clear_custom_actions()
if not prop_selected:
_prompt.text = "Choose a prop to use"
@@ -65,8 +86,17 @@ func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) ->
func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
_set_capture_actions(prompt, actions, false)
func set_scenery_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
_set_capture_actions(prompt, actions, true)
func _set_capture_actions(prompt: String, actions: Array[Dictionary], scenery_capture_style: bool) -> void:
if _prompt == null:
return
_show_standard_capture_layout()
_prompt.text = prompt
_change_prop_button.visible = false
_cancel_button.visible = false
@@ -77,14 +107,75 @@ func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
continue
var button := Button.new()
button.name = "Action_%s" % String(action_id)
button.text = String(action.get("label", action_id))
var action_label: String = String(action.get("label", action_id))
var cue: Control = action.get("cue") as Control
button.text = "" if cue != null else action_label
button.tooltip_text = button.text
if cue != null:
button.tooltip_text = action_label
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 4)
margin.add_theme_constant_override("margin_right", 4)
margin.add_theme_constant_override("margin_top", 3)
margin.add_theme_constant_override("margin_bottom", 3)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
var cue_layout := BoxContainer.new()
cue_layout.vertical = true
cue_layout.add_theme_constant_override("separation", 2)
cue_layout.alignment = BoxContainer.ALIGNMENT_CENTER
cue_layout.mouse_filter = Control.MOUSE_FILTER_IGNORE
cue.mouse_filter = Control.MOUSE_FILTER_IGNORE
cue_layout.add_child(cue)
var cue_label := Label.new()
cue_label.text = action_label
cue_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
cue_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
cue_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
cue_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
cue_layout.add_child(cue_label)
margin.add_child(cue_layout)
button.add_child(margin)
button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
button.focus_mode = Control.FOCUS_ALL
button.set_meta("capture_primary", bool(action.get("primary", false)))
button.set_meta("capture_choice", bool(action.get("choice", false)))
button.set_meta("capture_selected", bool(action.get("selected", false)))
button.pressed.connect(func() -> void: capture_action_requested.emit(action_id))
_style_button(button, bool(action.get("primary", false)))
_custom_action_buttons.append(button)
_actions_parent.add_child(button)
_scenery_capture_active = scenery_capture_style
if _scenery_capture_active:
_set_layout(true, false)
visible = true
apply_theme()
_layout_capture_bar()
func set_scenery_edit_state(selected_count: int) -> void:
if _scenery_edit_layout == null:
return
_scenery_capture_active = false
_scenery_edit_active = true
_clear_custom_actions()
if _active_layout.get_parent() == self:
_active_layout.visible = false
remove_child(_active_layout)
_layout_stash.add_child(_active_layout)
if _scenery_edit_layout.get_parent() == _layout_stash:
_layout_stash.remove_child(_scenery_edit_layout)
add_child(_scenery_edit_layout)
_scenery_edit_layout.visible = true
_scenery_edit_prompt.text = "Tap scenery to select it"
_scenery_edit_count.text = "%d selected" % maxi(0, selected_count)
var has_group: bool = selected_count > 1
_scenery_select_several_button.visible = not has_group
_scenery_select_all_button.visible = not has_group
_scenery_move_group_button.visible = has_group
_scenery_resize_group_button.visible = has_group
_scenery_erase_area_button.visible = selected_count == 0
_scenery_clear_selection_button.visible = selected_count > 0
visible = true
apply_theme()
_layout_capture_bar()
@@ -104,11 +195,18 @@ func clear_horizontal_safe_bounds() -> void:
func hide_capture() -> void:
visible = false
_show_standard_capture_layout()
func apply_theme() -> void:
if not is_node_ready() or _prompt == null:
return
if _scenery_edit_active:
_apply_scenery_edit_theme()
return
if _scenery_capture_active:
_apply_scenery_capture_theme()
return
theme = _stage_theme.control_theme() if _stage_theme != null else null
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
var shell: StyleBoxFlat = StyleBoxFlat.new()
@@ -155,6 +253,197 @@ func apply_theme() -> void:
_layout_capture_bar()
func _apply_scenery_edit_theme() -> void:
theme = _stage_theme.control_theme() if _stage_theme != null else null
var edit_accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var panel_style: StyleBoxFlat = StyleBoxFlat.new()
panel_style.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA"))
panel_style.border_color = Color("#667386")
panel_style.set_border_width_all(2)
panel_style.set_corner_radius_all(18)
panel_style.content_margin_left = 18.0
panel_style.content_margin_right = 18.0
panel_style.content_margin_top = 14.0
panel_style.content_margin_bottom = 14.0
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.34)
panel_style.shadow_size = 8
panel_style.shadow_offset = Vector2(0.0, 4.0)
add_theme_stylebox_override("panel", panel_style)
var font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font
if font != null:
_scenery_edit_prompt.add_theme_font_override("font", font)
_scenery_edit_count.add_theme_font_override("font", font)
for button: Button in [_scenery_select_several_button, _scenery_select_all_button, _scenery_move_group_button, _scenery_resize_group_button, _scenery_clear_selection_button, _scenery_erase_area_button, _scenery_done_button]:
if font != null:
button.add_theme_font_override("font", font)
button.add_theme_font_size_override("font_size", 19)
button.custom_minimum_size.y = SCENERY_EDIT_BUTTON_HEIGHT
_style_scenery_edit_button(button, button == _scenery_done_button, false)
_scenery_edit_prompt.add_theme_font_size_override("font_size", 27)
_scenery_edit_prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
_scenery_edit_count.add_theme_font_size_override("font_size", 17)
_scenery_edit_count.add_theme_color_override("font_color", Color("#F5F7FA"))
var badge_style: StyleBoxFlat = StyleBoxFlat.new()
badge_style.bg_color = edit_accent.darkened(0.78)
badge_style.border_color = edit_accent
badge_style.set_border_width_all(1)
badge_style.set_corner_radius_all(14)
badge_style.content_margin_left = 14.0
badge_style.content_margin_right = 14.0
badge_style.content_margin_top = 6.0
badge_style.content_margin_bottom = 6.0
_scenery_edit_count_badge.add_theme_stylebox_override("panel", badge_style)
var group_style: StyleBoxFlat = StyleBoxFlat.new()
group_style.bg_color = _color("stage_panels.action.surface_color", Color("#171B24"))
group_style.border_color = Color("#667386")
group_style.set_border_width_all(1)
group_style.set_corner_radius_all(14)
group_style.content_margin_left = 2.0
group_style.content_margin_right = 2.0
group_style.content_margin_top = 2.0
group_style.content_margin_bottom = 2.0
_scenery_selection_group.add_theme_stylebox_override("panel", group_style)
_scenery_edit_layout.add_theme_constant_override("separation", 12)
_scenery_selection_actions.add_theme_constant_override("separation", 0)
_style_scenery_selection_segment(_scenery_select_several_button, true, false)
_style_scenery_selection_segment(_scenery_select_all_button, false, true)
_layout_capture_bar()
func _apply_scenery_capture_theme() -> void:
theme = _stage_theme.control_theme() if _stage_theme != null else null
var panel_style: StyleBoxFlat = StyleBoxFlat.new()
panel_style.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA"))
panel_style.border_color = Color("#667386")
panel_style.set_border_width_all(2)
panel_style.set_corner_radius_all(18)
panel_style.content_margin_left = 18.0
panel_style.content_margin_right = 18.0
panel_style.content_margin_top = 14.0
panel_style.content_margin_bottom = 14.0
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.34)
panel_style.shadow_size = 8
panel_style.shadow_offset = Vector2(0.0, 4.0)
add_theme_stylebox_override("panel", panel_style)
var font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font
if font != null:
_prompt.add_theme_font_override("font", font)
_prompt.add_theme_font_size_override("font_size", 25)
_prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
for button: Button in _custom_action_buttons:
if font != null:
button.add_theme_font_override("font", font)
button.add_theme_font_size_override("font_size", 19)
button.custom_minimum_size.y = SCENERY_EDIT_BUTTON_HEIGHT
if bool(button.get_meta("capture_choice", false)):
_style_scenery_depth_choice(button, bool(button.get_meta("capture_selected", false)))
var margin: MarginContainer = button.get_child(0) as MarginContainer if button.get_child_count() > 0 else null
var cue_stack: VBoxContainer = margin.get_child(0) as VBoxContainer if margin != null and margin.get_child_count() > 0 else null
if cue_stack != null and cue_stack.get_child_count() > 1:
var cue_label: Label = cue_stack.get_child(1) as Label
if font != null:
cue_label.add_theme_font_override("font", font)
cue_label.add_theme_font_size_override("font_size", 17)
cue_label.add_theme_color_override("font_color", Color("#F5F7FA"))
else:
_style_scenery_edit_button(button, bool(button.get_meta("capture_primary", false)), false)
_actions_row.add_theme_constant_override("separation", 12)
_layout_capture_bar()
func _style_scenery_depth_choice(button: Button, selected: bool) -> void:
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var background: Color = _color("stage_panels.action.primary_color", Color("#087DBA")) if selected else _color("stage_panels.action.surface_color", Color("#171B24"))
var hover: Color = _color("stage_panels.action.primary_hover_color", Color("#1596D3")) if selected else _color("stage_panels.action.hover_surface", Color("#303A4B"))
var border: Color = accent if selected else Color("#667386")
var border_width: int = 2 if selected else 1
button.add_theme_stylebox_override("normal", _scenery_depth_choice_button_style(background, border, border_width))
button.add_theme_stylebox_override("hover", _scenery_depth_choice_button_style(hover, accent, 2))
button.add_theme_stylebox_override("pressed", _scenery_depth_choice_button_style(background, accent, 2))
button.add_theme_stylebox_override("hover_pressed", _scenery_depth_choice_button_style(hover, accent, 2))
button.add_theme_stylebox_override("focus", _scenery_depth_choice_button_style(background, accent, 2))
button.add_theme_color_override("font_color", Color("#F5F7FA"))
button.add_theme_color_override("font_hover_color", Color("#F5F7FA"))
button.add_theme_color_override("font_pressed_color", Color("#F5F7FA"))
button.add_theme_color_override("font_focus_color", Color("#F5F7FA"))
func _scenery_depth_choice_button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style: StyleBoxFlat = _scenery_button_style(background, border, border_width)
style.content_margin_left = 6.0
style.content_margin_right = 6.0
style.content_margin_top = 4.0
style.content_margin_bottom = 4.0
return style
func _style_scenery_edit_button(button: Button, primary: bool, destructive: bool) -> void:
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var danger: Color = _color("stage_panels.action.destructive_color", Color("#FF626B"))
var border: Color = danger if destructive else (accent if primary else Color("#667386"))
var background: Color = _color("stage_panels.action.primary_color", Color("#087DBA")) if primary else _color("stage_panels.action.surface_color", Color("#171B24"))
var hover_background: Color = _color("stage_panels.action.primary_hover_color", Color("#1596D3")) if primary else _color("stage_panels.action.hover_surface", Color("#303A4B"))
var pressed_background: Color = _color("stage_panels.action.primary_pressed_color", Color("#066B9F")) if primary else _color("stage_panels.action.selected_surface", Color("#12394A"))
var text_color: Color = Color("#F5F7FA")
button.add_theme_stylebox_override("normal", _scenery_button_style(background, border, 2 if primary or destructive else 1))
button.add_theme_stylebox_override("hover", _scenery_button_style(hover_background, border.lightened(0.12), 2))
button.add_theme_stylebox_override("pressed", _scenery_button_style(pressed_background, border, 2))
button.add_theme_stylebox_override("hover_pressed", _scenery_button_style(hover_background, border.lightened(0.12), 2))
button.add_theme_stylebox_override("focus", _scenery_button_style(background, accent, 2))
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", text_color)
button.add_theme_color_override("font_pressed_color", text_color)
button.add_theme_color_override("font_focus_color", text_color)
func _style_scenery_selection_segment(button: Button, is_first: bool, is_last: bool) -> void:
var base: Color = _color("stage_panels.action.surface_color", Color("#171B24"))
var hover: Color = _color("stage_panels.action.hover_surface", Color("#303A4B"))
var pressed: Color = _color("stage_panels.action.selected_surface", Color("#12394A"))
var divider: Color = Color("#667386")
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _scenery_segment_style(base, divider, is_first, is_last))
button.add_theme_stylebox_override("hover", _scenery_segment_style(hover, divider, is_first, is_last))
button.add_theme_stylebox_override("pressed", _scenery_segment_style(pressed, divider, is_first, is_last))
button.add_theme_stylebox_override("hover_pressed", _scenery_segment_style(hover, divider, is_first, is_last))
button.add_theme_stylebox_override("focus", _scenery_segment_style(base, accent, is_first, is_last, true))
func _scenery_segment_style(background: Color, border: Color, is_first: bool, is_last: bool, is_focused: bool = false) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
if is_focused:
style.set_border_width_all(2)
else:
style.border_width_left = 0 if is_first else 1
style.border_width_right = 0
style.border_width_top = 0
style.border_width_bottom = 0
style.corner_radius_top_left = 12 if is_first else 0
style.corner_radius_bottom_left = 12 if is_first else 0
style.corner_radius_top_right = 12 if is_last else 0
style.corner_radius_bottom_right = 12 if is_last else 0
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 6.0
style.content_margin_bottom = 6.0
return style
func _scenery_button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(14)
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 6.0
style.content_margin_bottom = 6.0
return style
func _build_controls() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
_layout_stash = Node.new()
@@ -207,6 +496,100 @@ func _build_controls() -> void:
_cancel_button.focus_mode = Control.FOCUS_ALL
_cancel_button.pressed.connect(func() -> void: cancel_requested.emit())
_actions_row.add_child(_cancel_button)
_build_scenery_edit_controls()
func _build_scenery_edit_controls() -> void:
_scenery_edit_layout = VBoxContainer.new()
_scenery_edit_layout.name = "SceneryEditLayout"
_scenery_edit_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scenery_edit_layout.visible = false
_layout_stash.add_child(_scenery_edit_layout)
var header_row: HBoxContainer = HBoxContainer.new()
header_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header_row.add_theme_constant_override("separation", 12)
_scenery_edit_layout.add_child(header_row)
_scenery_edit_prompt = Label.new()
_scenery_edit_prompt.name = "SceneryPrompt"
_scenery_edit_prompt.text = "Tap scenery to select it"
_scenery_edit_prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_scenery_edit_prompt.max_lines_visible = 2
_scenery_edit_prompt.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scenery_edit_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_scenery_edit_prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE
header_row.add_child(_scenery_edit_prompt)
_scenery_edit_count_badge = PanelContainer.new()
_scenery_edit_count_badge.name = "ScenerySelectionCountBadge"
header_row.add_child(_scenery_edit_count_badge)
_scenery_edit_count = Label.new()
_scenery_edit_count.name = "ScenerySelectionCount"
_scenery_edit_count.text = "0 selected"
_scenery_edit_count.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_scenery_edit_count_badge.add_child(_scenery_edit_count)
_scenery_clear_selection_button = _make_scenery_action_button("ClearSelection", "Clear selection", &"clear_selection")
_scenery_clear_selection_button.custom_minimum_size.x = 150.0
_scenery_clear_selection_button.visible = false
header_row.add_child(_scenery_clear_selection_button)
_scenery_edit_actions = HBoxContainer.new()
_scenery_edit_actions.name = "SceneryEditActions"
_scenery_edit_actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_scenery_edit_actions.alignment = BoxContainer.ALIGNMENT_CENTER
_scenery_edit_actions.add_theme_constant_override("separation", 10)
_scenery_edit_layout.add_child(_scenery_edit_actions)
_scenery_selection_group = PanelContainer.new()
_scenery_selection_group.name = "SelectionActionsGroup"
_scenery_selection_group.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
_scenery_edit_actions.add_child(_scenery_selection_group)
_scenery_selection_actions = HBoxContainer.new()
_scenery_selection_actions.add_theme_constant_override("separation", 6)
_scenery_selection_group.add_child(_scenery_selection_actions)
_scenery_select_several_button = _make_scenery_action_button("SelectSeveral", "Select several", &"select_several")
_scenery_select_several_button.custom_minimum_size.x = 158.0
_scenery_selection_actions.add_child(_scenery_select_several_button)
_scenery_select_all_button = _make_scenery_action_button("SelectAll", "Select all", &"select_all")
_scenery_select_all_button.custom_minimum_size.x = 148.0
_scenery_selection_actions.add_child(_scenery_select_all_button)
_scenery_move_group_button = _make_scenery_action_button("MoveGroup", "Move together", &"move_group")
_scenery_move_group_button.custom_minimum_size.x = 168.0
_scenery_move_group_button.visible = false
_scenery_selection_actions.add_child(_scenery_move_group_button)
_scenery_resize_group_button = _make_scenery_action_button("ResizeGroup", "Resize together", &"resize_group")
_scenery_resize_group_button.custom_minimum_size.x = 182.0
_scenery_resize_group_button.visible = false
_scenery_selection_actions.add_child(_scenery_resize_group_button)
_scenery_erase_area_button = _make_scenery_action_button("EraseArea", "Erase Area", &"erase_area")
_scenery_erase_area_button.custom_minimum_size.x = 152.0
_scenery_edit_actions.add_child(_scenery_erase_area_button)
_scenery_done_button = _make_scenery_action_button("DoneEditing", "Done editing", &"done_edit")
_scenery_done_button.custom_minimum_size.x = 160.0
_scenery_edit_actions.add_child(_scenery_done_button)
func _make_scenery_action_button(button_name: String, label: String, action_id: StringName) -> Button:
var button: Button = Button.new()
button.name = button_name
button.text = label
button.tooltip_text = label
button.focus_mode = Control.FOCUS_ALL
button.custom_minimum_size = Vector2(144.0, SCENERY_EDIT_BUTTON_HEIGHT)
button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
button.pressed.connect(func() -> void: capture_action_requested.emit(action_id))
return button
func _show_standard_capture_layout() -> void:
_scenery_edit_active = false
_scenery_capture_active = false
if _scenery_edit_layout != null and _scenery_edit_layout.get_parent() == self:
_scenery_edit_layout.visible = false
remove_child(_scenery_edit_layout)
_layout_stash.add_child(_scenery_edit_layout)
if _active_layout != null and _active_layout.get_parent() == _layout_stash:
_layout_stash.remove_child(_active_layout)
add_child(_active_layout)
_active_layout.visible = true
if is_node_ready():
apply_theme()
func _clear_custom_actions() -> void:
@@ -255,6 +638,12 @@ func _layout_capture_bar() -> void:
if not is_node_ready() or _prompt == null:
return
var viewport_size: Vector2 = get_viewport_rect().size
if _scenery_edit_active:
_layout_scenery_edit_capture_bar(viewport_size)
return
if _scenery_capture_active:
_layout_scenery_capture_bar(viewport_size)
return
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var safe_left: float = inset
var safe_right: float = viewport_size.x - inset
@@ -281,6 +670,123 @@ func _layout_capture_bar() -> void:
call_deferred("_fit_height_to_content")
func _layout_scenery_edit_capture_bar(viewport_size: Vector2) -> void:
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var safe_left: float = inset
var safe_right: float = viewport_size.x - inset
if _has_horizontal_safe_bounds:
safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset))
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(SCENERY_EDIT_MAX_WIDTH, available_width)
var narrow: bool = width < 700.0
custom_minimum_size.x = width
size.x = width
size_flags_horizontal = Control.SIZE_SHRINK_CENTER
clip_contents = false
position = Vector2(safe_left + (available_width - width) * 0.5, _top_bar_height + 56.0)
_scenery_edit_prompt.add_theme_font_size_override("font_size", 27 if width >= 700.0 else (21 if width >= 520.0 else 18))
if narrow and _scenery_erase_area_button.get_parent() == _scenery_edit_actions:
_scenery_edit_actions.remove_child(_scenery_erase_area_button)
_scenery_edit_layout.get_child(0).add_child(_scenery_erase_area_button)
elif not narrow and _scenery_erase_area_button.get_parent() != _scenery_edit_actions:
var header_row: HBoxContainer = _scenery_edit_layout.get_child(0) as HBoxContainer
header_row.remove_child(_scenery_erase_area_button)
_scenery_edit_actions.add_child(_scenery_erase_area_button)
_scenery_edit_actions.move_child(_scenery_erase_area_button, 1)
_scenery_selection_actions.add_theme_constant_override("separation", 0)
_scenery_select_several_button.custom_minimum_size.x = 134.0 if narrow else 158.0
_scenery_select_all_button.custom_minimum_size.x = 124.0 if narrow else 148.0
_scenery_move_group_button.custom_minimum_size.x = 146.0 if narrow else 168.0
_scenery_resize_group_button.custom_minimum_size.x = 158.0 if narrow else 182.0
_scenery_done_button.custom_minimum_size.x = 142.0 if narrow else 160.0
_scenery_erase_area_button.custom_minimum_size.x = 136.0 if narrow else 152.0
_scenery_clear_selection_button.custom_minimum_size.x = 128.0 if narrow else 150.0
for button: Button in [_scenery_select_several_button, _scenery_select_all_button, _scenery_move_group_button, _scenery_resize_group_button, _scenery_clear_selection_button, _scenery_erase_area_button, _scenery_done_button]:
button.add_theme_font_size_override("font_size", 19 if width >= 700.0 else 16)
button.custom_minimum_size.y = 52.0 if narrow else SCENERY_EDIT_BUTTON_HEIGHT
button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
_scenery_edit_actions.add_theme_constant_override("separation", 8 if narrow else 10)
call_deferred("_fit_scenery_edit_height")
func _layout_scenery_capture_bar(viewport_size: Vector2) -> void:
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var available_width: float = maxf(1.0, viewport_size.x - inset * 2.0)
var width: float = minf(SCENERY_CAPTURE_MAX_WIDTH, available_width)
var button_count: int = maxi(1, _custom_action_buttons.size())
var has_depth_choices: bool = button_count >= 5
var horizontal_depth_cards: bool = has_depth_choices and width >= 700.0
var compact_spacing: float = 8.0 if has_depth_choices and width < 760.0 else 12.0
var minimum_row_width: float = 40.0 + 5.0 * 84.0 + float(button_count - 1) * compact_spacing
var stack_actions: bool = width < ACTION_ROW_MIN_WIDTH or (has_depth_choices and width < minimum_row_width)
custom_minimum_size.x = width
size.x = width
size_flags_horizontal = Control.SIZE_SHRINK_CENTER
clip_contents = false
position = Vector2((viewport_size.x - width) * 0.5, _top_bar_height + 56.0)
_prompt.add_theme_font_size_override("font_size", 25 if width >= 520.0 else 19)
_set_layout(true, stack_actions)
_actions_row.alignment = BoxContainer.ALIGNMENT_END
var available_button_width: float = maxf(1.0, width - 40.0 - compact_spacing * float(button_count - 1))
var depth_button_width: float = available_button_width
var secondary_button_width: float = available_button_width
if stack_actions:
depth_button_width = maxf(120.0, width - 72.0)
secondary_button_width = depth_button_width
elif has_depth_choices:
secondary_button_width = clampf(available_button_width * 0.16, 84.0, 132.0)
depth_button_width = maxf(84.0, (available_button_width - secondary_button_width * 2.0) / 3.0)
else:
depth_button_width = minf(ACTION_BUTTON_WIDTH, maxf(120.0, available_button_width / float(button_count)))
secondary_button_width = depth_button_width
var cue_size := Vector2(56.0, 42.0)
var choice_button_height: float = 84.0
if width >= 560.0:
cue_size = Vector2(64.0, 48.0)
choice_button_height = 88.0
if width >= 700.0:
cue_size = Vector2(66.0, 50.0)
choice_button_height = 100.0
if width >= 800.0:
cue_size = Vector2(96.0, 72.0)
choice_button_height = 112.0
for button: Button in _custom_action_buttons:
var is_depth_choice: bool = bool(button.get_meta("capture_choice", false))
button.custom_minimum_size = Vector2(depth_button_width if is_depth_choice else secondary_button_width, choice_button_height if has_depth_choices else SCENERY_CAPTURE_BUTTON_HEIGHT)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_SHRINK_CENTER
if is_depth_choice:
var margin: MarginContainer = button.get_child(0) as MarginContainer if button.get_child_count() > 0 else null
var cue_layout: BoxContainer = margin.get_child(0) as BoxContainer if margin != null and margin.get_child_count() > 0 else null
if cue_layout != null and cue_layout.get_child_count() > 1:
cue_layout.vertical = not horizontal_depth_cards
cue_layout.add_theme_constant_override("separation", 8 if horizontal_depth_cards else 2)
var cue: Control = cue_layout.get_child(0) as Control
if cue != null:
cue.custom_minimum_size = cue_size
var cue_label: Label = cue_layout.get_child(1) as Label
if cue_label != null:
cue_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT if horizontal_depth_cards else HORIZONTAL_ALIGNMENT_CENTER
cue_label.add_theme_font_size_override("font_size", 20 if width >= 800.0 else (18 if width >= 620.0 else 17))
button.add_theme_font_size_override("font_size", 17 if width < 760.0 else 19)
_actions_row.add_theme_constant_override("separation", int(compact_spacing))
call_deferred("_fit_scenery_capture_height_after_layout")
func _fit_scenery_capture_height_after_layout() -> void:
await get_tree().process_frame
_fit_height_to_content()
func _fit_scenery_edit_height() -> void:
if not is_inside_tree() or not visible or not _scenery_edit_active:
return
var panel_style: StyleBox = get_theme_stylebox("panel")
var panel_height: float = panel_style.get_minimum_size().y if panel_style != null else 0.0
custom_minimum_size.y = _scenery_edit_layout.get_combined_minimum_size().y + panel_height
size.y = custom_minimum_size.y
func _set_layout(compact: bool, stack_actions: bool, wrapped: bool = false) -> void:
var target_layout: Container = _wrapped_layout if wrapped else (_vertical_layout if compact else _horizontal_layout)
var target_actions: BoxContainer = _actions_column if stack_actions else _actions_row
@@ -348,12 +854,13 @@ func _apply_compact_action_labels(compact: bool) -> void:
"cancel_group": "Cancel",
"done_tool": "Done",
"cancel_tool": "Cancel",
"cancel_single": "Cancel",
"depth_far": "Far",
"depth_near": "Near",
"depth_floor": "Floor",
"depth_far": "Back",
"depth_near": "Middle",
"depth_floor": "Front",
}
for button: Button in _custom_action_buttons:
if bool(button.get_meta("capture_choice", false)):
continue
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
@@ -0,0 +1,97 @@
class_name StageDepthChoicePreview
extends Control
## Small fixed-size diagram showing how scenery depth changes draw order.
const DEPTH_FAR := &"far"
const DEPTH_NEAR := &"near"
const DEPTH_FLOOR := &"floor"
var depth_id: StringName = DEPTH_NEAR
var artwork_texture: Texture2D
func configure(depth: StringName, texture: Texture2D, preview_size: Vector2) -> void:
depth_id = depth if depth in [DEPTH_FAR, DEPTH_NEAR, DEPTH_FLOOR] else DEPTH_NEAR
artwork_texture = texture
custom_minimum_size = preview_size
size_flags_horizontal = Control.SIZE_SHRINK_CENTER
size_flags_vertical = Control.SIZE_SHRINK_CENTER
mouse_filter = Control.MOUSE_FILTER_IGNORE
clip_contents = true
queue_redraw()
func set_artwork_texture(texture: Texture2D) -> void:
artwork_texture = texture
queue_redraw()
func _draw() -> void:
var bounds := Rect2(Vector2.ZERO, size)
draw_rect(bounds, Color("#B9D9D9"), true)
_draw_hills()
if depth_id != DEPTH_FAR:
_draw_near_scenery()
if depth_id == DEPTH_FLOOR:
_draw_ground_tiles()
_draw_artwork()
if depth_id == DEPTH_FAR:
_draw_near_scenery()
if depth_id != DEPTH_FLOOR:
_draw_ground_tiles()
draw_rect(bounds, Color("#566273"), false, 1.0)
func _draw_hills() -> void:
var points := PackedVector2Array([
Vector2(0.0, size.y * 0.68),
Vector2(0.0, size.y * 0.52),
Vector2(size.x * 0.18, size.y * 0.39),
Vector2(size.x * 0.38, size.y * 0.54),
Vector2(size.x * 0.62, size.y * 0.36),
Vector2(size.x * 0.84, size.y * 0.49),
Vector2(size.x, size.y * 0.41),
Vector2(size.x, size.y * 0.68),
])
draw_colored_polygon(points, Color("#79AFA4"))
func _draw_near_scenery() -> void:
var canopy := Color("#277447")
var trunk := Color("#84502B")
var canopy_center := Vector2(size.x * 0.49, size.y * 0.56)
var radius := maxf(4.0, size.y * 0.25)
draw_rect(Rect2(Vector2(size.x * 0.49 - 2.0, size.y * 0.46), Vector2(4.0, size.y * 0.23)), trunk, true)
draw_circle(canopy_center, radius, canopy)
draw_circle(canopy_center + Vector2(-radius * 0.52, radius * 0.08), radius * 0.62, Color("#48A84E"))
draw_circle(canopy_center + Vector2(radius * 0.48, radius * 0.02), radius * 0.55, Color("#36934A"))
func _draw_ground_tiles() -> void:
var top: float = size.y * 0.64
var tile_height: float = size.y - top
var tile_count: int = 5
var tile_width: float = size.x / float(tile_count)
for index: int in tile_count:
var tile := Rect2(Vector2(float(index) * tile_width, top), Vector2(tile_width + 0.5, tile_height))
draw_rect(tile, Color("#87512C"), true)
draw_rect(tile, Color("#A96C3B"), false, 1.0)
draw_line(Vector2(float(index) * tile_width + 2.0, top + tile_height * 0.58), Vector2(float(index + 1) * tile_width - 2.0, top + tile_height * 0.58), Color("#C9894B"), 1.0)
draw_rect(Rect2(Vector2(0.0, top - 3.0), Vector2(size.x, 6.0)), Color("#62D34D"), true)
draw_line(Vector2(0.0, top - 3.0), Vector2(size.x, top - 3.0), Color("#D5F27B"), 1.0)
func _draw_artwork() -> void:
if artwork_texture == null:
var center := Vector2(size.x * 0.49, size.y * 0.59)
draw_circle(center, maxf(3.0, minf(size.x, size.y) * 0.20), Color("#F4F1E8"))
draw_circle(center, maxf(3.0, minf(size.x, size.y) * 0.20), Color("#58616D"), false, 1.0)
return
var texture_size := Vector2(artwork_texture.get_size())
if texture_size.x <= 0.0 or texture_size.y <= 0.0:
return
var available := Vector2(size.x * 0.84, size.y * 0.84)
var scale_factor: float = minf(available.x / texture_size.x, available.y / texture_size.y)
var target_size := texture_size * scale_factor
var target := Rect2(Vector2((size.x - target_size.x) * 0.5, size.y * 0.59 - target_size.y * 0.5), target_size)
draw_texture_rect(artwork_texture, target, false)
@@ -0,0 +1 @@
uid://cc5hn0jge7758
@@ -116,6 +116,52 @@ func style_action_button(button: Button, prominent: bool = false) -> void:
_style_action_button(button, prominent)
func style_primary_action_button(button: Button) -> void:
if button == null:
return
var primary := _color("stage_panels.action.primary_color", Color("#087DBA"))
var hover := _color("stage_panels.action.primary_hover_color", Color("#1596D3"))
var pressed := _color("stage_panels.action.primary_pressed_color", Color("#066B9F"))
var accent := _color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _make_style(primary, accent.lightened(0.15), 2, 10, 6))
button.add_theme_stylebox_override("hover", _make_style(hover, accent, 2, 10, 6))
button.add_theme_stylebox_override("pressed", _make_style(pressed, accent, 2, 10, 6))
button.add_theme_stylebox_override("hover_pressed", _make_style(pressed.lightened(0.05), accent, 2, 10, 6))
button.add_theme_stylebox_override("disabled", _make_style(_color("stage_panels.choice.disabled_surface", Color("#252B38")), _color("global.border_color", Color("#FFFFFF33")), 1, 10, 6))
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2, 10, 6))
button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("font_hover_color", Color.WHITE)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
if _stage_theme != null:
var ui_font := _stage_theme.font()
if ui_font != null:
button.add_theme_font_override("font", ui_font)
func style_destructive_button(button: Button) -> void:
if button == null:
return
var danger := _color("stage_panels.action.destructive_color", Color("#FF626B"))
var neutral := _color("stage_panels.action.surface_color", Color("#171B24"))
var hover := _color("stage_panels.action.destructive_hover_surface", Color("#3B252C"))
var pressed := _color("stage_panels.action.destructive_pressed_surface", Color("#4A252D"))
var border := _color("global.border_color", Color("#FFFFFF33"))
var accent := _color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _make_style(neutral, danger, 2, 10, 6))
button.add_theme_stylebox_override("hover", _make_style(hover, danger, 2, 10, 6))
button.add_theme_stylebox_override("pressed", _make_style(pressed, danger, 2, 10, 6))
button.add_theme_stylebox_override("hover_pressed", _make_style(pressed.lightened(0.06), danger, 2, 10, 6))
button.add_theme_stylebox_override("disabled", _make_style(_color("stage_panels.choice.disabled_surface", Color("#252B38")), border, 1, 10, 6))
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2, 10, 6))
button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
button.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if _stage_theme != null:
var ui_font := _stage_theme.font()
if ui_font != null:
button.add_theme_font_override("font", ui_font)
func _build_shell() -> void:
if _body != null:
return
@@ -145,11 +191,12 @@ func _build_shell() -> void:
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_header.add_child(_title)
_close_button = Button.new()
_close_button.text = "✕ Close"
_close_button.text = "Close"
_close_button.custom_minimum_size = Vector2(
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
)
_close_button.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
_close_button.focus_mode = Control.FOCUS_ALL
_close_button.pressed.connect(func() -> void: close_requested.emit())
_header.add_child(_close_button)
@@ -1,5 +1,5 @@
class_name StagePanelChoiceGroup
extends VBoxContainer
extends BoxContainer
## Exclusive, full-width choices with optional decorative cues.
signal choice_selected(id: StringName)
@@ -8,20 +8,43 @@ var _stage_theme: SandboxTheme = null
var _group := ButtonGroup.new()
var _buttons: Dictionary = {}
var _selected_id: StringName = &""
var _vertical_layout: bool = true
var _minimum_height_override: float = -1.0
var _font_size_override: int = -1
var _corner_radius_override: int = -1
var _border_width_override: int = -1
var _selected_surface_override: Color = Color.TRANSPARENT
var _has_selected_surface_override: bool = false
func _ready() -> void:
vertical = _vertical_layout
_group.allow_unpress = true
add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32))
func add_choice(id: StringName, caption: String, cue: Control = null) -> Button:
func configure_layout(vertical_layout: bool, minimum_height: float = -1.0, font_size: int = -1, corner_radius: int = -1, border_width: int = -1, selected_surface: Color = Color.TRANSPARENT) -> void:
_vertical_layout = vertical_layout
vertical = vertical_layout
_minimum_height_override = minimum_height
_font_size_override = font_size
_corner_radius_override = corner_radius
_border_width_override = border_width
_selected_surface_override = selected_surface
_has_selected_surface_override = selected_surface.a > 0.0
for entry: Dictionary in _buttons.values():
_apply_choice_theme(entry["button"] as Button, entry["label"] as Label, entry.get("detail") as Label)
func add_choice(id: StringName, caption: String, cue: Control = null, detail: String = "") -> Button:
var button := Button.new()
button.name = "Choice_%s" % String(id)
button.toggle_mode = true
button.button_group = _group
button.custom_minimum_size.y = float(_integer("stage_panels.choice.minimum_height", 56, 44, 112))
button.custom_minimum_size.y = _minimum_height_override if _minimum_height_override > 0.0 else float(_integer("stage_panels.choice.minimum_height", 56, 44, 112))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if not _vertical_layout:
button.size_flags_vertical = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_ALL
button.text = ""
var margin := MarginContainer.new()
@@ -42,16 +65,31 @@ func add_choice(id: StringName, caption: String, cue: Control = null) -> Button:
var label := Label.new()
label.text = caption
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT if not detail.is_empty() else HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(label)
var detail_label: Label
if detail.is_empty():
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
else:
var text_stack := VBoxContainer.new()
text_stack.add_theme_constant_override("separation", 0)
text_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
text_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
text_stack.add_child(label)
detail_label = Label.new()
detail_label.text = detail
detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
detail_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
text_stack.add_child(detail_label)
row.add_child(text_stack)
button.pressed.connect(_on_choice_pressed.bind(id, button))
add_child(button)
_buttons[id] = {"button": button, "label": label}
_buttons[id] = {"button": button, "label": label, "detail": detail_label}
if _stage_theme != null:
_apply_choice_theme(button, label)
_apply_choice_theme(button, label, detail_label)
return button
@@ -75,7 +113,7 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32))
for entry: Dictionary in _buttons.values():
_apply_choice_theme(entry["button"] as Button, entry["label"] as Label)
_apply_choice_theme(entry["button"] as Button, entry["label"] as Label, entry.get("detail") as Label)
func _on_choice_pressed(id: StringName, button: Button) -> void:
@@ -87,26 +125,40 @@ func _on_choice_pressed(id: StringName, button: Button) -> void:
choice_selected.emit(id)
func _apply_choice_theme(button: Button, label: Label) -> void:
func _apply_choice_theme(button: Button, label: Label, detail: Label = null) -> void:
if _stage_theme == null:
return
var font := _stage_theme.font()
if font != null:
label.add_theme_font_override("font", font)
label.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
if not _vertical_layout:
var strong_font := FontVariation.new()
strong_font.base_font = font
strong_font.variation_embolden = 0.25
label.add_theme_font_override("font", strong_font)
else:
label.add_theme_font_override("font", font)
var font_size: int = _font_size_override if _font_size_override > 0 else _integer("stage_panels.type.body_size", 20, 14, 40)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if detail != null:
if font != null:
detail.add_theme_font_override("font", font)
detail.add_theme_font_size_override("font_size", maxi(12, font_size - 4))
detail.add_theme_color_override("font_color", _color("stage_panels.type.muted_text_color", Color("#AAB2C0")))
var neutral := _color("stage_panels.choice.neutral_surface", Color("#171B24"))
var hover := _color("stage_panels.choice.hover_surface", Color("#303A4B"))
var selected := _color("stage_panels.choice.selected_surface", Color("#12394A"))
var selected := _selected_surface_override if _has_selected_surface_override else _color("stage_panels.choice.selected_surface", Color("#12394A"))
var disabled := _color("stage_panels.choice.disabled_surface", Color("#252B38"))
var border := _color("stage_panels.choice.border_color", Color("#FFFFFF33"))
var accent := _color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _make_style(neutral, border, 1))
button.add_theme_stylebox_override("hover", _make_style(hover, border, 1))
button.add_theme_stylebox_override("pressed", _make_style(selected, accent, 2))
button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 2))
button.add_theme_stylebox_override("disabled", _make_style(disabled, border, 1))
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2))
var border_width: int = _border_width_override if _border_width_override >= 0 else 1
var selected_border_width: int = border_width + 1 if _border_width_override >= 0 else 2
button.add_theme_stylebox_override("normal", _make_style(neutral, border, border_width))
button.add_theme_stylebox_override("hover", _make_style(hover, border, border_width))
button.add_theme_stylebox_override("pressed", _make_style(selected, accent, selected_border_width))
button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, selected_border_width))
button.add_theme_stylebox_override("disabled", _make_style(disabled, border, border_width))
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, selected_border_width))
func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat:
@@ -114,7 +166,8 @@ func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat:
style.bg_color = bg
style.border_color = border
style.set_border_width_all(width)
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
var radius: int = _corner_radius_override if _corner_radius_override >= 0 else _integer("stage_panels.shell.corner_radius", 10, 0, 32)
style.set_corner_radius_all(radius)
return style
@@ -17,7 +17,6 @@ var _current_value: Label
var _recent_empty_label: Label
var _recent_swatches: ColorSwatchSelector
var _preset_swatches: ColorSwatchSelector
var _more_button: Button
var _compact_mode: bool = false
@@ -54,60 +53,107 @@ func set_recent_colors(colors: Array[Color]) -> void:
func focus_custom_button() -> void:
if _more_button != null:
_more_button.grab_focus()
if _current_chip != null:
_current_chip.grab_focus()
func _build() -> void:
_built = true
_apply_compact_layout()
var current_heading: Label = _make_heading("Color")
add_child(current_heading)
var current_parent: Control = self
var recent_parent: Control = self
var presets_parent: Control = self
if _compact_mode:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", _integer("popups.asset_selector.color_group_gap", 8, 4, 32))
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_child(row)
current_parent = _make_compact_section(132.0)
row.add_child(current_parent)
row.add_child(_make_compact_separator())
recent_parent = _make_compact_section(92.0)
row.add_child(recent_parent)
row.add_child(_make_compact_separator())
presets_parent = _make_compact_section(180.0)
presets_parent.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(presets_parent)
_build_current_color_section(current_parent)
_build_recent_color_section(recent_parent)
_build_palette_section(presets_parent)
if _stage_theme != null:
var font: Font = _stage_theme.font()
if font != null:
if _compact_mode:
var strong_font := FontVariation.new()
strong_font.base_font = font
strong_font.variation_embolden = 0.2
_current_value.add_theme_font_override("font", strong_font)
else:
_current_value.add_theme_font_override("font", font)
_current_value.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
func _build_current_color_section(parent: Control) -> void:
parent.add_child(_make_heading("Color"))
var current_row := HBoxContainer.new()
current_row.add_theme_constant_override("separation", _compact_gap())
add_child(current_row)
parent.add_child(current_row)
_current_chip = Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48, 44, 96))
if _compact_mode:
swatch_size = minf(swatch_size, 44.0)
swatch_size = maxf(swatch_size, 52.0)
_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_chip.focus_mode = Control.FOCUS_ALL
_current_chip.tooltip_text = "Choose a custom color"
_current_chip.pressed.connect(func() -> void: custom_color_requested.emit())
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"))
func _build_recent_color_section(parent: Control) -> void:
parent.add_child(_make_heading("Recently used"))
_recent_empty_label = Label.new()
_recent_empty_label.text = "Colors you use will appear here"
_recent_empty_label.text = "No colors yet" if _compact_mode else "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)
if _compact_mode:
_recent_empty_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.helper_size", 16, 12, 32))
parent.add_child(_recent_empty_label)
_recent_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_recent_swatches.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if _compact_mode:
_recent_swatches.set_swatch_layout_override(_integer("popups.asset_selector.compact_swatch_size", 42, 28, 52), 8)
_recent_swatches.color_selected.connect(_on_swatch_selected)
add_child(_recent_swatches)
add_child(_make_heading("Colors"))
parent.add_child(_recent_swatches)
func _build_palette_section(parent: Control) -> void:
parent.add_child(_make_heading("Colors"))
_preset_swatches = COLOR_SWATCH_SELECTOR.new() as ColorSwatchSelector
_preset_swatches.size_flags_horizontal = Control.SIZE_EXPAND_FILL
if _compact_mode:
_preset_swatches.set_swatch_layout_override(_integer("popups.asset_selector.compact_swatch_size", 42, 28, 52), 8)
_preset_swatches.color_selected.connect(_on_swatch_selected)
add_child(_preset_swatches)
_more_button = Button.new()
_more_button.text = "More colors…"
var action_height: float = float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
_more_button.custom_minimum_size.y = minf(action_height, 44.0) if _compact_mode else action_height
_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())
parent.add_child(_preset_swatches)
func _make_compact_section(minimum_width: float) -> VBoxContainer:
var section := VBoxContainer.new()
section.custom_minimum_size.x = minimum_width
section.add_theme_constant_override("separation", 4)
return section
func _make_compact_separator() -> ColorRect:
var separator := ColorRect.new()
separator.color = _color("popups.asset_selector.separator_color", Color("#AAB2C0A0"))
separator.custom_minimum_size = Vector2(2.0, 64.0)
separator.size_flags_vertical = Control.SIZE_SHRINK_CENTER
separator.mouse_filter = Control.MOUSE_FILTER_IGNORE
return separator
func _refresh_visuals() -> void:
@@ -124,11 +170,16 @@ 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.border_color = _color("mode_colors.edit", Color("#22C6FF"))
chip_style.set_border_width_all(_integer("stage_surface_colors.swatch_border_width", 3, 1, 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())
_current_chip.add_theme_stylebox_override("pressed", chip_style.duplicate())
var focus_style: StyleBoxFlat = chip_style.duplicate() as StyleBoxFlat
focus_style.border_color = Color.WHITE
focus_style.set_border_width_all(5)
_current_chip.add_theme_stylebox_override("focus", focus_style)
func _on_swatch_selected(color: Color) -> void:
@@ -143,18 +194,23 @@ func _make_heading(text_value: String) -> Label:
var heading := Label.new()
heading.text = text_value
var heading_size: int = _integer("stage_panels.type.section_size", 20, 12, 40)
if _compact_mode:
heading_size = mini(heading_size, 18)
heading.add_theme_font_size_override("font_size", heading_size)
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())
var font: Font = _stage_theme.font()
if _compact_mode:
var strong_font := FontVariation.new()
strong_font.base_font = font
strong_font.variation_embolden = 0.2
heading.add_theme_font_override("font", strong_font)
else:
heading.add_theme_font_override("font", font)
return heading
func _apply_compact_layout() -> void:
var section_gap: int = _integer("stage_surface_colors.section_gap", 8, 0, 28)
add_theme_constant_override("separation", mini(section_gap, 4) if _compact_mode else section_gap)
add_theme_constant_override("separation", section_gap)
func _compact_gap() -> int:
@@ -1,22 +1,37 @@
class_name StageSurfaceColorDialog
extends Window
## Bounded custom-color dialog with a live Stage surface preview.
extends PopupPanel
## Rounded, reusable Stage color popup with the shared surface picker view.
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")
const SURFACE_COLOR_PICKER_VIEW := preload("res://scripts/shared/stage_ui/stage_surface_color_picker_view.gd")
const COMPACT_LAYOUT_MAX_HEIGHT := 640
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 _margin: MarginContainer
var _layout: VBoxContainer
var _heading: Label
var _view_scroll: ScrollContainer
var _picker_view: StageSurfaceColorPickerView
var _actions: HBoxContainer
var _cancel_button: Button
var _use_button: Button
var _built: bool = false
func _ready() -> void:
exclusive = true
_build()
close_requested.connect(_cancel)
get_tree().root.size_changed.connect(_on_root_size_changed)
_apply_visual_style()
func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void:
_surface_kind = surface_kind
_finish_id = finish
@@ -24,26 +39,20 @@ func configure(surface_kind: StringName, finish: StringName, color: Color, stage
_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
if _picker_view != null:
_picker_view.configure(_surface_kind, _finish_id, color, _stage_theme)
theme = _stage_theme.control_theme() if _stage_theme != null else null
_apply_visual_style()
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()
if not _built or _picker_view == null:
return
var popup_size: Vector2i = _bounded_dialog_size()
_apply_responsive_layout(popup_size)
size = popup_size
popup_centered(popup_size)
call_deferred("_focus_picker")
func _unhandled_key_input(event: InputEvent) -> void:
@@ -53,98 +62,168 @@ func _unhandled_key_input(event: InputEvent) -> void:
func _build() -> void:
if _built:
return
_built = true
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_margin = MarginContainer.new()
_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(_margin)
_layout = VBoxContainer.new()
_margin.add_child(_layout)
_heading = Label.new()
_heading.name = "ChooseColorHeading"
_heading.text = "Choose a color"
_heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_heading.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
_layout.add_child(_heading)
_view_scroll = ScrollContainer.new()
_view_scroll.name = "PickerScroll"
_view_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_view_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_view_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_layout.add_child(_view_scroll)
_picker_view = SURFACE_COLOR_PICKER_VIEW.new() as StageSurfaceColorPickerView
_picker_view.name = "SurfaceColorPickerView"
_picker_view.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_picker_view.preview_color_changed.connect(_on_preview_color_changed)
_view_scroll.add_child(_picker_view)
_actions = HBoxContainer.new()
_actions.name = "Actions"
_actions.alignment = BoxContainer.ALIGNMENT_END
_actions.add_theme_constant_override("separation", 8)
_layout.add_child(_actions)
_cancel_button = Button.new()
_cancel_button.name = "CancelButton"
_cancel_button.text = "Cancel"
_cancel_button.focus_mode = Control.FOCUS_ALL
_cancel_button.pressed.connect(_cancel)
_actions.add_child(_cancel_button)
_use_button = Button.new()
_use_button.name = "UseColorButton"
_use_button.text = "Use color"
_use_button.focus_mode = Control.FOCUS_ALL
_use_button.pressed.connect(_confirm_color)
_actions.add_child(_use_button)
func _apply_visual_style() -> void:
if _stage_theme == null or not _built:
return
theme = _stage_theme.control_theme()
var radius: int = _integer("popups.asset_selector.corner_radius", 16, 8, 32)
var shell: StyleBoxFlat = _stage_theme.stylebox("popups.asset_selector")
shell.set_corner_radius_all(radius)
shell.content_margin_left = 16.0
shell.content_margin_right = 16.0
shell.content_margin_top = 14.0
shell.content_margin_bottom = 14.0
add_theme_stylebox_override("panel", shell)
var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA"))
var font: Font = _stage_theme.font()
if font != null:
_heading.add_theme_font_override("font", font)
_cancel_button.add_theme_font_override("font", font)
var strong_font := FontVariation.new()
strong_font.base_font = font
strong_font.variation_embolden = 0.55
_use_button.add_theme_font_override("font", strong_font)
_heading.add_theme_font_size_override("font_size", _integer("popups.asset_selector.setup_title_size", 30, 22, 48))
_heading.add_theme_color_override("font_color", text_color)
_apply_action_button(_cancel_button, false)
_apply_action_button(_use_button, true)
func _apply_action_button(button: Button, primary: bool) -> void:
var accent: Color = _color("mode_colors.edit", Color("#22C6FF"))
var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA"))
var neutral: Color = _color("stage_panels.choice.neutral_surface", Color("#171B24"))
var hover: Color = _color("stage_panels.choice.hover_surface", Color("#303A4B"))
var selected: Color = _color("stage_panels.choice.selected_surface", Color("#12394A"))
var border: Color = _color("stage_panels.choice.border_color", Color("#FFFFFF33"))
var blue: Color = _color("popups.asset_selector.action_color", Color("#087DBA"))
var radius: int = _integer("popups.asset_selector.corner_radius", 16, 8, 32)
var border_width: int = _integer("popups.asset_selector.button_border_width", 2, 1, 5)
var normal_color: Color = blue if primary else neutral
var hover_color: Color = blue.lightened(0.12) if primary else hover
var pressed_color: Color = blue.darkened(0.12) if primary else selected
var edge: Color = accent.lightened(0.15) if primary else border
button.add_theme_stylebox_override("normal", _make_button_style(normal_color, edge, border_width, radius))
button.add_theme_stylebox_override("hover", _make_button_style(hover_color, edge, border_width + 1, radius))
button.add_theme_stylebox_override("pressed", _make_button_style(pressed_color, accent, border_width + 1, radius))
button.add_theme_stylebox_override("hover_pressed", _make_button_style(pressed_color.lightened(0.05), accent, border_width + 1, radius))
button.add_theme_stylebox_override("focus", _make_button_style(Color(0, 0, 0, 0), accent, border_width + 1, radius))
button.add_theme_color_override("font_color", Color.WHITE if primary else text_color)
button.add_theme_color_override("font_hover_color", Color.WHITE if primary else text_color)
button.add_theme_color_override("font_pressed_color", Color.WHITE if primary else text_color)
button.add_theme_font_size_override("font_size", _integer("popups.asset_selector.setup_button_font_size", 20, 14, 40))
button.custom_minimum_size.y = float(_integer("popups.dialog.action_min_target_size", 48, 44, 96))
func _apply_responsive_layout(popup_size: Vector2i) -> void:
if not _built:
return
var compact: bool = popup_size.y < COMPACT_LAYOUT_MAX_HEIGHT
var margin_size: int = 8 if compact else 12
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")))
_margin.add_theme_constant_override("margin_" + side, margin_size)
_layout.add_theme_constant_override("separation", 4 if compact else 8)
_heading.add_theme_font_size_override("font_size", _integer("popups.asset_selector.setup_title_size", 30, 22, 48))
var content_width: float = maxf(1.0, float(popup_size.x - margin_size * 2))
_picker_view.set_available_size(Vector2(content_width, float(popup_size.y)))
_apply_action_layout(content_width)
func _on_picker_color_changed(color: Color) -> void:
_update_preview(color)
func _apply_action_layout(content_width: float) -> void:
if _actions == null or _cancel_button == null or _use_button == null:
return
var stack_buttons: bool = content_width < 344.0
_actions.vertical = stack_buttons
_actions.alignment = BoxContainer.ALIGNMENT_END
var action_height: float = float(_integer("popups.dialog.action_min_target_size", 48, 44, 96))
var button_width: float = content_width if stack_buttons else maxf(112.0, (content_width - 8.0) * 0.5)
_cancel_button.custom_minimum_size = Vector2(button_width, action_height)
_use_button.custom_minimum_size = Vector2(button_width, action_height)
func _on_preview_color_changed(color: Color) -> void:
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
if _picker_view == null:
return
var confirmed_color: Color = _picker_view.get_color()
hide()
color_confirmed.emit(confirmed_color)
func _cancel() -> void:
_update_preview(_initial_color)
if _picker_view != null:
_picker_view.set_color_no_signal(_initial_color)
hide()
cancelled.emit()
func _focus_picker() -> void:
if visible and _picker_view != null:
_picker_view.focus_picker()
func _on_root_size_changed() -> void:
if visible:
open_dialog()
func _bounded_dialog_size() -> Vector2i:
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]))
return Vector2i(maxi(1, mini(preferred_size.x, max_size.x)), maxi(1, mini(preferred_size.y, max_size.y)))
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())
@@ -156,5 +235,22 @@ func _usable_window_size() -> Vector2i:
return available
func _make_button_style(background: Color, border: Color, border_width: int, radius: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(radius)
style.content_margin_left = 12.0
style.content_margin_right = 12.0
style.content_margin_top = 8.0
style.content_margin_bottom = 8.0
return style
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,130 @@
class_name StageSurfaceColorPickerView
extends VBoxContainer
## Inline color picker content for the scenery chooser.
const SURFACE_PREVIEW := preload("res://scripts/stage/scenery/stage_surface_preview.gd")
const COMPACT_LAYOUT_MAX_HEIGHT := 640.0
const COMPACT_PREVIEW_HEIGHT := 64.0
const COMPACT_PICKER_MIN_HEIGHT := 260.0
const COMPACT_SV_HEIGHT := 144
const REGULAR_SV_HEIGHT := 224
signal preview_color_changed(color: Color)
var _surface_kind: StringName = &"wall"
var _finish_id: StringName = &"finish_wall_paint"
var _stage_theme: SandboxTheme
var _color_picker: ColorPicker
var _surface_preview: StageSurfacePreview
var _preview_heading: Label
var _preferred_preview_size: Vector2 = Vector2(420.0, 140.0)
var _preferred_preview_height: float = 140.0
var _preferred_picker_min_size: Vector2 = Vector2(320.0, 340.0)
var _built: bool = false
var _suppress_color_signal: bool = false
func configure(surface_kind: StringName, finish: StringName, color: Color, stage_theme: SandboxTheme) -> void:
_surface_kind = surface_kind
_finish_id = finish
_stage_theme = stage_theme
if not _built:
_build()
set_color_no_signal(color)
theme = stage_theme.control_theme() if stage_theme != null else null
_apply_preview_heading_theme()
func get_color() -> Color:
return _color_picker.color if _color_picker != null else Color.WHITE
func set_color_no_signal(color: Color) -> void:
if _color_picker == null:
return
_suppress_color_signal = true
_color_picker.color = color
_suppress_color_signal = false
_update_preview(color)
func set_available_size(available_size: Vector2) -> void:
if not _built:
return
var compact: bool = available_size.y < COMPACT_LAYOUT_MAX_HEIGHT
var preview_size: Vector2 = _surface_preview.custom_minimum_size
preview_size.x = minf(_preferred_preview_size.x, maxf(1.0, available_size.x - 56.0))
preview_size.y = minf(_preferred_preview_height, COMPACT_PREVIEW_HEIGHT) if compact else _preferred_preview_height
_surface_preview.custom_minimum_size = preview_size
var picker_size: Vector2 = _color_picker.custom_minimum_size
picker_size.x = minf(_preferred_picker_min_size.x, maxf(1.0, available_size.x - 56.0))
picker_size.y = minf(_preferred_picker_min_size.y, COMPACT_PICKER_MIN_HEIGHT) if compact else _preferred_picker_min_size.y
_color_picker.custom_minimum_size = picker_size
_color_picker.add_theme_constant_override("sv_height", COMPACT_SV_HEIGHT if compact else REGULAR_SV_HEIGHT)
func focus_picker() -> void:
if _color_picker != null:
_color_picker.grab_focus()
func _build() -> void:
_built = true
add_theme_constant_override("separation", 8)
_surface_preview = SURFACE_PREVIEW.new() as StageSurfacePreview
_preview_heading = Label.new()
_preview_heading.name = "PreviewHeading"
_preview_heading.text = "Preview"
_preview_heading.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_preview_heading)
var preview_size := _preferred_preview_size
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]))
_preferred_preview_size = preview_size
_preferred_preview_height = preview_size.y
_surface_preview.custom_minimum_size = preview_size
_surface_preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_surface_preview.set_rounded_frame(
float(_stage_theme.integer("popups.asset_selector.surface_corner_radius", 14, 4, 32)) if _stage_theme != null else 14.0,
_stage_theme.color("popups.style_defaults.bg_color", Color("#202633FA")) if _stage_theme != null else Color("#202633FA")
)
add_child(_surface_preview)
_color_picker = ColorPicker.new()
_color_picker.name = "ColorPickerControl"
var picker_size := _preferred_picker_min_size
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_size = Vector2(float((raw_picker_size as Array)[0]), float((raw_picker_size as Array)[1]))
_preferred_picker_min_size = picker_size
_color_picker.custom_minimum_size = picker_size
_color_picker.presets_visible = false
_color_picker.sampler_visible = false
_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)
add_child(_color_picker)
_apply_preview_heading_theme()
func _apply_preview_heading_theme() -> void:
if _preview_heading == null:
return
if _stage_theme == null:
return
_preview_heading.add_theme_font_size_override("font_size", _stage_theme.integer("stage_panels.type.section_size", 20, 12, 40))
_preview_heading.add_theme_color_override("font_color", _stage_theme.color("stage_panels.type.text_color", Color("#F5F7FA")))
var font: Font = _stage_theme.font()
if font != null:
_preview_heading.add_theme_font_override("font", font)
func _on_picker_color_changed(color: Color) -> void:
_update_preview(color)
if not _suppress_color_signal:
preview_color_changed.emit(color)
func _update_preview(color: Color) -> void:
if _surface_preview != null:
_surface_preview.configure(_surface_kind, _finish_id, color)
@@ -0,0 +1 @@
uid://dbvatqk4a287y
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -8,6 +8,15 @@ var _group: ButtonGroup = ButtonGroup.new()
var _buttons: Array[Button] = []
var _stage_theme: SandboxTheme
var _colors: Array[Color] = []
var _swatch_size_override: float = -1.0
var _swatch_gap_override: int = -1
func set_swatch_layout_override(size: float, gap: int) -> void:
_swatch_size_override = size
_swatch_gap_override = gap
if is_inside_tree() and not _colors.is_empty():
configure(_colors, _selected, _stage_theme)
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
@@ -25,8 +34,9 @@ func configure(colors: Array[Color], selected: Color, theme: Variant = null) ->
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 gap: int = _swatch_gap_override if _swatch_gap_override >= 0 else _integer("stage_surface_colors.swatch_gap", 8)
add_theme_constant_override("h_separation", gap)
add_theme_constant_override("v_separation", gap)
var seen: Dictionary = {}
for color: Color in _colors:
var color_key: String = color.to_html(true).to_upper()
@@ -34,7 +44,7 @@ func configure(colors: Array[Color], selected: Color, theme: Variant = null) ->
continue
seen[color_key] = true
var button := Button.new()
var swatch_size: float = float(_integer("stage_surface_colors.swatch_size", 48))
var swatch_size: float = _swatch_size_override if _swatch_size_override > 0.0 else 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
+34 -2
View File
@@ -4,6 +4,29 @@ extends RefCounted
const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"
const CONFIRMATION_DEFAULTS: Dictionary = {
"scenery_erase_size": [520, 208],
"padding": 18,
"body_gap": 10,
"footer_gap": 12,
"corner_radius": 16,
"border_width": 2,
"title_font_size": 26,
"message_font_size": 20,
"button_font_size": 20,
"button_height": 48,
"button_radius": 12,
"button_border_width": 2,
"remove_button_width": 170,
"keep_button_width": 132,
"surface_color": "#202633FA",
"border_color": "#22C6FF",
"accent_color": "#22C6FF",
"action_color": "#087DBA",
"danger_color": "#FF626B",
"danger_hover_surface": "#3B252C",
"danger_pressed_surface": "#4A252D"
}
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
@@ -13,14 +36,14 @@ 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": {"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}},
"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":8, "setup_title_size":30, "setup_heading_size":26, "setup_choice_font_size":22, "setup_choice_height":68, "setup_choice_corner_radius":22, "setup_choice_border_width":2, "tab_font_size":22, "tab_height":60, "filter_chip_font_size":20, "filter_chip_height":48, "filter_chip_gap":8, "setup_button_font_size":20, "button_border_width":2, "footer_gap":16, "surface_corner_radius":14, "look_corner_radius":8, "corner_radius":16, "compact_swatch_size":42, "color_group_gap":8, "action_color":"#087DBA", "separator_color":"#AAB2C0A0"}},
"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"},
"type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"},
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "primary_color": "#087DBA", "primary_hover_color": "#1596D3", "primary_pressed_color": "#066B9F", "destructive_color": "#FF626B", "destructive_hover_surface": "#3B252C", "destructive_pressed_surface": "#4A252D"},
"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}
},
@@ -36,9 +59,13 @@ var _style_cache: Dictionary = {}
var _control_theme: Theme = null
var _warned: Dictionary = {}
func _init() -> void:
_apply_confirmation_defaults()
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
_apply_confirmation_defaults()
if not FileAccess.file_exists(path):
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
return false
@@ -55,6 +82,11 @@ func load_from(path: String = DEFAULT_PATH) -> bool:
_config = _deep_merge(_config, parsed as Dictionary)
return true
func _apply_confirmation_defaults() -> void:
var popups: Dictionary = _config.get("popups", {}).duplicate(true)
popups["confirmation"] = CONFIRMATION_DEFAULTS.duplicate(true)
_config["popups"] = popups
func raw_config() -> Dictionary:
return _config.duplicate(true)
@@ -2,14 +2,20 @@ 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")
## Keeps procedural ground finishes aligned when this surface is split by the area eraser.
var motif_origin_offset: Vector2 = Vector2.ZERO
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 get_surface_draw_origin_world() -> Vector2:
return global_position + motif_origin_offset
func _draw() -> void:
SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
_draw_surface(false)
+182 -1
View File
@@ -2,17 +2,47 @@ class_name StageRectSurface
extends Node2D
## Shared rectangular geometry and transient tint preview for inert Stage scenery.
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const SURFACE_CACHE := preload("res://scripts/stage/scenery/stage_surface_cache.gd")
const PREVIEW_DRAW_BUDGET_USEC: int = 6000
## Each rectangle needs one handle; the image itself is shared across rectangles.
const MAX_RESIDENT_TILES: int = 1
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
var _preview_over_budget: bool = false
var _surface_tiles: Dictionary = {}
var _cache_style: Array = []
var _cache_dirty: bool = true
var _last_view_transform: Transform2D
var _last_view_rect: Rect2
var _was_covered: bool = false
var _cover_regions_frame: int = -1
var _cover_regions: Array[Dictionary] = []
var _last_cover_regions: Array[Dictionary] = []
## Placement uses adaptive vector detail; committed surfaces share material textures.
var interactive_preview: bool = false:
set(value):
if interactive_preview == value:
return
interactive_preview = value
_preview_over_budget = false
queue_redraw()
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))
var next_size := Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
if panel_size == next_size and finish_id == finish and tint == color:
return
if finish_id != finish:
_preview_over_budget = false
panel_size = next_size
finish_id = finish
tint = color
_cache_dirty = true
queue_redraw()
@@ -37,3 +67,154 @@ func get_render_tint() -> Color:
func get_surface_draw_origin_world() -> Vector2:
return global_position
func _exit_tree() -> void:
# Erase/Cancel retains detached geometry, never native drawing resources.
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.dispose()
_surface_tiles.clear()
_cache_dirty = true
_cover_regions_frame = -1
_last_cover_regions.clear()
RenderingServer.canvas_item_clear(get_canvas_item())
func _process(_delta: float) -> void:
if not is_visible_in_tree():
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.wanted = false
SURFACE_CACHE.build_next()
return
var covered: bool = not interactive_preview and _covered_by_surface()
var coverage_changed: bool = _get_cover_regions() != _last_cover_regions
if coverage_changed:
_last_cover_regions = _cover_regions.duplicate()
if covered:
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.wanted = false
SURFACE_CACHE.build_next()
# Camera pans, zooms, and window resizing can reveal previously culled motifs.
if coverage_changed or covered != _was_covered or get_global_transform_with_canvas() != _last_view_transform or get_viewport_rect() != _last_view_rect:
queue_redraw()
_was_covered = covered
func _draw_surface(wall: bool) -> void:
_last_view_transform = get_global_transform_with_canvas()
_last_view_rect = get_viewport_rect()
if not interactive_preview and DisplayServer.get_name() != "headless" and finish_id not in [&"finish_wall_paint", &"finish_ground_paint"]:
if _covered_by_surface():
for tile: SURFACE_CACHE in _surface_tiles.values():
tile.wanted = false
return
_draw_cached_surface(wall)
return
var started_usec: int = Time.get_ticks_usec() if interactive_preview else 0
if wall:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world(), Vector2.ONE, true, interactive_preview, _preview_over_budget)
else:
SURFACE_RENDERER.draw_background(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world(), Vector2.ONE, true, interactive_preview, _preview_over_budget)
# Latch for this placement session to avoid oscillating between detail levels.
# The complexity estimate handles large first draws before timing is available.
if interactive_preview and Time.get_ticks_usec() - started_usec > PREVIEW_DRAW_BUDGET_USEC:
_preview_over_budget = true
func _draw_cached_surface(wall: bool) -> void:
var visible_rect: Rect2 = get_local_bounds().intersection(SURFACE_RENDERER._visible_local_rect(self))
var color: Color = get_render_tint()
var style: Array = [wall, finish_id, color]
if _cache_style != style:
for previous: SURFACE_CACHE in _surface_tiles.values():
previous.dispose()
_surface_tiles.clear()
_cache_style = style
_cache_dirty = false
for previous: SURFACE_CACHE in _surface_tiles.values():
previous.wanted = false
if visible_rect.size.x <= 0.0 or visible_rect.size.y <= 0.0:
return
var period: Vector2 = SURFACE_RENDERER.pattern_size_for(finish_id)
if period.x <= 0.0 or period.y <= 0.0:
draw_rect(visible_rect, SURFACE_RENDERER.surface_base_color(finish_id, color, wall))
return
var pattern: SURFACE_CACHE = _surface_tiles.get(&"pattern") as SURFACE_CACHE
if pattern == null:
pattern = SURFACE_CACHE.new() as SURFACE_CACHE
pattern.attach_to(self)
pattern.request_pattern(wall, finish_id, color, period)
pattern.updated.connect(queue_redraw)
_surface_tiles[&"pattern"] = pattern
pattern.wanted = true
pattern.last_used_frame = Engine.get_process_frames()
pattern.ensure_queued()
var texture: Texture2D = pattern.get_texture()
if texture == null:
draw_rect(visible_rect, SURFACE_RENDERER.surface_base_color(finish_id, color, wall))
return
texture_repeat = CanvasItem.TEXTURE_REPEAT_ENABLED
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS
# One quad samples a repeating material. Camera motion, panel resizing and
# erase fragments change only these UVs, never the image or its mip levels.
var sample_origin: Vector2 = visible_rect.position + get_surface_draw_origin_world()
sample_origin = Vector2(fposmod(sample_origin.x, period.x), fposmod(sample_origin.y, period.y))
var pixels_per_unit: Vector2 = Vector2(pattern.size) / period
var source := Rect2(sample_origin * pixels_per_unit, visible_rect.size * pixels_per_unit)
draw_texture_rect_region(texture, visible_rect, source, Color.WHITE, false, false)
func _covered_by_surface(local_visible: Rect2 = Rect2()) -> bool:
var visible: Rect2 = local_visible
if visible.size.x <= 0.0 or visible.size.y <= 0.0:
visible = get_local_bounds().intersection(SURFACE_RENDERER._visible_local_rect(self))
if visible.size.x <= 0.0 or visible.size.y <= 0.0:
return true
var corners := PackedVector2Array([visible.position, Vector2(visible.end.x, visible.position.y), visible.end, Vector2(visible.position.x, visible.end.y)])
for region: Dictionary in _get_cover_regions():
var fully_covered: bool = true
var cover_bounds: Rect2 = region["bounds"]
var to_cover: Transform2D = region["transform"]
for corner: Vector2 in corners:
if not cover_bounds.has_point(to_cover * corner):
fully_covered = false
break
if fully_covered:
return true
return false
func _get_cover_regions() -> Array[Dictionary]:
var frame: int = Engine.get_process_frames()
if _cover_regions_frame == frame:
return _cover_regions
_cover_regions_frame = frame
_cover_regions.clear()
var parent: Node = get_parent()
if parent == null or (parent is Node2D and (parent as Node2D).y_sort_enabled):
return _cover_regions
# Transparent ancestors and custom materials can reveal underlying layers.
var ancestor: Node = self
while ancestor is CanvasItem:
if (ancestor as CanvasItem).modulate.a < 0.999 or (ancestor as CanvasItem).material != null:
return _cover_regions
ancestor = ancestor.get_parent()
for sibling: Node in parent.get_children():
if sibling == self or not (sibling is StageRectSurface):
continue
var cover: StageRectSurface = sibling as StageRectSurface
if cover.z_as_relative != z_as_relative:
continue
if not cover.is_visible_in_tree() or cover.interactive_preview or cover.material != null or cover.modulate.a < 0.999 or cover.self_modulate.a < 0.999:
continue
if cover.z_index < z_index or (cover.z_index == z_index and cover.get_index() < get_index()):
continue
if SURFACE_RENDERER.surface_base_color(cover.finish_id, cover.get_render_tint(), SURFACE_RENDERER.is_wall_finish(cover.finish_id)).a < 0.999:
continue
if absf(cover.global_transform.determinant()) < 0.000001:
continue
_cover_regions.append({
"bounds": cover.get_local_bounds().grow(-0.01),
"transform": cover.global_transform.affine_inverse() * global_transform,
})
return _cover_regions
@@ -0,0 +1,929 @@
class_name StageSurfaceCache
extends RefCounted
## Lightweight per-surface handle into a scene-scoped immutable bitmap cache.
const MAX_TEXTURE_EDGE: int = 4096
const MAX_TEXTURE_PIXELS: float = 4194304.0
const MAX_CACHE_BYTES: int = 64 * 1024 * 1024
const MAX_CACHE_ENTRIES: int = 512
const MAX_QUEUED_BUILDS: int = 512
const MAX_QUEUE_SCAN_PER_FRAME: int = 8
const RECENT_USE_PROTECTION_FRAMES: int = 3
const MIN_DETAILED_SCALE: float = 0.5
const PATTERN_CACHE_VERSION: int = 1
const PATTERN_SOURCE_OVERSCAN: float = 4.0
const PATTERN_PIXEL_SCALE: float = 2.0
static var _pool_refs: Dictionary = {}
static var _last_service_frame: int = -1
static var _active_pool_ref: WeakRef
signal updated
class TileEntry extends RefCounted:
var key: String
var canonical_rect: Rect2
var source_scale: Vector2
var wall: bool
var finish: StringName
var tint: Color
var detail_view_size: Vector2
var size: Vector2i
var byte_count: int = 0
var texture: Texture2D
var is_pattern: bool = false
var period: Vector2 = Vector2.ZERO
var watchers: Array[WeakRef] = []
var canvas_owners: Array[WeakRef] = []
var last_used_frame: int = 0
var build_usec: int = 0
var queued: bool = false
var building: bool = false
class SurfaceDrawing extends Node2D:
const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
var source_rect: Rect2
var wall: bool
var finish: StringName
var tint: Color
var origin: Vector2 = Vector2.ZERO
var detail_view_size: Vector2
var low_detail: bool = false
var active: bool = false
var pool_ref: WeakRef
func _draw() -> void:
var pool: Variant = pool_ref.get_ref() if pool_ref != null else null
if not active or pool == null:
return
active = false
var started_usec: int = Time.get_ticks_usec()
if wall:
RENDERER.draw_wall(self, source_rect, finish, tint, origin, Vector2.ONE, false, low_detail, low_detail, detail_view_size)
else:
RENDERER.draw_background(self, source_rect, finish, tint, origin, Vector2.ONE, false, low_detail, low_detail)
pool._finish_vector_submission(Time.get_ticks_usec() - started_usec)
class BakePool extends Node:
const RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const BAKE_GUTTER_PIXELS: int = 4
var scope_id: int = 0
var entries: Dictionary = {}
var queue: Array[String] = []
var _waiting_handles: Array[WeakRef] = []
var resident_bytes: int = 0
var builds: int = 0
var cache_hits: int = 0
var evictions: int = 0
var refusals: int = 0
var readbacks: int = 0
var pattern_builds: int = 0
var pattern_readbacks: int = 0
var pattern_resident_bytes: int = 0
var pattern_retired_bytes: int = 0
var last_build_usec: int = 0
var vector_submission_usec: int = 0
var retired_bytes: int = 0
var budget_blocked_requests: int = 0
var budget_pressure: bool = false
var budget_pressure_frame: int = -1
var _viewport: SubViewport
var _drawing: SurfaceDrawing
var _building_entry: TileEntry
var _building_started_usec: int = 0
var _reserved_bytes: int = 0
var _retired_textures: Array[Texture2D] = []
var _retire_callback_pending: bool = false
var _capture_pending: bool = false
var _disposed: bool = false
func _ready() -> void:
set_process(true)
_ensure_baker()
func _process(_delta: float) -> void:
_prune_queue()
StageSurfaceCache._service_pools()
func _ensure_baker() -> void:
if is_instance_valid(_viewport):
return
_viewport = SubViewport.new()
_viewport.name = "SurfaceBitmapBaker"
_viewport.disable_3d = true
_viewport.transparent_bg = true
_viewport.render_target_clear_mode = SubViewport.CLEAR_MODE_ALWAYS
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
_viewport.world_2d = World2D.new()
add_child(_viewport)
_drawing = SurfaceDrawing.new()
_drawing.name = "SurfaceVectorSource"
_drawing.pool_ref = weakref(self)
_viewport.add_child(_drawing)
func queue_handle(handle: StageSurfaceCache) -> void:
if _disposed or handle._disposed:
return
var entry: TileEntry = _find_or_create_entry(handle)
if entry == null:
refusals += 1
_add_waiter(handle)
return
_bind_handle(handle, entry)
if not _handle_is_interested(handle):
return
handle.last_used_frame = Engine.get_process_frames()
entry.last_used_frame = Engine.get_process_frames()
if entry.texture != null or entry.queued or entry.building:
return
if queue.size() >= MAX_QUEUED_BUILDS:
refusals += 1
_add_waiter(handle)
return
entry.queued = true
queue.append(entry.key)
handle._enqueued = true
func _find_or_create_entry(handle: StageSurfaceCache) -> TileEntry:
var existing: TileEntry = entries.get(handle._cache_key) as TileEntry
if existing != null:
cache_hits += 1
return existing
while entries.size() + _retired_textures.size() >= MAX_CACHE_ENTRIES:
if not _retired_textures.is_empty():
return null
if not _evict_one(null):
return null
if not _retired_textures.is_empty():
return null
var arguments: Array = handle._pending_arguments
if arguments.size() < 6:
return null
var entry := TileEntry.new()
entry.key = handle._cache_key
entry.canonical_rect = arguments[0] as Rect2
entry.source_scale = arguments[1] as Vector2
entry.wall = bool(arguments[2])
entry.finish = arguments[3] as StringName
entry.tint = arguments[4] as Color
entry.detail_view_size = arguments[5] as Vector2
if arguments.size() >= 8:
entry.is_pattern = bool(arguments[6])
entry.period = arguments[7] as Vector2
entries[entry.key] = entry
return entry
func _bind_handle(handle: StageSurfaceCache, entry: TileEntry) -> void:
_register_canvas_owner(entry, handle)
var current: TileEntry = handle._entry_ref.get_ref() as TileEntry if handle._entry_ref != null else null
if current == entry:
handle.ready_for_display = entry.texture != null
handle._building = entry.building
handle._enqueued = entry.queued
handle.build_usec = entry.build_usec
return
if current != null:
_unwatch_handle(current, handle)
handle._entry_ref = weakref(entry)
handle.ready_for_display = entry.texture != null
handle._building = entry.building
handle._enqueued = entry.queued
handle.build_usec = entry.build_usec
var found: bool = false
for watcher_ref: WeakRef in entry.watchers:
if watcher_ref.get_ref() == handle:
found = true
break
if not found:
entry.watchers.append(weakref(handle))
func _unwatch_handle(entry: TileEntry, handle: StageSurfaceCache) -> void:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var watcher: Variant = entry.watchers[index].get_ref()
if watcher == null or watcher == handle:
entry.watchers.remove_at(index)
func _register_canvas_owner(entry: TileEntry, handle: StageSurfaceCache) -> void:
var owner: Variant = handle._owner_ref.get_ref() if handle._owner_ref != null else null
if owner == null or not is_instance_valid(owner):
return
for index: int in range(entry.canvas_owners.size() - 1, -1, -1):
var registered_owner: Variant = entry.canvas_owners[index].get_ref()
if registered_owner == null or not is_instance_valid(registered_owner):
entry.canvas_owners.remove_at(index)
elif registered_owner == owner:
return
entry.canvas_owners.append(weakref(owner))
func _start_next_build() -> bool:
if _disposed or _capture_pending:
return false
_prune_queue()
var attempts: int = mini(queue.size(), MAX_QUEUE_SCAN_PER_FRAME)
while attempts > 0 and not queue.is_empty():
attempts -= 1
var key: String = _pop_next_build_key()
var entry: TileEntry = entries.get(key) as TileEntry
if entry == null or not entry.queued or entry.texture != null:
continue
entry.queued = false
if not _entry_has_live_watchers(entry):
_evict_entry(entry)
continue
var size: Vector2i = _baker_size_for(entry)
var required_bytes: int = _estimated_texture_bytes(entry)
if not _make_room(required_bytes, entry):
refusals += 1
if queue.size() < MAX_QUEUED_BUILDS:
entry.queued = true
queue.append(entry.key)
budget_blocked_requests += 1
budget_pressure = true
budget_pressure_frame = Engine.get_process_frames()
continue
_begin_build(entry, size)
return true
return false
func _baker_rect_for(entry: TileEntry) -> Rect2:
return entry.canonical_rect.grow(PATTERN_SOURCE_OVERSCAN) if entry.is_pattern else entry.canonical_rect
func _baker_size_for(entry: TileEntry) -> Vector2i:
var draw_rect: Rect2 = _baker_rect_for(entry)
return StageSurfaceCache.texture_size_for(draw_rect.size, entry.source_scale)
func _pattern_output_size(entry: TileEntry) -> Vector2i:
var draw_rect: Rect2 = _baker_rect_for(entry)
var baker_size: Vector2i = StageSurfaceCache.texture_size_for(draw_rect.size, entry.source_scale)
return Vector2i(
maxi(1, roundi(float(baker_size.x) * entry.period.x / draw_rect.size.x)),
maxi(1, roundi(float(baker_size.y) * entry.period.y / draw_rect.size.y))
)
func _estimated_texture_bytes(entry: TileEntry) -> int:
var base_size: Vector2i = _pattern_output_size(entry) if entry.is_pattern else _baker_size_for(entry)
return _mip_chain_byte_count(base_size) if entry.is_pattern else base_size.x * base_size.y * 4
func _crop_pattern_image(image: Image, entry: TileEntry) -> Image:
var draw_rect: Rect2 = _baker_rect_for(entry)
var output_size: Vector2i = _pattern_output_size(entry)
var scale: Vector2 = Vector2(image.get_size()) / draw_rect.size
var crop_position := Vector2i(
roundi((entry.canonical_rect.position.x - draw_rect.position.x) * scale.x),
roundi((entry.canonical_rect.position.y - draw_rect.position.y) * scale.y)
)
crop_position.x = clampi(crop_position.x, 0, maxi(0, image.get_width() - output_size.x))
crop_position.y = clampi(crop_position.y, 0, maxi(0, image.get_height() - output_size.y))
var safe_size := Vector2i(
mini(output_size.x, image.get_width() - crop_position.x),
mini(output_size.y, image.get_height() - crop_position.y)
)
if safe_size.x <= 0 or safe_size.y <= 0:
return Image.new()
return image.get_region(Rect2i(crop_position, safe_size))
func _mip_chain_byte_count(size: Vector2i) -> int:
var width: int = maxi(1, size.x)
var height: int = maxi(1, size.y)
var total: int = 0
while true:
total += width * height * 4
if width == 1 and height == 1:
break
width = maxi(1, floori(float(width) * 0.5))
height = maxi(1, floori(float(height) * 0.5))
return total
func _pop_next_build_key() -> String:
var selected_index: int = 0
var selected_scale: float = INF
for index: int in range(queue.size()):
var entry: TileEntry = entries.get(queue[index]) as TileEntry
if entry == null:
continue
var scale_rank: float = maxf(entry.source_scale.x, entry.source_scale.y)
if scale_rank < selected_scale:
selected_scale = scale_rank
selected_index = index
return queue.pop_at(selected_index)
func _begin_build(entry: TileEntry, texture_size: Vector2i) -> void:
_ensure_baker()
RenderingServer.canvas_item_clear(_drawing.get_canvas_item())
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
_viewport.size = texture_size
var draw_rect: Rect2 = _baker_rect_for(entry)
_drawing.source_rect = draw_rect
_drawing.wall = entry.wall
_drawing.finish = entry.finish
_drawing.tint = entry.tint
_drawing.origin = Vector2.ZERO
_drawing.detail_view_size = entry.period if entry.is_pattern else entry.detail_view_size
_drawing.low_detail = not entry.is_pattern and minf(entry.source_scale.x, entry.source_scale.y) < MIN_DETAILED_SCALE
var draw_scale := Vector2(texture_size) / draw_rect.size
_drawing.scale = draw_scale
_drawing.position = -draw_rect.position * draw_scale
_drawing.active = true
_building_entry = entry
_building_started_usec = Time.get_ticks_usec()
_reserved_bytes = _estimated_texture_bytes(entry)
entry.building = true
_capture_pending = true
_drawing.queue_redraw()
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
if not RenderingServer.frame_post_draw.is_connected(_on_frame_post_draw):
RenderingServer.frame_post_draw.connect(_on_frame_post_draw, CONNECT_ONE_SHOT)
StageSurfaceCache._active_pool_ref = weakref(self)
func _finish_vector_submission(elapsed_usec: int) -> void:
vector_submission_usec = elapsed_usec
func _on_frame_post_draw() -> void:
if not _capture_pending or _building_entry == null:
return
var entry: TileEntry = _building_entry
var image: Image
if _entry_has_live_watchers(entry) and is_instance_valid(_viewport):
image = _viewport.get_texture().get_image()
if image != null and not image.is_empty():
if entry.is_pattern:
image = _crop_pattern_image(image, entry)
if image != null and not image.is_empty():
image.generate_mipmaps()
if image != null and not image.is_empty():
var texture := ImageTexture.create_from_image(image)
entry.texture = texture
entry.size = image.get_size()
entry.byte_count = image.get_data_size()
resident_bytes += entry.byte_count
if entry.is_pattern:
pattern_builds += 1
pattern_readbacks += 1
pattern_resident_bytes += entry.byte_count
_reserved_bytes = 0
builds += 1
readbacks += 1
budget_pressure = false
else:
_reserved_bytes = 0
refusals += 1
_notify_entry_failed(entry)
last_build_usec = Time.get_ticks_usec() - _building_started_usec
if entry.texture != null:
entry.build_usec = last_build_usec
_notify_entry_ready(entry)
entry.building = false
_building_entry = null
_capture_pending = false
if is_instance_valid(_viewport):
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
if is_instance_valid(_drawing):
_drawing.active = false
RenderingServer.canvas_item_clear(_drawing.get_canvas_item())
if StageSurfaceCache._active_pool_ref != null and StageSurfaceCache._active_pool_ref.get_ref() == self:
StageSurfaceCache._active_pool_ref = null
_wake_waiters()
StageSurfaceCache._service_pools()
func _notify_entry_ready(entry: TileEntry) -> void:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null:
entry.watchers.remove_at(index)
continue
handle._on_entry_ready(entry)
func _notify_entry_failed(entry: TileEntry) -> void:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null:
entry.watchers.remove_at(index)
continue
handle._on_entry_failed(entry)
func _entry_has_live_watchers(entry: TileEntry) -> bool:
var has_watcher: bool = false
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null or handle._disposed:
entry.watchers.remove_at(index)
continue
if _handle_is_interested(handle):
has_watcher = true
return has_watcher
func _make_room(required_bytes: int, protected_entry: TileEntry) -> bool:
if resident_bytes + retired_bytes + _reserved_bytes + required_bytes <= MAX_CACHE_BYTES:
return true
if retired_bytes > 0:
return false
if not _evict_one(protected_entry):
return false
# Keep the old texture alive until the frame that releases its CanvasItem RIDs.
# The request will retry after the post-draw retirement callback.
return resident_bytes + retired_bytes + _reserved_bytes + required_bytes <= MAX_CACHE_BYTES
func _evict_one(protected_entry: TileEntry) -> bool:
var candidate: TileEntry
for value: Variant in entries.values():
var entry: TileEntry = value as TileEntry
if entry == null or entry == protected_entry or entry.building:
continue
if _entry_is_protected(entry):
continue
if candidate == null or entry.last_used_frame < candidate.last_used_frame:
candidate = entry
if candidate == null:
return false
_evict_entry(candidate)
return true
func _entry_is_protected(entry: TileEntry) -> bool:
if entry.texture == null:
return false
var current_frame: int = Engine.get_process_frames()
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null or handle._disposed:
entry.watchers.remove_at(index)
continue
if _handle_is_visible(handle) and (handle.wanted or current_frame - handle.last_used_frame <= RECENT_USE_PROTECTION_FRAMES):
return true
return false
func _evict_entry(entry: TileEntry) -> void:
if entry == null or entry.building:
return
if entry.queued:
queue.erase(entry.key)
entry.queued = false
_clear_entry_canvas_owners(entry)
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null:
entry.watchers.remove_at(index)
continue
handle._on_entry_evicted(entry)
var retired_texture: Texture2D = entry.texture
var retired_size_bytes: int = entry.byte_count
entry.texture = null
resident_bytes = maxi(0, resident_bytes - entry.byte_count)
if entry.is_pattern:
pattern_resident_bytes = maxi(0, pattern_resident_bytes - entry.byte_count)
entry.byte_count = 0
if retired_texture != null:
_retired_textures.append(retired_texture)
retired_bytes += retired_size_bytes
if entry.is_pattern:
pattern_retired_bytes += retired_size_bytes
if not _retire_callback_pending:
_retire_callback_pending = true
RenderingServer.frame_post_draw.connect(_on_retired_frame_post_draw, CONNECT_ONE_SHOT)
if entries.get(entry.key) == entry:
entries.erase(entry.key)
evictions += 1
func _clear_owner_canvas(owner: Variant) -> void:
if not (owner is CanvasItem) or not is_instance_valid(owner):
return
var canvas_item: CanvasItem = owner as CanvasItem
RenderingServer.canvas_item_clear(canvas_item.get_canvas_item())
canvas_item.queue_redraw()
func _clear_entry_canvas_owners(entry: TileEntry) -> void:
var cleared_owners: Dictionary = {}
for owner_ref: WeakRef in entry.canvas_owners:
var owner: Variant = owner_ref.get_ref()
if owner == null or not is_instance_valid(owner):
continue
var owner_id: int = owner.get_instance_id()
if cleared_owners.has(owner_id):
continue
_clear_owner_canvas(owner)
cleared_owners[owner_id] = true
entry.canvas_owners.clear()
func _stats() -> Dictionary:
var queued_count: int = 0
for value: Variant in entries.values():
var entry: TileEntry = value as TileEntry
if entry != null and entry.queued:
queued_count += 1
return {
"bytes": resident_bytes,
"resident_bytes": resident_bytes,
"retired_bytes": retired_bytes,
"entries": entries.size(),
"queued": queued_count,
"bakers": 1 if is_instance_valid(_viewport) else 0,
"live_vector_bakers": 1 if _capture_pending else 0,
"live_vector_items": 1 if _capture_pending else 0,
"builds": builds,
"cache_hits": cache_hits,
"evictions": evictions,
"refusals": refusals,
"budget_pressure": budget_pressure,
"budget_pressure_frame": budget_pressure_frame,
"budget_blocked_requests": budget_blocked_requests,
"waiting_handles": _waiting_handles.size(),
"readbacks": readbacks,
"pattern_builds": pattern_builds,
"pattern_readbacks": pattern_readbacks,
"pattern_resident_bytes": pattern_resident_bytes,
"pattern_retired_bytes": pattern_retired_bytes,
"last_build_usec": last_build_usec,
"vector_submission_usec": vector_submission_usec,
"active_build": _building_entry.key if _building_entry != null else ""
}
func _exit_tree() -> void:
_disposed = true
if RenderingServer.frame_post_draw.is_connected(_on_frame_post_draw):
RenderingServer.frame_post_draw.disconnect(_on_frame_post_draw)
if _capture_pending and _building_entry != null:
_building_entry.building = false
_notify_entry_failed(_building_entry)
_building_entry = null
_capture_pending = false
if StageSurfaceCache._active_pool_ref != null and StageSurfaceCache._active_pool_ref.get_ref() == self:
StageSurfaceCache._active_pool_ref = null
for value: Variant in entries.values():
var entry: TileEntry = value as TileEntry
if entry == null:
continue
_clear_entry_canvas_owners(entry)
for watcher_ref: WeakRef in entry.watchers:
var handle: Variant = watcher_ref.get_ref()
if handle != null:
handle._on_entry_evicted(entry)
entry.texture = null
entries.clear()
queue.clear()
_waiting_handles.clear()
_retired_textures.clear()
resident_bytes = 0
retired_bytes = 0
pattern_resident_bytes = 0
pattern_retired_bytes = 0
if is_instance_valid(_viewport):
_viewport.queue_free()
_viewport = null
_drawing = null
StageSurfaceCache._forget_pool(scope_id, self)
func _prune_queue() -> void:
if queue.is_empty():
return
var kept: Array[String] = []
for key: String in queue:
var entry: TileEntry = entries.get(key) as TileEntry
if entry == null or not entry.queued or entry.texture != null or not _entry_has_live_watchers(entry):
if entry != null:
entry.queued = false
_set_entry_handle_queue_state(entry, false)
if entry.texture == null and not entry.building and not _entry_has_live_watchers(entry):
_evict_entry(entry)
continue
kept.append(key)
queue = kept
if queue.size() < MAX_QUEUED_BUILDS and not _waiting_handles.is_empty():
_wake_waiters()
func _entry_has_any_handles(entry: TileEntry) -> bool:
for index: int in range(entry.watchers.size() - 1, -1, -1):
var handle: Variant = entry.watchers[index].get_ref()
if handle == null or handle._disposed:
entry.watchers.remove_at(index)
else:
return true
return false
func _set_entry_handle_queue_state(entry: TileEntry, is_queued: bool) -> void:
for watcher_ref: WeakRef in entry.watchers:
var handle: Variant = watcher_ref.get_ref()
if handle != null:
handle._enqueued = is_queued
func _handle_is_interested(handle: StageSurfaceCache) -> bool:
return handle != null and not handle._disposed and handle.wanted and _handle_is_visible(handle)
func _handle_is_visible(handle: StageSurfaceCache) -> bool:
if handle == null or handle._disposed:
return false
var owner: Variant = handle._owner_ref.get_ref() if handle._owner_ref != null else null
if owner == null or not is_instance_valid(owner) or not (owner is Node2D):
return false
if not owner.is_inside_tree() or not owner.is_visible_in_tree():
return false
var visible_rect: Rect2 = RENDERER._visible_local_rect(owner)
if handle.is_pattern:
if not owner.has_method("get_local_bounds"):
return false
var local_bounds: Variant = owner.call("get_local_bounds")
if not (local_bounds is Rect2):
return false
var bounds: Rect2 = local_bounds
return bounds.intersects(visible_rect)
return handle.source_rect.intersects(visible_rect) or handle.core_rect.intersects(visible_rect)
func _add_waiter(handle: StageSurfaceCache) -> void:
for waiter_ref: WeakRef in _waiting_handles:
if waiter_ref.get_ref() == handle:
return
_waiting_handles.append(weakref(handle))
func _wake_waiters() -> void:
if _waiting_handles.is_empty() or _disposed:
return
var pending: Array[WeakRef] = _waiting_handles
_waiting_handles = []
for waiter_ref: WeakRef in pending:
var handle: Variant = waiter_ref.get_ref()
if handle == null or handle._disposed or not _handle_is_interested(handle):
continue
queue_handle(handle)
func _on_retired_frame_post_draw() -> void:
_retired_textures.clear()
retired_bytes = 0
pattern_retired_bytes = 0
_retire_callback_pending = false
_wake_waiters()
StageSurfaceCache._service_pools()
var source_rect: Rect2
var source_scale: Vector2
var core_rect: Rect2
var ready_for_display: bool = false
var wanted: bool = false
var last_used_frame: int = 0
var build_usec: int = 0
var _enqueued: bool = false
var _building: bool = false
var _pending_arguments: Array = []
var _owner_ref: WeakRef
var _pool_ref: WeakRef
var _entry_ref: WeakRef
var _cache_key: String = ""
var _disposed: bool = false
var _finish: StringName
var _tint: Color
var _origin: Vector2
var is_pattern: bool = false
var size: Vector2i:
get:
var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null
return entry.size if entry != null else Vector2i.ZERO
var render_target_update_mode: int:
get:
return SubViewport.UPDATE_DISABLED
func attach_to(owner_node: Node) -> void:
_owner_ref = weakref(owner_node)
var pool: BakePool = _get_or_create_pool(owner_node)
_pool_ref = weakref(pool) if pool != null else null
func dispose() -> void:
wanted = false
_disposed = true
var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null
var pool: BakePool = _pool_ref.get_ref() as BakePool if _pool_ref != null else null
if entry != null and pool != null:
pool._unwatch_handle(entry, self)
_entry_ref = null
ready_for_display = false
_building = false
_enqueued = false
func get_texture() -> Texture2D:
var entry: TileEntry = _entry_ref.get_ref() as TileEntry if _entry_ref != null else null
return entry.texture if entry != null else null
func request_build(core: Rect2, rect: Rect2, pixel_scale: Vector2, wall: bool, finish: StringName, tint: Color, origin: Vector2, detail_view_size: Vector2) -> void:
if _disposed:
return
is_pattern = false
core_rect = core
source_rect = rect
source_scale = pixel_scale
wanted = true
_finish = finish
_tint = tint
_origin = origin
var canonical_position: Vector2 = _snap_canonical_position(rect.position + origin, pixel_scale)
var canonical_rect := Rect2(canonical_position, rect.size)
_pending_arguments = [canonical_rect, pixel_scale, wall, finish, tint, detail_view_size]
_cache_key = _make_cache_key(canonical_rect, pixel_scale, wall, finish, tint, detail_view_size)
queue_if_needed()
func request_pattern(wall: bool, finish: StringName, tint: Color, period: Vector2) -> void:
if _disposed:
return
var safe_period := Vector2(maxf(1.0, period.x), maxf(1.0, period.y))
is_pattern = true
core_rect = Rect2(Vector2.ZERO, safe_period)
source_rect = core_rect
source_scale = Vector2.ONE * PATTERN_PIXEL_SCALE
wanted = true
_finish = finish
_tint = tint
_origin = Vector2.ZERO
_pending_arguments = [core_rect, source_scale, wall, finish, tint, safe_period, true, safe_period]
_cache_key = _make_pattern_cache_key(wall, finish, tint, safe_period)
queue_if_needed()
func ensure_queued() -> void:
if _disposed:
return
queue_if_needed()
func queue_if_needed() -> void:
var pool: BakePool = _pool_ref.get_ref() as BakePool if _pool_ref != null else null
if pool == null:
var owner: Variant = _owner_ref.get_ref() if _owner_ref != null else null
if owner != null and is_instance_valid(owner):
pool = _get_or_create_pool(owner)
_pool_ref = weakref(pool) if pool != null else null
if pool != null:
pool.queue_handle(self)
func _on_entry_ready(entry: TileEntry) -> void:
if _disposed:
return
ready_for_display = true
_building = false
_enqueued = false
build_usec = entry.build_usec
updated.emit()
func _on_entry_failed(entry: TileEntry) -> void:
if _disposed:
return
if _entry_ref != null and _entry_ref.get_ref() == entry:
ready_for_display = false
_building = false
_enqueued = false
updated.emit()
func _on_entry_evicted(entry: TileEntry) -> void:
if _entry_ref != null and _entry_ref.get_ref() == entry:
_entry_ref = null
ready_for_display = false
_building = false
_enqueued = false
updated.emit()
static func build_next() -> void:
_service_pools()
static func _service_pools() -> void:
var frame: int = Engine.get_process_frames()
if frame == _last_service_frame:
return
_last_service_frame = frame
var active_pool: Variant = _active_pool_ref.get_ref() if _active_pool_ref != null else null
if active_pool != null and is_instance_valid(active_pool):
if not active_pool._capture_pending:
active_pool._start_next_build()
return
_active_pool_ref = null
for scope_id: Variant in _pool_refs.keys():
var pool: Variant = _pool_refs[scope_id].get_ref()
if pool == null or not is_instance_valid(pool):
_pool_refs.erase(scope_id)
continue
if pool._start_next_build():
return
static func _get_or_create_pool(owner_node: Node) -> BakePool:
if owner_node == null or not is_instance_valid(owner_node) or owner_node.get_tree() == null:
return null
var scope: Node = _get_scope_node(owner_node)
if scope == null or not is_instance_valid(scope):
return null
var scope_id: int = scope.get_instance_id()
var pool_ref: WeakRef = _pool_refs.get(scope_id) as WeakRef
var existing: BakePool = pool_ref.get_ref() as BakePool if pool_ref != null else null
if existing != null and is_instance_valid(existing):
return existing
var pool := BakePool.new()
pool.name = "StageSurfaceBakePool"
pool.scope_id = scope_id
scope.add_child(pool)
_pool_refs[scope_id] = weakref(pool)
return pool
static func _get_scope_node(owner_node: Node) -> Node:
var tree: SceneTree = owner_node.get_tree()
if tree == null:
return null
var current_scene: Node = tree.current_scene
if current_scene != null and (current_scene == owner_node or current_scene.is_ancestor_of(owner_node)):
return current_scene
var root: Window = tree.root
var scope: Node = owner_node
while scope.get_parent() != null and scope.get_parent() != root:
scope = scope.get_parent()
return scope
static func _forget_pool(scope_id: int, pool: BakePool) -> void:
var pool_ref: WeakRef = _pool_refs.get(scope_id) as WeakRef
if pool_ref != null and pool_ref.get_ref() == pool:
_pool_refs.erase(scope_id)
static func _make_cache_key(rect: Rect2, pixel_scale: Vector2, wall: bool, finish: StringName, tint: Color, detail_view_size: Vector2) -> String:
return var_to_str([wall, finish, tint, rect.position.x, rect.position.y, rect.size.x, rect.size.y, pixel_scale.x, pixel_scale.y, detail_view_size.x, detail_view_size.y])
static func _make_pattern_cache_key(wall: bool, finish: StringName, tint: Color, period: Vector2) -> String:
return var_to_str([&"periodic_style", PATTERN_CACHE_VERSION, wall, finish, tint, period.x, period.y, PATTERN_PIXEL_SCALE, &"mipmaps"])
static func _snap_canonical_position(position: Vector2, pixel_scale: Vector2) -> Vector2:
var scale_x: float = maxf(0.001, pixel_scale.x)
var scale_y: float = maxf(0.001, pixel_scale.y)
return Vector2(roundf(position.x * scale_x) / scale_x, roundf(position.y * scale_y) / scale_y)
static func get_stats(owner_node: Node = null) -> Dictionary:
if owner_node != null and is_instance_valid(owner_node):
var scope: Node = _get_scope_node(owner_node)
if scope != null:
var pool_ref: WeakRef = _pool_refs.get(scope.get_instance_id()) as WeakRef
var pool: BakePool = pool_ref.get_ref() as BakePool if pool_ref != null else null
return pool._stats() if pool != null else _empty_stats()
var totals: Dictionary = _empty_stats()
for scope_id: Variant in _pool_refs.keys():
var pool: BakePool = _pool_refs[scope_id].get_ref() as BakePool
if pool == null or not is_instance_valid(pool):
continue
var stats: Dictionary = pool._stats()
for key: String in totals.keys():
if key == "active_build":
if not str(stats[key]).is_empty():
totals[key] = stats[key]
elif key == "last_build_usec" or key == "vector_submission_usec" or key == "budget_pressure_frame":
totals[key] = maxi(int(totals[key]), int(stats[key]))
else:
totals[key] = int(totals[key]) + int(stats[key])
return totals
static func _empty_stats() -> Dictionary:
return {
"bytes": 0,
"resident_bytes": 0,
"retired_bytes": 0,
"entries": 0,
"queued": 0,
"bakers": 0,
"live_vector_bakers": 0,
"live_vector_items": 0,
"builds": 0,
"cache_hits": 0,
"evictions": 0,
"refusals": 0,
"budget_pressure": 0,
"budget_pressure_frame": -1,
"budget_blocked_requests": 0,
"waiting_handles": 0,
"readbacks": 0,
"pattern_builds": 0,
"pattern_readbacks": 0,
"pattern_resident_bytes": 0,
"pattern_retired_bytes": 0,
"last_build_usec": 0,
"vector_submission_usec": 0,
"active_build": ""
}
static func texture_size_for(local_size: Vector2, pixel_scale: Vector2) -> Vector2i:
var safe_size := Vector2(maxf(1.0, local_size.x), maxf(1.0, local_size.y))
var safe_scale := Vector2(maxf(0.001, pixel_scale.x), maxf(0.001, pixel_scale.y))
var pixels: Vector2 = safe_size * safe_scale
var reduction: float = maxf(1.0, maxf(pixels.x, pixels.y) / float(MAX_TEXTURE_EDGE))
reduction = maxf(reduction, sqrt(pixels.x * pixels.y / MAX_TEXTURE_PIXELS))
return Vector2i(maxi(1, floori(pixels.x / reduction)), maxi(1, floori(pixels.y / reduction)))
@@ -0,0 +1 @@
uid://cwcf7h5yf3xi0
+54 -1
View File
@@ -7,6 +7,8 @@ const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_ren
var surface_kind: StringName = &"wall"
var finish_id: StringName = &"finish_wall_paint"
var tint: Color = Color("#5A9D70")
var corner_radius: float = 0.0
var outside_color: Color = Color("#202633FA")
func configure(kind: StringName, finish: StringName, color: Color) -> void:
@@ -17,6 +19,12 @@ func configure(kind: StringName, finish: StringName, color: Color) -> void:
queue_redraw()
func set_rounded_frame(radius: float, background: Color) -> void:
corner_radius = maxf(0.0, radius)
outside_color = background
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:
@@ -25,4 +33,49 @@ func _draw() -> void:
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)
var radius: float = minf(corner_radius, minf(rect.size.x, rect.size.y) * 0.5)
if radius > 0.0:
var wall: bool = surface_kind != &"background_ground"
var base_color: Color = SURFACE_RENDERER.surface_base_color(finish_id, tint, wall)
_draw_rounded_corner(rect, radius, base_color, outside_color, 0)
_draw_rounded_corner(rect, radius, base_color, outside_color, 1)
_draw_rounded_corner(rect, radius, base_color, outside_color, 2)
_draw_rounded_corner(rect, radius, base_color, outside_color, 3)
var outline := StyleBoxFlat.new()
outline.bg_color = Color.TRANSPARENT
outline.draw_center = false
outline.border_color = Color("#F5F7FA")
outline.set_border_width_all(2)
outline.set_corner_radius_all(roundi(radius))
draw_style_box(outline, rect)
else:
draw_rect(rect, Color("#F5F7FA"), false, 2.0)
func _draw_rounded_corner(rect: Rect2, radius: float, base_color: Color, background: Color, corner: int) -> void:
var square := Rect2()
var center := Vector2.ZERO
var start_angle: float = 0.0
match corner:
0:
square = Rect2(rect.position, Vector2.ONE * radius)
center = rect.position + Vector2.ONE * radius
start_angle = PI
1:
square = Rect2(Vector2(rect.end.x - radius, rect.position.y), Vector2.ONE * radius)
center = Vector2(rect.end.x - radius, rect.position.y + radius)
start_angle = -PI * 0.5
2:
square = Rect2(rect.end - Vector2.ONE * radius, Vector2.ONE * radius)
center = rect.end - Vector2.ONE * radius
start_angle = 0.0
_:
square = Rect2(Vector2(rect.position.x, rect.end.y - radius), Vector2.ONE * radius)
center = Vector2(rect.position.x + radius, rect.end.y - radius)
start_angle = PI * 0.5
draw_rect(square, background, true)
var arc := PackedVector2Array([center])
for point_index: int in 13:
var angle: float = start_angle + PI * 0.5 * float(point_index) / 12.0
arc.append(center + Vector2(cos(angle), sin(angle)) * radius)
draw_colored_polygon(arc, base_color)
+173 -68
View File
@@ -13,12 +13,37 @@ 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
## Finite material variations repeat independently of wall size and camera travel.
## Even row periods preserve staggered brick/plank courses across the seam.
const BRICK_PATTERN_CELLS := Vector2i(8, 8)
const WOOD_PATTERN_CELLS := Vector2i(3, 6)
const GRASS_PATTERN_CELLS := Vector2i(16, 16)
const CONCRETE_PATTERN_CELLS := Vector2i(4, 4)
const CARPET_PATTERN_CELLS := Vector2i(16, 16)
## Conservative work estimate to avoid an expensive first placement redraw.
## Runtime timing in StageRectSurface also catches slower machines.
const PREVIEW_WORK_BUDGET: float = 6000.0
const MAX_PREVIEW_TEXTURE_CELLS: int = 128
static func is_wall_finish(id: StringName) -> bool:
return WALL_FINISHES.has(id)
static func pattern_size_for(finish: StringName) -> Vector2:
match finish:
&"finish_wall_brick": return Vector2(BRICK_PATTERN_CELLS) * Vector2(WALL_BRICK_WIDTH, WALL_BRICK_HEIGHT)
&"finish_wall_wood": return Vector2(WOOD_PATTERN_CELLS) * Vector2(320.0, 64.0)
&"finish_background_grass": return Vector2(GRASS_PATTERN_CELLS) * Vector2(44.0, 36.0)
&"finish_background_concrete": return Vector2(CONCRETE_PATTERN_CELLS) * Vector2(280.0, 190.0)
&"finish_background_carpet": return Vector2(CARPET_PATTERN_CELLS) * Vector2(34.0, 26.0)
_: return Vector2.ZERO
static func _pattern_seed(row: int, column: int, cells: Vector2i, row_factor: int, column_factor: int, offset: int = 0) -> int:
return posmod(row, cells.y) * row_factor + posmod(column, cells.x) * column_factor + offset
static func is_background_finish(id: StringName) -> bool:
return BACKGROUND_FINISHES.has(id)
@@ -38,6 +63,28 @@ static func is_ground_finish(id: StringName) -> bool:
return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy"
static func surface_base_color(finish: StringName, tint: Color, wall: bool) -> Color:
if finish == &"finish_ground_legacy":
return Color("#468B42")
if finish.ends_with("brick"):
return Color("#D2CBAF") if wall else Color("#994D35")
if finish.ends_with("wood"):
return Color("#9A673C") if wall else Color("#B88951")
if finish == &"finish_ground_grass" or finish == &"finish_background_grass":
return Color("#72A84C")
if finish == &"finish_background_concrete":
return Color("#A6AAA3")
if finish == &"finish_background_carpet":
return tint
if finish == &"finish_ground_dirt":
return Color("#8E633E")
if finish == &"finish_ground_sidewalk":
return Color("#B7B9B2")
if finish == &"finish_ground_asphalt":
return Color("#505A62")
return tint
static func draw_wall(
canvas: CanvasItem,
rect: Rect2,
@@ -45,9 +92,12 @@ static func draw_wall(
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
cull_to_viewport: bool = true,
interactive_preview: bool = false,
force_simple_preview: bool = false,
detail_view_size: Vector2 = Vector2.ZERO
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true, false, cull_to_viewport, interactive_preview, force_simple_preview, detail_view_size)
static func draw_ground(
@@ -68,9 +118,11 @@ static func draw_background(
tint: Color,
world_origin: Vector2,
drawing_scale: Vector2 = Vector2.ONE,
cull_to_viewport: bool = true
cull_to_viewport: bool = true,
interactive_preview: bool = false,
force_simple_preview: bool = false
) -> void:
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport)
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false, true, cull_to_viewport, interactive_preview, force_simple_preview)
static func _draw_surface(
@@ -82,66 +134,47 @@ static func _draw_surface(
drawing_scale: Vector2,
wall: bool,
background: bool,
cull_to_viewport: bool
cull_to_viewport: bool,
interactive_preview: bool = false,
force_simple_preview: bool = false,
detail_view_size: Vector2 = Vector2.ZERO
) -> void:
var normalized := rect.abs()
if normalized.size.x <= 0.0 or normalized.size.y <= 0.0:
return
var motif_scale: Vector2 = _normalized_scale(drawing_scale)
var base: Color = tint
if finish == &"finish_ground_legacy":
base = Color("#468B42")
elif finish.ends_with("brick"):
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" 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":
base = Color("#B7B9B2")
elif finish == &"finish_ground_asphalt":
base = Color("#505A62")
var base: Color = surface_base_color(finish, tint, wall)
canvas.draw_rect(normalized, base, true)
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
var simplified: bool = interactive_preview and (force_simple_preview or needs_light_preview(finish, pattern_rect.size, motif_scale))
if finish.ends_with("brick"):
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_size: Vector2 = view_rect.size if not view_rect.size.is_zero_approx() else normalized.size
if detail_view_size.x > 0.0 and detail_view_size.y > 0.0:
detail_size = detail_view_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)
_draw_wall_bricks(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint, simplified)
else:
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
elif finish.ends_with("wood"):
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)
var detail_size: Vector2 = detail_view_size if detail_view_size.x > 0.0 and detail_view_size.y > 0.0 else pattern_rect.size
var detail_scale: float = wood_detail_scale_for_view(detail_size, motif_scale)
_draw_wall_wood(canvas, pattern_rect, world_origin, motif_scale, detail_scale, tint, simplified)
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)
_draw_background_grass(canvas, pattern_rect, world_origin, motif_scale, simplified)
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)
_draw_background_concrete(canvas, pattern_rect, world_origin, motif_scale, simplified)
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)
_draw_background_carpet(canvas, pattern_rect, world_origin, motif_scale, tint, simplified)
elif finish == &"finish_ground_grass":
_draw_grass(canvas, normalized, world_origin, motif_scale)
elif finish == &"finish_ground_dirt":
@@ -154,6 +187,39 @@ static func _draw_surface(
push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish))
static func needs_light_preview(finish: StringName, visible_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> bool:
if visible_size.x <= 0.0 or visible_size.y <= 0.0:
return false
var cell_size: Vector2
var work_per_cell: float
match finish:
&"finish_wall_brick":
cell_size = Vector2(WALL_BRICK_WIDTH, WALL_BRICK_HEIGHT)
work_per_cell = 240.0
&"finish_wall_wood":
cell_size = Vector2(320.0, 64.0)
work_per_cell = 750.0
&"finish_background_grass":
cell_size = Vector2(44.0, 36.0)
work_per_cell = 70.0
&"finish_background_concrete":
cell_size = Vector2(280.0, 190.0)
work_per_cell = 20.0
&"finish_background_carpet":
cell_size = Vector2(34.0, 26.0)
work_per_cell = 35.0
_:
return false
var world_size: Vector2 = visible_size * _normalized_scale(motif_scale)
var cells: float = (ceil(world_size.x / cell_size.x) + 1.0) * (ceil(world_size.y / cell_size.y) + 1.0)
return cells * work_per_cell > PREVIEW_WORK_BUDGET
static func _preview_texture_stride(size: Vector2, motif_scale: Vector2, cell_size: Vector2) -> int:
var cells: Vector2 = size * motif_scale / cell_size + Vector2(3.0, 3.0)
return maxi(1, ceili(sqrt(cells.x * cells.y / float(MAX_PREVIEW_TEXTURE_CELLS))))
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))
@@ -185,7 +251,7 @@ static func wood_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2
return scale
static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color) -> void:
static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color, simplified: bool = false) -> void:
const BOARD_HEIGHT: float = 64.0
const PLANK_WIDTH: float = 320.0
var safe_scale: Vector2 = _normalized_scale(motif_scale)
@@ -234,14 +300,15 @@ static func _draw_wall_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, mo
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
)
if not simplified:
_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),
@@ -274,7 +341,7 @@ static func _draw_wall_wood_grain(
) -> void:
const TRACK_COUNT: int = 5
const SAMPLES: int = 32
var seed: int = row * 73856093 + column * 19349663
var seed: int = _pattern_seed(row, column, WOOD_PATTERN_CELLS, 73856093, 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
@@ -312,7 +379,7 @@ static func _draw_wall_wood_knot(
tint: Color,
natural: Color
) -> void:
var seed: int = row * 92821 + column * 68917 + 31
var seed: int = _pattern_seed(row, column, WOOD_PATTERN_CELLS, 92821, 68917, 31)
if _wood_hash(seed) > 0.24:
return
var center_world := Vector2(
@@ -376,7 +443,7 @@ static func _material_color(source: Color, selected: Color, natural: Color) -> C
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:
static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, simplified: bool = false) -> 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
@@ -388,12 +455,16 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect
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):
var stride: int = _preview_texture_stride(rect.size, motif_scale, Vector2(CELL_WIDTH, CELL_HEIGHT)) if simplified else 1
# Sample the same world cells at a lower density, including in negative coordinates.
first_row = floori(float(first_row) / float(stride)) * stride
first_column = floori(float(first_column) / float(stride)) * stride
for row: int in range(first_row, last_row + 1, stride):
for column: int in range(first_column, last_column + 1, stride):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 73856093 + column * 19349663 + 197
var seed: int = _pattern_seed(row, column, GRASS_PATTERN_CELLS, 73856093, 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)
@@ -403,7 +474,7 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect
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:
for blade: int in (1 if simplified else 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
@@ -413,7 +484,7 @@ static func _draw_background_grass(canvas: CanvasItem, rect: Rect2, origin: Vect
_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:
static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, simplified: bool = false) -> 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
@@ -431,11 +502,13 @@ static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: V
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)
if simplified:
continue
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 seed: int = _pattern_seed(row, column, CONCRETE_PATTERN_CELLS, 68917, 92821, 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):
@@ -447,25 +520,28 @@ static func _draw_background_concrete(canvas: CanvasItem, rect: Rect2, origin: V
_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:
static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, tint: Color, simplified: bool = false) -> 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 first_column: int = floori(world_left / CELL_WIDTH) - 1
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):
var stride: int = _preview_texture_stride(rect.size, motif_scale, Vector2(CELL_WIDTH, CELL_HEIGHT)) if simplified else 1
first_row = floori(float(first_row) / float(stride)) * stride
first_column = floori(float(first_column) / float(stride)) * stride
for row: int in range(first_row, last_row + 1, stride):
for column: int in range(first_column, last_column + 1, stride):
if remaining <= 0:
return
remaining -= 1
var seed: int = row * 92821 + column * 68917 + 71
var seed: int = _pattern_seed(row, column, CARPET_PATTERN_CELLS, 92821, 68917, 71)
var cell_left: float = float(column) * CELL_WIDTH
var cell_top: float = float(row) * CELL_HEIGHT
for fiber: int in 2:
@@ -479,7 +555,7 @@ static func _draw_background_carpet(canvas: CanvasItem, rect: Rect2, origin: Vec
_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:
static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2, detail_scale: float, tint: Color, interactive_preview: bool = false) -> 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
@@ -508,7 +584,7 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2,
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 seed: int = _pattern_seed(row, column, BRICK_PATTERN_CELLS, 92821, 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
@@ -526,6 +602,15 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2,
continue
if face_rect.end.y < rect.position.y or face_rect.position.y > rect.end.y:
continue
var color_index: int = int(_wood_hash(seed + 5) * float(palette.size()))
var face_color: Color = palette[color_index]
if interactive_preview:
# Keep the committed pattern's world phase, dimensions, jitter, and palette.
# Fine outlines and texture are expensive to regenerate on every drag frame.
var visible_face: Rect2 = face_rect.intersection(rect)
if visible_face.size.x > 0.0 and visible_face.size.y > 0.0:
canvas.draw_rect(visible_face, face_color)
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)
@@ -535,8 +620,6 @@ static func _draw_wall_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2,
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])
@@ -628,12 +711,34 @@ static func _offset_polygon(points: PackedVector2Array, offset: Vector2) -> Pack
static func _clip_polygon_to_rect(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
var fully_inside: bool = true
for point: Vector2 in points:
if not rect.has_point(point):
fully_inside = false
break
if fully_inside:
return points
var clipped := points
for edge: int in 4:
clipped = _clip_polygon_to_edge(clipped, rect, edge)
if clipped.is_empty():
return clipped
return clipped
# Clipping at a corner can produce repeated vertices or a zero-area sliver.
# These cannot be triangulated, even when the array still has three entries.
var cleaned := PackedVector2Array()
for point: Vector2 in clipped:
if cleaned.is_empty() or cleaned[cleaned.size() - 1].distance_squared_to(point) > 0.000001:
cleaned.append(point)
if cleaned.size() > 1 and cleaned[0].distance_squared_to(cleaned[cleaned.size() - 1]) <= 0.000001:
cleaned.remove_at(cleaned.size() - 1)
if cleaned.size() < 3:
return PackedVector2Array()
var area: float = 0.0
for index: int in range(1, cleaned.size() - 1):
area += (cleaned[index] - cleaned[0]).cross(cleaned[index + 1] - cleaned[0])
if absf(area) <= 0.001 or Geometry2D.triangulate_polygon(cleaned).is_empty():
return PackedVector2Array()
return cleaned
static func _clip_polygon_to_edge(points: PackedVector2Array, rect: Rect2, edge: int) -> PackedVector2Array:
+1 -2
View File
@@ -2,7 +2,6 @@ class_name StageWallPanel
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")
## 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.
@@ -23,4 +22,4 @@ func get_surface_draw_origin_world() -> Vector2:
func _draw() -> void:
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, get_render_tint(), get_surface_draw_origin_world())
_draw_surface(true)
+2
View File
@@ -307,6 +307,8 @@ static func _validate_root(data: Dictionary) -> Dictionary:
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."}
if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")):
return {"ok": false, "error": "Background ground motif origin 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():