Add new test file for phase 19 scenery with unique identifier

This commit is contained in:
2026-10-17 12:53:38 -04:00
parent 36032ebb1e
commit a0510e6405
74 changed files with 40948 additions and 149 deletions
+250 -4
View File
@@ -33,6 +33,7 @@ signal anchor_selected(anchor_id: String)
signal whole_prop_resize_requested(factor: float)
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
signal stage_size_cancel_requested
# ---------------------------------------------------------------------------
# Enums / constants
@@ -141,6 +142,14 @@ var _whole_prop_delta: Vector2 = Vector2.ZERO
var _whole_prop_total_angle: float = 0.0
var _whole_prop_previous_angle: float = 0.0
var _whole_prop_preview_angle: float = 0.0
var _stage_size_preview_active: bool = false
var _stage_size_start_shapes: Array[Dictionary] = []
var _stage_size_preview_shapes: Array[Dictionary] = []
var _stage_size_start_anchors: Dictionary = {}
var _stage_size_preview_anchors: Dictionary = {}
var _stage_size_start_pan: Vector2 = Vector2.ZERO
var _stage_size_start_zoom: float = 1.0
var _stage_size_factor: float = 1.0
const SCALE_COORDINATE_LIMIT: float = 1000000.0
const WHOLE_PROP_HANDLE_HIT_RADIUS: float = 16.0
@@ -202,6 +211,8 @@ func set_edit_mode(mode: int) -> void:
var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM
if m == _edit_mode:
return
if _stage_size_preview_active:
cancel_stage_size_preview()
_edit_mode = m
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
@@ -280,6 +291,188 @@ func set_show_stickman_guide(enabled: bool) -> void:
func get_show_stickman_guide() -> bool:
return _show_stickman_guide
func begin_stage_size_preview(anchors: Dictionary) -> bool:
if _stage_size_preview_active or _whole_prop_resize_enabled or _edit_mode != EditMode.TRANSFORM or not has_shape_geometry():
return false
var bounds: Rect2 = get_visible_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
if not _valid_rect(bounds) or not is_finite(extent) or extent <= 0.0:
return false
_stage_size_start_shapes = _duplicate_shapes(shapes)
_stage_size_preview_shapes = _duplicate_shapes(shapes)
_stage_size_start_anchors = anchors.duplicate(true)
_stage_size_preview_anchors = anchors.duplicate(true)
_stage_size_start_pan = _pan_offset
_stage_size_start_zoom = _zoom
_stage_size_factor = 1.0
_stage_size_preview_active = true
queue_redraw()
return true
func preview_stage_size(factor: float) -> bool:
if not _stage_size_preview_active or not is_finite(factor) or factor <= 0.0:
return false
var candidate: Array[Dictionary] = _scaled_stage_size_shapes(_stage_size_start_shapes, factor)
if candidate.is_empty() or not _shapes_are_representable(candidate):
return false
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_stage_size_start_anchors, factor)
if not bool(anchor_result.get("ok", false)):
return false
var candidate_bounds: Rect2 = _visible_bounds_for_shapes(candidate)
if not _valid_rect(candidate_bounds):
return false
_stage_size_factor = factor
_stage_size_preview_shapes = candidate
_stage_size_preview_anchors = (anchor_result.get("anchors", {}) as Dictionary).duplicate(true)
queue_redraw()
return true
func commit_stage_size_preview() -> Dictionary:
if not _stage_size_preview_active:
return {"changed": false, "anchors": get_anchor_data()}
var changed: bool = not is_equal_approx(_stage_size_factor, 1.0)
var committed_anchors: Dictionary = _stage_size_preview_anchors.duplicate(true)
if changed:
shapes = _duplicate_shapes(_stage_size_preview_shapes)
set_anchor_data(committed_anchors)
_end_stage_size_preview()
if changed:
shape_changed.emit(get_shape_data())
return {"changed": changed, "anchors": committed_anchors}
func cancel_stage_size_preview() -> void:
if _stage_size_preview_active:
_end_stage_size_preview()
func is_stage_size_preview_active() -> bool:
return _stage_size_preview_active
func get_visible_shapes_bounds() -> Rect2:
return _visible_bounds_for_shapes(_stage_size_preview_shapes if _stage_size_preview_active else shapes)
func get_stage_size_comparison_bounds() -> Rect2:
var artwork_bounds: Rect2 = _visible_bounds_for_shapes(_stage_size_preview_shapes if _stage_size_preview_active else shapes)
if not _valid_rect(artwork_bounds):
return Rect2()
return artwork_bounds.merge(_stage_size_guide_bounds(artwork_bounds))
func fit_stage_size_view(bounds: Rect2, padding_px: float, force: bool = true) -> void:
if not _stage_size_preview_active or not _valid_rect(bounds):
return
if not force and _stage_size_view_contains(bounds, padding_px):
return
var available: Vector2 = size - Vector2.ONE * maxf(0.0, padding_px) * 2.0
if available.x <= 0.0 or available.y <= 0.0:
return
var fit_x: float = available.x / maxf(bounds.size.x, 1.0)
var fit_y: float = available.y / maxf(bounds.size.y, 1.0)
_zoom = clampf(minf(fit_x, fit_y), 0.02, MAX_ZOOM)
_pan_offset = size * 0.5 - bounds.get_center() * _zoom
queue_redraw()
func _end_stage_size_preview() -> void:
_stage_size_preview_active = false
_stage_size_start_shapes.clear()
_stage_size_preview_shapes.clear()
_stage_size_start_anchors.clear()
_stage_size_preview_anchors.clear()
_stage_size_factor = 1.0
_pan_offset = _stage_size_start_pan
_zoom = _stage_size_start_zoom
queue_redraw()
func _stage_size_view_contains(bounds: Rect2, padding_px: float) -> bool:
var padding: float = maxf(0.0, padding_px)
var available: Vector2 = size - Vector2.ONE * padding * 2.0
if available.x <= 0.0 or available.y <= 0.0:
return false
var world_origin: Vector2 = -_pan_offset / maxf(_zoom, 0.001)
var world_size: Vector2 = size / maxf(_zoom, 0.001)
var world_padding: float = padding / maxf(_zoom, 0.001)
var visible_bounds: Rect2 = Rect2(world_origin + Vector2.ONE * world_padding, world_size - Vector2.ONE * world_padding * 2.0)
return visible_bounds.encloses(bounds)
func _visible_bounds_for_shapes(source: Array[Dictionary]) -> Rect2:
var result: Rect2 = Rect2()
var found: bool = false
for shape: Dictionary in source:
if not _shape_has_geometry(shape):
continue
var bounds: Rect2 = _shape_bbox(shape).grow(maxf(0.0, float(shape.get("outline_width", 0.0))) * 0.5)
if not found:
result = bounds
found = true
else:
result = result.merge(bounds)
return result if found else Rect2()
func _stage_size_guide_bounds(artwork_bounds: Rect2) -> Rect2:
var head_center: Vector2 = WholeStickmanPreviewScript.GUIDE_JOINTS["Head"]
var ankle: Vector2 = WholeStickmanPreviewScript.GUIDE_JOINTS["LeftAnkle"]
var height: float = ankle.y - (head_center.y - WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS)
var guide_half_width: float = 170.0
var figure_center_x: float
if artwork_bounds.size.x <= guide_half_width * 2.0 + 40.0:
figure_center_x = artwork_bounds.position.x + minf(artwork_bounds.size.x * 0.25, 40.0)
else:
figure_center_x = artwork_bounds.position.x - guide_half_width - 32.0
var floor_y: float = artwork_bounds.end.y
return Rect2(Vector2(figure_center_x - guide_half_width, floor_y - height), Vector2(guide_half_width * 2.0, height))
func _shapes_are_representable(source: Array[Dictionary]) -> bool:
if source.is_empty():
return false
for shape: Dictionary in source:
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for point: Vector2 in points:
if not is_finite(point.x) or not is_finite(point.y) or absf(point.x) > SCALE_COORDINATE_LIMIT or absf(point.y) > SCALE_COORDINATE_LIMIT:
return false
var radius: float = float(shape.get("radius", 0.0))
var outline_width: float = float(shape.get("outline_width", 0.0))
if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT:
return false
if not is_finite(outline_width) or outline_width < 0.0 or outline_width > SCALE_COORDINATE_LIMIT:
return false
return true
func _scaled_stage_size_shapes(source: Array[Dictionary], factor: float) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for source_shape: Dictionary in source:
var shape: Dictionary = source_shape.duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for index: int in points.size():
points[index] *= factor
shape["points"] = points
shape["radius"] = float(shape.get("radius", 0.0)) * factor
shape["outline_width"] = float(shape.get("outline_width", 0.0)) * factor
result.append(shape)
return result
func _duplicate_shapes(source: Array[Dictionary]) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for shape: Dictionary in source:
result.append(shape.duplicate(true))
return result
func _valid_rect(rect: Rect2) -> bool:
return rect.position.is_finite() and rect.size.is_finite() and rect.size.x >= 0.0 and rect.size.y >= 0.0 and (rect.size.x > 0.0 or rect.size.y > 0.0)
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
@@ -378,6 +571,7 @@ func set_origin_marker_color(color: Color) -> void:
func set_shape_data(data: Variant) -> void:
## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y}
## format) or a single Dictionary.
cancel_stage_size_preview()
set_whole_prop_resize_enabled(false)
shapes.clear()
if data is Array:
@@ -403,6 +597,7 @@ func get_shape_data() -> Array[Dictionary]:
func clear() -> void:
## Remove all shapes without emitting shape_changed.
cancel_stage_size_preview()
set_whole_prop_resize_enabled(false)
shapes.clear()
_selected_shape_idx = -1
@@ -788,7 +983,6 @@ func global_to_world(global_pos: Vector2) -> Vector2:
func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
_draw_stickman_guide()
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
@@ -797,10 +991,14 @@ func _on_canvas_draw() -> void:
if font != null:
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
var drawn_shapes: Array[Dictionary] = _whole_prop_preview_shapes if _whole_prop_dragging else shapes
var drawn_shapes: Array[Dictionary] = _stage_size_preview_shapes if _stage_size_preview_active else (_whole_prop_preview_shapes if _whole_prop_dragging else shapes)
for i: int in drawn_shapes.size():
var selected: bool = not _whole_prop_resize_enabled and i == _selected_shape_idx
var selected: bool = not _stage_size_preview_active and not _whole_prop_resize_enabled and i == _selected_shape_idx
_draw_shape(drawn_shapes[i], selected, _dragging_vertex if selected else -1)
if _stage_size_preview_active:
_draw_stage_size_guide(_visible_bounds_for_shapes(drawn_shapes))
else:
_draw_stickman_guide()
_draw_origin_marker()
if _whole_prop_resize_enabled:
@@ -836,6 +1034,54 @@ func _draw_stickman_guide() -> void:
draw_line(joints[first] as Vector2, joints[second] as Vector2, color, width)
if joints.has("Head"):
draw_arc(joints["Head"] as Vector2, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, color, width)
var ankle: Vector2 = joints["LeftAnkle"]
_draw_reference_figure(0.0, ankle.y, ankle.x - 250.0, ankle.x + 250.0)
func _draw_stage_size_guide(artwork_bounds: Rect2) -> void:
var guide_bounds: Rect2 = _stage_size_guide_bounds(artwork_bounds)
var floor_y: float = artwork_bounds.end.y
_draw_reference_figure(
guide_bounds.get_center().x,
floor_y,
minf(artwork_bounds.position.x, guide_bounds.position.x - 28.0),
maxf(artwork_bounds.end.x, guide_bounds.end.x + 28.0)
)
func _draw_reference_figure(center_x: float, floor_y: float, floor_start: float, floor_end: float) -> void:
var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS
var ankle_y: float = (joints["LeftAnkle"] as Vector2).y
var offset: Vector2 = Vector2(center_x, floor_y - ankle_y)
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES:
var first: String = str(bone[0])
var second: String = str(bone[1])
if not joints.has(first) or not joints.has(second):
continue
var color: Color = WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL
if first in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS or second in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_COLOR_LEFT
elif first in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS or second in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_COLOR_RIGHT
var start: Vector2 = (joints[first] as Vector2) + offset
var finish: Vector2 = (joints[second] as Vector2) + offset
draw_line(start, finish, Color("#111821D9"), 8.0 * inv_zoom, true)
draw_line(start, finish, color, 3.0 * inv_zoom, true)
var head: Vector2 = (joints["Head"] as Vector2) + offset
draw_arc(head, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, Color("#111821E8"), 8.0 * inv_zoom, true)
draw_arc(head, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL, 3.0 * inv_zoom, true)
for joint_name: String in joints:
var point: Vector2 = (joints[joint_name] as Vector2) + offset
var color: Color = WholeStickmanPreviewScript.GUIDE_COLOR_CENTRAL
if joint_name in WholeStickmanPreviewScript.GUIDE_LEFT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_JOINT_COLOR_LEFT
elif joint_name in WholeStickmanPreviewScript.GUIDE_RIGHT_JOINTS:
color = WholeStickmanPreviewScript.GUIDE_JOINT_COLOR_RIGHT
draw_circle(point, 6.5 * inv_zoom, Color("#111821E8"))
draw_circle(point, 3.2 * inv_zoom, color)
draw_line(Vector2(floor_start, floor_y), Vector2(floor_end, floor_y), Color("#111821E8"), 8.0 * inv_zoom, true)
draw_line(Vector2(floor_start, floor_y), Vector2(floor_end, floor_y), Color("#F8FAFC"), 2.5 * inv_zoom, true)
func _draw_whole_prop_resize_gizmos() -> void:
var bounds: Rect2 = _whole_preview_bounds() if _whole_prop_dragging else get_shapes_bounds()
@@ -852,7 +1098,7 @@ func _draw_whole_prop_resize_gizmos() -> void:
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rotation_dot, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, SELECTION_HALO_COLOR)
draw_circle(rotation_dot, ROTATION_CIRCLE_RADIUS / _zoom, SELECTION_CORE_COLOR)
var anchors: Dictionary = _whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data()
var anchors: Dictionary = _stage_size_preview_anchors if _stage_size_preview_active else (_whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data())
for id: String in anchors:
if id == "origin":
continue
+364 -31
View File
@@ -36,7 +36,13 @@ const SETTINGS_PATH: String = "user://prop_editor_settings.json"
const RECENT_MAX: int = 5
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
const NORMALIZED_MAX_EXTENT: float = 96.0
const STAGE_REFERENCE_HEIGHT: float = 939.5 # WholeStickmanPreview head top (-463.5 - 100) to ankle line (376).
const STAGE_SIZE_STEP: float = 1.1
const ART_TAG_IDS: Array[String] = ["nature", "buildings", "walls", "roads", "objects"]
const ART_TAG_LABELS: Array[String] = ["Nature", "Buildings", "Walls", "Roads", "Objects"]
signal artwork_saved(path: String)
signal return_requested
const MENU_NEW := 0
const MENU_LOAD := 2
@@ -113,6 +119,7 @@ var _current_path: String = ""
var _dirty: bool = false
var _clipboard: Dictionary = {}
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _tags: Array[String] = []
var _selected_anchor_id: String = ""
var _thumb: PropThumbnail
var _thumb_cache: ThumbnailCache
@@ -138,15 +145,26 @@ var _params_box: VBoxContainer
var _param_spins: Dictionary = {}
var _add_button: Button
var _updating_inspector: bool = false
var _tag_checks: Dictionary = {}
var _tag_section: VBoxContainer
var _stage_authoring: bool = false
var _stage_return_pending: bool = false
var _stage_place_button: Button
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
var _grid_spin: SpinBox
var _snap_check: CheckBox
var _center_prop_button: Button
var _normalize_size_button: Button
var _size_for_stage_button: Button
var _stage_size_controls: FlowContainer
var _stage_size_status: Label
var _show_stickman_guide_check: CheckBox
var _show_stickman_guide: bool = false
var _whole_prop_resize_button: Button
var _stage_size_preview_active: bool = false
var _stage_size_preview_factor: float = 1.0
var _stage_size_entry_extent: float = 0.0
var _pending_save_after_stage_size: int = -1
# Edit-mode toolbar (Round 2/3)
var _mode_group: ButtonGroup
@@ -192,7 +210,8 @@ var _recent_props: Array[String] = []
# ---------------------------------------------------------------------------
func _ready() -> void:
get_tree().auto_accept_quit = false
if not _stage_authoring:
get_tree().auto_accept_quit = false
_theme = PropThemeScript.new()
_theme.load_from(PropThemeScript.DEFAULT_PATH)
_thumb_cache = ThumbnailCacheScript.new()
@@ -225,6 +244,7 @@ func _ready() -> void:
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
_canvas.stage_size_cancel_requested.connect(func() -> void: _finish_stage_size_preview(false))
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
@@ -250,12 +270,24 @@ func _ready() -> void:
_canvas.call_deferred("reset_view")
call_deferred("_initialize_inspector_split")
_refresh_center_prop_button()
_check_autosave_recovery()
if _stage_authoring:
if _canvas.get_edit_mode() == PropCanvasScript.EditMode.ATTACH:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
_do_new()
_build_stage_authoring_controls()
_apply_authoring_context()
else:
_check_autosave_recovery()
func configure_stage_authoring() -> void:
_stage_authoring = true
process_mode = Node.PROCESS_MODE_ALWAYS
func _process(delta: float) -> void:
_update_cursor_coords()
if _autosave_remaining > 0.0:
if not _stage_authoring and _autosave_remaining > 0.0:
_autosave_remaining -= delta
if _autosave_remaining <= 0.0:
_autosave_remaining = 0.0
@@ -266,6 +298,17 @@ func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
if _stage_size_preview_active:
_finish_stage_size_preview(false)
get_viewport().set_input_as_handled()
return
if _stage_authoring:
var stage_focus: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if stage_focus is LineEdit or stage_focus is TextEdit:
return
_request_stage_return()
get_viewport().set_input_as_handled()
return
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_whole_prop_resize_dragging():
@@ -349,6 +392,11 @@ func _on_attach_mode_pressed() -> void:
func _on_canvas_mode_changed(mode: int) -> void:
if _stage_size_preview_active:
_cancel_stage_size_preview()
if _stage_authoring and mode == PropCanvasScript.EditMode.ATTACH:
_canvas.set_edit_mode(PropCanvasScript.EditMode.TRANSFORM)
return
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
@@ -373,11 +421,11 @@ func _sync_mode_buttons(mode: int) -> void:
func _sync_mode_controls(mode: int) -> void:
if _whole_prop_resize_button != null:
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _normalize_size_button != null:
_normalize_size_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM and not _stage_size_preview_active
if _size_for_stage_button != null:
_size_for_stage_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM and not _stage_size_preview_active
if _attach_tools != null:
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _add_section != null:
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
@@ -385,9 +433,9 @@ func _sync_mode_controls(mode: int) -> void:
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _anchor_section != null:
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH and not _stage_authoring
if _attach_preview != null:
_attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed)
if _attach_preview.has_method("set_document"):
@@ -666,11 +714,12 @@ func _build_size_tools() -> void:
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
_size_tools.add_child(_whole_prop_resize_button)
_normalize_size_button = Button.new()
_normalize_size_button.text = "📏 Normalize Size"
_normalize_size_button.custom_minimum_size.y = float(_theme.button_min_height())
_normalize_size_button.pressed.connect(_on_normalize_size_pressed)
_size_tools.add_child(_normalize_size_button)
_size_for_stage_button = Button.new()
_size_for_stage_button.text = "📏 Size for Stage"
_size_for_stage_button.tooltip_text = "Compare this drawing with a standing stickman."
_size_for_stage_button.custom_minimum_size = Vector2(148.0, maxf(44.0, float(_theme.button_min_height())))
_size_for_stage_button.pressed.connect(_on_size_for_stage_pressed)
_size_tools.add_child(_size_for_stage_button)
_center_prop_button = Button.new()
_center_prop_button.text = "🎯 Center Prop"
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
@@ -683,9 +732,57 @@ func _build_size_tools() -> void:
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
_size_tools.add_child(_show_stickman_guide_check)
_stage_size_controls = FlowContainer.new()
_stage_size_controls.name = "StageSizeControls"
_stage_size_controls.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_stage_size_controls.add_theme_constant_override("h_separation", 6)
_stage_size_controls.add_theme_constant_override("v_separation", 6)
_stage_size_controls.hide()
_size_tools.add_child(_stage_size_controls)
for preset: Dictionary in [
{"label": "Small", "factor": 0.35},
{"label": "Room", "factor": 1.0},
{"label": "Big", "factor": 2.0},
]:
var preset_button: Button = _make_stage_size_button(str(preset["label"]))
var preset_factor: float = float(preset["factor"])
preset_button.pressed.connect(func() -> void: _choose_stage_size(preset_factor))
_stage_size_controls.add_child(preset_button)
var smaller_button: Button = _make_stage_size_button("Make smaller")
smaller_button.pressed.connect(func() -> void: _adjust_stage_size(1.0 / STAGE_SIZE_STEP))
_stage_size_controls.add_child(smaller_button)
var bigger_button: Button = _make_stage_size_button("Make bigger")
bigger_button.pressed.connect(func() -> void: _adjust_stage_size(STAGE_SIZE_STEP))
_stage_size_controls.add_child(bigger_button)
var keep_button: Button = _make_stage_size_button("Keep size", true)
keep_button.pressed.connect(func() -> void: _finish_stage_size_preview(true))
_stage_size_controls.add_child(keep_button)
var cancel_button: Button = _make_stage_size_button("✕ Cancel")
cancel_button.pressed.connect(func() -> void: _finish_stage_size_preview(false))
_stage_size_controls.add_child(cancel_button)
_stage_size_status = Label.new()
_stage_size_status.name = "StageSizeStatus"
# This label lives in the size-tools FlowContainer. With an auto-wrapping
# label and no minimum width, FlowContainer can collapse it to a sliver and
# grow the toolbar to thousands of pixels tall when the preview opens.
_stage_size_status.custom_minimum_size.x = 360.0
_stage_size_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_stage_size_status.hide()
_size_tools.add_child(_stage_size_status)
_refresh_scale_buttons()
func _make_stage_size_button(label: String, prominent: bool = false) -> Button:
var button: Button = Button.new()
button.text = label
button.custom_minimum_size.y = maxf(44.0, float(_theme.button_min_height()))
button.focus_mode = Control.FOCUS_ALL
button.theme = _ui_theme
if prominent:
button.add_theme_color_override("font_color", _theme.accent_color())
return button
func _on_top_grid_changed(value: float) -> void:
_canvas.set_grid_size(int(value))
@@ -693,12 +790,110 @@ func _on_top_grid_changed(value: float) -> void:
func _on_top_snap_toggled(pressed: bool) -> void:
_canvas.set_snap_enabled(pressed)
func _on_normalize_size_pressed() -> void:
var bounds: Rect2 = _canvas.get_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
if is_finite(extent) and extent > 0.0:
_scale_document(NORMALIZED_MAX_EXTENT / extent)
func _on_size_for_stage_pressed() -> void:
if _stage_size_preview_active or _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM:
return
_set_whole_prop_resize_enabled(false)
var bounds: Rect2 = _canvas.get_visible_shapes_bounds()
_stage_size_entry_extent = maxf(bounds.size.x, bounds.size.y)
if not is_finite(_stage_size_entry_extent) or _stage_size_entry_extent <= 0.0:
return
if not _canvas.begin_stage_size_preview(_anchors):
return
_stage_size_preview_factor = 1.0
_stage_size_preview_active = true
_stage_size_status.text = "Compare the drawing with the guide, then keep it or cancel."
_update_stage_size_controls_visibility()
# Hiding the inspector and mode bar changes the canvas allocation. Wait for
# that layout pass before centering/fitting, otherwise the guide can be
# pushed outside the newly-sized viewport until a preset is clicked.
await get_tree().process_frame
if not _stage_size_preview_active:
return
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, true)
func _choose_stage_size(relative_height: float) -> void:
if not _stage_size_preview_active or not is_finite(relative_height) or relative_height <= 0.0:
return
var factor: float = STAGE_REFERENCE_HEIGHT * relative_height / _stage_size_entry_extent
if _canvas.preview_stage_size(factor):
_stage_size_preview_factor = factor
_stage_size_status.text = ""
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, true)
else:
_stage_size_status.text = "That size is too large to display. Try a smaller choice."
func _adjust_stage_size(multiplier: float) -> void:
if not _stage_size_preview_active:
return
var factor: float = _stage_size_preview_factor * multiplier
if _canvas.preview_stage_size(factor):
_stage_size_preview_factor = factor
_stage_size_status.text = ""
_canvas.fit_stage_size_view(_canvas.get_stage_size_comparison_bounds(), 48.0, false)
else:
_stage_size_status.text = "That size is outside the drawing area. Try a smaller change."
func _finish_stage_size_preview(keep: bool) -> void:
if not _stage_size_preview_active:
return
var pending_save: int = _pending_save_after_stage_size if keep else -1
_pending_save_after_stage_size = -1
if keep:
var result: Dictionary = _canvas.commit_stage_size_preview()
_anchors = (result.get("anchors", _anchors) as Dictionary).duplicate(true)
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(_selected_anchor_id)
_refresh_anchor_inspector()
_sync_attach_preview()
else:
_canvas.cancel_stage_size_preview()
_stage_size_preview_active = false
_stage_size_preview_factor = 1.0
_stage_size_entry_extent = 0.0
_stage_size_status.text = ""
_update_stage_size_controls_visibility()
_refresh_scale_buttons()
if pending_save >= 0:
_continue_save_after_stage_size(pending_save)
func _cancel_stage_size_preview() -> void:
if _stage_size_preview_active:
_pending_save_after_stage_size = -1
_finish_stage_size_preview(false)
func _request_save_after_stage_size(menu_id: int) -> void:
_pending_save_after_stage_size = menu_id
_stage_size_status.text = "Keep size before saving, or cancel to leave the drawing unchanged."
func _continue_save_after_stage_size(menu_id: int) -> void:
if menu_id == MENU_SAVE:
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
_begin_save_request(_current_path, true)
elif menu_id == MENU_SAVE_AS:
_save_dialog.popup_centered()
func _update_stage_size_controls_visibility() -> void:
if _stage_size_controls == null:
return
_stage_size_controls.visible = _stage_size_preview_active
_stage_size_status.visible = _stage_size_preview_active
_size_for_stage_button.visible = not _stage_size_preview_active and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM
_whole_prop_resize_button.visible = not _stage_size_preview_active and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM
_center_prop_button.visible = not _stage_size_preview_active
_show_stickman_guide_check.visible = not _stage_size_preview_active and not _stage_authoring
_mode_bar.visible = not _stage_size_preview_active
_inspector_column.visible = not _stage_size_preview_active
func _on_stickman_guide_toggled(enabled: bool) -> void:
_show_stickman_guide = enabled
@@ -755,6 +950,9 @@ func _notification(what: int) -> void:
func _request_close() -> void:
if _stage_authoring:
_request_stage_return()
return
if not _dirty:
get_tree().quit()
return
@@ -765,6 +963,7 @@ func _request_close() -> void:
func _request_new() -> void:
_cancel_stage_size_preview()
if not _dirty:
_do_new()
return
@@ -774,6 +973,22 @@ func _request_new() -> void:
_exit_dialog.popup_centered()
func _request_stage_return() -> void:
if not _stage_authoring:
return
_cancel_stage_size_preview()
if not _dirty:
if not _current_path.is_empty():
artwork_saved.emit(_current_path)
return_requested.emit()
return
_stage_return_pending = true
_pending_new = false
_pending_quit = false
_exit_dialog.dialog_text = "Save your artwork before returning? Saving places it on the Stage."
_exit_dialog.popup_centered()
func _on_exit_save() -> void:
_exit_dialog.hide()
if _current_path.is_empty():
@@ -785,8 +1000,12 @@ func _on_exit_save() -> void:
func _on_exit_custom_action(action: StringName) -> void:
if action == &"discard":
_exit_dialog.hide()
_clear_autosave()
if _pending_quit:
if not _stage_authoring:
_clear_autosave()
if _stage_return_pending:
_stage_return_pending = false
return_requested.emit()
elif _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
@@ -797,6 +1016,7 @@ func _on_exit_custom_action(action: StringName) -> void:
func _on_exit_cancel() -> void:
_pending_quit = false
_pending_new = false
_stage_return_pending = false
_cancel_pending_save_request()
@@ -805,6 +1025,8 @@ func _do_new() -> void:
_set_whole_prop_resize_enabled(false)
_prop_name_edit.text = ""
_material_preset = PropBlockScript.MaterialPreset.NONE
_tags.clear()
_refresh_tag_controls()
_preset_option.selected = _material_preset
_update_physics_labels()
_canvas.clear()
@@ -820,21 +1042,29 @@ func _do_new() -> void:
_refresh_anchor_inspector()
_refresh_center_prop_button()
_update_save_status()
_refresh_tag_controls()
func _on_menu_id_pressed(id: int) -> void:
match id:
MENU_NEW:
_request_new()
MENU_LOAD:
_load_dialog.popup_centered()
MENU_SAVE:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE)
return
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
_begin_save_request(_current_path, true)
MENU_SAVE_AS:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE_AS)
return
_save_dialog.popup_centered()
MENU_LOAD:
_cancel_stage_size_preview()
_load_dialog.popup_centered()
MENU_RESET_VIEWS:
_canvas.reset_view()
@@ -983,7 +1213,95 @@ func _build_inspector() -> void:
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
_build_tag_inspector()
_build_anchor_inspector()
_refresh_tag_controls()
func _build_stage_authoring_controls() -> void:
var top_bar: HBoxContainer = get_node_or_null(NodePath("TopBar")) as HBoxContainer
if top_bar == null:
return
var name_label: Label = top_bar.get_node_or_null(NodePath("NameLabel")) as Label
if name_label != null:
name_label.text = "Artwork name:"
_prop_name_edit.placeholder_text = "Name this artwork"
_stage_place_button = Button.new()
_stage_place_button.text = "✨ Save & Place as Scenery"
_stage_place_button.tooltip_text = "Save this drawing and put a picture of it on your Stage."
_stage_place_button.custom_minimum_size.y = float(_theme.button_min_height())
_stage_place_button.size_flags_horizontal = Control.SIZE_SHRINK_END
_stage_place_button.theme = _ui_theme
_stage_place_button.pressed.connect(_request_stage_return)
top_bar.add_child(_stage_place_button)
func _apply_authoring_context() -> void:
if not _stage_authoring:
return
if _material_section != null:
_material_section.hide()
if _anchor_section != null:
_anchor_section.hide()
if _attach_tools != null:
_attach_tools.hide()
if _attach_button != null:
_attach_button.hide()
if _show_stickman_guide_check != null:
_show_stickman_guide_check.hide()
if _tag_section != null:
_tag_section.show()
_sync_mode_buttons(_canvas.get_edit_mode())
_sync_mode_controls(_canvas.get_edit_mode())
_save_dialog.title = "Save Scenery Artwork"
func _build_tag_inspector() -> void:
_tag_section = VBoxContainer.new()
_tag_section.add_theme_constant_override("separation", 4)
_inspector_list.add_child(_tag_section)
var title := Label.new()
title.text = "Artwork Tags"
title.add_theme_font_size_override("font_size", _theme.font_header())
_tag_section.add_child(title)
var hint := Label.new()
hint.text = "Choose a few so this art is easy to find."
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tag_section.add_child(hint)
var rows := FlowContainer.new()
rows.add_theme_constant_override("h_separation", 6)
rows.add_theme_constant_override("v_separation", 4)
_tag_section.add_child(rows)
for index: int in ART_TAG_IDS.size():
var tag_id: String = ART_TAG_IDS[index]
var check := CheckBox.new()
check.text = ART_TAG_LABELS[index]
check.custom_minimum_size.y = float(_theme.button_min_height())
check.toggled.connect(_on_art_tag_toggled.bind(tag_id))
rows.add_child(check)
_tag_checks[tag_id] = check
func _on_art_tag_toggled(enabled: bool, tag_id: String) -> void:
if _updating_inspector:
return
if enabled and not _tags.has(tag_id):
_tags.append(tag_id)
elif not enabled:
_tags.erase(tag_id)
_tags = PropFileScript.normalize_tags(_tags)
_mark_dirty()
func _refresh_tag_controls() -> void:
if _tag_checks.is_empty():
return
_updating_inspector = true
for tag_id: String in ART_TAG_IDS:
var check: CheckBox = _tag_checks.get(tag_id) as CheckBox
if check != null:
check.set_pressed_no_signal(_tags.has(tag_id))
_updating_inspector = false
func _build_anchor_inspector() -> void:
_anchor_section = VBoxContainer.new()
@@ -1313,11 +1631,12 @@ func _refresh_center_prop_button() -> void:
_refresh_scale_buttons()
func _refresh_scale_buttons() -> void:
if _normalize_size_button == null:
if _size_for_stage_button == null:
return
var bounds: Rect2 = _canvas.get_shapes_bounds()
var bounds: Rect2 = _canvas.get_visible_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
_normalize_size_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
_size_for_stage_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
_size_for_stage_button.tooltip_text = "Add visible artwork to use Size for Stage." if _size_for_stage_button.disabled else "Compare this drawing with a standing stickman."
if _whole_prop_resize_button != null:
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
@@ -1422,6 +1741,8 @@ func _write_autosave() -> void:
func _clear_autosave() -> void:
_autosave_remaining = 0.0
if _stage_authoring:
return
if FileAccess.file_exists(AUTOSAVE_PATH):
DirAccess.remove_absolute(AUTOSAVE_PATH)
@@ -1469,7 +1790,7 @@ func _check_autosave_recovery() -> void:
# ---------------------------------------------------------------------------
func _build_json_data() -> Dictionary:
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors)
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors, _tags)
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
@@ -1479,6 +1800,8 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
if model.is_empty():
return "The file could not be read: unsupported version or malformed data."
_prop_name_edit.text = str(model.get("prop_name", ""))
_tags = model.get("tags", []) as Array[String]
_refresh_tag_controls()
_material_preset = int(model.get("material_preset", PropBlockScript.MaterialPreset.NONE))
if _preset_option != null:
_preset_option.selected = _material_preset
@@ -1500,6 +1823,9 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
if _stage_size_preview_active:
_request_save_after_stage_size(MENU_SAVE)
return false
path = ContentPathsScript.canonicalize(path)
var ok: bool = PropFileScript.save(path, _build_json_data())
if not ok:
@@ -1507,7 +1833,8 @@ func _save_to(path: String, with_thumbnail: bool = true) -> bool:
return false
_current_path = path
_dirty = false
_clear_autosave()
if not _stage_authoring:
_clear_autosave()
_push_recent(path)
_update_save_status()
if with_thumbnail:
@@ -1554,21 +1881,27 @@ func _execute_pending_save(center_first: bool) -> void:
elif _pending_new:
_pending_new = false
_do_new()
elif _stage_return_pending:
_stage_return_pending = false
artwork_saved.emit(_current_path)
return_requested.emit()
func _cancel_pending_save_request() -> void:
_pending_save_path = ""
_pending_save_with_thumbnail = true
_pending_quit = false
_pending_new = false
_stage_return_pending = false
func _on_save_file_selected(path: String) -> void:
path = ContentPathsScript.canonicalize(path)
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
_begin_save_request(path, not (_pending_quit or _pending_new))
_begin_save_request(path, not (_pending_quit or _pending_new or _stage_return_pending))
func _load_from(path: String) -> void:
_cancel_stage_size_preview()
path = ContentPathsScript.canonicalize(path)
var raw: Dictionary = PropFileScript.load(path)
if raw.is_empty():
+19 -4
View File
@@ -2,7 +2,7 @@ class_name PropFile
extends RefCounted
## PropFile - Static schema + converters for .prop vector prop assets.
##
## The file stores the editor's full multi-shape stack (version "1.0") plus
## The file stores the editor's full multi-shape stack (version "1.1") plus
## enough provenance for later sandbox placement. flatten_to_payload() converts
## the stack into the single-shape payload PropUtils.spawn_prop() already
## consumes: frontmost closed polygon -> else circle -> else first shape.
@@ -12,10 +12,11 @@ const TerrainUtilsScript := preload("res://scripts/terrain/terrain_utils.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
const FILE_VERSION: String = "1.0"
const FILE_VERSION: String = "1.1"
const FILE_EXT: String = ".prop"
const DEFAULT_DIR: String = "res://content/props"
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1"]
const KNOWN_TAGS: Array[String] = ["nature", "buildings", "walls", "roads", "objects"]
# index == PropBlock.MaterialPreset
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
@@ -63,7 +64,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
return result
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}, tags: Array[String] = []) -> Dictionary:
var serialized: Array = []
for shape: Dictionary in shapes:
serialized.append(_serialize_shape(shape))
@@ -74,6 +75,7 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
"material_preset": preset_to_string(material_preset),
"shapes": serialized,
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
"tags": normalize_tags(tags),
"metadata": {"created_at": now, "modified_at": now},
}
@@ -98,6 +100,7 @@ static func from_file_dict(data: Variant) -> Dictionary:
return {}
var preset: int = preset_from_string(str(dict.get("material_preset", "none")))
var tags: Array[String] = normalize_tags(dict.get("tags", []))
var shapes: Array[Dictionary] = []
var raw_shapes: Variant = dict.get("shapes", [])
if raw_shapes is Array:
@@ -109,9 +112,21 @@ static func from_file_dict(data: Variant) -> Dictionary:
"material_preset": preset,
"shapes": shapes,
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
"tags": tags,
}
static func normalize_tags(value: Variant) -> Array[String]:
var tags: Array[String] = []
if not value is Array:
return tags
for raw_tag: Variant in value as Array:
var tag: String = str(raw_tag).strip_edges().to_lower()
if KNOWN_TAGS.has(tag) and not tags.has(tag):
tags.append(tag)
return tags
## Deterministic conversion of the multi-shape stack into a single
## PropUtils.spawn_prop payload. Rule: frontmost closed polygon -> else circle
## -> else first shape. Returns {} when shapes is empty.
File diff suppressed because it is too large Load Diff
+65 -5
View File
@@ -6,6 +6,8 @@ signal asset_selected_requested(entry: Dictionary)
signal asset_load_failed(kind: String, path: String)
signal selector_cancel_requested
signal refresh_requested
signal make_artwork_requested
signal edit_scenery_requested
const ASSET_SELECTOR_SCENE := preload("res://scenes/stage/asset_selector.tscn")
const STICKMAN_LIBRARY_SCRIPT := preload("res://scripts/characters/stickman/stickman_library.gd")
@@ -55,6 +57,8 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
selector.popup_hide.connect(_on_selector_cancelled)
selector.browse_requested.connect(show_browse_dialog)
selector.refresh_requested.connect(func() -> void: refresh_requested.emit())
selector.make_artwork_requested.connect(func() -> void: make_artwork_requested.emit())
selector.edit_scenery_requested.connect(func() -> void: edit_scenery_requested.emit())
ui_layer.add_child(selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
selector.apply_font(ui_font, emoji_font)
@@ -73,24 +77,64 @@ func build_ui(ui_layer: CanvasLayer, ui_font: Font, emoji_font: Font, sandbox_th
func scan_entries(kind: String) -> Array[Dictionary]:
if kind == "stickman":
return stickman_library.scan()
if kind == "scenery":
return PROP_LIBRARY_SCRIPT.scan_visual_entries()
if kind == "starters":
return _starter_scene_entries()
return PROP_LIBRARY_SCRIPT.scan()
func _starter_scene_entries() -> Array[Dictionary]:
return [
{
"id": "starter_blank_scene",
"name": "Blank Scene",
"description": "Start fresh with an open stage and a clear sky.",
"category": "Make your own",
"path": "res://content/stages/starters/blank.scene",
"icon": "✨",
},
{
"id": "starter_town_street",
"name": "Town Street",
"description": "Cloudy sky, shops, trees, sidewalk, and road.",
"category": "Ready to explore",
"path": "res://content/stages/starters/town_street.scene",
"icon": "🏘️",
},
{
"id": "starter_living_room",
"name": "Living Room",
"description": "A cozy room with a window, rug, and furniture.",
"category": "Ready to explore",
"path": "res://content/stages/starters/living_room.scene",
"icon": "🛋️",
},
]
func make_browse_entry(kind: String, path: String) -> Dictionary:
if kind == "prop":
return PROP_LIBRARY_SCRIPT.make_custom_entry(path)
if kind == "scenery":
return PROP_LIBRARY_SCRIPT.make_visual_entry(path)
return stickman_library.make_entry(path)
func open_selector(kind: String, current_stickman_path: String) -> Dictionary:
func open_selector(kind: String, current_stickman_path: String, current_sky_id: StringName = &"") -> Dictionary:
set_selector_kind(kind)
var entries: Array[Dictionary] = scan_entries(kind)
if kind == "starters":
open(kind, entries)
return {"entries": entries, "selected_stickman_path": current_stickman_path, "auto_selection": {}}
if kind == "scenery":
entries.append_array(_scenery_background_entries())
var selected_stickman_path: String = current_stickman_path
if kind == "stickman" and not entries.is_empty() and stickman_library.find_by_path(current_stickman_path).is_empty():
selected_stickman_path = String(entries[0].get("path", ""))
var auto_selection: Dictionary = entries[0] if kind == "stickman" and entries.size() == 1 else {}
if auto_selection.is_empty():
open(kind, entries)
open(kind, entries, current_sky_id)
return {
"entries": entries,
"selected_stickman_path": selected_stickman_path,
@@ -98,17 +142,29 @@ func open_selector(kind: String, current_stickman_path: String) -> Dictionary:
}
func _scenery_background_entries() -> Array[Dictionary]:
return [
{"id": "sky_clear_day", "name": "Clear Day", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "☀️"},
{"id": "sky_cloudy_day", "name": "Cloudy Day", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "☁️"},
{"id": "sky_sunset", "name": "Sunset", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌅"},
{"id": "sky_starry_night", "name": "Starry Night", "category": "Sky", "gallery_tab": "backgrounds", "background_kind": "sky", "icon": "🌙"},
{"id": "finish_wall_paint", "name": "Wall panel · Paint", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_paint", "icon": "🟩"},
{"id": "finish_wall_brick", "name": "Wall panel · Brick", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_brick", "icon": "🧱"},
{"id": "finish_wall_wood", "name": "Wall panel · Wood", "category": "Wall finish", "gallery_tab": "backgrounds", "background_kind": "wall", "finish_id": "finish_wall_wood", "icon": "🪵"}
]
func set_selector_kind(kind: String) -> void:
selector_kind = kind
func open(kind: String, entries: Array[Dictionary]) -> void:
func open(kind: String, entries: Array[Dictionary], current_sky_id: StringName = &"") -> void:
selector_kind = kind
selector_open = true
if selector_dim != null:
selector_dim.visible = true
if selector != null:
selector.open(kind, entries)
selector.open(kind, entries, String(current_sky_id))
load_or_enqueue_thumbnails(kind, entries)
@@ -126,7 +182,7 @@ func close() -> void:
func show_browse_dialog() -> void:
if browse_dialog == null:
return
if selector_kind == "prop":
if selector_kind == "prop" or selector_kind == "scenery":
browse_dialog.title = "Open .prop"
browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
@@ -160,6 +216,8 @@ func load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> voi
thumbnail_generation += 1
var generation: int = thumbnail_generation
for entry: Dictionary in entries:
if kind == "starters":
continue
if kind == "stickman":
var path: String = String(entry.get("path", ""))
var key: String = thumbnail_cache.stickman_key(path)
@@ -171,6 +229,8 @@ func load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> voi
else:
thumbnail_queue.append({"kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation})
else:
if kind == "starters" or String(entry.get("gallery_tab", "")) == "backgrounds":
continue
var id: String = String(entry.get("id", ""))
var png: String = thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else thumbnail_cache.prop_png(id)
var texture: Texture2D = thumbnail_cache.get_texture(png)
+7 -2
View File
@@ -395,10 +395,15 @@ func _build_top_bar() -> void:
chrome.add_child(left_group)
var right_group := HBoxContainer.new()
right_group.name = "RightControls"
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
# This row spans the top bar so it can align controls at the right edge;
# ignore empty space so it cannot steal clicks from File or Mode buttons.
right_group.mouse_filter = Control.MOUSE_FILTER_IGNORE
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
right_group.offset_left = 8.0
right_group.offset_right = -8.0
right_group.offset_top = 8.0
right_group.offset_bottom = TOP_BAR_HEIGHT - 8.0
right_group.alignment = BoxContainer.ALIGNMENT_END
right_group.add_theme_constant_override("separation", 8)
chrome.add_child(right_group)
_build_file_menu(left_group)
@@ -22,6 +22,7 @@ var wait_spin: SpinBox
var scene_open_dialog: FileDialog
var scene_save_dialog: FileDialog
var speech_save_gate_dialog: ConfirmationDialog
var group_preview_save_dialog: ConfirmationDialog
var rename_scene_dialog: ConfirmationDialog
var scene_name_edit: LineEdit
var missing_assets_dialog: AcceptDialog
@@ -103,6 +104,16 @@ func build_file_dialogs(parent: Node) -> void:
parent.add_child(speech_save_gate_dialog)
apply_dialog_theme(speech_save_gate_dialog)
group_preview_save_dialog = ConfirmationDialog.new()
group_preview_save_dialog.name = "GroupPreviewSaveDialog"
group_preview_save_dialog.title = "Finish scenery preview?"
group_preview_save_dialog.dialog_text = "Keep the scenery changes before saving, or cancel this save."
group_preview_save_dialog.ok_button_text = "Keep changes"
group_preview_save_dialog.cancel_button_text = "Cancel save"
group_preview_save_dialog.min_size = theme_dialog_size("confirm_size", Vector2i(620, 300))
parent.add_child(group_preview_save_dialog)
apply_dialog_theme(group_preview_save_dialog)
rename_scene_dialog = ConfirmationDialog.new()
rename_scene_dialog.name = "RenameSceneDialog"
rename_scene_dialog.title = "Rename Scene"
+61 -4
View File
@@ -10,6 +10,9 @@ const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_plac
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const STAGE_DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
func collect(
@@ -21,7 +24,7 @@ func collect(
) -> Dictionary:
var objects: Array[Dictionary] = []
var runtime_to_file: Dictionary = {}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0}
var counters: Dictionary = {"terrain": 0, "prop": 0, "stickman": 0, "area": 0, "wall_panel": 0, "scenery_artwork": 0}
for node: Node in world.get_children():
if not node is Node2D:
continue
@@ -33,8 +36,12 @@ func collect(
var file_id: String = "%s_%d" % [type, int(counters[type])]
runtime_to_file[stage_node.get_instance_id()] = file_id
var authored_state: Dictionary = authored.get(stage_node.get_instance_id(), {})
var position: Vector2 = authored_state.get("position", stage_node.global_position)
var rotation_degrees: float = rad_to_deg(float(authored_state.get("rotation", stage_node.global_rotation)))
# Scenery artwork is editor-authored and never receives runtime physics
# movement. Its live transform is therefore the committed document state;
# a cached pre-PLAY authored snapshot would erase later group edits.
var position: Vector2 = stage_node.global_position if type == "scenery_artwork" else authored_state.get("position", stage_node.global_position)
var rotation: float = stage_node.global_rotation if type == "scenery_artwork" else float(authored_state.get("rotation", stage_node.global_rotation))
var rotation_degrees: float = rad_to_deg(rotation)
var entry: Dictionary = {
"file_id": file_id,
"type": type,
@@ -44,6 +51,25 @@ func collect(
if type == "terrain":
entry["asset_id"] = String(stage_node.get("spawn_id"))
entry["scale"] = STAGE_SCENE_IO.vector_to_data(stage_node.scale)
if stage_node is TerrainBlock and String(stage_node.get("spawn_id")) == "ground":
var ground_finish: StringName = (stage_node as TerrainBlock).finish_id
entry["finish_id"] = String(ground_finish) if not ground_finish.is_empty() else "finish_ground_legacy"
elif stage_node.get_script() == STAGE_WALL_PANEL:
var wall: StageWallPanel = stage_node as StageWallPanel
entry["size"] = STAGE_SCENE_IO.vector_to_data(wall.panel_size)
entry["finish_id"] = String(wall.finish_id)
entry["color"] = "#" + wall.tint.to_html(true)
if not wall.motif_origin_offset.is_zero_approx():
entry["motif_origin_offset"] = STAGE_SCENE_IO.vector_to_data(wall.motif_origin_offset)
elif stage_node.get_script() == STAGE_SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = stage_node as StageSceneryArtwork
entry["asset_id"] = CONTENT_PATHS.canonicalize(artwork.asset_path)
entry["display_name"] = artwork.display_name
entry["depth"] = String(artwork.depth)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(artwork.scale)
if artwork.has_known_bounds:
var known_bounds: Rect2 = artwork.get_saved_footprint()
entry["last_known_local_bounds"] = {"position": STAGE_SCENE_IO.vector_to_data(known_bounds.position), "size": STAGE_SCENE_IO.vector_to_data(known_bounds.size)}
elif type == "prop" and stage_node.get_script() == MISSING_PROP_PLACEHOLDER:
var original_entry: Dictionary = stage_node.get("original_entry") as Dictionary
entry = original_entry.duplicate(true)
@@ -95,6 +121,7 @@ func collect(
return {
"version": STAGE_SCENE_IO.FILE_VERSION,
"scene_name": scene_name,
"sky_id": String(document_metadata.get("sky_id", "sky_neutral")),
"camera": {"position": STAGE_SCENE_IO.vector_to_data(camera_position), "zoom": camera_zoom},
"objects": objects,
"event_rules": rules_result["rules"],
@@ -131,6 +158,29 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
match type:
"terrain":
node = spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), position)
if node is TerrainBlock and String(entry.get("asset_id", "")) == "ground":
(node as TerrainBlock).set_finish(StringName(String(entry.get("finish_id", "finish_ground_legacy"))))
"wall_panel":
var wall := STAGE_WALL_PANEL.new() as StageWallPanel
wall.position = position
wall.configure(STAGE_SCENE_IO.data_to_vector(entry.get("size"), Vector2(320.0, 400.0)), StringName(String(entry.get("finish_id", "finish_wall_paint"))), STAGE_SCENE_IO.color_from_data(entry.get("color", "#5A9D70"), Color("#5A9D70")))
if entry.has("motif_origin_offset") and STAGE_SCENE_IO.is_vector_data(entry.get("motif_origin_offset")):
wall.motif_origin_offset = STAGE_SCENE_IO.data_to_vector(entry.get("motif_origin_offset"), Vector2.ZERO)
world.add_child(wall)
node = wall
"scenery_artwork":
var artwork := STAGE_SCENERY_ARTWORK.new() as StageSceneryArtwork
var raw_bounds: Variant = entry.get("last_known_local_bounds", {})
var footprint := Rect2()
if raw_bounds is Dictionary and STAGE_SCENE_IO._valid_rect_data(raw_bounds):
var bounds_data: Dictionary = raw_bounds
footprint = Rect2(STAGE_SCENE_IO.data_to_vector(bounds_data.get("position"), Vector2.ZERO), STAGE_SCENE_IO.data_to_vector(bounds_data.get("size"), Vector2(64.0, 64.0)))
artwork.position = position
artwork.rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
artwork.scale = STAGE_SCENE_IO.data_to_vector(entry.get("scale"), Vector2.ONE)
artwork.configure_missing(String(entry.get("asset_id", "")), String(entry.get("display_name", "Scenery")), footprint, StringName(String(entry.get("depth", "near"))))
world.add_child(artwork)
node = artwork
"prop":
node = spawner.spawn_prop_asset(String(entry.get("asset_id", "")), position)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
@@ -158,7 +208,7 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
warnings.append("Unknown object asset")
continue
node.global_rotation = deg_to_rad(float(entry.get("rotation", 0.0)))
if type != "stickman":
if type not in ["stickman", "wall_panel", "scenery_artwork"]:
if entry.has("scale") and not STAGE_SCENE_IO.is_vector_data(entry["scale"]):
warnings.append("Malformed object scale")
world.remove_child(node)
@@ -216,6 +266,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
loaded_nodes.append(node)
if node is TerrainBlock:
STAGE_DRAW_ORDER.apply_terrain(node as TerrainBlock)
file_to_runtime[file_id] = node.get_instance_id()
var decoded_rules: Dictionary = STAGE_SCENE_IO.decode_rules(data.get("event_rules", []) as Array, file_to_runtime, unavailable)
return {
@@ -226,10 +278,15 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
"missing_stickmen": missing_stickmen,
"missing_paths": missing_paths,
"decoded_rules": decoded_rules,
"sky_id": String(data.get("sky_id", "sky_neutral")),
}
func _object_type(node: Node2D) -> String:
if node.get_script() == STAGE_WALL_PANEL:
return "wall_panel"
if node.get_script() == STAGE_SCENERY_ARTWORK:
return "scenery_artwork"
if node is TerrainBlock:
return "terrain"
if node is PropBlock or node.get_script() == MISSING_PROP_PLACEHOLDER:
@@ -0,0 +1,801 @@
class_name StageSceneryController
extends Node
## Owns inert Scenery placement drafts and snapshot-based group transforms.
signal placement_committed(node: Node2D)
signal document_change_requested
signal preview_state_changed(active: bool)
signal single_gesture_changed(active: bool)
const WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
const SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
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 WALL_ERASER_CURSOR_SIZE: Vector2 = Vector2(120.0, 120.0)
const MIN_ART_SCALE: float = 0.08
const MAX_ART_SCALE: float = 12.0
var _world: Node2D
var _ghost_holder: Node2D
var _camera: Camera2D
var _ghost: Node2D
var _active_tool: StringName = &""
var _asset_path: String = ""
var _depth: StringName = &"near"
var _wall_finish: StringName = &"finish_wall_paint"
var _wall_tint: Color = Color("#5A9D70")
var _wall_anchor: Vector2 = Vector2.INF
var _wall_drag_start: Vector2 = Vector2.INF
var _wall_dragging: bool = false
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 _snap_enabled: bool = false
var _grid_size: float = 15.0
var _group_kind: StringName = &""
var _group_snapshots: Array[Dictionary] = []
var _group_bounds: Rect2 = Rect2()
var _group_pending: bool = false
var _single_kind: StringName = &""
var _single_snapshot: Dictionary = {}
var _single_active: bool = false
var _single_release_guard: bool = false
func configure(world: Node2D, ghost_holder: Node2D, camera: Camera2D) -> void:
_world = world
_ghost_holder = ghost_holder
_camera = camera
func set_grid_snap(enabled: bool, grid_size: float) -> void:
_snap_enabled = enabled
_grid_size = maxf(1.0, grid_size)
func set_depth(depth: StringName) -> void:
if depth not in [&"far", &"near", &"floor"]:
return
_depth = depth
if _ghost != null and is_instance_valid(_ghost) and _ghost.get_script() == SCENERY_ARTWORK:
(_ghost as StageSceneryArtwork).set_depth(_depth)
func is_active() -> bool:
return not _active_tool.is_empty()
func is_group_preview_pending() -> bool:
return _group_pending
func is_single_transform_active() -> bool:
return _single_active
func get_single_transform_node() -> Node2D:
return _single_snapshot.get("node") as Node2D if _single_active else null
func get_single_transform_kind() -> StringName:
return _single_kind if _single_active else &""
func consume_cancelled_single_release() -> bool:
if not _single_release_guard:
return false
_single_release_guard = false
return true
func get_active_tool() -> StringName:
return _active_tool
func get_wall_areas_erased() -> int:
return _wall_areas_erased
func get_group_bounds() -> Rect2:
return _group_bounds
func get_active_artwork_name() -> String:
if _ghost != null and is_instance_valid(_ghost) and _ghost.get_script() == SCENERY_ARTWORK:
return String((_ghost as StageSceneryArtwork).display_name)
return _asset_path.get_file().get_basename()
func handle_key(event: InputEventKey) -> bool:
if is_active() and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
exit_tool()
return true
if _group_pending and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
cancel_group()
return true
return false
func begin_artwork(path: String, depth: StringName = &"near") -> bool:
if _world == null or _ghost_holder == null:
return false
exit_tool()
_active_tool = &"artwork"
_asset_path = path
_depth = depth if depth in [&"far", &"near", &"floor"] else &"near"
var visual := SCENERY_ARTWORK.new() as StageSceneryArtwork
if not visual.load_visual(path):
visual.queue_free()
_active_tool = &""
return false
var asset_data: Dictionary = PROP_FILE.from_file_dict(PROP_FILE.load(path))
visual.display_name = String(asset_data.get("prop_name", path.get_file().get_basename()))
visual.set_depth(_depth)
visual.modulate = Color(1.0, 1.0, 1.0, 0.52)
_ghost_holder.add_child(visual)
_ghost = visual
return true
func begin_wall(finish: StringName, initial_world_position: Vector2, tint: Color = Color("#5A9D70")) -> void:
if _world == null or _ghost_holder == null:
return
exit_tool()
_active_tool = &"wall"
_wall_finish = finish
_wall_tint = tint
_wall_anchor = Vector2.INF
_wall_dragging = false
_wall_anchor_active = false
var panel := WALL_PANEL.new() as StageWallPanel
panel.configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
panel.modulate = Color(1.0, 1.0, 1.0, 0.45)
_ghost_holder.add_child(panel)
_ghost = panel
# The palette may close without generating a Stage mouse-motion event.
# Position the preview from the current cursor immediately so the visible
# ghost and the first press use the same starting corner.
update_pointer(initial_world_position)
func begin_wall_eraser(initial_world_position: Vector2) -> bool:
if _world == null or _ghost_holder == null:
return false
exit_tool()
_active_tool = &"wall_eraser"
_wall_erase_start = Vector2.INF
_wall_erase_dragging = false
_wall_areas_erased = 0
_wall_erase_pointer = initial_world_position
var preview := Node2D.new()
preview.name = "WallEraserPreview"
var fill := Polygon2D.new()
fill.name = "EraseArea"
fill.color = Color("#FF5B62", 0.2)
preview.add_child(fill)
var outline := Line2D.new()
outline.name = "EraseOutline"
outline.width = 3.0
outline.default_color = Color("#FF7278")
preview.add_child(outline)
var cross_a := Line2D.new()
cross_a.name = "EraseCrossA"
cross_a.width = 2.0
cross_a.default_color = Color("#FF7278", 0.9)
preview.add_child(cross_a)
var cross_b := Line2D.new()
cross_b.name = "EraseCrossB"
cross_b.width = 2.0
cross_b.default_color = Color("#FF7278", 0.9)
preview.add_child(cross_b)
_ghost_holder.add_child(preview)
_ghost = preview
update_pointer(initial_world_position)
return true
func update_pointer(world_position: Vector2) -> void:
if not is_active() or _ghost == null or not is_instance_valid(_ghost):
return
var snapped_position: Vector2 = _snap(world_position)
if _active_tool == &"wall" and _wall_dragging:
var size: Vector2 = Vector2(absf(snapped_position.x - _wall_drag_start.x), absf(snapped_position.y - _wall_drag_start.y))
size.x = maxf(MIN_PANEL_SIZE, size.x)
size.y = maxf(MIN_PANEL_SIZE, size.y)
(_ghost as StageWallPanel).configure(size, _wall_finish, _wall_tint)
var top_left: Vector2 = Vector2(minf(_wall_drag_start.x, snapped_position.x), minf(_wall_drag_start.y, snapped_position.y))
_ghost.global_position = top_left + size * 0.5
elif _active_tool == &"wall":
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":
_wall_erase_pointer = world_position
if _wall_erase_dragging:
_set_wall_eraser_preview_rect(_drag_rect(_wall_erase_start, _snap(world_position)))
else:
var cursor_origin: Vector2 = _snap(world_position) if _snap_enabled else world_position
_set_wall_eraser_preview_rect(Rect2(cursor_origin, WALL_ERASER_CURSOR_SIZE))
else:
_ghost.global_position = _snap_visual_center(_ghost, world_position)
func handle_mouse_button(event: InputEventMouseButton, world_position: Vector2, over_ui: bool) -> bool:
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()
return true
var completes_drag: bool = not event.pressed and event.button_index == MOUSE_BUTTON_LEFT and (
(_active_tool == &"wall" and _wall_dragging)
or (_active_tool == &"wall_eraser" and _wall_erase_dragging)
)
if (over_ui and not completes_drag) or event.button_index != MOUSE_BUTTON_LEFT:
return false
if _active_tool == &"artwork":
if event.pressed:
_commit_artwork(world_position)
return true
if _active_tool == &"wall":
if event.pressed:
# Refresh from the press coordinates in case no mouse-motion event was
# delivered after the palette closed. The visible ghost corner is the
# drawing anchor, so preview and committed geometry cannot jump apart.
update_pointer(world_position)
_wall_anchor = _world_bounds(_ghost).position
_wall_anchor_active = true
_wall_drag_start = _wall_anchor
_wall_dragging = true
update_pointer(world_position)
return true
if _wall_dragging:
var end: Vector2 = _snap(world_position)
var size: Vector2 = Vector2(absf(end.x - _wall_drag_start.x), absf(end.y - _wall_drag_start.y))
_wall_dragging = false
if size.x < MIN_PANEL_SIZE or size.y < MIN_PANEL_SIZE:
_wall_anchor_active = false
_wall_anchor = Vector2.INF
_restore_wall_ghost(world_position)
return true
_commit_wall(_wall_drag_start, end)
_wall_anchor = Vector2.INF
_wall_anchor_active = false
_restore_wall_ghost(world_position)
return true
if _active_tool == &"wall_eraser":
if event.pressed:
_wall_erase_start = _snap(world_position) if _snap_enabled else world_position
_wall_erase_dragging = true
update_pointer(world_position)
return true
if _wall_erase_dragging:
var erase_end: Vector2 = _snap(world_position) if _snap_enabled else world_position
var erase_rect: Rect2 = _drag_rect(_wall_erase_start, erase_end)
_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)
if changed > 0:
_wall_areas_erased += 1
document_change_requested.emit()
update_pointer(world_position)
return true
return false
func handle_mouse_motion(world_position: Vector2) -> bool:
if not is_active():
return false
update_pointer(world_position)
return true
func cancel_active_gesture() -> void:
_wall_dragging = false
_wall_anchor = Vector2.INF
_wall_drag_start = Vector2.INF
_wall_anchor_active = false
_wall_erase_dragging = false
_wall_erase_start = Vector2.INF
if _active_tool == &"wall" and _ghost != null and is_instance_valid(_ghost):
(_ghost as StageWallPanel).configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
elif _active_tool == &"wall_eraser" and _ghost != null and is_instance_valid(_ghost):
_set_wall_eraser_preview_rect(Rect2(_wall_erase_pointer, WALL_ERASER_CURSOR_SIZE))
func exit_tool() -> void:
if _single_active:
cancel_single_transform()
cancel_active_gesture()
_free_ghost()
_active_tool = &""
_asset_path = ""
_group_kind = &""
_group_snapshots.clear()
_group_pending = false
preview_state_changed.emit(false)
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
var snapshots: Array[Dictionary] = []
var union_bounds: Rect2 = Rect2()
var has_bounds: bool = false
for member: Node2D in members:
if member == null or not is_instance_valid(member):
return false
var snapshot: Dictionary = {"node": member, "position": member.global_position, "rotation": member.global_rotation, "scale": member.scale}
var bounds: Rect2
if member.get_script() == WALL_PANEL:
var wall: StageWallPanel = member as StageWallPanel
snapshot["panel_size"] = wall.panel_size
bounds = member.global_transform * wall.get_local_bounds()
elif member.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = member as StageSceneryArtwork
bounds = STAGE_SELECTION.get_world_aabb(member)
if artwork.missing and not artwork.has_known_bounds:
if kind == &"resize":
return false
else:
return false
if kind == &"resize" and (bounds.size.x <= 0.0 or bounds.size.y <= 0.0):
return false
snapshots.append(snapshot)
union_bounds = bounds if not has_bounds else union_bounds.merge(bounds)
has_bounds = true
_group_snapshots = snapshots
_group_kind = kind
_group_bounds = union_bounds
_group_pending = true
preview_state_changed.emit(true)
return true
func preview_group(delta: Vector2, factor: float) -> bool:
if not _group_pending or factor <= 0.0 or not delta.is_finite():
return false
# Validate the whole preview before moving any member, so a rejected factor
# never leaves a partially transformed group.
for snapshot: Dictionary in _group_snapshots:
var member: Node2D = snapshot["node"] as Node2D
if member == null or not is_instance_valid(member):
return false
if _group_kind == &"resize":
if member.get_script() == WALL_PANEL:
var next_size: Vector2 = (snapshot["panel_size"] as Vector2) * factor
if next_size.x < MIN_PANEL_SIZE or next_size.y < MIN_PANEL_SIZE:
return false
elif member.get_script() == SCENERY_ARTWORK:
var next_scale: Vector2 = (snapshot["scale"] as Vector2) * factor
if next_scale.x < 0.08 or next_scale.x > 12.0 or next_scale.y < 0.08 or next_scale.y > 12.0:
return false
var anchor: Vector2 = _group_bounds.position
for snapshot: Dictionary in _group_snapshots:
var member: Node2D = snapshot["node"] as Node2D
if member == null or not is_instance_valid(member):
return false
if _group_kind == &"move":
member.global_position = (snapshot["position"] as Vector2) + delta
continue
var art_scale: Vector2 = snapshot["scale"] as Vector2
var new_scale: Vector2 = art_scale * factor
member.global_position = anchor + ((snapshot["position"] as Vector2) - anchor) * factor
member.global_rotation = float(snapshot["rotation"])
if member.get_script() == WALL_PANEL:
var wall: StageWallPanel = member as StageWallPanel
wall.configure((snapshot["panel_size"] as Vector2) * factor, wall.finish_id, wall.tint)
else:
member.scale = new_scale
return true
func commit_group() -> void:
if not _group_pending:
return
_group_pending = false
_group_snapshots.clear()
_group_kind = &""
preview_state_changed.emit(false)
document_change_requested.emit()
func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dragged_corner: Vector2 = Vector2.INF) -> bool:
if _single_active or _group_pending or node == null or not is_instance_valid(node) or kind not in [&"move", &"resize"]:
return false
if node.get_script() != WALL_PANEL and node.get_script() != SCENERY_ARTWORK:
return false
if not pointer.is_finite():
return false
var bounds: Rect2 = _world_bounds(node)
if kind == &"resize" and (not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.x <= 0.0 or bounds.size.y <= 0.0):
return false
if node.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = node as StageSceneryArtwork
if artwork.missing and not artwork.has_known_bounds and kind == &"resize":
return false
var corner_index: int = _nearest_corner_index(bounds, dragged_corner if dragged_corner.is_finite() else pointer)
var corners: Array[Vector2] = _corners(bounds)
var snapshot: Dictionary = {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
"scale": node.scale,
"bounds": bounds,
"pointer": pointer,
"corner_index": corner_index,
"dragged_corner": corners[corner_index],
"pivot": _opposite_corner(bounds, corner_index),
}
if node.get_script() == WALL_PANEL:
snapshot["panel_size"] = (node as StageWallPanel).panel_size
else:
var initial_scale: Vector2 = node.scale
if not initial_scale.is_finite() or initial_scale.x <= 0.0 or initial_scale.y <= 0.0:
return false
_single_snapshot = snapshot
_single_kind = kind
_single_active = true
_single_release_guard = false
single_gesture_changed.emit(true)
return true
func preview_single_transform(pointer: Vector2) -> bool:
if not _single_active or not pointer.is_finite():
return false
var node: Node2D = _single_snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return false
if _single_kind == &"move":
var start_bounds: Rect2 = _single_snapshot["bounds"]
var raw_delta: Vector2 = pointer - (_single_snapshot["pointer"] as Vector2)
var delta: Vector2 = raw_delta
if _snap_enabled:
delta = _snap(start_bounds.position + raw_delta) - start_bounds.position
var next_position: Vector2 = (_single_snapshot["position"] as Vector2) + delta
if not next_position.is_finite():
return false
node.global_position = next_position
return true
var dragged_corner: Vector2 = _single_snapshot["dragged_corner"]
var pivot: Vector2 = _single_snapshot["pivot"]
var diagonal: Vector2 = dragged_corner - pivot
var denominator: float = diagonal.length_squared()
if denominator <= 0.0001:
return false
var raw_factor: float = 1.0 + (pointer - (_single_snapshot["pointer"] as Vector2)).dot(diagonal) / denominator
return _apply_single_scale(raw_factor, pivot)
func commit_single_transform() -> bool:
if not _single_active:
return false
var changed: bool = _single_transform_changed()
_single_active = false
_single_kind = &""
_single_snapshot.clear()
single_gesture_changed.emit(false)
if changed:
document_change_requested.emit()
return changed
func cancel_single_transform() -> void:
if not _single_active:
return
_restore_single_snapshot()
_single_active = false
_single_kind = &""
_single_snapshot.clear()
_single_release_guard = true
single_gesture_changed.emit(false)
func step_single_size(node: Node2D, factor: float) -> bool:
if node == null or not is_instance_valid(node) or not is_finite(factor) or factor <= 0.0 or _single_active or _group_pending:
return false
if node.get_script() != WALL_PANEL and node.get_script() != SCENERY_ARTWORK:
return false
var bounds: Rect2 = _world_bounds(node)
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
return false
if node.get_script() == SCENERY_ARTWORK:
var artwork: StageSceneryArtwork = node as StageSceneryArtwork
if artwork.missing and not artwork.has_known_bounds:
return false
var snapshot: Dictionary = {
"node": node,
"position": node.global_position,
"rotation": node.global_rotation,
"scale": node.scale,
"bounds": bounds,
"pivot": bounds.position,
"panel_size": (node as StageWallPanel).panel_size if node.get_script() == WALL_PANEL else Vector2.ZERO,
}
var pivot: Vector2 = bounds.position
if not _can_apply_single_scale(snapshot, factor):
return false
_apply_single_scale_to_snapshot(snapshot, factor, pivot)
if _snapshot_matches_current(snapshot):
return false
document_change_requested.emit()
return true
func cancel_group() -> void:
if not _group_pending:
return
for snapshot: Dictionary in _group_snapshots:
var member: Node2D = snapshot["node"] as Node2D
if member == null or not is_instance_valid(member):
continue
member.global_position = snapshot["position"] as Vector2
member.global_rotation = float(snapshot["rotation"])
member.scale = snapshot["scale"] as Vector2
if member.get_script() == WALL_PANEL and snapshot.has("panel_size"):
var wall: StageWallPanel = member as StageWallPanel
wall.configure(snapshot["panel_size"] as Vector2, wall.finish_id, wall.tint)
_group_pending = false
_group_snapshots.clear()
_group_kind = &""
preview_state_changed.emit(false)
static func bounds_for(members: Array[Node2D]) -> Rect2:
var result: Rect2 = Rect2()
var has_rect: bool = false
for member: Node2D in members:
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(member) if member.get_script() == SCENERY_ARTWORK else (member.global_transform * (member as StageWallPanel).get_local_bounds())
result = rect if not has_rect else result.merge(rect)
has_rect = true
return result
func _commit_artwork(position: Vector2) -> void:
var artwork := SCENERY_ARTWORK.new() as StageSceneryArtwork
if not artwork.load_visual(_asset_path):
artwork.free()
return
artwork.display_name = String(PROP_FILE.from_file_dict(PROP_FILE.load(_asset_path)).get("prop_name", artwork.display_name))
artwork.set_depth(_depth)
artwork.global_position = _snap_visual_center(artwork, position)
_world.add_child(artwork)
placement_committed.emit(artwork)
document_change_requested.emit()
func _commit_wall(first: Vector2, second: Vector2) -> void:
var size: Vector2 = Vector2(maxf(MIN_PANEL_SIZE, absf(second.x - first.x)), maxf(MIN_PANEL_SIZE, absf(second.y - first.y)))
var panel := WALL_PANEL.new() as StageWallPanel
panel.configure(size, _wall_finish, _wall_tint)
panel.global_position = (first + second) * 0.5
_world.add_child(panel)
placement_committed.emit(panel)
document_change_requested.emit()
func _restore_wall_ghost(world_position: Vector2) -> void:
if _ghost == null or not is_instance_valid(_ghost) or _ghost.get_script() != WALL_PANEL:
return
(_ghost as StageWallPanel).configure(DEFAULT_PANEL_SIZE, _wall_finish, _wall_tint)
update_pointer(world_position)
func _erase_wall_region(world_rect: Rect2) -> int:
if _world == null or world_rect.size.x <= 0.0 or world_rect.size.y <= 0.0:
return 0
var changed_panels: int = 0
for child: Node in _world.get_children().duplicate():
if child.get_script() != WALL_PANEL:
continue
var wall: StageWallPanel = child as StageWallPanel
var panel_rect: Rect2 = wall.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])
if corner_index == 0:
local_erase = Rect2(local_point, Vector2.ZERO)
else:
local_erase = local_erase.expand(local_point)
if not panel_rect.intersects(local_erase):
continue
var cut: Rect2 = panel_rect.intersection(local_erase)
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()
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()
for fragment_rect: Rect2 in fragments:
var 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)
parent.add_child(fragment)
fragment.motif_origin_offset = source_motif_origin - fragment.global_position
parent.move_child(fragment, sibling_index)
sibling_index += 1
parent.remove_child(wall)
wall.queue_free()
changed_panels += 1
return changed_panels
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)))
_add_wall_fragment(fragments, Rect2(Vector2(panel_rect.position.x, cut.end.y), Vector2(panel_rect.size.x, panel_rect.end.y - cut.end.y)))
_add_wall_fragment(fragments, Rect2(Vector2(panel_rect.position.x, cut.position.y), Vector2(cut.position.x - panel_rect.position.x, cut.size.y)))
_add_wall_fragment(fragments, Rect2(Vector2(cut.end.x, cut.position.y), Vector2(panel_rect.end.x - cut.end.x, cut.size.y)))
return fragments
func _add_wall_fragment(fragments: Array[Rect2], rect: Rect2) -> void:
if rect.size.x > 1.0 and rect.size.y > 1.0:
fragments.append(rect)
func _drag_rect(first: Vector2, second: Vector2) -> Rect2:
var top_left: Vector2 = Vector2(minf(first.x, second.x), minf(first.y, second.y))
var size: Vector2 = Vector2(absf(first.x - second.x), absf(first.y - second.y))
return Rect2(top_left, size)
func _set_wall_eraser_preview_rect(rect: Rect2) -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
var preview: Node2D = _ghost
var normalized: Rect2 = rect.abs()
var size: Vector2 = Vector2(maxf(1.0, normalized.size.x), maxf(1.0, normalized.size.y))
var half: Vector2 = size * 0.5
preview.global_position = normalized.position + half
var points := PackedVector2Array([Vector2(-half.x, -half.y), Vector2(half.x, -half.y), Vector2(half.x, half.y), Vector2(-half.x, half.y)])
var fill: Polygon2D = preview.get_node("EraseArea") as Polygon2D
fill.polygon = points
var outline: Line2D = preview.get_node("EraseOutline") as Line2D
outline.points = PackedVector2Array([points[0], points[1], points[2], points[3], points[0]])
var inset: Vector2 = Vector2(minf(8.0, half.x * 0.35), minf(8.0, half.y * 0.35))
var cross_a: Line2D = preview.get_node("EraseCrossA") as Line2D
cross_a.points = PackedVector2Array([Vector2(-half.x + inset.x, -half.y + inset.y), Vector2(half.x - inset.x, half.y - inset.y)])
var cross_b: Line2D = preview.get_node("EraseCrossB") as Line2D
cross_b.points = PackedVector2Array([Vector2(half.x - inset.x, -half.y + inset.y), Vector2(-half.x + inset.x, half.y - inset.y)])
func _snap(position: Vector2) -> Vector2:
if not _snap_enabled:
return position
return Vector2(roundf(position.x / _grid_size) * _grid_size, roundf(position.y / _grid_size) * _grid_size)
func _apply_single_scale(factor: float, pivot: Vector2) -> bool:
if not _can_apply_single_scale(_single_snapshot, factor):
return false
_apply_single_scale_to_snapshot(_single_snapshot, factor, pivot)
return true
func _can_apply_single_scale(snapshot: Dictionary, factor: float) -> bool:
if not is_finite(factor) or factor <= 0.0:
return false
var node: Node2D = snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return false
var next_position: Vector2 = (snapshot["pivot"] as Vector2) + ((snapshot["position"] as Vector2) - (snapshot["pivot"] as Vector2)) * factor
if not next_position.is_finite():
return false
if node.get_script() == WALL_PANEL:
var next_size: Vector2 = (snapshot["panel_size"] as Vector2) * factor
return next_size.is_finite() and next_size.x >= MIN_PANEL_SIZE and next_size.y >= MIN_PANEL_SIZE
var start_scale: Vector2 = snapshot["scale"] as Vector2
var next_scale: Vector2 = start_scale * factor
return start_scale.is_finite() and start_scale.x > 0.0 and start_scale.y > 0.0 and next_scale.is_finite() and next_scale.x >= MIN_ART_SCALE and next_scale.x <= MAX_ART_SCALE and next_scale.y >= MIN_ART_SCALE and next_scale.y <= MAX_ART_SCALE
func _apply_single_scale_to_snapshot(snapshot: Dictionary, factor: float, pivot: Vector2) -> void:
var node: Node2D = snapshot["node"] as Node2D
node.global_position = pivot + ((snapshot["position"] as Vector2) - pivot) * factor
node.global_rotation = float(snapshot["rotation"])
if node.get_script() == WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall.scale = snapshot["scale"] as Vector2
wall.configure((snapshot["panel_size"] as Vector2) * factor, wall.finish_id, wall.tint)
else:
node.scale = (snapshot["scale"] as Vector2) * factor
func _restore_single_snapshot() -> void:
var node: Node2D = _single_snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return
node.global_position = _single_snapshot["position"] as Vector2
node.global_rotation = float(_single_snapshot["rotation"])
node.scale = _single_snapshot["scale"] as Vector2
if node.get_script() == WALL_PANEL:
var wall: StageWallPanel = node as StageWallPanel
wall.configure(_single_snapshot["panel_size"] as Vector2, wall.finish_id, wall.tint)
func _single_transform_changed() -> bool:
return not _snapshot_matches_current(_single_snapshot)
func _snapshot_matches_current(snapshot: Dictionary) -> bool:
var node: Node2D = snapshot.get("node") as Node2D
if node == null or not is_instance_valid(node):
return false
if not node.global_position.is_equal_approx(snapshot["position"] as Vector2):
return false
if not is_equal_approx(node.global_rotation, float(snapshot["rotation"])) or not node.scale.is_equal_approx(snapshot["scale"] as Vector2):
return false
if node.get_script() == WALL_PANEL:
return (node as StageWallPanel).panel_size.is_equal_approx(snapshot["panel_size"] as Vector2)
return true
func _world_bounds(node: Node2D) -> Rect2:
if node.get_script() == WALL_PANEL:
return node.global_transform * (node as StageWallPanel).get_local_bounds()
if node.get_script() == SCENERY_ARTWORK:
return (node as StageSceneryArtwork).get_visual_world_bounds()
return STAGE_SELECTION.get_world_aabb(node)
func _corners(bounds: Rect2) -> Array[Vector2]:
return [bounds.position, Vector2(bounds.end.x, bounds.position.y), bounds.end, Vector2(bounds.position.x, bounds.end.y)]
func _nearest_corner_index(bounds: Rect2, point: Vector2) -> int:
var corners: Array[Vector2] = _corners(bounds)
var nearest_index: int = 0
var nearest_distance: float = INF
for index: int in corners.size():
var distance: float = corners[index].distance_squared_to(point)
if distance < nearest_distance:
nearest_distance = distance
nearest_index = index
return nearest_index
func _opposite_corner(bounds: Rect2, corner_index: int) -> Vector2:
match corner_index:
0:
return bounds.end
1:
return Vector2(bounds.position.x, bounds.end.y)
2:
return bounds.position
_:
return Vector2(bounds.end.x, bounds.position.y)
func _snap_visual_center(node: Node2D, desired_center: Vector2) -> Vector2:
var target_center: Vector2 = _snap(desired_center) if _snap_enabled and _grid_size > 0.0 else desired_center
if node.get_script() != SCENERY_ARTWORK:
return target_center
var bounds: Rect2 = _world_bounds(node)
return node.global_position + target_center - bounds.get_center()
func _move_node_visible_top_left(node: Node2D, desired_top_left: Vector2) -> Vector2:
if node.get_script() not in [WALL_PANEL, SCENERY_ARTWORK]:
return desired_top_left
var bounds: Rect2 = _world_bounds(node)
return node.global_position + desired_top_left - bounds.position
func _free_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.queue_free()
_ghost = null
@@ -0,0 +1 @@
uid://gvfhmaqednxv
@@ -20,6 +20,7 @@ var _overlay: StagePlacementOverlay
var _spatial_index: StageSpatialIndex
var _placement_id: String = ""
var _ground_finish: StringName = &"finish_ground_grass"
var _dragging: bool = false
var _painting: bool = false
var _press_world: Vector2 = Vector2.ZERO
@@ -46,6 +47,14 @@ func configure(
_spatial_index = spatial_index
func set_ground_finish(finish_id: StringName) -> void:
_ground_finish = finish_id if finish_id in [
&"finish_ground_grass", &"finish_ground_dirt", &"finish_ground_sidewalk",
&"finish_ground_asphalt", &"finish_ground_brick", &"finish_ground_wood",
&"finish_ground_paint"
] else &"finish_ground_grass"
func begin(placement_id: String, world_pos: Vector2, snap_enabled: bool, grid_size: float) -> void:
if _spawner == null or not _spawner.is_terrain_id(placement_id):
return
@@ -94,6 +103,7 @@ func commit(placement_id: String, snap_enabled: bool, grid_size: float) -> void:
var click_pos: Vector2 = _snap_to_grid(_press_world, grid_size) if snap_enabled else _press_world
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
if click_node != null:
_apply_ground_finish(click_node)
placement_requested.emit(click_node)
placed_count += 1
for cell: Vector2i in _drag_cells:
@@ -102,6 +112,7 @@ func commit(placement_id: String, snap_enabled: bool, grid_size: float) -> void:
var node: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
if node == null:
continue
_apply_ground_finish(node)
placement_requested.emit(node)
placed_count += 1
_drag_painted[cell] = true
@@ -217,6 +228,7 @@ func refresh_terrain_ghosts() -> void:
var ghost: Node2D = _spawner.spawn(_placement_id, terrain_cell_center(cell, _placement_id))
if ghost == null:
continue
_apply_ground_finish(ghost)
if ghost.get_parent() == _world:
_world.remove_child(ghost)
_ghost_holder.add_child(ghost)
@@ -272,6 +284,11 @@ func _configure_ghost_collision(ghost: Node2D) -> void:
rig.set_collision_bodies_enabled(false)
func _apply_ground_finish(node: Node2D) -> void:
if node is TERRAIN_BLOCK and (node as TerrainBlock).spawn_id == "ground":
(node as TerrainBlock).set_finish(_ground_finish)
func _snap_to_grid(pos: Vector2, grid_size: float) -> Vector2:
if grid_size <= 0.0:
return pos
@@ -4,6 +4,7 @@ extends PanelContainer
signal cancel_requested
signal change_prop_requested
signal capture_action_requested(action_id: StringName)
const MAX_BAR_WIDTH: float = 800.0
const VIEWPORT_WIDTH_RATIO: float = 0.68
@@ -16,6 +17,7 @@ var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
var _horizontal_layout: HBoxContainer
var _vertical_layout: VBoxContainer
var _wrapped_layout: VBoxContainer
var _active_layout: Container
var _actions_row: HBoxContainer
var _actions_column: VBoxContainer
@@ -23,6 +25,10 @@ var _actions_parent: BoxContainer
var _prompt: Label
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
func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void:
@@ -43,6 +49,7 @@ func _ready() -> void:
func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void:
if _prompt == null:
return
_clear_custom_actions()
if not prop_selected:
_prompt.text = "Choose a prop to use"
elif prop_has_use_spots:
@@ -50,10 +57,48 @@ func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) ->
else:
_prompt.text = "This prop has no Use spots. Add one in EDIT."
_change_prop_button.visible = prop_selected
_cancel_button.visible = true
visible = true
_layout_capture_bar()
func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
if _prompt == null:
return
_prompt.text = prompt
_change_prop_button.visible = false
_cancel_button.visible = false
_clear_custom_actions()
for action: Dictionary in actions:
var action_id: StringName = StringName(String(action.get("id", "")))
if action_id.is_empty():
continue
var button := Button.new()
button.name = "Action_%s" % String(action_id)
button.text = String(action.get("label", action_id))
button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
button.focus_mode = Control.FOCUS_ALL
button.pressed.connect(func() -> void: capture_action_requested.emit(action_id))
_style_button(button, bool(action.get("primary", false)))
_custom_action_buttons.append(button)
_actions_parent.add_child(button)
visible = true
apply_theme()
_layout_capture_bar()
func set_horizontal_safe_bounds(left: float, right: float) -> void:
_has_horizontal_safe_bounds = true
_safe_left = minf(left, right)
_safe_right = maxf(left, right)
_layout_capture_bar()
func clear_horizontal_safe_bounds() -> void:
_has_horizontal_safe_bounds = false
_layout_capture_bar()
func hide_capture() -> void:
visible = false
@@ -92,9 +137,16 @@ func apply_theme() -> void:
_cancel_button.add_theme_font_size_override("font_size", body_size)
_style_button(_change_prop_button, false)
_style_button(_cancel_button, true)
for button: Button in _custom_action_buttons:
if body_font != null:
button.add_theme_font_override("font", body_font)
button.add_theme_font_size_override("font_size", body_size)
_style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done"))
var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96)))
_change_prop_button.custom_minimum_size.y = button_height
_cancel_button.custom_minimum_size.y = button_height
for button: Button in _custom_action_buttons:
button.custom_minimum_size.y = button_height
_actions_row.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 12, 8, 24))
_actions_column.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 10, 8, 24))
_layout_capture_bar()
@@ -108,6 +160,9 @@ func _build_controls() -> void:
_vertical_layout = VBoxContainer.new()
_vertical_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_vertical_layout.add_theme_constant_override("separation", 12)
_wrapped_layout = VBoxContainer.new()
_wrapped_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_wrapped_layout.add_theme_constant_override("separation", 10)
_active_layout = _horizontal_layout
add_child(_active_layout)
_prompt = Label.new()
@@ -141,6 +196,17 @@ func _build_controls() -> void:
_actions_row.add_child(_cancel_button)
func _clear_custom_actions() -> void:
for button: Button in _custom_action_buttons:
if button == null or not is_instance_valid(button):
continue
var parent: Node = button.get_parent()
if parent != null:
parent.remove_child(button)
button.queue_free()
_custom_action_buttons.clear()
func _style_button(button: Button, prominent: bool) -> void:
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
var text_color: Color = Color("#171B24") if prominent else _color("stage_panels.type.text_color", Color("#F5F7FA"))
@@ -177,20 +243,33 @@ func _layout_capture_bar() -> void:
return
var viewport_size: Vector2 = get_viewport_rect().size
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 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(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
custom_minimum_size.x = width
size.x = width
position = Vector2((viewport_size.x - width) * 0.5, _top_bar_height + 56.0)
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH, width < ACTION_ROW_MIN_WIDTH)
var left: float = safe_left + (available_width - width) * 0.5 if not _has_horizontal_safe_bounds else safe_left
position = Vector2(left, _top_bar_height + 56.0)
var many_actions: bool = _custom_action_buttons.size() > 3
var stack_actions: bool = width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0)
var wrapped: bool = (_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions)
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped, stack_actions, wrapped)
_fit_custom_action_widths(width, wrapped)
call_deferred("_fit_height_to_content")
func _set_layout(compact: bool, stack_actions: bool) -> void:
var target_layout: Container = _vertical_layout if compact else _horizontal_layout
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
if _actions_parent != target_actions:
var previous_actions: BoxContainer = _actions_parent
for button: Button in _custom_action_buttons:
if button.get_parent() == previous_actions:
previous_actions.remove_child(button)
previous_actions.remove_child(_change_prop_button)
previous_actions.remove_child(_cancel_button)
_active_layout.remove_child(previous_actions)
@@ -198,6 +277,8 @@ func _set_layout(compact: bool, stack_actions: bool) -> void:
_active_layout.add_child(_actions_parent)
_actions_parent.add_child(_change_prop_button)
_actions_parent.add_child(_cancel_button)
for button: Button in _custom_action_buttons:
_actions_parent.add_child(button)
if _active_layout != target_layout:
_active_layout.remove_child(_prompt)
_active_layout.remove_child(_actions_parent)
@@ -212,6 +293,18 @@ func _set_layout(compact: bool, stack_actions: bool) -> void:
_cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL
func _fit_custom_action_widths(width: float, wrapped: bool) -> void:
var button_width: float = ACTION_BUTTON_WIDTH
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
var action_font_size: int = maxi(18, body_size - (4 if wrapped else 0))
if wrapped and not _custom_action_buttons.is_empty():
var gaps: float = float(maxi(0, _custom_action_buttons.size() - 1) * 12)
button_width = minf(ACTION_BUTTON_WIDTH, maxf(144.0, (width - 44.0 - gaps) / float(_custom_action_buttons.size())))
for button: Button in _custom_action_buttons:
button.custom_minimum_size.x = button_width
button.add_theme_font_size_override("font_size", action_font_size)
func _fit_height_to_content() -> void:
if not is_inside_tree() or not visible:
return