Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 deletions
+548
View File
@@ -0,0 +1,548 @@
class_name PropAttachPreview
extends PanelContainer
## Private editor visualization. It never creates a PropBlock or touches Sandbox state.
enum PreviewHand { LEFT, RIGHT }
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const StickmanFactoryScript := preload("res://scripts/characters/stickman/stickman_factory.gd")
const StkRigAdapterScript := preload("res://scripts/characters/stickman/stk_rig_adapter.gd")
const RigScene := preload("res://scenes/characters/stickman/stickman_rig.tscn")
const RIG_CENTER := Vector2(340.0, 575.0)
const TARGET_RADIUS := 14.0
var _viewport: SubViewport
var _viewport_container: SubViewportContainer
var _world: Node2D
var _rig_root: Node2D
var _rig: StickmanRig
var _prop: Node2D
var _target_marker: Polygon2D
var _camera: Camera2D
var _hint: Label
var _walk_playback_button: Button
var _reset_arm_button: Button
var _reset_view_button: Button
var _hand_option: OptionButton
var _motion_option: OptionButton
var _shapes: Array[Dictionary] = []
var _anchors: Dictionary = {}
var _selected: String = ""
var _hand: int = PreviewHand.RIGHT
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
var _walk_paused: bool = true
var _manual_arm_override: bool = false
# Retained only for old test harnesses; Phase 9c UI uses _walk_paused.
var _animate: bool = false
var _dragging_target: bool = false
var _camera_dragging: bool = false
var _last_pointer := Vector2.ZERO
var _hand_target_rests_local: Dictionary = {}
var _camera_zoom := 1.0
var _fitted_zoom := 1.0
func _ready() -> void:
custom_minimum_size.x = 360.0
var surface := StyleBoxFlat.new()
surface.bg_color = Color(0.075, 0.09, 0.12, 1.0)
surface.set_border_width_all(0)
surface.set_corner_radius_all(5)
add_theme_stylebox_override("panel", surface)
_build_ui()
_build_private_world()
_reframe_camera()
func _build_ui() -> void:
var layout := VBoxContainer.new()
layout.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(layout)
var header := FlowContainer.new()
layout.add_child(header)
var title := Label.new()
title.text = "Holding Preview"
title.custom_minimum_size.x = 132.0
header.add_child(title)
_hand_option = OptionButton.new()
_hand_option.name = "HandOption"
_hand_option.add_item("Left Hand", PreviewHand.LEFT)
_hand_option.add_item("Right Hand", PreviewHand.RIGHT)
_hand_option.select(PreviewHand.RIGHT)
_hand_option.item_selected.connect(set_preview_hand)
header.add_child(_hand_option)
_motion_option = OptionButton.new()
_motion_option.name = "MotionOption"
_motion_option.add_item("Front Pose", PreviewMotion.FRONT_POSE)
_motion_option.add_item("Walk Left", PreviewMotion.WALK_LEFT)
_motion_option.add_item("Walk Right", PreviewMotion.WALK_RIGHT)
_motion_option.item_selected.connect(set_preview_motion)
header.add_child(_motion_option)
_hint = Label.new()
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
layout.add_child(_hint)
_viewport_container = SubViewportContainer.new()
# The pane keeps its 360 px width, but its height must yield to the editor's
# fixed name/status rows when Attach mode makes the preview visible.
_viewport_container.custom_minimum_size = Vector2(360.0, 0.0)
_viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
_viewport_container.stretch = true
_viewport_container.gui_input.connect(_on_viewport_gui_input)
layout.add_child(_viewport_container)
var footer := FlowContainer.new()
layout.add_child(footer)
_walk_playback_button = Button.new()
_walk_playback_button.name = "WalkPlaybackButton"
_walk_playback_button.pressed.connect(toggle_walk_playback)
footer.add_child(_walk_playback_button)
_reset_arm_button = Button.new()
_reset_arm_button.text = "Reset Arm"
_reset_arm_button.pressed.connect(reset_arm_pose)
footer.add_child(_reset_arm_button)
_reset_view_button = Button.new()
_reset_view_button.text = "Reset View"
_reset_view_button.pressed.connect(_reframe_camera)
footer.add_child(_reset_view_button)
_apply_hit_target_metrics(36.0)
func set_ui_hit_target_height(height: float) -> void:
_apply_hit_target_metrics(maxf(height, 36.0))
func _apply_hit_target_metrics(height: float) -> void:
for option: OptionButton in [_hand_option, _motion_option]:
if option != null:
option.custom_minimum_size = Vector2(88.0, height)
option.mouse_filter = Control.MOUSE_FILTER_STOP
option.focus_mode = Control.FOCUS_NONE
for button: Button in [_walk_playback_button, _reset_arm_button, _reset_view_button]:
if button != null:
button.custom_minimum_size = Vector2(112.0, height)
button.mouse_filter = Control.MOUSE_FILTER_STOP
button.focus_mode = Control.FOCUS_NONE
func _build_private_world() -> void:
_viewport = SubViewport.new()
_viewport.name = "PreviewViewport"
_viewport.transparent_bg = false
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport_container.add_child(_viewport)
_viewport_container.resized.connect(_resize_viewport)
_world = Node2D.new()
_world.name = "PreviewWorld"
_viewport.add_child(_world)
_rig_root = Node2D.new()
_rig_root.name = "PreviewRigRoot"
_rig_root.position = RIG_CENTER
_world.add_child(_rig_root)
_rig = RigScene.instantiate() as StickmanRig
_rig.set_physics_process(false)
_rig_root.add_child(_rig)
var data: Dictionary = StickmanFactoryScript.load_stk("res://content/stickmen/test.stk")
if not data.is_empty():
StkRigAdapterScript.apply(data, _rig)
_prop = Node2D.new()
_prop.name = "PreviewProp"
_world.add_child(_prop)
_target_marker = Polygon2D.new()
_target_marker.polygon = PackedVector2Array([Vector2(0, -10), Vector2(10, 0), Vector2(0, 10), Vector2(-10, 0)])
_target_marker.color = Color(0.25, 0.9, 1.0, 0.85)
_world.add_child(_target_marker)
_camera = Camera2D.new()
_camera.name = "PreviewCamera"
_world.add_child(_camera)
_camera.make_current()
_disable_collisions(_rig)
call_deferred("_finish_rig_setup")
func _finish_rig_setup() -> void:
var player := _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer
if player != null:
player.stop()
_rig.stop_queue()
_rig.clear_queue()
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
var target := _target_for(hand)
if target != null:
_hand_target_rests_local[hand] = target.position
_apply_motion_pose()
_update_target_marker()
_rebuild_prop()
_reframe_camera()
func set_document(shapes: Array[Dictionary], anchors: Dictionary, selected_anchor_id: String) -> void:
_shapes = shapes.duplicate(true)
_anchors = anchors.duplicate(true)
_selected = selected_anchor_id
_rebuild_prop()
_reframe_camera()
func set_selected_anchor(anchor_id: String) -> void:
_selected = anchor_id
_rebuild_prop()
func set_preview_hand(hand: int) -> void:
_hand = clampi(hand, PreviewHand.LEFT, PreviewHand.RIGHT)
_manual_arm_override = false
_apply_motion_pose()
_rebuild_prop()
func set_preview_motion(motion: int) -> void:
_motion = clampi(motion, PreviewMotion.FRONT_POSE, PreviewMotion.WALK_RIGHT)
_manual_arm_override = false
_apply_motion_pose(true)
_rebuild_prop()
_reframe_camera()
# Compatibility facade for Phase 9a callers. The visible Facing control was
# removed in 9c; callers now map to the equivalent preview motion.
func set_facing_profile(profile: int) -> void:
if profile == StickmanRig.FacingProfile.LEFT:
set_preview_motion(PreviewMotion.WALK_LEFT)
elif profile == StickmanRig.FacingProfile.RIGHT:
set_preview_motion(PreviewMotion.WALK_RIGHT)
else:
set_preview_motion(PreviewMotion.FRONT_POSE)
func set_walk_paused(paused: bool) -> void:
if _motion == PreviewMotion.FRONT_POSE:
_walk_paused = true
_update_walk_button()
return
var player := _animation_player()
_walk_paused = paused
if player != null:
if paused:
player.pause()
else:
player.play()
_manual_arm_override = false if not paused else _manual_arm_override
_update_walk_button()
func toggle_walk_playback() -> void:
set_walk_paused(not _walk_paused)
func set_arm_animation_enabled(enabled: bool) -> void:
_animate = enabled
if enabled and _motion == PreviewMotion.FRONT_POSE:
set_preview_motion(PreviewMotion.WALK_RIGHT)
elif not enabled:
set_walk_paused(true)
func reset_arm_pose() -> void:
if _motion != PreviewMotion.FRONT_POSE:
set_walk_paused(true)
var player := _animation_player()
if player != null:
player.seek(player.current_animation_position, true)
else:
_restore_standing_targets()
_manual_arm_override = false
_animate = false
_update_target_marker()
_update_prop_transform()
func is_preview_available() -> bool:
return _rig != null and is_instance_valid(_rig) and _viewport != null
func _process(delta: float) -> void:
if not is_preview_available():
return
_update_target_marker()
_update_prop_transform()
func _animation_player() -> AnimationPlayer:
return _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if _rig != null else null
func _restore_standing_targets() -> void:
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
var target := _target_for(hand)
if target != null and _hand_target_rests_local.has(hand):
target.position = _hand_target_rests_local[hand]
func _apply_motion_pose(restart: bool = false) -> void:
if _rig == null:
return
var player := _animation_player()
if _motion == PreviewMotion.FRONT_POSE:
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
_rig_root.scale = Vector2.ONE
if player != null: player.stop(true)
_restore_standing_targets()
_walk_paused = true
else:
var profile: int = StickmanRig.FacingProfile.LEFT if _motion == PreviewMotion.WALK_LEFT else StickmanRig.FacingProfile.RIGHT
_rig.set_facing_profile(profile)
_rig.scale = Vector2.ONE
_rig_root.scale = Vector2(-1.0, 1.0) if _motion == PreviewMotion.WALK_LEFT else Vector2.ONE
if player != null:
if restart or player.current_animation != "walk_right":
player.play("walk_right")
player.seek(0.0, true)
else:
player.play()
_walk_paused = false
_enable_arm_ik()
_update_walk_button()
func _update_walk_button() -> void:
if _walk_playback_button == null:
return
var front: bool = _motion == PreviewMotion.FRONT_POSE
_walk_playback_button.disabled = front
_walk_playback_button.text = "Resume Walk" if _walk_paused else "Pause Walk"
_walk_playback_button.tooltip_text = "Choose Walk Left or Walk Right to preview walking." if front else ""
func _hand_target() -> Marker2D:
return _target_for(_hand)
func _target_for(hand: int) -> Marker2D:
if _rig == null:
return null
return _rig.get_node_or_null("IK_Targets/Left_Hand" if hand == PreviewHand.LEFT else "IK_Targets/Right_Hand") as Marker2D
func _lower_arm() -> Bone2D:
if _rig == null:
return null
return _rig.get_node_or_null("Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" if _hand == PreviewHand.LEFT else "Skeleton2D/Torso/RightUpperArm/RightLowerArm") as Bone2D
func _solved_wrist_transform() -> Transform2D:
var lower_visual := _rig.get_node_or_null("Body/LeftLowerArm" if _hand == PreviewHand.LEFT else "Body/RightLowerArm") as Node2D
var lower := _lower_arm()
if lower == null or lower_visual == null:
return Transform2D.IDENTITY
var elbow := lower_visual.to_global(Vector2.ZERO)
var wrist := lower_visual.to_global(Vector2(0.0, lower.length))
var direction := (wrist - elbow).normalized()
if direction.length_squared() < 0.0001:
return Transform2D.IDENTITY
return Transform2D(direction.angle(), wrist)
func _rebuild_prop() -> void:
if _prop == null:
return
for child: Node in _prop.get_children():
child.queue_free()
var anchor: Dictionary = _anchors.get(_selected, {})
var grip_ok := str(anchor.get("role", "")) == "grip" and _grip_allows_hand(anchor)
_update_hand_choices(anchor)
_prop.visible = grip_ok
if _hint != null:
_hint.text = "Drag the cyan target to pose the arm." if grip_ok else "Select a grip anchor compatible with this hand to preview holding."
if not grip_ok:
return
for shape: Dictionary in _shapes:
_add_shape_visual(shape)
_update_prop_transform()
func _add_shape_visual(shape: Dictionary) -> void:
# PropCanvas exports JSON-safe {x,y} dictionaries. Assigning that Array
# directly to PackedVector2Array silently turns every point into zero.
var points := _coerce_points(shape.get("points", PackedVector2Array()))
if points.is_empty() and str(shape.get("shape_type", "polygon")) == "circle":
var radius := maxf(float(shape.get("radius", 24.0)), 0.001)
var center := Vector2.ZERO
for index: int in 24:
points.append(center + Vector2.from_angle(TAU * float(index) / 24.0) * radius)
if points.is_empty():
return
var color := Color.from_string(str(shape.get("fill_color", "#999999")), Color.GRAY)
if int(shape.get("shape_type", 0)) == 2 or not bool(shape.get("closed", true)):
var line := Line2D.new()
line.points = points
line.width = maxf(float(shape.get("outline_width", 2.0)), 1.0)
line.default_color = Color.from_string(str(shape.get("outline_color", "#eeeeee")), Color.WHITE)
_prop.add_child(line)
else:
var polygon := Polygon2D.new()
polygon.polygon = points
polygon.color = color
_prop.add_child(polygon)
func _coerce_points(raw: Variant) -> PackedVector2Array:
if raw is PackedVector2Array:
return raw as PackedVector2Array
var result := PackedVector2Array()
if raw is Array:
for item: Variant in raw:
if item is Vector2:
result.append(item as Vector2)
elif item is Dictionary:
var point: Dictionary = item as Dictionary
result.append(Vector2(float(point.get("x", 0.0)), float(point.get("y", 0.0))))
return result
func _update_prop_transform() -> void:
if _prop == null or not _prop.visible:
return
var anchor: Dictionary = _anchors.get(_selected, {})
var mirror := _motion == PreviewMotion.WALK_LEFT
_prop.global_transform = PropAnchorDataScript.solve_attachment_transform(_solved_wrist_transform(), anchor, Vector2.ONE, mirror)
# PreviewProp follows PreviewRigRoot in tree order, so zero remains visible
# above the stickman; negative puts it behind and positive remains in front.
_prop.z_index = int(anchor.get("z_index_offset", 0))
func _grip_allows_hand(anchor: Dictionary) -> bool:
var allowed := str(anchor.get("allowed_hands", "both"))
return allowed == "both" or (allowed == "left_only" and _hand == PreviewHand.LEFT) or (allowed == "right_only" and _hand == PreviewHand.RIGHT)
func _update_target_marker() -> void:
var target := _hand_target()
if target != null and _target_marker != null:
_target_marker.global_position = target.global_position
_target_marker.visible = _prop == null or _prop.visible
func _resize_viewport() -> void:
if _viewport != null:
# SubViewportContainer.stretch owns the viewport size. Resizing it here
# fights that ownership and produces an engine warning.
_reframe_camera()
func _on_viewport_gui_input(event: InputEvent) -> void:
if _camera == null:
return
if event is InputEventMouseButton:
var mouse := event as InputEventMouseButton
if mouse.button_index == MOUSE_BUTTON_WHEEL_UP or mouse.button_index == MOUSE_BUTTON_WHEEL_DOWN:
var world_before := _screen_to_world(mouse.position)
_camera_zoom = clampf(_camera_zoom * (1.1 if mouse.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.1), _fitted_zoom * 0.3, _fitted_zoom * 3.0)
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
_camera.position += world_before - _screen_to_world(mouse.position)
return
var world_pos := _screen_to_world(mouse.position)
if mouse.button_index == MOUSE_BUTTON_LEFT:
if mouse.pressed and world_pos.distance_to(_target_marker.global_position) <= TARGET_RADIUS / _camera_zoom:
_dragging_target = true
if _motion != PreviewMotion.FRONT_POSE:
set_walk_paused(true)
_manual_arm_override = true
elif not mouse.pressed:
_dragging_target = false
if mouse.button_index == MOUSE_BUTTON_MIDDLE:
_camera_dragging = mouse.pressed
_last_pointer = mouse.position
elif event is InputEventMouseMotion:
var motion := event as InputEventMouseMotion
if _dragging_target:
_set_target_clamped(_screen_to_world(motion.position))
elif _camera_dragging:
_camera.position -= (motion.position - _last_pointer) / _camera_zoom
_last_pointer = motion.position
func _screen_to_world(screen: Vector2) -> Vector2:
return _viewport.get_canvas_transform().affine_inverse() * screen
func _set_target_clamped(requested: Vector2) -> void:
var target := _hand_target()
var lower := _lower_arm()
if target == null or lower == null:
return
target.global_position = _clamp_target(requested)
func _clamp_target(requested: Vector2) -> Vector2:
var target := _hand_target()
var lower := _lower_arm()
var upper := lower.get_parent() as Bone2D if lower != null else null
if target == null or lower == null or upper == null:
return requested
var origin := upper.global_position
var offset := requested - origin
var rest: Vector2 = target.to_global(_hand_target_rests_local.get(_hand, Vector2.RIGHT)) - origin
if offset.length_squared() < 0.0001:
offset = rest.normalized() if rest.length_squared() > 0.0001 else Vector2.RIGHT
var min_reach := absf(upper.length - lower.length) + 8.0
var max_reach := maxf(min_reach, upper.length + lower.length - 8.0)
return origin + offset.normalized() * clampf(offset.length(), min_reach, max_reach)
func _update_hand_choices(anchor: Dictionary) -> void:
var allowed := str(anchor.get("allowed_hands", "both"))
if allowed == "left_only" and _hand != PreviewHand.LEFT:
_hand = PreviewHand.LEFT
reset_arm_pose()
if allowed == "right_only" and _hand != PreviewHand.RIGHT:
_hand = PreviewHand.RIGHT
reset_arm_pose()
if _hand_option == null:
return
_hand_option.set_item_disabled(PreviewHand.LEFT, allowed == "right_only")
_hand_option.set_item_disabled(PreviewHand.RIGHT, allowed == "left_only")
_hand_option.select(_hand)
func _enable_arm_ik() -> void:
var skeleton := _rig.get_node_or_null("Skeleton2D") as Skeleton2D
if skeleton == null:
return
var stack := skeleton.get_modification_stack()
if stack == null:
return
for index: int in 2:
var modification := stack.get_modification(index)
if modification is SkeletonModification2DTwoBoneIK:
(modification as SkeletonModification2DTwoBoneIK).enabled = true
func _reframe_camera() -> void:
if _camera == null or _viewport == null or _viewport.size.x < 1:
return
var bounds := _visual_bounds(true)
if bounds.size.length_squared() < 0.001:
return
bounds = bounds.grow(32.0)
_camera.position = bounds.get_center()
_fitted_zoom = minf(float(_viewport.size.x) / bounds.size.x, float(_viewport.size.y) / bounds.size.y)
_fitted_zoom = maxf(_fitted_zoom, 0.001)
_camera_zoom = _fitted_zoom
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
func _visual_bounds(full_figure: bool) -> Rect2:
var points := PackedVector2Array()
_collect_visual_points(_rig.get_node_or_null("Body"), points, full_figure)
_collect_visual_points(_prop, points, true)
if points.is_empty():
return Rect2(RIG_CENTER - Vector2(200.0, 200.0), Vector2(400.0, 400.0))
var rect := Rect2(points[0], Vector2.ZERO)
for point: Vector2 in points:
rect = rect.expand(point)
return rect
func get_prop_global_bounds() -> Rect2:
return _visual_bounds_for_node(_prop)
func get_preview_world_rect() -> Rect2:
if _viewport == null:
return Rect2()
var transform := _viewport.get_canvas_transform().affine_inverse()
var top_left := transform * Vector2.ZERO
var bottom_right := transform * Vector2(_viewport.size)
return Rect2(top_left, bottom_right - top_left).abs()
func get_solved_wrist_transform() -> Transform2D:
return _solved_wrist_transform()
func _visual_bounds_for_node(node: Node) -> Rect2:
var points := PackedVector2Array()
_collect_visual_points(node, points, true)
if points.is_empty():
return Rect2()
var rect := Rect2(points[0], Vector2.ZERO)
for point: Vector2 in points:
rect = rect.expand(point)
return rect
func _collect_visual_points(node: Node, out: PackedVector2Array, full_figure: bool) -> void:
if node == null or not (node as CanvasItem).visible:
return
var include := full_figure or not (node.name.contains("Leg"))
if include and node is Polygon2D:
for point: Vector2 in (node as Polygon2D).polygon:
out.append((node as Polygon2D).to_global(point))
if include and node is Line2D:
for point: Vector2 in (node as Line2D).points:
out.append((node as Line2D).to_global(point))
for child: Node in node.get_children():
_collect_visual_points(child, out, full_figure)
func _disable_collisions(node: Node) -> void:
if node is CollisionObject2D:
var body := node as CollisionObject2D
body.collision_layer = 0
body.collision_mask = 0
for child: Node in node.get_children():
_disable_collisions(child)
@@ -0,0 +1 @@
uid://bthsk33yw5rr8
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://lophq30fv72p
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://v8gaqlxoei3a
+341
View File
@@ -0,0 +1,341 @@
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
## 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.
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
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_EXT: String = ".prop"
const DEFAULT_DIR: String = "res://content/props"
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
# index == PropBlock.MaterialPreset
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
const DEFAULT_RADIUS: float = 24.0
const DEFAULT_CIRCLE_SEGMENTS: int = 12
static func preset_to_string(preset: int) -> String:
return PRESET_STRINGS[clampi(preset, 0, PRESET_STRINGS.size() - 1)]
static func preset_from_string(s: String) -> int:
var idx: int = PRESET_STRINGS.find(s.to_lower())
if idx < 0:
return PropBlockScript.MaterialPreset.NONE
return idx
static func shape_type_to_payload_type(s: String) -> int:
if s.to_lower() == "circle":
return PropBlockScript.ShapeType.CIRCLE
return PropBlockScript.ShapeType.POLYGON
## Sanitize a shape dictionary: polygon/line points run through
## TerrainUtils.sanitize_points (grid snap, redundancy/collinear removal,
## clockwise enforcement). Circles pass through unchanged (radius-based).
static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScript.DEFAULT_GRID_SIZE) -> Dictionary:
var result: Dictionary = shape.duplicate(true)
var shape_type: String = str(result.get("shape_type", "polygon"))
if shape_type == "circle":
return result
var pts: PackedVector2Array = _coerce_points(result.get("points", PackedVector2Array()))
var cleaned: PackedVector2Array = TerrainUtilsScript.sanitize_points(pts, grid_size)
var flags: PackedInt32Array = result.get("vertex_flags", PackedInt32Array())
var kept_flags := PackedInt32Array()
# Keep vertex flags aligned: sanitize only ever drops points, so walk the
# cleaned list and retain a flag for each surviving vertex (match by index
# order; approximate alignment is acceptable for write-only metadata).
kept_flags.resize(cleaned.size())
for i: int in mini(cleaned.size(), flags.size()):
kept_flags[i] = flags[i]
result["points"] = cleaned
result["vertex_flags"] = kept_flags
return result
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
var serialized: Array = []
for shape: Dictionary in shapes:
serialized.append(_serialize_shape(shape))
var now: String = Time.get_datetime_string_from_system()
return {
"version": FILE_VERSION,
"prop_name": prop_name,
"material_preset": preset_to_string(material_preset),
"shapes": serialized,
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
"metadata": {"created_at": now, "modified_at": now},
}
## Parse a file-level dictionary (or JSON string) into the editor model.
## Returns {} on any schema error (with a push_warning), otherwise
## { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }.
static func from_file_dict(data: Variant) -> Dictionary:
if data is String:
var parsed: Variant = JSON.parse_string(data as String)
if not parsed is Dictionary:
push_warning("PropFile: file data is not a valid JSON object.")
return {}
data = parsed
if not data is Dictionary:
push_warning("PropFile: file data is not a dictionary.")
return {}
var dict: Dictionary = data as Dictionary
var version: String = str(dict.get("version", ""))
if not SUPPORTED_VERSIONS.has(version):
push_warning("PropFile: unsupported file version '%s'." % version)
return {}
var preset: int = preset_from_string(str(dict.get("material_preset", "none")))
var shapes: Array[Dictionary] = []
var raw_shapes: Variant = dict.get("shapes", [])
if raw_shapes is Array:
for item: Variant in raw_shapes as Array:
if item is Dictionary:
shapes.append(_parse_shape(item as Dictionary, preset))
return {
"prop_name": str(dict.get("prop_name", "")),
"material_preset": preset,
"shapes": shapes,
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
}
## 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.
static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary:
if shapes.is_empty():
push_warning("PropFile: cannot flatten an empty shape stack.")
return {}
var chosen: Dictionary = {}
var kind: String = "polygon"
# 1. Frontmost closed polygon (scan back to front).
for i: int in range(shapes.size() - 1, -1, -1):
var sd: Dictionary = shapes[i]
if str(sd.get("shape_type", "polygon")) == "polygon" and bool(sd.get("closed", true)):
chosen = sd
kind = "polygon"
break
# 2. Else frontmost circle.
if chosen.is_empty():
for i: int in range(shapes.size() - 1, -1, -1):
var sd: Dictionary = shapes[i]
if str(sd.get("shape_type", "polygon")) == "circle":
chosen = sd
kind = "circle"
break
# 3. Else the first shape (even a line) as a polygon.
if chosen.is_empty() and not shapes.is_empty():
chosen = shapes[0]
kind = "polygon"
var payload: Dictionary = {}
if kind == "circle":
payload = {
"type": PropBlockScript.ShapeType.CIRCLE,
"radius": float(chosen.get("radius", DEFAULT_RADIUS)),
}
else:
var pts: PackedVector2Array = _coerce_points(chosen.get("points", PackedVector2Array()))
payload = {
"type": PropBlockScript.ShapeType.POLYGON,
"points": TerrainUtilsScript.sanitize_points(pts),
}
payload["fill_color"] = _color_from_hex(str(chosen.get("fill_color", "")), PropBlockScript.tint_for(PropBlockScript.MaterialPreset.NONE))
payload["outline_color"] = _color_from_hex(str(chosen.get("outline_color", "")), PropBlockScript.DEFAULT_OUTLINE_COLOR)
payload["outline_width"] = float(chosen.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH))
return payload
## Write `data` (a to_file_dict result) as pretty-printed JSON to `path`.
static func save(path: String, data: Dictionary) -> bool:
path = ContentPathsScript.canonicalize(path)
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
if file == null:
push_warning("PropFile: could not open '%s' for writing." % path)
return false
file.store_string(JSON.stringify(data, "\t"))
file.close()
return true
## Atomic write mirroring StageSceneIO.write_file_safe: write a temp file,
## swap the previous file to .bak, rename the temp into place, then remove the
## backup. Used by the editor autosave so a crash mid-write cannot corrupt it.
static func save_safe(path: String, data: Dictionary) -> bool:
path = ContentPathsScript.canonicalize(path)
var tmp := path + ".tmp"
var bak := path + ".bak"
var file := FileAccess.open(tmp, FileAccess.WRITE)
if file == null:
push_warning("PropFile: could not write temporary file '%s'." % tmp)
return false
file.store_string(JSON.stringify(data, "\t"))
file.flush()
file.close()
if FileAccess.file_exists(bak):
DirAccess.remove_absolute(bak)
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
DirAccess.remove_absolute(tmp)
return false
if DirAccess.rename_absolute(tmp, path) != OK:
if FileAccess.file_exists(bak):
DirAccess.rename_absolute(bak, path)
return false
if FileAccess.file_exists(bak):
DirAccess.remove_absolute(bak)
return true
## Read and parse a .prop file. Returns the raw dictionary on success, {}
## on read/parse failure (with a push_warning). Schema validation (version,
## shapes) happens in from_file_dict.
static func load(path: String) -> Dictionary:
path = ContentPathsScript.canonicalize(path)
if not FileAccess.file_exists(path):
push_warning("PropFile: '%s' does not exist." % path)
return {}
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
push_warning("PropFile: could not open '%s' for reading." % path)
return {}
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
push_warning("PropFile: '%s' is not a valid JSON object." % path)
return {}
return parsed as Dictionary
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _serialize_shape(shape: Dictionary) -> Dictionary:
# Accept both internal (PackedVector2Array) and export (Array of {x,y})
# point formats; a typed assignment would silently zero an Array of dicts.
var points: PackedVector2Array = _coerce_points(shape.get("points", PackedVector2Array()))
var points_arr: Array = []
for pt: Vector2 in points:
points_arr.append({"x": pt.x, "y": pt.y})
var flags: PackedInt32Array = shape.get("vertex_flags", PackedInt32Array())
var flags_arr: Array = []
for i: int in flags.size():
flags_arr.append(flags[i])
var result := {
"shape_type": str(shape.get("shape_type", "polygon")),
"points": points_arr,
"fill_color": str(shape.get("fill_color", "")),
"outline_color": str(shape.get("outline_color", "")),
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
"closed": bool(shape.get("closed", false)),
"vertex_flags": flags_arr,
"reshaped": bool(shape.get("reshaped", false)),
}
if str(shape.get("shape_type", "")) == "circle":
result["radius"] = float(shape.get("radius", DEFAULT_RADIUS))
return result
static func _parse_shape(shape: Dictionary, preset: int) -> Dictionary:
var shape_type: String = str(shape.get("shape_type", "polygon"))
if not SHAPE_STRINGS.has(shape_type):
shape_type = "polygon"
var pts := PackedVector2Array()
var pts_variant: Variant = shape.get("points", [])
if pts_variant is Array:
for p: Variant in pts_variant as Array:
if p is Dictionary:
var d: Dictionary = p as Dictionary
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
elif p is Vector2:
pts.append(p as Vector2)
elif pts_variant is PackedVector2Array:
pts = pts_variant as PackedVector2Array
var closed: bool
if shape.has("closed"):
closed = bool(shape.get("closed", false))
else:
closed = shape_type != "line"
var radius: float = float(shape.get("radius", DEFAULT_RADIUS))
if shape_type == "circle" and pts.is_empty():
pts = _circle_points(radius, DEFAULT_CIRCLE_SEGMENTS)
var vf := PackedInt32Array()
var vf_variant: Variant = shape.get("vertex_flags", [])
if vf_variant is Array:
var vf_arr: Array = vf_variant as Array
vf.resize(vf_arr.size())
for i: int in vf_arr.size():
vf[i] = int(vf_arr[i])
elif vf_variant is PackedInt32Array:
vf = vf_variant as PackedInt32Array
if vf.size() != pts.size():
vf.resize(pts.size())
var tint: Color = PropBlockScript.tint_for(preset)
var fill_hex: String = str(shape.get("fill_color", ""))
if fill_hex.is_empty():
fill_hex = tint.to_html()
var outline_hex: String = str(shape.get("outline_color", ""))
if outline_hex.is_empty():
outline_hex = tint.darkened(0.55).to_html()
var result := {
"shape_type": shape_type,
"points": pts,
"radius": radius,
"fill_color": fill_hex,
"outline_color": outline_hex,
"outline_width": float(shape.get("outline_width", PropBlockScript.DEFAULT_OUTLINE_WIDTH)),
"closed": closed,
"vertex_flags": vf,
"reshaped": bool(shape.get("reshaped", false)),
}
return result
static func _circle_points(radius: float, segments: int) -> PackedVector2Array:
var pts := PackedVector2Array()
var seg: int = maxi(segments, 3)
for i: int in seg:
var angle: float = TAU * float(i) / float(seg)
pts.append(Vector2(cos(angle), sin(angle)) * radius)
return pts
static func _color_from_hex(hex: String, fallback: Color) -> Color:
if hex.is_empty():
return fallback
return Color.from_string(hex, fallback)
## Accept either internal (PackedVector2Array) or export (Array of {x,y})
## point formats.
static func _coerce_points(variant: Variant) -> PackedVector2Array:
var pts := PackedVector2Array()
if variant is PackedVector2Array:
pts = variant as PackedVector2Array
elif variant is Array:
for p: Variant in variant as Array:
if p is Dictionary:
var d: Dictionary = p as Dictionary
pts.append(Vector2(float(d.get("x", 0.0)), float(d.get("y", 0.0))))
elif p is Vector2:
pts.append(p as Vector2)
return pts
+1
View File
@@ -0,0 +1 @@
uid://rbk8by20x7g4
+242
View File
@@ -0,0 +1,242 @@
class_name PropTheme
extends RefCounted
## Validated, cached presentation data for the Prop Editor.
##
## Mirrors SandboxTheme's loader mechanics (deep-merge, dotted-path accessors,
## warn-once, clamp, invalid-input fallback) but scoped to prop-editor tokens.
## Fallback hierarchy: prop_theme.json -> DEFAULT_CONFIG -> hardcoded
## PropBlock constants. Physics values (mass/friction/bounce) are deliberately
## NOT themeable; PropBlock's static preset tables remain the single source of
## truth.
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
const SCHEMA_VERSION: String = "1.0"
const DEFAULT_PATH: String = "res://prop_theme.json"
const PRESET_KEYS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"outline_widths": {"default": 2.0, "min": 0.5, "max": 32.0},
"material_tints": {
"wood": {"fill": "#8C6133", "outline": "#40290F"},
"rubber": {"fill": "#E63333", "outline": "#590D0D"},
"cardboard": {"fill": "#D9B873", "outline": "#66521D"},
"metal": {"fill": "#808C99", "outline": "#262E38"},
"none": {"fill": "#8C6133", "outline": "#26201E"},
},
"primitive_defaults": {
"box": {"size_x": 48.0, "size_y": 48.0},
"ball": {"radius": 24.0},
"plank": {"length": 160.0, "thickness": 16.0},
"triangle": {"base": 56.0, "height": 48.0},
"line": {"length": 120.0},
"circle": {"radius": 40.0, "segments": 12},
},
"canvas": {
"grid_size": 15,
"background_color": "#1F1F1F",
"grid_color": "#FFFFFF26",
},
"ui": {
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
},
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "PropTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "PropTheme: '%s' could not be read; using defaults." % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
_warn_once(path, "PropTheme: '%s' is malformed; using defaults." % path)
return false
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func value(path: String, fallback: Variant = null) -> Variant:
var cursor: Variant = _config
for piece: String in path.split("."):
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
return fallback
cursor = (cursor as Dictionary)[piece]
return cursor
func color(path: String, fallback: Color) -> Color:
var raw: Variant = value(path, null)
if not raw is String:
return fallback
var text: String = String(raw)
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
_warn_once(path, "PropTheme: invalid color at '%s'." % path)
return fallback
return Color.from_string(text, fallback)
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
var raw: Variant = value(path, fallback)
if not raw is int and not raw is float:
_warn_once(path, "PropTheme: invalid number at '%s'." % path)
return fallback
var n: float = float(raw)
if not is_finite(n):
return fallback
return clampf(n, minimum, maximum)
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
## Material tint for a PropBlock.MaterialPreset value and channel ("fill" or
## "outline"). Hierarchy: theme file -> DEFAULT_CONFIG -> PropBlock.tint_for
## (outline's final fallback is the tint darkened 0.55).
func material_tint(preset: int, channel: String) -> Color:
var key: String = PRESET_KEYS[preset] if preset >= 0 and preset < PRESET_KEYS.size() else "none"
var tint: Color = PropBlockScript.tint_for(preset)
var fallback: Color = tint if channel == "fill" else tint.darkened(0.55)
return color("material_tints.%s.%s" % [key, channel], fallback)
func outline_width_default() -> float:
return number("outline_widths.default", PropBlockScript.DEFAULT_OUTLINE_WIDTH, 0.0, 256.0)
func outline_width_min() -> float:
return number("outline_widths.min", 0.5, 0.0, 256.0)
func outline_width_max() -> float:
return number("outline_widths.max", 32.0, 0.0, 1024.0)
## Primitive default dimension, e.g. primitive_default("box", "size_x").
## Falls back to the DEFAULT_CONFIG primitive table (which mirrors the
## PropUtils.create_* generator defaults).
func primitive_default(primitive: String, key: String, fallback: float = 0.0) -> float:
var table: Dictionary = value("primitive_defaults.%s" % primitive, {})
if table is Dictionary and (table as Dictionary).has(key):
return number("primitive_defaults.%s.%s" % [primitive, key], fallback)
var defaults: Dictionary = DEFAULT_CONFIG.get("primitive_defaults", {})
var entry: Dictionary = defaults.get(primitive, {})
return float(entry.get(key, fallback))
func grid_size() -> int:
return integer("canvas.grid_size", 15, 1, 512)
func canvas_background_color() -> Color:
return color("canvas.background_color", Color("#1F1F1F"))
func canvas_grid_color() -> Color:
return color("canvas.grid_color", Color(1.0, 1.0, 1.0, 0.15))
# ---------------------------------------------------------------------------
# Kid-friendly UI tokens (Round 2)
# ---------------------------------------------------------------------------
func font_body() -> int:
return integer("ui.fonts.body", 18, 8, 72)
func font_header() -> int:
return integer("ui.fonts.header", 24, 8, 96)
func font_menu() -> int:
return integer("ui.fonts.menu", 22, 8, 72)
func font_status() -> int:
return integer("ui.fonts.status", 18, 8, 72)
func font_mode() -> int:
return integer("ui.fonts.mode", 26, 8, 96)
func font_menu_bar() -> int:
return integer("ui.fonts.menu_bar", 22, 8, 72)
func font_prop_name() -> int:
return integer("ui.fonts.prop_name", 20, 8, 96)
func font_context_menu() -> int:
return integer("ui.fonts.context_menu", 22, 8, 96)
func button_min_height() -> int:
return integer("ui.hit_targets.button_min_height", 36, 16, 200)
func row_min_height() -> int:
return integer("ui.hit_targets.row_min_height", 36, 16, 200)
func mode_button_min_height() -> int:
return integer("ui.hit_targets.mode_button_min_height", 56, 16, 200)
func text_color() -> Color:
return color("ui.colors.text", Color("#F2F2F2"))
func text_muted_color() -> Color:
return color("ui.colors.text_muted", Color("#9A9A9A"))
func border_color() -> Color:
return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
func accent_color() -> Color:
return color("ui.colors.accent", Color("#4FA3FF"))
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
func invalidate_caches() -> void:
_warned.clear()
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
for key: Variant in overrides:
if merged.get(key) is Dictionary and overrides[key] is Dictionary:
merged[key] = _deep_merge(merged[key], overrides[key])
else:
merged[key] = overrides[key]
return merged
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key):
_warned[key] = true
push_warning(message)
+1
View File
@@ -0,0 +1 @@
uid://qxwnxe3u1o33