Add new test file for phase 19 scenery with unique identifier
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -58,6 +58,14 @@ static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
|
||||
|
||||
|
||||
static func make_custom_entry(path: String) -> Dictionary:
|
||||
return _make_custom_entry(path, false)
|
||||
|
||||
|
||||
static func make_visual_entry(path: String) -> Dictionary:
|
||||
return _make_custom_entry(path, true)
|
||||
|
||||
|
||||
static func _make_custom_entry(path: String, allow_visual_only: bool) -> Dictionary:
|
||||
var normalized_path: String = canonical_path(path)
|
||||
if not is_custom_path(normalized_path): return {}
|
||||
var raw: Dictionary = PROP_FILE.load(normalized_path)
|
||||
@@ -70,13 +78,52 @@ static func make_custom_entry(path: String) -> Dictionary:
|
||||
return {}
|
||||
var shapes: Array[Dictionary] = parsed.get("shapes", [])
|
||||
var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes)
|
||||
if shapes.is_empty() or hull.size() < 3:
|
||||
if shapes.is_empty() or (not allow_visual_only and hull.size() < 3):
|
||||
push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path)
|
||||
return {}
|
||||
var name := String(parsed.get("prop_name", "")).strip_edges()
|
||||
if name.is_empty(): name = normalized_path.get_file().get_basename()
|
||||
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
|
||||
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {})}
|
||||
var tags: Array[String] = parsed.get("tags", []) as Array[String]
|
||||
if tags.is_empty():
|
||||
tags = _legacy_tags_for_path(normalized_path)
|
||||
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {}), "tags": tags, "visual_only": hull.size() < 3}
|
||||
|
||||
|
||||
static func scan_visual_entries(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
|
||||
var entries: Array[Dictionary] = []
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
return entries
|
||||
var directory: DirAccess = DirAccess.open(dir_path)
|
||||
if directory == null:
|
||||
return entries
|
||||
var paths: Array[String] = []
|
||||
directory.list_dir_begin()
|
||||
var file_name: String = directory.get_next()
|
||||
while not file_name.is_empty():
|
||||
if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT):
|
||||
paths.append(dir_path.path_join(file_name))
|
||||
file_name = directory.get_next()
|
||||
directory.list_dir_end()
|
||||
paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0)
|
||||
for path: String in paths:
|
||||
var entry: Dictionary = make_visual_entry(path)
|
||||
if not entry.is_empty():
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
static func _legacy_tags_for_path(path: String) -> Array[String]:
|
||||
var basename: String = path.get_file().get_basename().to_lower()
|
||||
if basename.contains("tree") or basename.contains("plant"):
|
||||
return ["nature", "objects"] if basename.contains("plant") else ["nature"]
|
||||
if basename.contains("skyline") or basename.contains("storefront") or basename.contains("house"):
|
||||
return ["buildings"]
|
||||
if basename.contains("window") or basename.contains("fireplace") or basename.contains("clock"):
|
||||
return ["walls", "objects"]
|
||||
if basename.contains("bookshelf") or basename.contains("picture") or basename.contains("rug") or basename.contains("couch") or basename.contains("armchair") or basename.contains("table"):
|
||||
return ["objects"]
|
||||
return []
|
||||
|
||||
|
||||
static func is_custom_path(path: String) -> bool:
|
||||
|
||||
@@ -10,7 +10,10 @@ var _dragging: bool = false
|
||||
var _drag_threshold: float = 8.0
|
||||
var _icon: TextureRect = null
|
||||
var _glyph: Label = null
|
||||
var _box: VBoxContainer = null
|
||||
var _label: Label = null
|
||||
var _description: Label = null
|
||||
var _status_label: Label = null
|
||||
|
||||
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Variant = null) -> void:
|
||||
_payload = payload.duplicate(true)
|
||||
@@ -25,11 +28,55 @@ func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Varia
|
||||
focus_mode = Control.FOCUS_ALL
|
||||
tooltip_text = String(_payload.get("label", ""))
|
||||
for child: Node in get_children(): child.queue_free()
|
||||
var box := VBoxContainer.new(); box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); box.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.clip_contents = true
|
||||
box.alignment = BoxContainer.ALIGNMENT_CENTER; box.add_theme_constant_override("separation", 2); add_child(box)
|
||||
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_icon)
|
||||
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); box.add_child(_glyph)
|
||||
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_label)
|
||||
_box = VBoxContainer.new(); _box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); _box.mouse_filter = Control.MOUSE_FILTER_IGNORE; _box.clip_contents = true
|
||||
_box.alignment = BoxContainer.ALIGNMENT_CENTER; _box.add_theme_constant_override("separation", 2); add_child(_box)
|
||||
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; _box.add_child(_icon)
|
||||
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); _box.add_child(_glyph)
|
||||
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; _box.add_child(_label)
|
||||
var description: String = String(_payload.get("description", ""))
|
||||
var status: String = String(_payload.get("status", ""))
|
||||
if not status.is_empty():
|
||||
_status_label = Label.new()
|
||||
_status_label.name = "StatusLabel"
|
||||
_status_label.text = status
|
||||
_status_label.custom_minimum_size = Vector2(0.0, 22.0)
|
||||
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_status_label.add_theme_font_size_override("font_size", 16)
|
||||
_status_label.add_theme_color_override("font_color", _payload.get("status_color", Color.WHITE))
|
||||
_status_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_box.add_child(_status_label)
|
||||
if not description.is_empty():
|
||||
_description = Label.new()
|
||||
_description.text = description
|
||||
_description.custom_minimum_size = Vector2(0.0, 34.0)
|
||||
_description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_description.vertical_alignment = VERTICAL_ALIGNMENT_TOP
|
||||
_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_description.max_lines_visible = 2
|
||||
_description.add_theme_font_size_override("font_size", 14)
|
||||
_description.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_box.add_child(_description)
|
||||
|
||||
|
||||
func set_preview_control(preview: Control) -> void:
|
||||
if preview == null or _box == null:
|
||||
return
|
||||
var preview_size: Vector2 = _icon.custom_minimum_size if _icon != null else Vector2(64.0, 64.0)
|
||||
if _icon != null and _icon.get_parent() == _box:
|
||||
_box.remove_child(_icon)
|
||||
_icon.queue_free()
|
||||
_icon = null
|
||||
if _glyph != null and _glyph.get_parent() == _box:
|
||||
_box.remove_child(_glyph)
|
||||
_glyph.queue_free()
|
||||
_glyph = null
|
||||
preview.custom_minimum_size = preview_size
|
||||
preview.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_box.add_child(preview)
|
||||
_box.move_child(preview, 0)
|
||||
|
||||
func set_thumbnail(texture: Texture2D) -> void:
|
||||
if _icon != null: _icon.texture = texture; _icon.visible = texture != null
|
||||
@@ -38,8 +85,20 @@ func set_thumbnail(texture: Texture2D) -> void:
|
||||
func thumbnail_rect() -> TextureRect:
|
||||
return _icon
|
||||
|
||||
func set_selected(selected: bool) -> void:
|
||||
button_pressed = selected
|
||||
func set_selected(selected: bool, selected_surface: Color = Color("#12394A"), selected_border: Color = Color("#22C6FF")) -> void:
|
||||
toggle_mode = true
|
||||
set_pressed_no_signal(selected)
|
||||
if not selected:
|
||||
return
|
||||
var selected_style := StyleBoxFlat.new()
|
||||
selected_style.bg_color = selected_surface
|
||||
selected_style.border_color = selected_border
|
||||
selected_style.set_border_width_all(3)
|
||||
selected_style.set_corner_radius_all(8)
|
||||
add_theme_stylebox_override("normal", selected_style)
|
||||
add_theme_stylebox_override("pressed", selected_style)
|
||||
add_theme_stylebox_override("hover", selected_style)
|
||||
add_theme_stylebox_override("hover_pressed", selected_style)
|
||||
|
||||
func payload() -> Dictionary:
|
||||
return _payload.duplicate(true)
|
||||
|
||||
@@ -5,6 +5,8 @@ signal item_selected(entry: Dictionary)
|
||||
signal cancelled()
|
||||
signal browse_requested()
|
||||
signal refresh_requested()
|
||||
signal make_artwork_requested()
|
||||
signal edit_scenery_requested()
|
||||
|
||||
const COLUMNS := 4
|
||||
const ROWS := 3
|
||||
@@ -13,6 +15,8 @@ const PAGE_SIZE := COLUMNS * ROWS
|
||||
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
|
||||
const THUMB_SIZE := Vector2(156.0, 156.0)
|
||||
const ASSET_CARD := preload("res://scripts/shared/ui/asset_card.gd")
|
||||
const BACKGROUND_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_background_card_preview.gd")
|
||||
const STARTER_CARD_PREVIEW := preload("res://scripts/stage/scenery/stage_starter_card_preview.gd")
|
||||
|
||||
var kind: String = ""
|
||||
|
||||
@@ -24,6 +28,10 @@ var _placeholder_tex: ImageTexture = null
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _sandbox_theme: Variant = null
|
||||
var _gallery_tab: String = "backgrounds"
|
||||
var _tag_id: String = "all"
|
||||
var _current_sky_id: String = ""
|
||||
var _sky_card_group: ButtonGroup
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _grid: GridContainer = %GridContainer
|
||||
@@ -34,15 +42,29 @@ var _sandbox_theme: Variant = null
|
||||
@onready var _browse_button: Button = %BrowseButton
|
||||
@onready var _refresh_button: Button = %RefreshButton
|
||||
@onready var _close_button: Button = %CloseButton
|
||||
@onready var _tabs: HBoxContainer = %GalleryTabs
|
||||
@onready var _backgrounds_tab: Button = %BackgroundsTab
|
||||
@onready var _artwork_tab: Button = %ArtworkTab
|
||||
@onready var _tag_filter: OptionButton = %TagFilter
|
||||
@onready var _make_artwork_button: Button = %MakeArtworkButton
|
||||
@onready var _edit_scenery_button: Button = %EditSceneryButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
exclusive = true
|
||||
if _tag_filter.item_count == 0:
|
||||
for label: String in ["All artwork", "Nature", "Buildings", "Walls", "Roads", "Objects"]:
|
||||
_tag_filter.add_item(label)
|
||||
_prev_button.pressed.connect(_on_prev)
|
||||
_next_button.pressed.connect(_on_next)
|
||||
_browse_button.pressed.connect(func() -> void: browse_requested.emit())
|
||||
_refresh_button.pressed.connect(func() -> void: refresh_requested.emit())
|
||||
_close_button.pressed.connect(func() -> void: cancelled.emit())
|
||||
_backgrounds_tab.pressed.connect(func() -> void: _set_gallery_tab("backgrounds"))
|
||||
_artwork_tab.pressed.connect(func() -> void: _set_gallery_tab("artwork"))
|
||||
_tag_filter.item_selected.connect(_on_tag_filter_selected)
|
||||
_make_artwork_button.pressed.connect(func() -> void: make_artwork_requested.emit())
|
||||
_edit_scenery_button.pressed.connect(func() -> void: edit_scenery_requested.emit())
|
||||
get_tree().root.size_changed.connect(_on_root_size_changed)
|
||||
|
||||
|
||||
@@ -51,13 +73,25 @@ func _on_root_size_changed() -> void:
|
||||
popup_centered()
|
||||
|
||||
|
||||
func open(p_kind: String, entries: Array[Dictionary]) -> void:
|
||||
func open(p_kind: String, entries: Array[Dictionary], current_sky_id: String = "") -> void:
|
||||
kind = p_kind
|
||||
_title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop"
|
||||
_browse_button.visible = kind == "stickman" or kind == "prop"
|
||||
_refresh_button.visible = kind == "stickman" or kind == "prop"
|
||||
_empty_label.text = "No props found. Browse for a .prop file." if kind == "prop" else "No stickmen found."
|
||||
_current_sky_id = current_sky_id if kind == "scenery" else ""
|
||||
_title_label.text = "Choose Your Stickman" if kind == "stickman" else ("Choose Scenery" if kind == "scenery" else ("Choose a starter scene" if kind == "starters" else "Choose a Prop"))
|
||||
_tabs.visible = kind == "scenery"
|
||||
_tag_filter.visible = false
|
||||
_make_artwork_button.visible = false
|
||||
_edit_scenery_button.visible = false
|
||||
_browse_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
|
||||
_refresh_button.visible = kind == "stickman" or kind == "prop" or kind == "scenery"
|
||||
_empty_label.text = "No scenery found. Choose Make artwork to draw a new piece." if kind == "scenery" else ("No props found. Browse for a .prop file." if kind == "prop" else ("No starter scenes are available." if kind == "starters" else "No stickmen found."))
|
||||
_gallery_tab = "backgrounds"
|
||||
_tag_id = "all"
|
||||
_backgrounds_tab.set_pressed_no_signal(true)
|
||||
_artwork_tab.set_pressed_no_signal(false)
|
||||
_tag_filter.select(0)
|
||||
set_entries(entries)
|
||||
if kind == "scenery":
|
||||
_set_gallery_tab("backgrounds")
|
||||
popup_centered()
|
||||
|
||||
|
||||
@@ -76,6 +110,15 @@ func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
|
||||
(_cell_texrects[key] as TextureRect).texture = tex
|
||||
|
||||
|
||||
func set_current_sky(sky_id: StringName) -> void:
|
||||
var next_id: String = String(sky_id)
|
||||
if kind != "scenery" or _current_sky_id == next_id:
|
||||
return
|
||||
_current_sky_id = next_id
|
||||
if _gallery_tab == "backgrounds":
|
||||
_rebuild()
|
||||
|
||||
|
||||
func close() -> void:
|
||||
hide()
|
||||
|
||||
@@ -129,6 +172,42 @@ func _entry_key(entry: Dictionary) -> String:
|
||||
return String(entry.get("id", ""))
|
||||
|
||||
|
||||
func _on_tag_filter_selected(index: int) -> void:
|
||||
var ids: Array[String] = ["all", "nature", "buildings", "walls", "roads", "objects"]
|
||||
_tag_id = ids[clampi(index, 0, ids.size() - 1)]
|
||||
_page = 0
|
||||
_rebuild()
|
||||
|
||||
|
||||
func _set_gallery_tab(tab: String) -> void:
|
||||
_gallery_tab = tab
|
||||
_backgrounds_tab.set_pressed_no_signal(tab == "backgrounds")
|
||||
_artwork_tab.set_pressed_no_signal(tab == "artwork")
|
||||
_tag_filter.visible = kind == "scenery" and tab == "artwork"
|
||||
_make_artwork_button.visible = kind == "scenery" and tab == "artwork"
|
||||
_edit_scenery_button.visible = kind == "scenery"
|
||||
_browse_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork")
|
||||
_refresh_button.visible = kind == "stickman" or kind == "prop" or (kind == "scenery" and tab == "artwork")
|
||||
_empty_label.text = "No artwork in this group yet." if tab == "artwork" else "Choose a Sky look or wall finish."
|
||||
_page = 0
|
||||
_rebuild()
|
||||
|
||||
|
||||
func _filtered_entries() -> Array[Dictionary]:
|
||||
if kind != "scenery":
|
||||
return _entries
|
||||
var filtered: Array[Dictionary] = []
|
||||
for entry: Dictionary in _entries:
|
||||
if String(entry.get("gallery_tab", "artwork")) != _gallery_tab:
|
||||
continue
|
||||
if _gallery_tab == "artwork" and _tag_id != "all":
|
||||
var tags: Array = entry.get("tags", []) as Array
|
||||
if not tags.has(_tag_id):
|
||||
continue
|
||||
filtered.append(entry)
|
||||
return filtered
|
||||
|
||||
|
||||
func _on_prev() -> void:
|
||||
if _page > 0:
|
||||
_page -= 1
|
||||
@@ -136,36 +215,39 @@ func _on_prev() -> void:
|
||||
|
||||
|
||||
func _on_next() -> void:
|
||||
if (_page + 1) * PAGE_SIZE < _entries.size():
|
||||
if (_page + 1) * PAGE_SIZE < _filtered_entries().size():
|
||||
_page += 1
|
||||
_rebuild()
|
||||
|
||||
|
||||
func _page_count() -> int:
|
||||
if _entries.is_empty():
|
||||
var entries: Array[Dictionary] = _filtered_entries()
|
||||
if entries.is_empty():
|
||||
return 1
|
||||
return int(ceil(float(_entries.size()) / float(PAGE_SIZE)))
|
||||
return int(ceil(float(entries.size()) / float(PAGE_SIZE)))
|
||||
|
||||
|
||||
func _rebuild() -> void:
|
||||
_sky_card_group = ButtonGroup.new()
|
||||
for c: Node in _grid.get_children():
|
||||
c.queue_free()
|
||||
_cell_texrects.clear()
|
||||
_empty_label.visible = _entries.is_empty()
|
||||
_grid.visible = not _entries.is_empty()
|
||||
var filtered: Array[Dictionary] = _filtered_entries()
|
||||
_empty_label.visible = filtered.is_empty()
|
||||
_grid.visible = not filtered.is_empty()
|
||||
var total := _page_count()
|
||||
var single := total <= 1
|
||||
_prev_button.visible = not single
|
||||
_next_button.visible = not single
|
||||
_page_label.visible = not single
|
||||
_prev_button.disabled = _page <= 0
|
||||
_next_button.disabled = (_page + 1) * PAGE_SIZE >= _entries.size()
|
||||
_next_button.disabled = (_page + 1) * PAGE_SIZE >= filtered.size()
|
||||
_page_label.text = "Page %d/%d" % [_page + 1, total]
|
||||
if _entries.is_empty():
|
||||
if filtered.is_empty():
|
||||
return
|
||||
var bounds := page_bounds(_entries.size(), _page)
|
||||
var bounds := page_bounds(filtered.size(), _page)
|
||||
for i: int in range(int(bounds["start"]), int(bounds["end"])):
|
||||
_grid.add_child(_build_cell(_entries[i]))
|
||||
_grid.add_child(_build_cell(filtered[i]))
|
||||
|
||||
|
||||
func _build_cell(entry: Dictionary) -> Control:
|
||||
@@ -173,17 +255,52 @@ func _build_cell(entry: Dictionary) -> Control:
|
||||
var cell: Variant = ASSET_CARD.new()
|
||||
# Selector cells inherit the popup theme. Avoid a per-card state override so
|
||||
# opening a selector never implies that one asset is pre-selected.
|
||||
cell.configure({"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": "", "category": String(entry.get("material_label", "")), "thumbnail_size": _thumbnail_size}, _thumbnails.get(key, null), null)
|
||||
cell.custom_minimum_size = _cell_min_size
|
||||
cell.focus_mode = Control.FOCUS_NONE
|
||||
var categories: Array = entry.get("tags", []) as Array
|
||||
var category: String = ", ".join(PackedStringArray(categories)) if not categories.is_empty() else String(entry.get("category", entry.get("material_label", "")))
|
||||
var card_size: Vector2 = _cell_min_size
|
||||
var thumb_size: Vector2 = _thumbnail_size
|
||||
if kind == "starters":
|
||||
card_size.y = maxf(card_size.y, 248.0)
|
||||
thumb_size = Vector2(128.0, 128.0)
|
||||
var card_payload: Dictionary = {"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": String(entry.get("icon", "")), "category": category, "description": String(entry.get("description", "")), "thumbnail_size": thumb_size}
|
||||
if entry.has("background_kind"):
|
||||
card_payload["background_kind"] = entry["background_kind"]
|
||||
card_payload["finish_id"] = entry.get("finish_id", "")
|
||||
card_payload["id"] = entry.get("id", "")
|
||||
var is_sky_choice: bool = String(entry.get("background_kind", "")) == "sky"
|
||||
var is_current_sky: bool = is_sky_choice and String(entry.get("id", "")) == _current_sky_id
|
||||
if is_current_sky:
|
||||
card_payload["status"] = "Current Sky"
|
||||
card_payload["status_color"] = _theme_color("mode_colors.edit", Color("#22C6FF"))
|
||||
cell.configure(card_payload, _thumbnails.get(key, null), null)
|
||||
cell.custom_minimum_size = card_size
|
||||
cell.focus_mode = Control.FOCUS_ALL
|
||||
cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
|
||||
if is_sky_choice:
|
||||
cell.button_group = _sky_card_group
|
||||
cell.set_selected(is_current_sky, _theme_color("stage_panels.choice.selected_surface", Color("#12394A")), _theme_color("mode_colors.edit", Color("#22C6FF")))
|
||||
var tex: Texture2D = _thumbnails.get(key, null)
|
||||
cell.set_thumbnail(tex if tex != null else _get_placeholder())
|
||||
_cell_texrects[key] = cell.thumbnail_rect()
|
||||
if kind == "starters":
|
||||
var preview: Control = STARTER_CARD_PREVIEW.new() as Control
|
||||
preview.call("configure", entry)
|
||||
cell.set_preview_control(preview)
|
||||
elif entry.has("background_kind"):
|
||||
var preview: StageBackgroundCardPreview = BACKGROUND_CARD_PREVIEW.new() as StageBackgroundCardPreview
|
||||
preview.configure(entry)
|
||||
cell.set_preview_control(preview)
|
||||
else:
|
||||
cell.set_thumbnail(tex if tex != null else _get_placeholder())
|
||||
_cell_texrects[key] = cell.thumbnail_rect()
|
||||
|
||||
return cell
|
||||
|
||||
|
||||
func _theme_color(path: String, fallback: Color) -> Color:
|
||||
if _sandbox_theme != null:
|
||||
return _sandbox_theme.color(path, fallback)
|
||||
return fallback
|
||||
|
||||
|
||||
func _on_cell_pressed(entry: Dictionary) -> void:
|
||||
item_selected.emit(entry)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
class_name StageBackgroundCardPreview
|
||||
extends Control
|
||||
## Reuses the Stage's Sky and wall-finish drawing rules in gallery cards.
|
||||
|
||||
const SKY_VIEW: Script = preload("res://scripts/stage/scenery/stage_sky_view.gd")
|
||||
const SURFACE_RENDERER: Script = preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
|
||||
var _background_kind: String = ""
|
||||
var _preview_id: StringName = &""
|
||||
var _sky_view: StageSkyView
|
||||
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
_background_kind = String(entry.get("background_kind", ""))
|
||||
_preview_id = StringName(String(entry.get("id", ""))) if _background_kind == "sky" else StringName(String(entry.get("finish_id", "")))
|
||||
clip_contents = true
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
if _background_kind == "sky":
|
||||
_sky_view = SKY_VIEW.new() as StageSkyView
|
||||
_sky_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_sky_view.set_sky(_preview_id)
|
||||
add_child(_sky_view)
|
||||
var frame: Panel = Panel.new()
|
||||
frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var frame_style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
frame_style.bg_color = Color.TRANSPARENT
|
||||
frame_style.draw_center = false
|
||||
frame_style.set_border_width_all(2)
|
||||
frame_style.border_color = Color("#D7DEE7")
|
||||
frame_style.set_corner_radius_all(4)
|
||||
frame.add_theme_stylebox_override("panel", frame_style)
|
||||
add_child(frame)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _background_kind == "wall" and SURFACE_RENDERER.is_wall_finish(_preview_id):
|
||||
var wall_tint: Color = Color("#7FA36C") if _preview_id == &"finish_wall_paint" else Color.WHITE
|
||||
SURFACE_RENDERER.draw_wall(self, Rect2(Vector2.ZERO, size), _preview_id, wall_tint, Vector2.ZERO)
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED:
|
||||
queue_redraw()
|
||||
@@ -0,0 +1 @@
|
||||
uid://csk4xjt1unoh
|
||||
@@ -0,0 +1,134 @@
|
||||
class_name StageSceneryArtwork
|
||||
extends Node2D
|
||||
## Visual-only live-reference Scenery placement. Never participates in physics.
|
||||
|
||||
const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
|
||||
const PROP_VISUALS := preload("res://scripts/props/prop_visuals.gd")
|
||||
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
|
||||
var asset_path: String = ""
|
||||
var display_name: String = "Scenery"
|
||||
var depth: StringName = &"near"
|
||||
var last_known_local_bounds: Rect2 = Rect2(Vector2(-32.0, -32.0), Vector2(64.0, 64.0))
|
||||
var has_known_bounds: bool = false
|
||||
var missing: bool = false
|
||||
var _shapes: Array[Dictionary] = []
|
||||
var _visuals: Node2D
|
||||
|
||||
|
||||
func load_visual(path: String) -> bool:
|
||||
var normalized_path: String = CONTENT_PATHS.canonicalize(path)
|
||||
if not FileAccess.file_exists(normalized_path):
|
||||
_set_missing()
|
||||
return false
|
||||
var raw: Dictionary = PROP_FILE.load(normalized_path)
|
||||
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
|
||||
var parsed_shapes: Array[Dictionary] = parsed.get("shapes", []) as Array[Dictionary]
|
||||
if parsed_shapes.is_empty():
|
||||
_set_missing()
|
||||
return false
|
||||
if _visuals != null and is_instance_valid(_visuals):
|
||||
_visuals.queue_free()
|
||||
_visuals = Node2D.new()
|
||||
_visuals.name = "Visuals"
|
||||
add_child(_visuals)
|
||||
asset_path = normalized_path
|
||||
_shapes = parsed_shapes
|
||||
var bounds: Rect2 = PROP_VISUALS.build(_visuals, _shapes)
|
||||
var stroke: float = _max_stroke_width(_shapes) * 0.5
|
||||
last_known_local_bounds = bounds.grow(stroke) if bounds.size.x > 0.0 or bounds.size.y > 0.0 else Rect2(Vector2(-32.0, -32.0), Vector2(64.0, 64.0))
|
||||
has_known_bounds = true
|
||||
missing = false
|
||||
set_process(false)
|
||||
_apply_depth()
|
||||
queue_redraw()
|
||||
return true
|
||||
|
||||
|
||||
func set_depth(value: StringName) -> void:
|
||||
depth = value if value in [&"far", &"near", &"floor"] else &"near"
|
||||
_apply_depth()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if missing:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func get_visual_world_bounds() -> Rect2:
|
||||
return global_transform * last_known_local_bounds
|
||||
|
||||
|
||||
func get_saved_footprint() -> Rect2:
|
||||
return last_known_local_bounds
|
||||
|
||||
|
||||
func configure_missing(path: String, name: String, saved_bounds: Rect2, saved_depth: StringName) -> void:
|
||||
asset_path = CONTENT_PATHS.canonicalize(path)
|
||||
display_name = name.strip_edges() if not name.strip_edges().is_empty() else asset_path.get_file().get_basename()
|
||||
if saved_bounds.size.x > 0.0 and saved_bounds.size.y > 0.0:
|
||||
last_known_local_bounds = saved_bounds
|
||||
has_known_bounds = true
|
||||
else:
|
||||
last_known_local_bounds = Rect2(Vector2(-32.0, -32.0), Vector2(64.0, 64.0))
|
||||
has_known_bounds = false
|
||||
depth = saved_depth
|
||||
load_visual(asset_path)
|
||||
|
||||
|
||||
func _set_missing() -> void:
|
||||
missing = true
|
||||
set_process(true)
|
||||
if _visuals != null and is_instance_valid(_visuals):
|
||||
_visuals.queue_free()
|
||||
_visuals = null
|
||||
_apply_depth()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _apply_depth() -> void:
|
||||
z_index = DRAW_ORDER.scenery_z(depth)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not missing:
|
||||
return
|
||||
var rect: Rect2 = last_known_local_bounds
|
||||
draw_rect(rect, Color("#87909D30"), true)
|
||||
_draw_dashed_line(rect.position, Vector2(rect.end.x, rect.position.y))
|
||||
_draw_dashed_line(Vector2(rect.end.x, rect.position.y), rect.end)
|
||||
_draw_dashed_line(rect.end, Vector2(rect.position.x, rect.end.y))
|
||||
_draw_dashed_line(Vector2(rect.position.x, rect.end.y), rect.position)
|
||||
var camera: Camera2D = get_viewport().get_camera_2d() if is_inside_tree() else null
|
||||
var zoom: float = maxf(0.05, camera.zoom.x) if camera != null else 1.0
|
||||
draw_set_transform(rect.position + Vector2(0.0, -18.0), 0.0, Vector2.ONE / zoom)
|
||||
draw_rect(Rect2(Vector2.ZERO, Vector2(188.0, 32.0)), Color("#242B37E8"), true)
|
||||
draw_circle(Vector2(16.0, 16.0), 10.0, Color("#FFD166"))
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
if font != null:
|
||||
draw_string(font, Vector2(13.0, 22.0), "?", HORIZONTAL_ALIGNMENT_LEFT, -1.0, 16, Color("#242B37"))
|
||||
draw_string(font, Vector2(32.0, 22.0), "Artwork not found", HORIZONTAL_ALIGNMENT_LEFT, -1.0, 18, Color("#F5F7FA"))
|
||||
draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
func _draw_dashed_line(start: Vector2, finish: Vector2) -> void:
|
||||
var length: float = start.distance_to(finish)
|
||||
if length <= 0.0:
|
||||
return
|
||||
var direction: Vector2 = (finish - start) / length
|
||||
var offset: float = 0.0
|
||||
var segment_index: int = 0
|
||||
while offset < length:
|
||||
var segment_end: float = minf(length, offset + 9.0)
|
||||
var color: Color = Color("#E2E6EC") if segment_index % 2 == 0 else Color("#687281")
|
||||
draw_line(start + direction * offset, start + direction * segment_end, color, 2.0)
|
||||
offset += 15.0
|
||||
segment_index += 1
|
||||
|
||||
|
||||
func _max_stroke_width(shapes: Array[Dictionary]) -> float:
|
||||
var width: float = 0.0
|
||||
for shape: Dictionary in shapes:
|
||||
width = maxf(width, float(shape.get("outline_width", 0.0)))
|
||||
return width
|
||||
@@ -0,0 +1 @@
|
||||
uid://ysop8p4ekoc4
|
||||
@@ -0,0 +1,323 @@
|
||||
class_name StageSkyView
|
||||
extends Control
|
||||
## Adaptive screen-space Sky behind the world.
|
||||
|
||||
const SKY_IDS: Array[StringName] = [&"sky_clear_day", &"sky_cloudy_day", &"sky_sunset", &"sky_starry_night", &"sky_neutral"]
|
||||
const GRADIENT_TEXTURE_HEIGHT: int = 2048
|
||||
const CLOUD_VIEWBOX: Vector2 = Vector2(210.0, 110.0)
|
||||
const NIGHT_STAR_COLOR: Color = Color("#FFF4CE")
|
||||
|
||||
var _sky_id: StringName = &"sky_neutral"
|
||||
var _stage_theme: SandboxTheme
|
||||
var _gradient_texture: GradientTexture2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
_update_gradient_texture()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_sky(id: StringName) -> void:
|
||||
var next_sky_id: StringName = id if SKY_IDS.has(id) else &"sky_neutral"
|
||||
if _sky_id == next_sky_id:
|
||||
return
|
||||
_sky_id = next_sky_id
|
||||
_update_gradient_texture()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func get_sky() -> StringName:
|
||||
return _sky_id
|
||||
|
||||
|
||||
func apply_theme(theme: SandboxTheme) -> void:
|
||||
_stage_theme = theme
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_RESIZED:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if size.x <= 0.0 or size.y <= 0.0:
|
||||
return
|
||||
if _gradient_texture == null:
|
||||
_update_gradient_texture()
|
||||
if _gradient_texture != null:
|
||||
draw_texture_rect(_gradient_texture, Rect2(Vector2.ZERO, size), false)
|
||||
if _sky_id == &"sky_starry_night":
|
||||
_draw_stars()
|
||||
_draw_moon()
|
||||
_draw_clouds()
|
||||
else:
|
||||
if _sky_id != &"sky_cloudy_day":
|
||||
_draw_sun()
|
||||
_draw_clouds()
|
||||
|
||||
|
||||
func _update_gradient_texture() -> void:
|
||||
var gradient: Gradient = Gradient.new()
|
||||
var offsets: PackedFloat32Array = PackedFloat32Array()
|
||||
var colors: PackedColorArray = PackedColorArray()
|
||||
match _sky_id:
|
||||
&"sky_clear_day":
|
||||
offsets = PackedFloat32Array([0.0, 0.48, 1.0])
|
||||
colors = PackedColorArray([Color("#54ADEA"), Color("#95D4F4"), Color("#E0F5FF")])
|
||||
&"sky_cloudy_day":
|
||||
offsets = PackedFloat32Array([0.0, 0.52, 1.0])
|
||||
colors = PackedColorArray([Color("#687D98"), Color("#A2B7C6"), Color("#D9E2E4")])
|
||||
&"sky_sunset":
|
||||
offsets = PackedFloat32Array([0.0, 0.38, 0.71, 1.0])
|
||||
colors = PackedColorArray([Color("#644B86"), Color("#A96B99"), Color("#E89491"), Color("#FFC57F")])
|
||||
&"sky_starry_night":
|
||||
offsets = PackedFloat32Array([0.0, 0.55, 1.0])
|
||||
colors = PackedColorArray([Color("#111A3C"), Color("#253963"), Color("#526E95")])
|
||||
_:
|
||||
offsets = PackedFloat32Array([0.0, 0.5, 1.0])
|
||||
colors = PackedColorArray([Color("#323944"), Color("#6B737F"), Color("#AEB6C1")])
|
||||
gradient.offsets = offsets
|
||||
gradient.colors = colors
|
||||
_gradient_texture = GradientTexture2D.new()
|
||||
_gradient_texture.gradient = gradient
|
||||
_gradient_texture.width = 2
|
||||
_gradient_texture.height = GRADIENT_TEXTURE_HEIGHT
|
||||
_gradient_texture.fill_from = Vector2.ZERO
|
||||
_gradient_texture.fill_to = Vector2(0.0, 1.0)
|
||||
|
||||
|
||||
func _draw_sun() -> void:
|
||||
var center: Vector2 = Vector2(size.x * 0.78125, size.y * (0.241 if _sky_id == &"sky_sunset" else 0.242))
|
||||
var radius: float = size.y * (0.0979 if _sky_id == &"sky_sunset" else 0.0792)
|
||||
var halo_color: Color = Color("#FFE1A2") if _sky_id == &"sky_sunset" else Color("#FFF1B3")
|
||||
var sun_color: Color = Color("#FFE296") if _sky_id == &"sky_sunset" else Color("#FFE990")
|
||||
draw_circle(center, radius * 1.5, Color(halo_color.r, halo_color.g, halo_color.b, 0.14))
|
||||
draw_circle(center, radius, sun_color)
|
||||
draw_circle(center + Vector2(-radius * 0.19, -radius * 0.2), radius * 0.54, Color(1.0, 0.97, 0.82, 0.32))
|
||||
|
||||
|
||||
func _draw_stars() -> void:
|
||||
for index: int in 48:
|
||||
var x: float = fposmod(float(index * 137 + 39), size.x)
|
||||
var y: float = fposmod(float(index * 83 + 21), size.y * 0.62)
|
||||
draw_circle(Vector2(x, y), 1.3 if index % 4 != 0 else 2.0, NIGHT_STAR_COLOR)
|
||||
for star_index: int in 3:
|
||||
var center: Vector2 = Vector2(size.x * [0.3675, 0.9425, 0.22125][star_index], size.y * [0.085, 0.229, 0.348][star_index])
|
||||
var radius: float = maxf(1.3, size.y / 480.0 * 7.0)
|
||||
draw_line(center - Vector2(0.0, radius), center + Vector2(0.0, radius), Color("#FFEABB"), maxf(1.0, radius * 0.22), true)
|
||||
draw_line(center - Vector2(radius, 0.0), center + Vector2(radius, 0.0), Color("#FFEABB"), maxf(1.0, radius * 0.22), true)
|
||||
|
||||
|
||||
func _draw_clouds() -> void:
|
||||
var origins: Array[Vector4] = []
|
||||
var opacities: Array[float] = []
|
||||
var outer_color: Color
|
||||
var underside_color: Color
|
||||
var accent_color: Color
|
||||
match _sky_id:
|
||||
&"sky_clear_day":
|
||||
origins = [Vector4(42.0, 66.0, 205.0, 108.0), Vector4(282.0, 167.0, 244.0, 127.0), Vector4(612.0, 288.0, 187.0, 98.0)]
|
||||
opacities = [0.93, 0.88, 0.8]
|
||||
outer_color = Color("#FFFFFF")
|
||||
underside_color = Color("#CBE6FF", 0.72)
|
||||
accent_color = Color("#D9EEFF", 0.86)
|
||||
&"sky_cloudy_day":
|
||||
origins = [Vector4(-16.0, 47.0, 246.0, 129.0), Vector4(188.0, 145.0, 242.0, 127.0), Vector4(508.0, 64.0, 281.0, 147.0), Vector4(74.0, 302.0, 190.0, 100.0), Vector4(535.0, 324.0, 231.0, 121.0)]
|
||||
opacities = [1.0, 0.93, 0.95, 0.7, 0.69]
|
||||
outer_color = Color("#F8FCFF")
|
||||
underside_color = Color("#BED7EF", 0.9)
|
||||
accent_color = Color("#D0E5F7", 1.0)
|
||||
&"sky_sunset":
|
||||
origins = [Vector4(24.0, 82.0, 235.0, 123.0), Vector4(294.0, 244.0, 258.0, 135.0), Vector4(585.0, 310.0, 218.0, 114.0)]
|
||||
opacities = [0.85, 0.9, 0.81]
|
||||
outer_color = Color("#FFE3D1")
|
||||
underside_color = Color("#E9A8BA", 0.8)
|
||||
accent_color = Color("#FFF2D7", 0.8)
|
||||
_:
|
||||
origins = [Vector4(-4.0, 331.0, 231.0, 121.0), Vector4(314.0, 380.0, 188.0, 98.0)]
|
||||
opacities = [0.28, 0.23]
|
||||
outer_color = Color("#A9BCE1")
|
||||
underside_color = Color("#7898C5", 0.8)
|
||||
accent_color = Color("#D7E3F7", 0.45)
|
||||
var viewport_scale: Vector2 = Vector2(size.x / 800.0, size.y / 480.0)
|
||||
for cloud_index: int in origins.size():
|
||||
var placement: Vector4 = origins[cloud_index]
|
||||
var cloud_rect: Rect2 = Rect2(
|
||||
Vector2(placement.x, placement.y) * viewport_scale,
|
||||
Vector2(placement.z, placement.w) * viewport_scale
|
||||
)
|
||||
_draw_cloud(cloud_rect, outer_color, underside_color, accent_color, opacities[cloud_index])
|
||||
|
||||
|
||||
func _draw_cloud(rect: Rect2, outer_color: Color, underside_color: Color, accent_color: Color, opacity: float) -> void:
|
||||
var outline: PackedVector2Array = _map_cloud_points(_build_cloud_outline(), rect)
|
||||
var cloud_color: Color = _with_opacity(outer_color, opacity)
|
||||
draw_colored_polygon(outline, cloud_color)
|
||||
draw_polyline(outline, cloud_color, maxf(1.0, rect.size.y / CLOUD_VIEWBOX.y * 1.5), true)
|
||||
var underside: PackedVector2Array = _map_cloud_points(_build_cloud_underside(), rect)
|
||||
draw_colored_polygon(underside, _with_opacity(underside_color, opacity))
|
||||
for accent_curve: PackedVector2Array in _build_cloud_accents():
|
||||
var accent_points: PackedVector2Array = _map_cloud_points(_sample_curve_chain([accent_curve], 12), rect)
|
||||
draw_polyline(accent_points, _with_opacity(accent_color, opacity), maxf(1.0, rect.size.y / CLOUD_VIEWBOX.y * 3.0), true)
|
||||
|
||||
|
||||
func _draw_moon() -> void:
|
||||
var moon_curves: Array[PackedVector2Array] = _build_moon_curves()
|
||||
var moon_local: PackedVector2Array = _sample_curve_chain(moon_curves, 16)
|
||||
var outer_arc: PackedVector2Array = _sample_curve_chain(_build_moon_outer_arc_curves(), 16)
|
||||
var moon_scale: float = size.y / 480.0 * 0.63
|
||||
var moon_origin: Vector2 = Vector2(size.x * 0.7625, size.y * 0.09583)
|
||||
var moon_size: Vector2 = Vector2(150.0, 240.0) * moon_scale
|
||||
var moon_rect: Rect2 = Rect2(moon_origin, moon_size)
|
||||
var moon_outline: PackedVector2Array = _map_moon_points(moon_local, moon_rect)
|
||||
draw_colored_polygon(moon_outline, Color("#17213A"))
|
||||
draw_colored_polygon(moon_outline, Color("#FFEA31"))
|
||||
var warm_rim: PackedVector2Array = _build_moon_warm_rim(outer_arc, 7.5)
|
||||
draw_colored_polygon(_map_moon_points(warm_rim, moon_rect), Color("#F4A629"))
|
||||
var highlight: PackedVector2Array = _sample_curve_chain(_build_moon_highlight(), 18)
|
||||
draw_polyline(_map_moon_points(highlight, moon_rect), Color(1.0, 0.98, 0.63, 0.62), maxf(1.0, moon_scale * 8.0), true)
|
||||
draw_polyline(moon_outline, Color("#17213A"), maxf(1.0, moon_scale * 5.5), true)
|
||||
|
||||
|
||||
func _build_cloud_outline() -> PackedVector2Array:
|
||||
return _sample_curve_chain(_build_cloud_outer_curves(), 10)
|
||||
|
||||
|
||||
func _build_cloud_underside() -> PackedVector2Array:
|
||||
return _sample_curve_chain(_build_cloud_underside_curves(), 10)
|
||||
|
||||
|
||||
func _build_cloud_outer_curves() -> Array[PackedVector2Array]:
|
||||
var curves: Array[PackedVector2Array] = []
|
||||
curves.append(PackedVector2Array([Vector2(18, 74), Vector2(7, 71), Vector2(2, 62), Vector2(5, 52)]))
|
||||
curves.append(PackedVector2Array([Vector2(5, 52), Vector2(8, 42), Vector2(19, 36), Vector2(32, 39)]))
|
||||
curves.append(PackedVector2Array([Vector2(32, 39), Vector2(29, 25), Vector2(40, 13), Vector2(54, 13)]))
|
||||
curves.append(PackedVector2Array([Vector2(54, 13), Vector2(64, 13), Vector2(72, 18), Vector2(77, 25)]))
|
||||
curves.append(PackedVector2Array([Vector2(77, 25), Vector2(84, 9), Vector2(101, 2), Vector2(116, 7)]))
|
||||
curves.append(PackedVector2Array([Vector2(116, 7), Vector2(131, 11), Vector2(141, 24), Vector2(142, 38)]))
|
||||
curves.append(PackedVector2Array([Vector2(142, 38), Vector2(153, 30), Vector2(167, 32), Vector2(176, 40)]))
|
||||
curves.append(PackedVector2Array([Vector2(176, 40), Vector2(182, 45), Vector2(185, 51), Vector2(185, 57)]))
|
||||
curves.append(PackedVector2Array([Vector2(185, 57), Vector2(197, 58), Vector2(205, 67), Vector2(204, 78)]))
|
||||
curves.append(PackedVector2Array([Vector2(204, 78), Vector2(203, 89), Vector2(193, 97), Vector2(180, 97)]))
|
||||
curves.append(PackedVector2Array([Vector2(180, 97), Vector2(171, 97), Vector2(164, 94), Vector2(159, 90)]))
|
||||
curves.append(PackedVector2Array([Vector2(159, 90), Vector2(148, 100), Vector2(131, 101), Vector2(119, 93)]))
|
||||
curves.append(PackedVector2Array([Vector2(119, 93), Vector2(107, 102), Vector2(88, 103), Vector2(75, 94)]))
|
||||
curves.append(PackedVector2Array([Vector2(75, 94), Vector2(63, 101), Vector2(47, 98), Vector2(39, 89)]))
|
||||
curves.append(PackedVector2Array([Vector2(39, 89), Vector2(30, 89), Vector2(23, 83), Vector2(18, 74)]))
|
||||
return curves
|
||||
|
||||
|
||||
func _build_cloud_underside_curves() -> Array[PackedVector2Array]:
|
||||
var curves: Array[PackedVector2Array] = []
|
||||
curves.append(PackedVector2Array([Vector2(9, 69), Vector2(20, 79), Vector2(31, 79), Vector2(42, 75)]))
|
||||
curves.append(PackedVector2Array([Vector2(42, 75), Vector2(57, 89), Vector2(71, 86), Vector2(82, 81)]))
|
||||
curves.append(PackedVector2Array([Vector2(82, 81), Vector2(96, 92), Vector2(111, 89), Vector2(122, 82)]))
|
||||
curves.append(PackedVector2Array([Vector2(122, 82), Vector2(139, 93), Vector2(152, 89), Vector2(163, 80)]))
|
||||
curves.append(PackedVector2Array([Vector2(163, 80), Vector2(179, 89), Vector2(195, 83), Vector2(202, 74)]))
|
||||
curves.append(PackedVector2Array([Vector2(202, 74), Vector2(205, 87), Vector2(195, 97), Vector2(180, 97)]))
|
||||
curves.append(PackedVector2Array([Vector2(180, 97), Vector2(171, 97), Vector2(164, 94), Vector2(159, 90)]))
|
||||
curves.append(PackedVector2Array([Vector2(159, 90), Vector2(148, 100), Vector2(131, 101), Vector2(119, 93)]))
|
||||
curves.append(PackedVector2Array([Vector2(119, 93), Vector2(107, 102), Vector2(88, 103), Vector2(75, 94)]))
|
||||
curves.append(PackedVector2Array([Vector2(75, 94), Vector2(63, 101), Vector2(47, 98), Vector2(39, 89)]))
|
||||
curves.append(PackedVector2Array([Vector2(39, 89), Vector2(25, 91), Vector2(13, 82), Vector2(9, 69)]))
|
||||
return curves
|
||||
|
||||
|
||||
func _build_cloud_accents() -> Array[PackedVector2Array]:
|
||||
var curves: Array[PackedVector2Array] = []
|
||||
curves.append(PackedVector2Array([Vector2(42, 43), Vector2(49, 54), Vector2(58, 59), Vector2(69, 58)]))
|
||||
curves.append(PackedVector2Array([Vector2(85, 33), Vector2(96, 21), Vector2(114, 21), Vector2(126, 32)]))
|
||||
curves.append(PackedVector2Array([Vector2(142, 58), Vector2(151, 48), Vector2(164, 47), Vector2(173, 53)]))
|
||||
return curves
|
||||
|
||||
|
||||
func _build_moon_curves() -> Array[PackedVector2Array]:
|
||||
var curves: Array[PackedVector2Array] = []
|
||||
curves.append(PackedVector2Array([Vector2(15, 13), Vector2(69, 20), Vector2(113, 54), Vector2(129, 104)]))
|
||||
curves.append(PackedVector2Array([Vector2(129, 104), Vector2(149, 166), Vector2(119, 224), Vector2(61, 235)]))
|
||||
curves.append(PackedVector2Array([Vector2(61, 235), Vector2(36, 240), Vector2(13, 231), Vector2(2, 216)]))
|
||||
curves.append(PackedVector2Array([Vector2(2, 216), Vector2(40, 218), Vector2(60, 193), Vector2(68, 158)]))
|
||||
curves.append(PackedVector2Array([Vector2(68, 158), Vector2(85, 103), Vector2(63, 47), Vector2(15, 13)]))
|
||||
return curves
|
||||
|
||||
|
||||
func _build_moon_outer_arc_curves() -> Array[PackedVector2Array]:
|
||||
var curves: Array[PackedVector2Array] = []
|
||||
curves.append(PackedVector2Array([Vector2(15, 13), Vector2(69, 20), Vector2(113, 54), Vector2(129, 104)]))
|
||||
curves.append(PackedVector2Array([Vector2(129, 104), Vector2(149, 166), Vector2(119, 224), Vector2(61, 235)]))
|
||||
curves.append(PackedVector2Array([Vector2(61, 235), Vector2(36, 240), Vector2(13, 231), Vector2(2, 216)]))
|
||||
return curves
|
||||
|
||||
|
||||
func _build_moon_highlight() -> Array[PackedVector2Array]:
|
||||
var curves: Array[PackedVector2Array] = []
|
||||
curves.append(PackedVector2Array([Vector2(29, 37), Vector2(63, 58), Vector2(85, 98), Vector2(87, 128)]))
|
||||
return curves
|
||||
|
||||
|
||||
func _sample_curve_chain(curves: Array[PackedVector2Array], steps_per_curve: int) -> PackedVector2Array:
|
||||
var sampled: PackedVector2Array = PackedVector2Array()
|
||||
if steps_per_curve <= 0:
|
||||
return sampled
|
||||
for curve_index: int in curves.size():
|
||||
var curve: PackedVector2Array = curves[curve_index]
|
||||
if curve.size() != 4:
|
||||
continue
|
||||
for step_index: int in range(steps_per_curve + 1):
|
||||
if curve_index > 0 and step_index == 0:
|
||||
continue
|
||||
var t: float = float(step_index) / float(steps_per_curve)
|
||||
sampled.append(_cubic_point(curve, t))
|
||||
return sampled
|
||||
|
||||
|
||||
func _cubic_point(curve: PackedVector2Array, t: float) -> Vector2:
|
||||
var inverse_t: float = 1.0 - t
|
||||
return (
|
||||
curve[0] * inverse_t * inverse_t * inverse_t
|
||||
+ curve[1] * 3.0 * inverse_t * inverse_t * t
|
||||
+ curve[2] * 3.0 * inverse_t * t * t
|
||||
+ curve[3] * t * t * t
|
||||
)
|
||||
|
||||
|
||||
func _map_cloud_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
|
||||
var mapped: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in points:
|
||||
mapped.append(rect.position + Vector2(point.x / CLOUD_VIEWBOX.x * rect.size.x, point.y / CLOUD_VIEWBOX.y * rect.size.y))
|
||||
return mapped
|
||||
|
||||
|
||||
func _map_moon_points(points: PackedVector2Array, rect: Rect2) -> PackedVector2Array:
|
||||
var mapped: PackedVector2Array = PackedVector2Array()
|
||||
for point: Vector2 in points:
|
||||
mapped.append(rect.position + point * (rect.size.y / 240.0))
|
||||
return mapped
|
||||
|
||||
|
||||
func _build_moon_warm_rim(outer_arc: PackedVector2Array, width: float) -> PackedVector2Array:
|
||||
var rim: PackedVector2Array = PackedVector2Array()
|
||||
if outer_arc.size() < 3:
|
||||
return rim
|
||||
for point: Vector2 in outer_arc:
|
||||
rim.append(point)
|
||||
for point_index: int in range(outer_arc.size() - 1, -1, -1):
|
||||
var tangent: Vector2
|
||||
if point_index == 0:
|
||||
tangent = outer_arc[1] - outer_arc[0]
|
||||
elif point_index == outer_arc.size() - 1:
|
||||
tangent = outer_arc[point_index] - outer_arc[point_index - 1]
|
||||
else:
|
||||
tangent = outer_arc[point_index + 1] - outer_arc[point_index - 1]
|
||||
var inward: Vector2 = Vector2(-tangent.y, tangent.x).normalized()
|
||||
var progress: float = float(point_index) / float(outer_arc.size() - 1)
|
||||
var local_width: float = width * sin(PI * progress)
|
||||
rim.append(outer_arc[point_index] + inward * local_width)
|
||||
return rim
|
||||
|
||||
|
||||
func _with_opacity(color: Color, opacity: float) -> Color:
|
||||
return Color(color.r, color.g, color.b, color.a * opacity)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxoqflho7kyik
|
||||
@@ -0,0 +1,116 @@
|
||||
class_name StageStarterCardPreview
|
||||
extends SubViewportContainer
|
||||
## Lightweight live preview of a saved starter Stage scene.
|
||||
|
||||
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
|
||||
const STAGE_SCENE_ADAPTER := preload("res://scripts/editors/stage/stage_scene_adapter.gd")
|
||||
const STAGE_SPAWNER := preload("res://scripts/stage/stage_spawner.gd")
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
const STAGE_SKY_VIEW := preload("res://scripts/stage/scenery/stage_sky_view.gd")
|
||||
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
|
||||
|
||||
const PREVIEW_SIZE := Vector2i(256, 256)
|
||||
const PREVIEW_MARGIN := 18.0
|
||||
const MIN_PREVIEW_ZOOM := 0.08
|
||||
const MAX_PREVIEW_ZOOM := 0.42
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _sky_view: StageSkyView
|
||||
var _world: Node2D
|
||||
var _loaded_nodes: Array[Node2D] = []
|
||||
var _scene_valid: bool = false
|
||||
|
||||
|
||||
func configure(entry: Dictionary) -> void:
|
||||
name = "StarterScenePreview"
|
||||
custom_minimum_size = Vector2(128.0, 128.0)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
stretch = true
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
|
||||
|
||||
var result: Dictionary = STAGE_SCENE_IO.read_file(String(entry.get("path", "")))
|
||||
if not bool(result.get("ok", false)):
|
||||
return
|
||||
var data: Dictionary = result.get("data", {}) as Dictionary
|
||||
_scene_valid = true
|
||||
_build_preview_viewport(data)
|
||||
|
||||
|
||||
func has_preview_scene() -> bool:
|
||||
return _scene_valid and _viewport != null and _sky_view != null and _world != null
|
||||
|
||||
|
||||
func get_loaded_object_count() -> int:
|
||||
return _loaded_nodes.size()
|
||||
|
||||
|
||||
func _build_preview_viewport(data: Dictionary) -> void:
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.name = "PreviewViewport"
|
||||
_viewport.size = PREVIEW_SIZE
|
||||
_viewport.world_2d = World2D.new()
|
||||
_viewport.transparent_bg = false
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_DISABLED
|
||||
add_child(_viewport)
|
||||
|
||||
var sky_layer := CanvasLayer.new()
|
||||
sky_layer.name = "PreviewSky"
|
||||
sky_layer.layer = -1
|
||||
_viewport.add_child(sky_layer)
|
||||
_sky_view = STAGE_SKY_VIEW.new() as StageSkyView
|
||||
_sky_view.name = "SkyView"
|
||||
_sky_view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_sky_view.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_sky_view.set_sky(StringName(String(data.get("sky_id", "sky_neutral"))))
|
||||
sky_layer.add_child(_sky_view)
|
||||
|
||||
_world = Node2D.new()
|
||||
_world.name = "PreviewWorld"
|
||||
_viewport.add_child(_world)
|
||||
var spawner: StageSpawner = STAGE_SPAWNER.new(_world) as StageSpawner
|
||||
var adapter: StageSceneAdapter = STAGE_SCENE_ADAPTER.new() as StageSceneAdapter
|
||||
var applied: Dictionary = adapter.apply(data, _world, spawner)
|
||||
for raw_node: Variant in applied.get("loaded_nodes", []):
|
||||
var stage_node: Node2D = raw_node as Node2D
|
||||
if stage_node == null:
|
||||
continue
|
||||
_loaded_nodes.append(stage_node)
|
||||
if stage_node is PropBlock:
|
||||
var prop: PropBlock = stage_node as PropBlock
|
||||
prop.freeze = true
|
||||
prop.gravity_scale = 0.0
|
||||
prop.collision_layer = 0
|
||||
prop.collision_mask = 0
|
||||
call_deferred("_fit_scene_to_preview")
|
||||
|
||||
|
||||
func _fit_scene_to_preview() -> void:
|
||||
if _world == null or not is_instance_valid(_world):
|
||||
return
|
||||
var content_bounds := Rect2()
|
||||
var has_bounds: bool = false
|
||||
for node: Node2D in _loaded_nodes:
|
||||
if node == null or not is_instance_valid(node):
|
||||
continue
|
||||
var bounds: Rect2
|
||||
if node.get_script() == STAGE_WALL_PANEL:
|
||||
bounds = node.global_transform * (node as StageWallPanel).get_local_bounds()
|
||||
else:
|
||||
bounds = STAGE_SELECTION.get_world_aabb(node)
|
||||
bounds = bounds.abs()
|
||||
if bounds.size.x <= 0.0 or bounds.size.y <= 0.0:
|
||||
continue
|
||||
content_bounds = bounds if not has_bounds else content_bounds.merge(bounds)
|
||||
has_bounds = true
|
||||
if not has_bounds:
|
||||
_world.scale = Vector2.ONE * 0.2
|
||||
_world.position = Vector2(PREVIEW_SIZE) * 0.5
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
||||
return
|
||||
var usable_size: Vector2 = Vector2(PREVIEW_SIZE) - Vector2.ONE * PREVIEW_MARGIN * 2.0
|
||||
var fit_zoom: float = minf(usable_size.x / content_bounds.size.x, usable_size.y / content_bounds.size.y)
|
||||
fit_zoom = clampf(fit_zoom, MIN_PREVIEW_ZOOM, MAX_PREVIEW_ZOOM)
|
||||
_world.scale = Vector2.ONE * fit_zoom
|
||||
_world.position = Vector2(PREVIEW_SIZE) * 0.5 - content_bounds.get_center() * fit_zoom
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ONCE
|
||||
@@ -0,0 +1 @@
|
||||
uid://lw57y3sl5wfk
|
||||
@@ -0,0 +1,267 @@
|
||||
class_name StageSurfaceRenderer
|
||||
extends RefCounted
|
||||
## World-aligned, bounded vector motifs for finite walls and Ground.
|
||||
|
||||
const WALL_FINISHES: Array[StringName] = [&"finish_wall_paint", &"finish_wall_brick", &"finish_wall_wood"]
|
||||
const GROUND_FINISHES: Array[StringName] = [&"finish_ground_paint", &"finish_ground_brick", &"finish_ground_wood", &"finish_ground_grass", &"finish_ground_dirt", &"finish_ground_sidewalk", &"finish_ground_asphalt"]
|
||||
const MAX_CELLS: int = 1200
|
||||
const BRICK_WIDTH: float = 82.0
|
||||
const BRICK_HEIGHT: float = 34.0
|
||||
const MAX_VISIBLE_BRICK_CELLS: int = 12000
|
||||
|
||||
|
||||
static func is_wall_finish(id: StringName) -> bool:
|
||||
return WALL_FINISHES.has(id)
|
||||
|
||||
|
||||
static func is_ground_finish(id: StringName) -> bool:
|
||||
return GROUND_FINISHES.has(id) or id == &"finish_ground_legacy"
|
||||
|
||||
|
||||
static func draw_wall(
|
||||
canvas: CanvasItem,
|
||||
rect: Rect2,
|
||||
finish: StringName,
|
||||
tint: Color,
|
||||
world_origin: Vector2,
|
||||
drawing_scale: Vector2 = Vector2.ONE
|
||||
) -> void:
|
||||
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, true)
|
||||
|
||||
|
||||
static func draw_ground(
|
||||
canvas: CanvasItem,
|
||||
rect: Rect2,
|
||||
finish: StringName,
|
||||
tint: Color,
|
||||
world_origin: Vector2,
|
||||
drawing_scale: Vector2 = Vector2.ONE
|
||||
) -> void:
|
||||
_draw_surface(canvas, rect, finish, tint, world_origin, drawing_scale, false)
|
||||
|
||||
|
||||
static func _draw_surface(
|
||||
canvas: CanvasItem,
|
||||
rect: Rect2,
|
||||
finish: StringName,
|
||||
tint: Color,
|
||||
world_origin: Vector2,
|
||||
drawing_scale: Vector2,
|
||||
wall: bool
|
||||
) -> void:
|
||||
var normalized := rect.abs()
|
||||
if normalized.size.x <= 0.0 or normalized.size.y <= 0.0:
|
||||
return
|
||||
var motif_scale: Vector2 = _normalized_scale(drawing_scale)
|
||||
var base: Color = tint
|
||||
if finish == &"finish_ground_legacy":
|
||||
base = Color("#468B42")
|
||||
elif finish.ends_with("brick"):
|
||||
base = Color("#B95F3D") if wall else Color("#994D35")
|
||||
elif finish.ends_with("wood"):
|
||||
base = Color("#9A673C") if wall else Color("#B88951")
|
||||
elif finish == &"finish_ground_grass":
|
||||
base = Color("#72A84C")
|
||||
elif finish == &"finish_ground_dirt":
|
||||
base = Color("#8E633E")
|
||||
elif finish == &"finish_ground_sidewalk":
|
||||
base = Color("#B7B9B2")
|
||||
elif finish == &"finish_ground_asphalt":
|
||||
base = Color("#505A62")
|
||||
canvas.draw_rect(normalized, base, true)
|
||||
if finish.ends_with("brick"):
|
||||
var view_rect: Rect2 = _visible_local_rect(canvas)
|
||||
var pattern_rect: Rect2 = normalized.intersection(view_rect) if not view_rect.size.is_zero_approx() else normalized
|
||||
if pattern_rect.size.x > 0.0 and pattern_rect.size.y > 0.0:
|
||||
var detail_scale: float = brick_detail_scale_for_view(view_rect.size if not view_rect.size.is_zero_approx() else normalized.size, motif_scale)
|
||||
_draw_bricks(canvas, pattern_rect, world_origin, base, motif_scale, detail_scale)
|
||||
elif finish.ends_with("wood"):
|
||||
_draw_wood(canvas, normalized, world_origin, base, motif_scale)
|
||||
elif finish == &"finish_ground_grass":
|
||||
_draw_grass(canvas, normalized, world_origin, motif_scale)
|
||||
elif finish == &"finish_ground_dirt":
|
||||
_draw_dirt(canvas, normalized, world_origin, motif_scale)
|
||||
elif finish == &"finish_ground_sidewalk":
|
||||
_draw_sidewalk(canvas, normalized, world_origin, motif_scale)
|
||||
elif finish == &"finish_ground_asphalt":
|
||||
_draw_asphalt(canvas, normalized, world_origin, motif_scale)
|
||||
elif finish != &"finish_wall_paint" and finish != &"finish_ground_paint" and finish != &"finish_ground_legacy":
|
||||
push_warning("StageSurfaceRenderer: unknown finish '%s'; drawing paint." % String(finish))
|
||||
|
||||
|
||||
static func brick_detail_scale_for_view(view_size: Vector2, motif_scale: Vector2 = Vector2.ONE) -> float:
|
||||
var scale: float = 1.0
|
||||
var safe_size: Vector2 = Vector2(maxf(1.0, view_size.x), maxf(1.0, view_size.y))
|
||||
var safe_motif_scale: Vector2 = _normalized_scale(motif_scale)
|
||||
for _iteration: int in 8:
|
||||
var columns: int = int(ceil(safe_size.x * safe_motif_scale.x / (BRICK_WIDTH * scale))) + 2
|
||||
var rows: int = int(ceil(safe_size.y * safe_motif_scale.y / (BRICK_HEIGHT * scale))) + 2
|
||||
var cell_count: int = rows * columns
|
||||
if cell_count <= MAX_VISIBLE_BRICK_CELLS:
|
||||
return scale
|
||||
scale *= sqrt(float(cell_count) / float(MAX_VISIBLE_BRICK_CELLS)) * 1.01
|
||||
return scale
|
||||
|
||||
|
||||
static func _draw_bricks(canvas: CanvasItem, rect: Rect2, origin: Vector2, base: Color, motif_scale: Vector2, detail_scale: float = 1.0) -> void:
|
||||
var brick_world_w: float = BRICK_WIDTH * detail_scale
|
||||
var brick_world_h: float = BRICK_HEIGHT * detail_scale
|
||||
var brick_w: float = brick_world_w / motif_scale.x
|
||||
var brick_h: float = brick_world_h / motif_scale.y
|
||||
var mortar := base.lightened(0.46)
|
||||
var x_start: float = (floorf((rect.position.x * motif_scale.x + origin.x) / brick_world_w) * brick_world_w - origin.x) / motif_scale.x
|
||||
var y_start: float = (floorf((rect.position.y * motif_scale.y + origin.y) / brick_world_h) * brick_world_h - origin.y) / motif_scale.y
|
||||
var rows: int = int(ceil(rect.size.y / brick_h)) + 2
|
||||
var columns: int = int(ceil(rect.size.x / brick_w)) + 2
|
||||
for row: int in rows:
|
||||
var y: float = y_start + float(row) * brick_h
|
||||
var offset: float = brick_w * 0.5 if posmod(row + int(floori((rect.position.y * motif_scale.y + origin.y) / brick_world_h)), 2) == 1 else 0.0
|
||||
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, y), Vector2(rect.end.x, y), mortar, 3.0 / motif_scale.y)
|
||||
for column: int in columns:
|
||||
var x: float = x_start + float(column) * brick_w + offset
|
||||
if x < rect.position.x or x > rect.end.x:
|
||||
continue
|
||||
var next_row_y: float = y + brick_h
|
||||
_draw_clipped_line(canvas, rect, Vector2(x, y), Vector2(x, next_row_y), mortar, 3.0 / motif_scale.x)
|
||||
|
||||
|
||||
static func _visible_local_rect(canvas: CanvasItem) -> Rect2:
|
||||
if canvas == null or not canvas.is_inside_tree():
|
||||
return Rect2()
|
||||
var viewport: Viewport = canvas.get_viewport()
|
||||
if viewport == null:
|
||||
return Rect2()
|
||||
var local_from_viewport: Transform2D = canvas.get_global_transform_with_canvas().affine_inverse()
|
||||
var viewport_rect: Rect2 = viewport.get_visible_rect()
|
||||
var corners: Array[Vector2] = [
|
||||
viewport_rect.position,
|
||||
Vector2(viewport_rect.end.x, viewport_rect.position.y),
|
||||
viewport_rect.end,
|
||||
Vector2(viewport_rect.position.x, viewport_rect.end.y),
|
||||
]
|
||||
var local_rect: Rect2 = Rect2(local_from_viewport * corners[0], Vector2.ZERO)
|
||||
for corner_index: int in range(1, corners.size()):
|
||||
local_rect = local_rect.expand(local_from_viewport * corners[corner_index])
|
||||
return local_rect
|
||||
|
||||
|
||||
static func _draw_wood(canvas: CanvasItem, rect: Rect2, origin: Vector2, base: Color, motif_scale: Vector2) -> void:
|
||||
const BOARD: float = 64.0
|
||||
var line_color := base.darkened(0.28)
|
||||
var board_h: float = BOARD / motif_scale.y
|
||||
var start_y: float = (floorf((rect.position.y * motif_scale.y + origin.y) / BOARD) * BOARD - origin.y) / motif_scale.y - board_h
|
||||
var count: int = mini(int(ceil(rect.size.y / board_h)) + 3, MAX_CELLS)
|
||||
for index: int in count:
|
||||
var y: float = start_y + float(index) * board_h
|
||||
_draw_clipped_line(canvas, rect, Vector2(rect.position.x, y), Vector2(rect.end.x, y), line_color, 2.0 / motif_scale.y)
|
||||
var seed_x: float = fposmod(origin.x * 0.37 + float(index * 41), 90.0) / motif_scale.x
|
||||
_draw_clipped_line(
|
||||
canvas,
|
||||
rect,
|
||||
Vector2(rect.position.x + seed_x, y + board_h * 0.5),
|
||||
Vector2(rect.position.x + seed_x + 28.0 / motif_scale.x, y + board_h * 0.5),
|
||||
line_color,
|
||||
1.0 / motif_scale.y
|
||||
)
|
||||
|
||||
|
||||
static func _draw_clipped_line(canvas: CanvasItem, rect: Rect2, from: Vector2, to: Vector2, color: Color, width: float) -> void:
|
||||
var clipped: PackedVector2Array = _clip_line_to_rect(rect, from, to, width)
|
||||
if clipped.size() == 2:
|
||||
canvas.draw_line(clipped[0], clipped[1], color, width)
|
||||
|
||||
|
||||
static func _clip_line_to_rect(rect: Rect2, from: Vector2, to: Vector2, width: float) -> PackedVector2Array:
|
||||
var inset: float = maxf(0.0, width) * 0.5
|
||||
var safe_rect: Rect2 = Rect2(rect.position + Vector2.ONE * inset, rect.size - Vector2.ONE * inset * 2.0)
|
||||
if safe_rect.size.x <= 0.0 or safe_rect.size.y <= 0.0:
|
||||
return PackedVector2Array()
|
||||
var delta: Vector2 = to - from
|
||||
var p_values: Array[float] = [-delta.x, delta.x, -delta.y, delta.y]
|
||||
var q_values: Array[float] = [
|
||||
from.x - safe_rect.position.x,
|
||||
safe_rect.end.x - from.x,
|
||||
from.y - safe_rect.position.y,
|
||||
safe_rect.end.y - from.y,
|
||||
]
|
||||
var t_min: float = 0.0
|
||||
var t_max: float = 1.0
|
||||
for edge_index: int in p_values.size():
|
||||
var p_value: float = p_values[edge_index]
|
||||
var q_value: float = q_values[edge_index]
|
||||
if is_zero_approx(p_value):
|
||||
if q_value < 0.0:
|
||||
return PackedVector2Array()
|
||||
continue
|
||||
var ratio: float = q_value / p_value
|
||||
if p_value < 0.0:
|
||||
if ratio > t_max:
|
||||
return PackedVector2Array()
|
||||
t_min = maxf(t_min, ratio)
|
||||
else:
|
||||
if ratio < t_min:
|
||||
return PackedVector2Array()
|
||||
t_max = minf(t_max, ratio)
|
||||
if t_min > t_max:
|
||||
return PackedVector2Array()
|
||||
return PackedVector2Array([from + delta * t_min, from + delta * t_max])
|
||||
|
||||
|
||||
static func _draw_grass(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
|
||||
var pitch: float = 18.0 / motif_scale.x
|
||||
var phase: float = fposmod(origin.x, 18.0) / motif_scale.x
|
||||
var count: int = mini(int(ceil(rect.size.x / pitch)) + 2, MAX_CELLS)
|
||||
var baseline: float = rect.position.y
|
||||
for index: int in count:
|
||||
var x: float = rect.position.x + float(index) * pitch - phase
|
||||
var seed: int = absi(int(floori((x * motif_scale.x + origin.x) * 0.23)))
|
||||
var height: float = 5.0 + float(seed % 8)
|
||||
var green := Color("#3D7E39") if seed % 3 == 0 else Color("#4D913F")
|
||||
canvas.draw_line(Vector2(x, baseline + 4.0 / motif_scale.y), Vector2(x - 3.0 / motif_scale.x, baseline - height / motif_scale.y), green, 2.0 / motif_scale.x)
|
||||
canvas.draw_line(Vector2(x + 4.0 / motif_scale.x, baseline + 4.0 / motif_scale.y), Vector2(x + 7.0 / motif_scale.x, baseline - height * 0.72 / motif_scale.y), green, 1.5 / motif_scale.x)
|
||||
if seed % 5 == 0:
|
||||
_draw_world_circle(canvas, Vector2(x + 2.0 / motif_scale.x, baseline - (height + 2.0) / motif_scale.y), 2.0, Color("#C6D96B"), motif_scale)
|
||||
|
||||
|
||||
static func _draw_dirt(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
|
||||
var pitch: float = 26.0 / motif_scale.x
|
||||
var phase: float = fposmod(origin.x, 26.0) / motif_scale.x
|
||||
for index: int in mini(int(ceil(rect.size.x / pitch)) + 1, MAX_CELLS):
|
||||
var x: float = rect.position.x + float(index) * pitch - phase
|
||||
var y: float = rect.position.y + fposmod(float(index * 17) + origin.y, 18.0) / motif_scale.y
|
||||
_draw_world_circle(canvas, Vector2(x, y), 2.0, Color("#67492F"), motif_scale)
|
||||
|
||||
|
||||
static func _draw_sidewalk(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
|
||||
const SIZE: float = 72.0
|
||||
var local_size: float = SIZE / motif_scale.x
|
||||
var color := Color("#92968F")
|
||||
var x: float = rect.position.x - fposmod(origin.x, SIZE) / motif_scale.x
|
||||
var count: int = mini(int(ceil(rect.size.x / local_size)) + 2, MAX_CELLS)
|
||||
for index: int in count:
|
||||
var px: float = x + float(index) * local_size
|
||||
canvas.draw_line(Vector2(px, rect.position.y), Vector2(px, rect.end.y), color, 2.0 / motif_scale.x)
|
||||
canvas.draw_line(Vector2(rect.position.x, rect.position.y), Vector2(rect.end.x, rect.position.y), color, 2.0 / motif_scale.y)
|
||||
|
||||
|
||||
static func _draw_asphalt(canvas: CanvasItem, rect: Rect2, origin: Vector2, motif_scale: Vector2) -> void:
|
||||
var local_pitch: float = 80.0 / motif_scale.x
|
||||
var phase: float = fposmod(origin.x, 80.0) / motif_scale.x
|
||||
var count: int = mini(int(ceil(rect.size.x / local_pitch)) + 2, MAX_CELLS)
|
||||
var world_height: float = maxf(rect.size.y * motif_scale.y, 1.0)
|
||||
for index: int in count:
|
||||
var x: float = rect.position.x + float(index) * local_pitch - phase
|
||||
var y: float = rect.position.y + fposmod(float(index * 31) + origin.y, world_height) / motif_scale.y
|
||||
_draw_world_circle(canvas, Vector2(x, y), 1.5, Color("#727A7D"), motif_scale)
|
||||
|
||||
|
||||
static func _draw_world_circle(canvas: CanvasItem, center: Vector2, radius: float, color: Color, motif_scale: Vector2) -> void:
|
||||
var points := PackedVector2Array()
|
||||
for point_index: int in 12:
|
||||
var angle: float = TAU * float(point_index) / 12.0
|
||||
points.append(center + Vector2(cos(angle) * radius / motif_scale.x, sin(angle) * radius / motif_scale.y))
|
||||
canvas.draw_colored_polygon(points, color)
|
||||
|
||||
|
||||
static func _normalized_scale(scale: Vector2) -> Vector2:
|
||||
return Vector2(maxf(absf(scale.x), 0.001), maxf(absf(scale.y), 0.001))
|
||||
@@ -0,0 +1 @@
|
||||
uid://cefj7bf1p3bq2
|
||||
@@ -0,0 +1,36 @@
|
||||
class_name StageWallPanel
|
||||
extends Node2D
|
||||
## Finite decorative wall panel with world-scale finish motifs.
|
||||
|
||||
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
const DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
|
||||
var finish_id: StringName = &"finish_wall_paint"
|
||||
var tint: Color = Color("#5A9D70")
|
||||
var panel_size: Vector2 = Vector2(320.0, 400.0)
|
||||
## Saved source-wall anchor offset, retained to preserve erased-fragment metadata.
|
||||
## Motif drawing uses the fragment's current world origin so its brick courses stay aligned.
|
||||
var motif_origin_offset: Vector2 = Vector2.ZERO
|
||||
|
||||
|
||||
func configure(new_size: Vector2, finish: StringName, color: Color) -> void:
|
||||
panel_size = Vector2(maxf(1.0, new_size.x), maxf(1.0, new_size.y))
|
||||
finish_id = finish if SURFACE_RENDERER.is_wall_finish(finish) else &"finish_wall_paint"
|
||||
tint = color
|
||||
z_index = DRAW_ORDER.WALL
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func get_local_bounds() -> Rect2:
|
||||
return Rect2(-panel_size * 0.5, panel_size)
|
||||
|
||||
|
||||
func get_effective_motif_origin_world() -> Vector2:
|
||||
return global_position + motif_origin_offset
|
||||
|
||||
|
||||
func get_surface_draw_origin_world() -> Vector2:
|
||||
return global_position
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
SURFACE_RENDERER.draw_wall(self, get_local_bounds(), finish_id, tint, get_surface_draw_origin_world())
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1ttvnqumh2hd
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name StageDrawOrder
|
||||
extends RefCounted
|
||||
## Semantic draw bands shared by scenery, terrain, Props, and characters.
|
||||
|
||||
const WALL: int = -40
|
||||
const FAR: int = -30
|
||||
const NEAR: int = -20
|
||||
const TERRAIN: int = -10
|
||||
const FLOOR: int = -5
|
||||
|
||||
|
||||
static func scenery_z(depth: StringName) -> int:
|
||||
match depth:
|
||||
&"far": return FAR
|
||||
&"floor": return FLOOR
|
||||
_: return NEAR
|
||||
|
||||
|
||||
static func apply_terrain(node: TerrainBlock) -> void:
|
||||
if node != null:
|
||||
node.z_index = TERRAIN
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsyoepjah36yk
|
||||
@@ -11,6 +11,8 @@ extends Node2D
|
||||
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||
const StageWallPanelScript := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||
const StageSceneryArtworkScript := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
@@ -42,6 +44,7 @@ const ROTATE_HANDLE_MARGIN_PX := 48.0
|
||||
const ROTATE_RING_TOLERANCE_PX := 12.0
|
||||
const SCALE_CROSS_SIZE_PX := 5.0
|
||||
const SCALE_HIT_RADIUS_PX := 12.0
|
||||
const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0
|
||||
const MIN_SCALE: float = 0.05
|
||||
const MAX_SCALE: float = 20.0
|
||||
|
||||
@@ -55,6 +58,7 @@ var camera: Camera2D = null
|
||||
var snap_size: float = 0.0
|
||||
|
||||
var _enabled: bool = true
|
||||
var _transform_handles_visible: bool = true
|
||||
var _targets: Array[Node2D] = []
|
||||
var _hovered: Node2D = null
|
||||
var _dragging: Handle = Handle.NONE
|
||||
@@ -91,6 +95,11 @@ func set_enabled(enabled: bool) -> void:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_transform_handles_visible(visible: bool) -> void:
|
||||
_transform_handles_visible = visible
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_snap_size(size: float) -> void:
|
||||
snap_size = maxf(size, 0.0)
|
||||
|
||||
@@ -102,12 +111,18 @@ func set_box_rect(rect: Rect2) -> void:
|
||||
|
||||
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
|
||||
func hit_test(world_pos: Vector2) -> Handle:
|
||||
if not _enabled or _targets.size() != 1:
|
||||
if not _enabled or not _transform_handles_visible or _targets.size() != 1:
|
||||
return Handle.NONE
|
||||
var target := _targets[0]
|
||||
if target == null or not is_instance_valid(target):
|
||||
return Handle.NONE
|
||||
var rect := STAGE_SELECTION.get_world_aabb(target)
|
||||
var rect: Rect2 = _target_bounds(target)
|
||||
if _is_scenery_target(target):
|
||||
for corner: Vector2 in _corners(rect):
|
||||
var offset: Vector2 = (world_pos - corner) * _zoom()
|
||||
if absf(offset.x) <= SCENERY_SCALE_HIT_HALF_SIZE_PX and absf(offset.y) <= SCENERY_SCALE_HIT_HALF_SIZE_PX:
|
||||
return Handle.SCALE
|
||||
return Handle.NONE
|
||||
if target is PropBlockScript:
|
||||
for corner: Vector2 in _corners(rect):
|
||||
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
||||
@@ -118,12 +133,29 @@ func hit_test(world_pos: Vector2) -> Handle:
|
||||
return Handle.NONE
|
||||
|
||||
|
||||
func get_scenery_resize_corner(world_pos: Vector2) -> Vector2:
|
||||
if not _enabled or not _transform_handles_visible or _targets.size() != 1 or not _is_scenery_target(_targets[0]):
|
||||
return Vector2.INF
|
||||
var bounds: Rect2 = _target_bounds(_targets[0])
|
||||
var closest: Vector2 = Vector2.INF
|
||||
var closest_distance: float = INF
|
||||
for corner: Vector2 in _corners(bounds):
|
||||
var offset: Vector2 = (world_pos - corner) * _zoom()
|
||||
if absf(offset.x) > SCENERY_SCALE_HIT_HALF_SIZE_PX or absf(offset.y) > SCENERY_SCALE_HIT_HALF_SIZE_PX:
|
||||
continue
|
||||
var distance: float = offset.length_squared()
|
||||
if distance < closest_distance:
|
||||
closest = corner
|
||||
closest_distance = distance
|
||||
return closest
|
||||
|
||||
|
||||
## Begins a rotate drag (used when the ring is clicked).
|
||||
func begin_drag(world_pos: Vector2) -> void:
|
||||
_dragging = hit_test(world_pos)
|
||||
_drag_last = world_pos
|
||||
if _dragging == Handle.ROTATE and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
_rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center()
|
||||
_rotate_center = _target_bounds(_targets[0]).get_center()
|
||||
_rotate_start_rotation = _targets[0].global_rotation
|
||||
_rotate_start_angle = (world_pos - _rotate_center).angle()
|
||||
transform_started.emit(_targets.duplicate())
|
||||
@@ -220,16 +252,21 @@ func _draw() -> void:
|
||||
return
|
||||
var zoom := _zoom()
|
||||
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
|
||||
draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
||||
draw_rect(_target_bounds(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
||||
# A selection outline for every selected object.
|
||||
for node: Node2D in _targets:
|
||||
if node != null and is_instance_valid(node):
|
||||
draw_rect(STAGE_SELECTION.get_world_aabb(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
|
||||
draw_rect(_target_bounds(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
|
||||
# Rotate ring only for a single selection.
|
||||
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
|
||||
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
|
||||
if _targets[0] is PropBlockScript:
|
||||
if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var rect: Rect2 = _target_bounds(_targets[0])
|
||||
if not _is_scenery_target(_targets[0]):
|
||||
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
|
||||
if _is_scenery_target(_targets[0]):
|
||||
for corner: Vector2 in _corners(rect):
|
||||
draw_circle(corner, 11.0 / zoom, Color("#111821E8"))
|
||||
draw_circle(corner, 7.0 / zoom, Color("#F5F7FA"))
|
||||
elif _targets[0] is PropBlockScript:
|
||||
var half: float = SCALE_CROSS_SIZE_PX / zoom
|
||||
for corner: Vector2 in _corners(rect):
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
@@ -261,6 +298,18 @@ func _snap(v: Vector2) -> Vector2:
|
||||
func _corners(rect: Rect2) -> Array[Vector2]:
|
||||
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
|
||||
|
||||
|
||||
func _is_scenery_target(node: Node2D) -> bool:
|
||||
return node != null and (node.get_script() == StageWallPanelScript or node.get_script() == StageSceneryArtworkScript)
|
||||
|
||||
|
||||
func _target_bounds(node: Node2D) -> Rect2:
|
||||
if node != null and node.get_script() == StageWallPanelScript:
|
||||
return node.global_transform * (node as StageWallPanel).get_local_bounds()
|
||||
if node != null and node.get_script() == StageSceneryArtworkScript:
|
||||
return (node as StageSceneryArtwork).get_visual_world_bounds()
|
||||
return STAGE_SELECTION.get_world_aabb(node)
|
||||
|
||||
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
|
||||
var nearest: Vector2 = rect.position
|
||||
var distance: float = INF
|
||||
|
||||
@@ -3,13 +3,17 @@ extends RefCounted
|
||||
## JSON boundary for Sandbox Stage documents. This class deliberately has no
|
||||
## scene-tree dependencies, which makes rejected documents non-destructive.
|
||||
|
||||
const FILE_VERSION: String = "1.4"
|
||||
const FILE_VERSION: String = "1.5"
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
||||
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
|
||||
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
|
||||
const SKY_IDS: Array[String] = ["sky_clear_day", "sky_cloudy_day", "sky_sunset", "sky_starry_night", "sky_neutral"]
|
||||
const SCENERY_DEPTHS: Array[String] = ["far", "near", "floor"]
|
||||
|
||||
static func vector_to_data(value: Vector2) -> Dictionary:
|
||||
return {"x": value.x, "y": value.y}
|
||||
@@ -26,6 +30,14 @@ static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
|
||||
var data: Dictionary = value
|
||||
return Vector2(float(data["x"]), float(data["y"]))
|
||||
|
||||
static func color_from_data(value: Variant, fallback: Color) -> Color:
|
||||
if not _valid_color(value):
|
||||
return fallback
|
||||
var html_color: String = String(value).strip_edges()
|
||||
if not html_color.begins_with("#"):
|
||||
html_color = "#" + html_color
|
||||
return Color.from_string(html_color, fallback)
|
||||
|
||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
@@ -171,9 +183,10 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
prop_entry["wobble_on_impact"] = false
|
||||
_assign_legacy_depths(current.get("objects", []))
|
||||
return current
|
||||
if version not in ["1.0", "1.1", "1.2", "1.3"]:
|
||||
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4"]:
|
||||
return {}
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
migrated["sky_id"] = "sky_neutral"
|
||||
for raw: Variant in migrated.get("objects", []):
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
@@ -187,6 +200,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
|
||||
if not object.has("wobble_on_impact"):
|
||||
object["wobble_on_impact"] = false
|
||||
if String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
|
||||
object["finish_id"] = "finish_ground_legacy"
|
||||
migrated["version"] = FILE_VERSION
|
||||
_assign_legacy_depths(migrated.get("objects", []))
|
||||
return migrated
|
||||
@@ -217,6 +232,9 @@ static func _assign_legacy_depths(raw_objects: Variant) -> void:
|
||||
roles["depth"] = PROP_STAGE_ROLES.DEPTH_FRONT if characters_before > characters_after else PROP_STAGE_ROLES.DEPTH_BEHIND
|
||||
|
||||
static func _validate_root(data: Dictionary) -> Dictionary:
|
||||
var sky_id: Variant = data.get("sky_id", "sky_neutral")
|
||||
if not sky_id is String or not SKY_IDS.has(String(sky_id)):
|
||||
return {"ok": false, "error": "Scene Sky is invalid."}
|
||||
var seen: Dictionary = {}
|
||||
var seen_stage_ids: Dictionary = {}
|
||||
for raw: Variant in data["objects"]:
|
||||
@@ -249,8 +267,62 @@ static func _validate_root(data: Dictionary) -> Dictionary:
|
||||
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
|
||||
if roles.has(key) and not roles[key] is bool:
|
||||
return {"ok": false, "error": "Prop role '%s' must be true or false." % key}
|
||||
elif String(entry.get("type", "")) == "wall_panel":
|
||||
if not is_vector_data(entry.get("size")):
|
||||
return {"ok": false, "error": "Wall panel size is invalid."}
|
||||
var wall_size: Vector2 = data_to_vector(entry.get("size"), Vector2.ZERO)
|
||||
if wall_size.x <= 0.0 or wall_size.y <= 0.0 or not WALL_FINISHES.has(String(entry.get("finish_id", ""))):
|
||||
return {"ok": false, "error": "Wall panel size or finish is invalid."}
|
||||
if not _valid_color(entry.get("color", "#5A9D70")):
|
||||
return {"ok": false, "error": "Wall panel color is invalid."}
|
||||
if entry.has("motif_origin_offset") and not is_vector_data(entry.get("motif_origin_offset")):
|
||||
return {"ok": false, "error": "Wall panel motif origin is invalid."}
|
||||
elif String(entry.get("type", "")) == "scenery_artwork":
|
||||
var asset_id: Variant = entry.get("asset_id", "")
|
||||
if not asset_id is String or String(asset_id).strip_edges().is_empty():
|
||||
return {"ok": false, "error": "Scenery artwork reference is invalid."}
|
||||
entry["asset_id"] = CONTENT_PATHS.canonicalize(String(asset_id))
|
||||
if not entry.get("display_name", "Scenery") is String or not SCENERY_DEPTHS.has(String(entry.get("depth", "near"))):
|
||||
return {"ok": false, "error": "Scenery artwork label or depth is invalid."}
|
||||
var art_scale: Vector2 = data_to_vector(entry.get("scale"), Vector2.ZERO)
|
||||
if not is_vector_data(entry.get("scale")) or art_scale.x <= 0.0 or art_scale.y <= 0.0:
|
||||
return {"ok": false, "error": "Scenery artwork scale is invalid."}
|
||||
if not _finite_number(entry.get("rotation", 0.0)):
|
||||
return {"ok": false, "error": "Scenery artwork rotation is invalid."}
|
||||
var bounds_value: Variant = entry.get("last_known_local_bounds", null)
|
||||
if bounds_value != null and not _valid_rect_data(bounds_value):
|
||||
entry.erase("last_known_local_bounds")
|
||||
elif String(entry.get("type", "")) == "terrain" and String(entry.get("asset_id", "")) == "ground":
|
||||
var finish: String = String(entry.get("finish_id", "finish_ground_legacy"))
|
||||
if not GROUND_FINISHES.has(finish):
|
||||
return {"ok": false, "error": "Ground finish is invalid."}
|
||||
return {"ok": true, "error": ""}
|
||||
|
||||
|
||||
static func _valid_color(value: Variant) -> bool:
|
||||
if not value is String:
|
||||
return false
|
||||
var raw: String = String(value).strip_edges()
|
||||
if raw.begins_with("#"):
|
||||
raw = raw.substr(1)
|
||||
if raw.length() not in [6, 8]:
|
||||
return false
|
||||
const HEX_DIGITS: String = "0123456789abcdefABCDEF"
|
||||
for index: int in range(raw.length()):
|
||||
if not HEX_DIGITS.contains(raw.substr(index, 1)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _valid_rect_data(value: Variant) -> bool:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var rect_data: Dictionary = value
|
||||
if not is_vector_data(rect_data.get("position")) or not is_vector_data(rect_data.get("size")):
|
||||
return false
|
||||
var size: Vector2 = data_to_vector(rect_data.get("size"), Vector2.ZERO)
|
||||
return size.x > 0.0 and size.y > 0.0
|
||||
|
||||
static func _recover_backup(path: String) -> void:
|
||||
var bak := path + ".bak"
|
||||
if not FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
|
||||
|
||||
@@ -19,6 +19,10 @@ signal selection_changed(nodes: Array[Node2D])
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
const STAGE_WALL_PANEL := preload("res://scripts/stage/scenery/stage_wall_panel.gd")
|
||||
const STAGE_SCENERY_ARTWORK := preload("res://scripts/stage/scenery/stage_scenery_artwork.gd")
|
||||
|
||||
enum SelectionScope { ACTION, SCENERY }
|
||||
|
||||
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
|
||||
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
|
||||
@@ -32,6 +36,7 @@ var _camera: Camera2D
|
||||
var _selected: Array[Node2D] = []
|
||||
var _hovered: Node2D = null
|
||||
var _primary: Node2D = null
|
||||
var _scope: SelectionScope = SelectionScope.ACTION
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -53,6 +58,12 @@ func get_primary() -> Node2D:
|
||||
return _primary
|
||||
|
||||
|
||||
func set_scope(scope: SelectionScope) -> void:
|
||||
_scope = scope
|
||||
clear_selection()
|
||||
clear_hover()
|
||||
|
||||
|
||||
func clear_selection() -> void:
|
||||
_selected.clear()
|
||||
_primary = null
|
||||
@@ -110,7 +121,7 @@ func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
|
||||
continue
|
||||
if get_world_aabb(node).has_point(world_pos):
|
||||
hits.append(node)
|
||||
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
|
||||
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos) if _scope == SelectionScope.ACTION else null
|
||||
if ragdoll_hit != null and not hits.has(ragdoll_hit):
|
||||
hits.append(ragdoll_hit)
|
||||
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
|
||||
@@ -219,6 +230,11 @@ func _is_selectable(node: Node) -> bool:
|
||||
return false
|
||||
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
|
||||
return false
|
||||
var is_scenery: bool = node.get_script() == STAGE_WALL_PANEL or node.get_script() == STAGE_SCENERY_ARTWORK
|
||||
if _scope == SelectionScope.ACTION and is_scenery:
|
||||
return false
|
||||
if _scope == SelectionScope.SCENERY and not is_scenery:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
||||
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
const TRIGGER_AREA := preload("res://scripts/stage/trigger_area.gd")
|
||||
const STAGE_DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -67,13 +68,14 @@ func get_spawnable_ids() -> Array[String]:
|
||||
|
||||
## UI-safe registry view. Callers receive no templates or mutable registry state.
|
||||
func get_palette_entries() -> Array[Dictionary]:
|
||||
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
|
||||
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
|
||||
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯", "scenery":"🌄"}
|
||||
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events", "scenery":"Backgrounds"}
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: Dictionary in _registry:
|
||||
var id: String = String(entry.get("id", ""))
|
||||
var kind: String = String(entry.get("kind", ""))
|
||||
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
|
||||
result.append({"placement_id":"scenery", "kind":"scenery", "label":"Scenery", "icon":"🌄", "category":"Backgrounds", "opens_selector":true})
|
||||
return result
|
||||
|
||||
|
||||
@@ -328,6 +330,10 @@ func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
|
||||
)
|
||||
block.position = world_position
|
||||
block.spawn_id = String(entry.get("id", ""))
|
||||
if block.spawn_id == "ground":
|
||||
block.finish_id = &"finish_ground_legacy"
|
||||
block.set_finish(&"finish_ground_legacy")
|
||||
STAGE_DRAW_ORDER.apply_terrain(block)
|
||||
return block
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ extends StaticBody2D
|
||||
const POLYGON_NODE_NAME := "Polygon2D"
|
||||
const OUTLINE_NODE_NAME := "Outline"
|
||||
const COLLISION_NODE_NAME := "CollisionPolygon2D"
|
||||
const STAGE_SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
|
||||
const STAGE_DRAW_ORDER := preload("res://scripts/stage/stage_draw_order.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exported properties
|
||||
@@ -55,6 +57,9 @@ const COLLISION_NODE_NAME := "CollisionPolygon2D"
|
||||
## overlaps. Plain var (not inspector-editable).
|
||||
var spawn_id: String = ""
|
||||
|
||||
## Appearance-only identifier for Ground. Empty preserves the legacy vector fill.
|
||||
var finish_id: StringName = &""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal node references (built in _ready, @tool-safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -71,6 +76,15 @@ func _ready() -> void:
|
||||
_ensure_children()
|
||||
_apply_points()
|
||||
_apply_style()
|
||||
if spawn_id == "ground":
|
||||
STAGE_DRAW_ORDER.apply_terrain(self)
|
||||
|
||||
|
||||
func set_finish(id: StringName) -> void:
|
||||
finish_id = id if STAGE_SURFACE_RENDERER.is_ground_finish(id) else &""
|
||||
if _polygon != null:
|
||||
_polygon.visible = finish_id.is_empty()
|
||||
queue_redraw()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal build / apply
|
||||
@@ -120,3 +134,14 @@ func _apply_style() -> void:
|
||||
if _outline != null:
|
||||
_outline.default_color = outline_color
|
||||
_outline.width = outline_width
|
||||
if _polygon != null:
|
||||
_polygon.visible = finish_id.is_empty()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if spawn_id != "ground" or finish_id.is_empty() or polygon_points.is_empty():
|
||||
return
|
||||
var rect := Rect2(polygon_points[0], Vector2.ZERO)
|
||||
for point: Vector2 in polygon_points:
|
||||
rect = rect.expand(point)
|
||||
STAGE_SURFACE_RENDERER.draw_ground(self, rect, finish_id, fill_color, global_position, global_scale)
|
||||
|
||||
Reference in New Issue
Block a user