Phase 14 - Organized directories.
This commit is contained in:
@@ -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
@@ -0,0 +1 @@
|
||||
uid://lophq30fv72p
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://v8gaqlxoei3a
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://rbk8by20x7g4
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qxwnxe3u1o33
|
||||
@@ -0,0 +1,349 @@
|
||||
class_name ActionEditor
|
||||
extends PopupPanel
|
||||
## ActionEditor - Single-action property editor popup (Phase 3c).
|
||||
##
|
||||
## Used for adding a new action and for editing an existing one (queue or rule).
|
||||
## The type dropdown and the param fields are generated from ActionRegistry, so
|
||||
## new action types appear automatically. For `walk_to`, the target position is
|
||||
## captured on the stage: the editor emits `target_requested()` and the stage
|
||||
## hides the editor, captures a click, then calls `set_walk_target()`.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
|
||||
signal committed(action: Dictionary)
|
||||
signal cancelled()
|
||||
signal target_requested()
|
||||
signal speech_requested(initial: Dictionary)
|
||||
|
||||
var _mode: String = "new"
|
||||
var _initial: Dictionary = {}
|
||||
var _type_option: OptionButton = null
|
||||
var _params_box: VBoxContainer = null
|
||||
var _text_edit: LineEdit = null
|
||||
var _duration_spin: SpinBox = null
|
||||
var _target_label: Label = null
|
||||
var _set_target_btn: Button = null
|
||||
var _prop_target_label: Label = null
|
||||
var _set_prop_target_btn: Button = null
|
||||
var _hand_option: OptionButton = null
|
||||
var _stop_current_check: CheckBox = null
|
||||
var _show_reactive_options: bool = false
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector2 = Vector2.ZERO
|
||||
var _prop_target_id: String = ""
|
||||
var _has_throw_target: bool = false
|
||||
var _throw_target_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _base_font_size: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
exclusive = true
|
||||
popup_window = true
|
||||
_build_ui()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||
cancelled.emit()
|
||||
|
||||
|
||||
## Opens the editor in "add" mode (empty, default type walk_to).
|
||||
func open_new(show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_mode = "new"
|
||||
_initial = {}
|
||||
_has_target = false
|
||||
_target_pos = Vector2.ZERO
|
||||
_prop_target_id = ""
|
||||
_has_throw_target = false
|
||||
_throw_target_pos = Vector2.ZERO
|
||||
_select_type("walk_to")
|
||||
_rebuild_params("walk_to")
|
||||
title = "Add Action"
|
||||
popup_centered()
|
||||
|
||||
|
||||
## Opens the editor pre-filled from a flat queue action.
|
||||
func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||
_show_reactive_options = show_reactive_options
|
||||
_mode = "edit"
|
||||
_initial = action.duplicate(true)
|
||||
var type := String(action.get("type", "walk_to"))
|
||||
if type == "speak":
|
||||
speech_requested.emit(_initial.duplicate(true))
|
||||
return
|
||||
_has_target = action.has("target") and action["target"] is Vector2
|
||||
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
|
||||
_prop_target_id = String(action.get("target_prop_id", ""))
|
||||
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
|
||||
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
|
||||
_select_type(type)
|
||||
_rebuild_params(type)
|
||||
title = "Edit Action"
|
||||
popup_centered()
|
||||
|
||||
|
||||
## Called by the stage after a walk-target capture click.
|
||||
func set_walk_target(pos: Vector2) -> void:
|
||||
_has_target = true
|
||||
_target_pos = pos
|
||||
_update_walk_target_label()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_prop_target(prop_id: String) -> void:
|
||||
_prop_target_id = prop_id
|
||||
_update_prop_target_label()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_throw_target(pos: Vector2) -> void:
|
||||
_has_throw_target = true
|
||||
_throw_target_pos = pos
|
||||
_update_throw_target_label()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func current_action_type() -> String:
|
||||
return _current_type()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(vbox)
|
||||
|
||||
var type_row := HBoxContainer.new()
|
||||
vbox.add_child(type_row)
|
||||
var type_label := Label.new()
|
||||
type_label.text = "Type:"
|
||||
type_row.add_child(type_label)
|
||||
_type_option = OptionButton.new()
|
||||
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
for type: String in ACTION_REGISTRY.types():
|
||||
_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||
_type_option.item_selected.connect(_on_type_selected)
|
||||
type_row.add_child(_type_option)
|
||||
|
||||
_params_box = VBoxContainer.new()
|
||||
_params_box.add_theme_constant_override("separation", 6)
|
||||
vbox.add_child(_params_box)
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
buttons.alignment = BoxContainer.ALIGNMENT_END
|
||||
buttons.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(buttons)
|
||||
|
||||
var cancel := Button.new()
|
||||
cancel.text = "Cancel"
|
||||
cancel.pressed.connect(func() -> void: cancelled.emit())
|
||||
buttons.add_child(cancel)
|
||||
|
||||
var ok := Button.new()
|
||||
ok.text = "OK"
|
||||
ok.pressed.connect(_on_ok_pressed)
|
||||
buttons.add_child(ok)
|
||||
|
||||
min_size = Vector2i(340, 200)
|
||||
|
||||
|
||||
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||
## the stage after add_child so the editor's UI is already built.
|
||||
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_ui_font = ui_font
|
||||
_emoji_font = emoji_font
|
||||
_base_font_size = int(sizes.get("action_editor", 18))
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
if child is Control:
|
||||
_apply_font_to(child as Control, size)
|
||||
_apply_font_recursive(child, size)
|
||||
|
||||
|
||||
func _apply_font_to(c: Control, size: int) -> void:
|
||||
if c == null:
|
||||
return
|
||||
if _ui_font != null:
|
||||
c.add_theme_font_override("font", _ui_font)
|
||||
elif _emoji_font != null:
|
||||
c.add_theme_font_override("font", _emoji_font)
|
||||
if size > 0:
|
||||
c.add_theme_font_size_override("font_size", size)
|
||||
|
||||
|
||||
func _select_type(type: String) -> void:
|
||||
var idx := ACTION_REGISTRY.types().find(type)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
_type_option.select(idx)
|
||||
|
||||
|
||||
func _current_type() -> String:
|
||||
var types := ACTION_REGISTRY.types()
|
||||
var idx := _type_option.selected
|
||||
if idx < 0 or idx >= types.size():
|
||||
return "walk_to"
|
||||
return types[idx]
|
||||
|
||||
|
||||
func _on_type_selected(_index: int) -> void:
|
||||
if _current_type() == "speak":
|
||||
speech_requested.emit(_initial.duplicate(true))
|
||||
return
|
||||
_rebuild_params(_current_type())
|
||||
|
||||
|
||||
## Rebuilds the param controls for the given action type.
|
||||
func _rebuild_params(type: String) -> void:
|
||||
for child: Node in _params_box.get_children():
|
||||
child.queue_free()
|
||||
_text_edit = null
|
||||
_duration_spin = null
|
||||
_target_label = null
|
||||
_set_target_btn = null
|
||||
_prop_target_label = null
|
||||
_set_prop_target_btn = null
|
||||
_hand_option = null
|
||||
_stop_current_check = null
|
||||
|
||||
match type:
|
||||
"walk_to":
|
||||
var trow := HBoxContainer.new()
|
||||
_params_box.add_child(trow)
|
||||
_target_label = Label.new()
|
||||
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
trow.add_child(_target_label)
|
||||
_set_target_btn = Button.new()
|
||||
_set_target_btn.text = "🎯 Click target…"
|
||||
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||
trow.add_child(_set_target_btn)
|
||||
_update_walk_target_label()
|
||||
"pick_up_prop":
|
||||
var prop_row := HBoxContainer.new()
|
||||
_params_box.add_child(prop_row)
|
||||
_prop_target_label = Label.new()
|
||||
_prop_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
prop_row.add_child(_prop_target_label)
|
||||
_set_prop_target_btn = Button.new()
|
||||
_set_prop_target_btn.text = "🎯 Click prop…"
|
||||
_set_prop_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||
prop_row.add_child(_set_prop_target_btn)
|
||||
_update_prop_target_label()
|
||||
var hand_row := HBoxContainer.new()
|
||||
_params_box.add_child(hand_row)
|
||||
var hand_label := Label.new()
|
||||
hand_label.text = "Hand:"
|
||||
hand_row.add_child(hand_label)
|
||||
_hand_option = OptionButton.new()
|
||||
for label: String in ["Auto", "Left", "Right"]:
|
||||
_hand_option.add_item(label)
|
||||
var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))
|
||||
_hand_option.select(maxi(hand_idx, 0))
|
||||
hand_row.add_child(_hand_option)
|
||||
"throw_prop":
|
||||
var throw_row := HBoxContainer.new()
|
||||
_params_box.add_child(throw_row)
|
||||
_target_label = Label.new()
|
||||
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
throw_row.add_child(_target_label)
|
||||
_set_target_btn = Button.new()
|
||||
_set_target_btn.text = "🎯 Click target…"
|
||||
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||
throw_row.add_child(_set_target_btn)
|
||||
_update_throw_target_label()
|
||||
"speak":
|
||||
var speech_label := Label.new()
|
||||
speech_label.text = "Opening the comic speech composer…"
|
||||
_params_box.add_child(speech_label)
|
||||
"wait":
|
||||
var wlabel := Label.new()
|
||||
wlabel.text = "Duration (s):"
|
||||
_params_box.add_child(wlabel)
|
||||
_duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0)))
|
||||
_params_box.add_child(_duration_spin)
|
||||
_:
|
||||
# ragdoll / recover (and any future no-param action) need no fields.
|
||||
pass
|
||||
_apply_font_recursive(_params_box, _base_font_size)
|
||||
|
||||
|
||||
func _make_duration_spin(value: float) -> SpinBox:
|
||||
var spin := SpinBox.new()
|
||||
spin.min_value = 0.1
|
||||
spin.max_value = 3600.0
|
||||
spin.step = 0.1
|
||||
spin.value = value
|
||||
spin.custom_minimum_size = Vector2(120.0, 0.0)
|
||||
return spin
|
||||
|
||||
|
||||
func _update_walk_target_label() -> void:
|
||||
if _target_label == null:
|
||||
return
|
||||
if _has_target:
|
||||
_target_label.text = "Target: (%d, %d)" % [int(roundf(_target_pos.x)), int(roundf(_target_pos.y))]
|
||||
else:
|
||||
_target_label.text = "Target: not set"
|
||||
|
||||
|
||||
func _update_prop_target_label() -> void:
|
||||
if _prop_target_label != null:
|
||||
_prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set")
|
||||
|
||||
|
||||
func _update_throw_target_label() -> void:
|
||||
if _target_label == null:
|
||||
return
|
||||
if _has_throw_target:
|
||||
_target_label.text = "Target: (%d, %d)" % [int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
|
||||
else:
|
||||
_target_label.text = "Target: not set"
|
||||
|
||||
|
||||
func _on_ok_pressed() -> void:
|
||||
var type := _current_type()
|
||||
var action: Dictionary = { "type": type }
|
||||
match type:
|
||||
"walk_to":
|
||||
if not _has_target:
|
||||
# Ask the stage to capture the target before committing.
|
||||
target_requested.emit()
|
||||
return
|
||||
action["target"] = _target_pos
|
||||
"pick_up_prop":
|
||||
if _prop_target_id.is_empty():
|
||||
target_requested.emit()
|
||||
return
|
||||
var hands: Array[String] = ["auto", "left", "right"]
|
||||
action["target_prop_id"] = _prop_target_id
|
||||
action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto"
|
||||
"throw_prop":
|
||||
if not _has_throw_target:
|
||||
target_requested.emit()
|
||||
return
|
||||
action["target_position"] = _throw_target_pos
|
||||
"speak":
|
||||
speech_requested.emit(_initial.duplicate(true))
|
||||
return
|
||||
"wait":
|
||||
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
|
||||
committed.emit(action)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3nsgtnt1cvh5
|
||||
@@ -0,0 +1,306 @@
|
||||
class_name QueuePanel
|
||||
extends PopupPanel
|
||||
## QueuePanel - Action Queue editor popup (Phase 3c.2).
|
||||
##
|
||||
## Shows all queued actions for one stickman, in order, with edit / delete /
|
||||
## drag-reorder controls. Mutations go through the StickmanRig queue API
|
||||
## (queue_action / remove_action / insert_action / clear_queue), which already
|
||||
## emits `queue_changed` and redraws the director overlay. Editing and adding
|
||||
## delegate to the stage via signals so this panel stays decoupled.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||
|
||||
signal edit_requested(index: int)
|
||||
signal delete_requested(index: int)
|
||||
signal add_requested()
|
||||
signal clear_requested()
|
||||
|
||||
var rig: StickmanRig = null
|
||||
|
||||
var _title_label: Label = null
|
||||
var _list: VBoxContainer = null
|
||||
var _empty_label: Label = null
|
||||
var _rows: Array[PanelContainer] = []
|
||||
|
||||
## Drag-reorder state.
|
||||
var _drag_index: int = -1
|
||||
var _drag_target: int = -1
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _base_font_size: int = 0
|
||||
var _row_font_size: int = 0
|
||||
var _title_font_size: int = 0
|
||||
var _title_font: Font = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
exclusive = true
|
||||
popup_window = true
|
||||
_build_ui()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||
hide()
|
||||
|
||||
|
||||
## Attaches a rig and rebuilds the row list.
|
||||
func setup(r: StickmanRig) -> void:
|
||||
rig = r
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _list == null:
|
||||
return
|
||||
for child: Node in _list.get_children():
|
||||
if child != _empty_label:
|
||||
child.queue_free()
|
||||
_rows.clear()
|
||||
_drag_index = -1
|
||||
_drag_target = -1
|
||||
|
||||
var queue_view: Array[Dictionary] = []
|
||||
if rig != null and is_instance_valid(rig):
|
||||
queue_view = rig.get_queue_view()
|
||||
_title_label.text = "Stickman: %s" % (String(rig.name) if rig != null and is_instance_valid(rig) else "?")
|
||||
_empty_label.visible = queue_view.is_empty()
|
||||
|
||||
for i: int in queue_view.size():
|
||||
var entry: Dictionary = queue_view[i]
|
||||
_rows.append(_make_row(i, entry.get("action", {}), int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED))))
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
title = "Action Queue"
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(vbox)
|
||||
|
||||
var title_bar := HBoxContainer.new()
|
||||
vbox.add_child(title_bar)
|
||||
_title_label = Label.new()
|
||||
_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_title_label.text = "Stickman: ?"
|
||||
title_bar.add_child(_title_label)
|
||||
var close_btn := Button.new()
|
||||
close_btn.text = "× Close"
|
||||
close_btn.pressed.connect(hide)
|
||||
title_bar.add_child(close_btn)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.custom_minimum_size = Vector2(0.0, 260.0)
|
||||
vbox.add_child(scroll)
|
||||
|
||||
_list = VBoxContainer.new()
|
||||
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_list.add_theme_constant_override("separation", 4)
|
||||
scroll.add_child(_list)
|
||||
|
||||
_empty_label = Label.new()
|
||||
_empty_label.text = "Queue is empty."
|
||||
_empty_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||||
_list.add_child(_empty_label)
|
||||
|
||||
var footer := HBoxContainer.new()
|
||||
footer.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(footer)
|
||||
var add_btn := Button.new()
|
||||
add_btn.text = "➕ Add Action"
|
||||
add_btn.pressed.connect(func() -> void: add_requested.emit())
|
||||
footer.add_child(add_btn)
|
||||
var clear_btn := Button.new()
|
||||
clear_btn.text = "🗑 Clear All"
|
||||
clear_btn.pressed.connect(func() -> void: clear_requested.emit())
|
||||
footer.add_child(clear_btn)
|
||||
|
||||
min_size = Vector2i(460, 360)
|
||||
|
||||
|
||||
func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
panel.add_child(row)
|
||||
|
||||
var number := Label.new()
|
||||
number.text = str(index + 1)
|
||||
row.add_child(number)
|
||||
|
||||
var summary := Label.new()
|
||||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
|
||||
row.add_child(summary)
|
||||
var status_label := Label.new()
|
||||
status_label.text = _status_label(status)
|
||||
status_label.modulate = Color(1.0, 1.0, 1.0, 0.65) if status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED else Color.WHITE
|
||||
row.add_child(status_label)
|
||||
|
||||
var edit := Button.new()
|
||||
edit.text = "✎"
|
||||
edit.tooltip_text = "Edit"
|
||||
edit.disabled = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
edit.pressed.connect(func() -> void: edit_requested.emit(index))
|
||||
row.add_child(edit)
|
||||
|
||||
var remove := Button.new()
|
||||
remove.text = "✕"
|
||||
remove.tooltip_text = "Delete"
|
||||
remove.pressed.connect(func() -> void: delete_requested.emit(index))
|
||||
row.add_child(remove)
|
||||
|
||||
var drag := Button.new()
|
||||
drag.text = "≡"
|
||||
drag.tooltip_text = "Drag to reorder"
|
||||
drag.disabled = status == ACTION_RUNNER.QueueStatus.ACTIVE or status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
|
||||
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
|
||||
row.add_child(drag)
|
||||
|
||||
# Parent the row into the list and apply the theme font/size overrides. Without
|
||||
# add_child the row never renders and drag-reorder has no geometry to work with.
|
||||
_list.add_child(panel)
|
||||
_apply_font_recursive(panel, _row_font_size)
|
||||
return panel
|
||||
|
||||
|
||||
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||
## the stage after add_child so the panel's UI is already built. `sizes` carries the
|
||||
## parsed per-widget size / title / row sizes, the panel-title bold flag, and the
|
||||
## resolved bold/italic Font variants.
|
||||
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_ui_font = ui_font
|
||||
_emoji_font = emoji_font
|
||||
_base_font_size = int(sizes.get("queue_panel", 18))
|
||||
_row_font_size = int(sizes.get("panel_row", 16))
|
||||
_title_font_size = int(sizes.get("panel_title", 18))
|
||||
var title_bold := bool(sizes.get("panel_title_bold", true))
|
||||
var bold_font: Font = sizes.get("bold_font", null)
|
||||
_title_font = bold_font if title_bold and bold_font != null else ui_font
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
if _title_label != null:
|
||||
_apply_font_to(_title_label, _title_font_size, _title_font)
|
||||
if _empty_label != null:
|
||||
_apply_font_to(_empty_label, _row_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
if child is Control:
|
||||
_apply_font_to(child as Control, size)
|
||||
_apply_font_recursive(child, size)
|
||||
|
||||
|
||||
func _apply_font_to(c: Control, size: int, font: Font = null) -> void:
|
||||
if c == null:
|
||||
return
|
||||
if font != null:
|
||||
c.add_theme_font_override("font", font)
|
||||
elif _ui_font != null:
|
||||
c.add_theme_font_override("font", _ui_font)
|
||||
elif _emoji_font != null:
|
||||
c.add_theme_font_override("font", _emoji_font)
|
||||
if size > 0:
|
||||
c.add_theme_font_size_override("font_size", size)
|
||||
|
||||
|
||||
func _row_style(bg: Color) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
sb.set_corner_radius_all(4)
|
||||
sb.content_margin_left = 6.0
|
||||
sb.content_margin_right = 6.0
|
||||
sb.content_margin_top = 2.0
|
||||
sb.content_margin_bottom = 2.0
|
||||
return sb
|
||||
|
||||
|
||||
func _status_label(status: int) -> String:
|
||||
match status:
|
||||
ACTION_RUNNER.QueueStatus.COMPLETED:
|
||||
return "✓ Done"
|
||||
ACTION_RUNNER.QueueStatus.INTERRUPTED:
|
||||
return "↯ Interrupted"
|
||||
ACTION_RUNNER.QueueStatus.ACTIVE:
|
||||
return "▶ Active"
|
||||
ACTION_RUNNER.QueueStatus.PENDING:
|
||||
return "• Pending"
|
||||
_:
|
||||
return ""
|
||||
|
||||
|
||||
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
|
||||
if (event as InputEventMouseButton).pressed:
|
||||
_drag_index = index
|
||||
_drag_target = index
|
||||
_refresh_drag_highlight()
|
||||
else:
|
||||
_commit_drag()
|
||||
elif event is InputEventMouseMotion and _drag_index >= 0:
|
||||
_update_drag_target()
|
||||
|
||||
|
||||
func _update_drag_target() -> void:
|
||||
var mouse_y := _list.get_local_mouse_position().y
|
||||
var best := _drag_target
|
||||
var best_d := INF
|
||||
for i: int in _rows.size():
|
||||
var row := _rows[i]
|
||||
var d := absf((row.position.y + row.size.y * 0.5) - mouse_y)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = i
|
||||
if best != _drag_target:
|
||||
_drag_target = best
|
||||
_refresh_drag_highlight()
|
||||
|
||||
|
||||
func _refresh_drag_highlight() -> void:
|
||||
for i: int in _rows.size():
|
||||
var bg := Color(0.0, 0.0, 0.0, 0.0)
|
||||
if _drag_index >= 0 and i == _drag_target:
|
||||
bg = Color(0.15, 0.4, 0.9, 0.4)
|
||||
(_rows[i] as PanelContainer).add_theme_stylebox_override("panel", _row_style(bg))
|
||||
|
||||
|
||||
func _commit_drag() -> void:
|
||||
var from := _drag_index
|
||||
var to := _drag_target
|
||||
_drag_index = -1
|
||||
_drag_target = -1
|
||||
_refresh_drag_highlight()
|
||||
if from < 0 or to < 0 or from == to:
|
||||
return
|
||||
_move_action(from, to)
|
||||
refresh()
|
||||
|
||||
|
||||
## Moves the action at `from` to `to` in the rig's queue (indices in the
|
||||
## pre-removal space), using the existing remove/insert API.
|
||||
func _move_action(from: int, to: int) -> void:
|
||||
if rig == null or not is_instance_valid(rig):
|
||||
return
|
||||
var queue := rig.get_queue()
|
||||
if from < 0 or from >= queue.size() or to < 0 or to >= queue.size():
|
||||
return
|
||||
rig.move_action(from, to)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkkscjlbhfmdq
|
||||
@@ -0,0 +1,400 @@
|
||||
class_name RuleEditor
|
||||
extends PopupPanel
|
||||
## RuleEditor - Rule property editor popup (Phase 3c).
|
||||
##
|
||||
## Two modes:
|
||||
## * "full" - trigger type + target + actions are all editable.
|
||||
## * "consequence" - trigger is read-only; only the actions are editable.
|
||||
##
|
||||
## Trigger targets and action actors are captured on the stage via the
|
||||
## `trigger_target_requested` / `action_add_requested` / `action_edit_requested`
|
||||
## signals; the stage hides this popup, captures a click, then calls
|
||||
## `set_trigger_target()` / `set_action()` and re-pops it.
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||
|
||||
signal committed(rule: Dictionary)
|
||||
signal cancelled()
|
||||
signal trigger_target_requested(trigger_type: String)
|
||||
signal action_add_requested()
|
||||
signal action_edit_requested(index: int)
|
||||
|
||||
var _mode: String = "full"
|
||||
var _rule_id: int = -1
|
||||
var _trigger: Dictionary = {}
|
||||
var _actions: Array[Dictionary] = []
|
||||
|
||||
var _trigger_box: VBoxContainer = null
|
||||
var _type_option: OptionButton = null
|
||||
var _target_row: HBoxContainer = null
|
||||
var _target_label: Label = null
|
||||
var _set_target_btn: Button = null
|
||||
var _action_type_option: OptionButton = null
|
||||
var _trigger_readonly_label: Label = null
|
||||
var _actions_box: VBoxContainer = null
|
||||
var _empty_actions_label: Label = null
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _base_font_size: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
exclusive = true
|
||||
popup_window = true
|
||||
_build_ui()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||
cancelled.emit()
|
||||
|
||||
|
||||
## Opens the full editor pre-filled from a stored rule.
|
||||
func open_full(rule: Dictionary) -> void:
|
||||
_mode = "full"
|
||||
_load_rule(rule)
|
||||
title = "Edit Rule"
|
||||
popup_centered()
|
||||
|
||||
|
||||
## Opens the consequence-only editor (trigger read-only) from a stored rule.
|
||||
func open_consequence(rule: Dictionary) -> void:
|
||||
_mode = "consequence"
|
||||
_load_rule(rule)
|
||||
title = "Edit Rule"
|
||||
popup_centered()
|
||||
|
||||
|
||||
## Called by the stage after a trigger-target capture click. `target_id` is the
|
||||
## instance id (or -1 for waypoint triggers), `params` holds type-specific data.
|
||||
func set_trigger_target(target_id: int, params: Dictionary) -> void:
|
||||
_trigger["target"] = target_id
|
||||
_trigger["params"] = params
|
||||
_refresh_trigger()
|
||||
popup_centered()
|
||||
|
||||
|
||||
## Called by the stage after an action is collected. `index` < 0 appends.
|
||||
func set_action(index: int, action: Dictionary) -> void:
|
||||
if index < 0:
|
||||
_actions.append(action)
|
||||
else:
|
||||
_actions[index] = action
|
||||
_refresh_actions()
|
||||
popup_centered()
|
||||
|
||||
|
||||
## Returns the rule-action at `index` (or {} when out of range). Used by the
|
||||
## stage to pre-fill the ActionEditor when editing an existing rule action.
|
||||
func get_action(index: int) -> Dictionary:
|
||||
if index < 0 or index >= _actions.size():
|
||||
return {}
|
||||
return _actions[index].duplicate(true)
|
||||
|
||||
|
||||
func _load_rule(rule: Dictionary) -> void:
|
||||
_rule_id = int(rule.get("id", -1))
|
||||
_trigger = (rule.get("trigger", {}) as Dictionary).duplicate(true)
|
||||
_actions.clear()
|
||||
for a: Dictionary in rule.get("actions", []):
|
||||
_actions.append(a.duplicate(true))
|
||||
var type := String(_trigger.get("type", "arrived_at_waypoint"))
|
||||
if not TRIGGER_REGISTRY.has_type(type):
|
||||
type = "arrived_at_waypoint"
|
||||
_select_type(type)
|
||||
_refresh_trigger()
|
||||
_refresh_actions()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(vbox)
|
||||
|
||||
# Trigger section.
|
||||
var trigger_header := Label.new()
|
||||
trigger_header.text = "Trigger:"
|
||||
vbox.add_child(trigger_header)
|
||||
|
||||
_trigger_box = VBoxContainer.new()
|
||||
_trigger_box.add_theme_constant_override("separation", 6)
|
||||
vbox.add_child(_trigger_box)
|
||||
|
||||
_trigger_readonly_label = Label.new()
|
||||
_trigger_readonly_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_trigger_box.add_child(_trigger_readonly_label)
|
||||
|
||||
var type_row := HBoxContainer.new()
|
||||
_trigger_box.add_child(type_row)
|
||||
var type_label := Label.new()
|
||||
type_label.text = "When:"
|
||||
type_row.add_child(type_label)
|
||||
_type_option = OptionButton.new()
|
||||
_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
for type: String in TRIGGER_REGISTRY.types():
|
||||
_type_option.add_item("%s %s" % [TRIGGER_REGISTRY.icon(type), TRIGGER_REGISTRY.label(type)])
|
||||
_type_option.item_selected.connect(_on_type_selected)
|
||||
type_row.add_child(_type_option)
|
||||
|
||||
_target_row = HBoxContainer.new()
|
||||
_trigger_box.add_child(_target_row)
|
||||
_target_label = Label.new()
|
||||
_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_target_row.add_child(_target_label)
|
||||
_set_target_btn = Button.new()
|
||||
_set_target_btn.text = "🎯 Click target…"
|
||||
_set_target_btn.pressed.connect(_on_set_trigger_target)
|
||||
_target_row.add_child(_set_target_btn)
|
||||
|
||||
_action_type_option = OptionButton.new()
|
||||
_action_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_action_type_option.add_item("Any action", 0)
|
||||
for type: String in ACTION_REGISTRY.types():
|
||||
_action_type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)])
|
||||
_target_row.add_child(_action_type_option)
|
||||
|
||||
# Actions section.
|
||||
var actions_header := Label.new()
|
||||
actions_header.text = "Actions:"
|
||||
vbox.add_child(actions_header)
|
||||
|
||||
_actions_box = VBoxContainer.new()
|
||||
_actions_box.add_theme_constant_override("separation", 4)
|
||||
vbox.add_child(_actions_box)
|
||||
|
||||
_empty_actions_label = Label.new()
|
||||
_empty_actions_label.text = "No actions yet."
|
||||
_empty_actions_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||||
_actions_box.add_child(_empty_actions_label)
|
||||
|
||||
var add_action := Button.new()
|
||||
add_action.text = "➕ Add Action"
|
||||
add_action.pressed.connect(func() -> void: action_add_requested.emit())
|
||||
vbox.add_child(add_action)
|
||||
|
||||
# Footer buttons.
|
||||
var buttons := HBoxContainer.new()
|
||||
buttons.alignment = BoxContainer.ALIGNMENT_END
|
||||
buttons.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(buttons)
|
||||
|
||||
var cancel := Button.new()
|
||||
cancel.text = "Cancel"
|
||||
cancel.pressed.connect(func() -> void: cancelled.emit())
|
||||
buttons.add_child(cancel)
|
||||
|
||||
var ok := Button.new()
|
||||
ok.text = "OK"
|
||||
ok.pressed.connect(_on_ok_pressed)
|
||||
buttons.add_child(ok)
|
||||
|
||||
min_size = Vector2i(420, 320)
|
||||
|
||||
|
||||
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||
## the stage after add_child so the editor's UI is already built.
|
||||
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_ui_font = ui_font
|
||||
_emoji_font = emoji_font
|
||||
_base_font_size = int(sizes.get("rule_editor", 18))
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
if child is Control:
|
||||
_apply_font_to(child as Control, size)
|
||||
_apply_font_recursive(child, size)
|
||||
|
||||
|
||||
func _apply_font_to(c: Control, size: int) -> void:
|
||||
if c == null:
|
||||
return
|
||||
if _ui_font != null:
|
||||
c.add_theme_font_override("font", _ui_font)
|
||||
elif _emoji_font != null:
|
||||
c.add_theme_font_override("font", _emoji_font)
|
||||
if size > 0:
|
||||
c.add_theme_font_size_override("font_size", size)
|
||||
|
||||
|
||||
func _select_type(type: String) -> void:
|
||||
var idx := TRIGGER_REGISTRY.types().find(type)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
_type_option.select(idx)
|
||||
|
||||
|
||||
func _current_type() -> String:
|
||||
var types := TRIGGER_REGISTRY.types()
|
||||
var idx := _type_option.selected
|
||||
if idx < 0 or idx >= types.size():
|
||||
return "arrived_at_waypoint"
|
||||
return types[idx]
|
||||
|
||||
|
||||
func _on_type_selected(_index: int) -> void:
|
||||
var type := _current_type()
|
||||
_trigger["type"] = type
|
||||
_trigger["target"] = -1
|
||||
_trigger["params"] = {}
|
||||
_refresh_trigger()
|
||||
|
||||
|
||||
func _on_set_trigger_target() -> void:
|
||||
trigger_target_requested.emit(_current_type())
|
||||
|
||||
|
||||
## Refreshes the trigger controls to match the current mode + type.
|
||||
func _refresh_trigger() -> void:
|
||||
if _type_option == null:
|
||||
return
|
||||
var type := String(_trigger.get("type", "arrived_at_waypoint"))
|
||||
var full: bool = _mode == "full"
|
||||
|
||||
_trigger_readonly_label.visible = not full
|
||||
_type_option.visible = full
|
||||
_target_row.visible = full
|
||||
|
||||
if not full:
|
||||
_trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), TRIGGER_REGISTRY.summarize(_trigger)]
|
||||
return
|
||||
|
||||
# Show only the target control relevant to this trigger type.
|
||||
_set_target_btn.visible = false
|
||||
_action_type_option.visible = false
|
||||
_target_label.text = ""
|
||||
match TRIGGER_REGISTRY.target_type(type):
|
||||
"waypoint":
|
||||
var params: Dictionary = _trigger.get("params", {})
|
||||
var wp: Vector2 = params.get("waypoint_pos", Vector2.INF)
|
||||
_target_label.text = "Target: waypoint (%d, %d)" % [int(roundf(wp.x)), int(roundf(wp.y))] if wp.is_finite() else "Target: waypoint (not set)"
|
||||
_set_target_btn.visible = true
|
||||
"action_type":
|
||||
var ap: Dictionary = _trigger.get("params", {})
|
||||
var want := String(ap.get("action_type", ""))
|
||||
_action_type_option.visible = true
|
||||
_select_action_type(want)
|
||||
"area":
|
||||
_target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1)))
|
||||
_set_target_btn.visible = true
|
||||
"prop":
|
||||
_target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1)))
|
||||
_set_target_btn.visible = true
|
||||
_:
|
||||
pass
|
||||
|
||||
|
||||
func _select_action_type(want: String) -> void:
|
||||
if want.is_empty():
|
||||
_action_type_option.select(0)
|
||||
return
|
||||
var types := ACTION_REGISTRY.types()
|
||||
var idx := types.find(want)
|
||||
_action_type_option.select(idx + 1 if idx >= 0 else 0)
|
||||
|
||||
|
||||
## Reads the action_type dropdown value back into the trigger params.
|
||||
func _sync_action_type() -> void:
|
||||
var sel := _action_type_option.selected
|
||||
var params: Dictionary = _trigger.get("params", {})
|
||||
if sel <= 0:
|
||||
params.erase("action_type")
|
||||
else:
|
||||
var types := ACTION_REGISTRY.types()
|
||||
if sel - 1 < types.size():
|
||||
params["action_type"] = types[sel - 1]
|
||||
_trigger["params"] = params
|
||||
|
||||
|
||||
func _refresh_actions() -> void:
|
||||
if _actions_box == null:
|
||||
return
|
||||
# Remove previous rows, keeping the empty-state label.
|
||||
for child: Node in _actions_box.get_children():
|
||||
if child != _empty_actions_label:
|
||||
child.queue_free()
|
||||
_empty_actions_label.visible = _actions.is_empty()
|
||||
for i: int in _actions.size():
|
||||
_actions_box.add_child(_make_action_row(i, _actions[i]))
|
||||
_apply_font_recursive(_actions_box, _base_font_size)
|
||||
|
||||
|
||||
func _make_action_row(index: int, action: Dictionary) -> Control:
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
|
||||
var number := Label.new()
|
||||
number.text = str(index + 1)
|
||||
row.add_child(number)
|
||||
|
||||
var summary := Label.new()
|
||||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
summary.text = "%s %s %s" % [
|
||||
ACTION_REGISTRY.icon(String(action.get("type", ""))),
|
||||
_actor_name(int(action.get("target", -1))),
|
||||
ACTION_REGISTRY.summarize(action),
|
||||
]
|
||||
row.add_child(summary)
|
||||
|
||||
var edit := Button.new()
|
||||
edit.text = "✎"
|
||||
edit.pressed.connect(func() -> void: action_edit_requested.emit(index))
|
||||
row.add_child(edit)
|
||||
|
||||
var remove := Button.new()
|
||||
remove.text = "✕"
|
||||
remove.pressed.connect(func() -> void: _remove_action(index))
|
||||
row.add_child(remove)
|
||||
|
||||
return row
|
||||
|
||||
|
||||
func _remove_action(index: int) -> void:
|
||||
if index < 0 or index >= _actions.size():
|
||||
return
|
||||
_actions.remove_at(index)
|
||||
_refresh_actions()
|
||||
|
||||
|
||||
func _on_ok_pressed() -> void:
|
||||
if _mode == "full":
|
||||
if _current_type() == "action_finished":
|
||||
_sync_action_type()
|
||||
_trigger["type"] = _current_type()
|
||||
var rule := {
|
||||
"id": _rule_id,
|
||||
"trigger": _trigger.duplicate(true),
|
||||
"actions": _actions.duplicate(true),
|
||||
}
|
||||
committed.emit(rule)
|
||||
|
||||
|
||||
func _actor_name(id: int) -> String:
|
||||
if id <= 0:
|
||||
# -1 is the "no source / no actor" sentinel; instance_from_id(-1) would
|
||||
# spam an engine error, so resolve the display name only for real ids.
|
||||
return "?"
|
||||
var node := instance_from_id(id)
|
||||
if node is Node and is_instance_valid(node):
|
||||
return String((node as Node).name)
|
||||
return "?"
|
||||
|
||||
|
||||
func _node_name(id: int) -> String:
|
||||
return _actor_name(id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8hgf3j8xc6tr
|
||||
@@ -0,0 +1,324 @@
|
||||
class_name RulePanel
|
||||
extends PopupPanel
|
||||
## RulePanel - Rule list editor popup (Phase 3c.4).
|
||||
##
|
||||
## Shows a list of rules (already filtered by the stage: by source stickman or
|
||||
## by waypoint), with edit / delete / drag-reorder controls and Add Rule / Clear
|
||||
## All. Mutations delegate to the stage via signals; reordering emits the new
|
||||
## order of the *displayed* rules' ids, which the stage maps back onto its
|
||||
## full `_event_rules` array (preserving un-filtered rules' positions).
|
||||
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||
|
||||
signal edit_requested(rule_id: int)
|
||||
signal delete_requested(rule_id: int)
|
||||
signal add_requested()
|
||||
signal clear_requested()
|
||||
signal reorder_requested(ordered_ids: Array[int])
|
||||
|
||||
var _title_label: Label = null
|
||||
var _list: VBoxContainer = null
|
||||
var _empty_label: Label = null
|
||||
var _rows: Array[PanelContainer] = []
|
||||
var _rules: Array[Dictionary] = []
|
||||
|
||||
var _drag_index: int = -1
|
||||
var _drag_target: int = -1
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _base_font_size: int = 0
|
||||
var _row_font_size: int = 0
|
||||
var _title_font_size: int = 0
|
||||
var _title_font: Font = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
exclusive = true
|
||||
popup_window = true
|
||||
_build_ui()
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
if (event as InputEventKey).keycode == KEY_ESCAPE:
|
||||
hide()
|
||||
|
||||
|
||||
## Updates the displayed rule list + title without popping up.
|
||||
func set_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||||
_rules = rules.duplicate(true)
|
||||
_title_label.text = title_hint
|
||||
refresh()
|
||||
|
||||
|
||||
## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up.
|
||||
func show_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||||
set_rules(rules, title_hint)
|
||||
popup_centered()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _list == null:
|
||||
return
|
||||
for child: Node in _list.get_children():
|
||||
if child != _empty_label:
|
||||
child.queue_free()
|
||||
_rows.clear()
|
||||
_drag_index = -1
|
||||
_drag_target = -1
|
||||
_empty_label.visible = _rules.is_empty()
|
||||
for i: int in _rules.size():
|
||||
_rows.append(_make_row(i, _rules[i]))
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
title = "Rules"
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 12)
|
||||
margin.add_theme_constant_override("margin_right", 12)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
margin.add_child(vbox)
|
||||
|
||||
var title_bar := HBoxContainer.new()
|
||||
vbox.add_child(title_bar)
|
||||
_title_label = Label.new()
|
||||
_title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_title_label.text = "Source: ?"
|
||||
title_bar.add_child(_title_label)
|
||||
var close_btn := Button.new()
|
||||
close_btn.text = "× Close"
|
||||
close_btn.pressed.connect(hide)
|
||||
title_bar.add_child(close_btn)
|
||||
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.custom_minimum_size = Vector2(0.0, 260.0)
|
||||
vbox.add_child(scroll)
|
||||
|
||||
_list = VBoxContainer.new()
|
||||
_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_list.add_theme_constant_override("separation", 4)
|
||||
scroll.add_child(_list)
|
||||
|
||||
_empty_label = Label.new()
|
||||
_empty_label.text = "No rules."
|
||||
_empty_label.modulate = Color(1.0, 1.0, 1.0, 0.5)
|
||||
_list.add_child(_empty_label)
|
||||
|
||||
var footer := HBoxContainer.new()
|
||||
footer.add_theme_constant_override("separation", 8)
|
||||
vbox.add_child(footer)
|
||||
var add_btn := Button.new()
|
||||
add_btn.text = "➕ Add Rule"
|
||||
add_btn.pressed.connect(func() -> void: add_requested.emit())
|
||||
footer.add_child(add_btn)
|
||||
var clear_btn := Button.new()
|
||||
clear_btn.text = "🗑 Clear All"
|
||||
clear_btn.pressed.connect(func() -> void: clear_requested.emit())
|
||||
footer.add_child(clear_btn)
|
||||
|
||||
min_size = Vector2i(520, 360)
|
||||
|
||||
|
||||
func _make_row(index: int, rule: Dictionary) -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", _row_style(Color(0.0, 0.0, 0.0, 0.0)))
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 6)
|
||||
panel.add_child(row)
|
||||
|
||||
var number := Label.new()
|
||||
number.text = str(index + 1)
|
||||
row.add_child(number)
|
||||
|
||||
var body := VBoxContainer.new()
|
||||
body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(body)
|
||||
|
||||
body.add_child(_make_label(_trigger_summary(rule)))
|
||||
var actions := _action_summaries(rule)
|
||||
if actions.is_empty():
|
||||
body.add_child(_make_label("→ (no actions)", true))
|
||||
else:
|
||||
for text: String in actions:
|
||||
body.add_child(_make_label(text, true))
|
||||
|
||||
var edit := Button.new()
|
||||
edit.text = "✎"
|
||||
edit.tooltip_text = "Edit"
|
||||
edit.pressed.connect(func() -> void: edit_requested.emit(int(rule.get("id", -1))))
|
||||
row.add_child(edit)
|
||||
|
||||
var remove := Button.new()
|
||||
remove.text = "✕"
|
||||
remove.tooltip_text = "Delete"
|
||||
remove.pressed.connect(func() -> void: delete_requested.emit(int(rule.get("id", -1))))
|
||||
row.add_child(remove)
|
||||
|
||||
var drag := Button.new()
|
||||
drag.text = "≡"
|
||||
drag.tooltip_text = "Drag to reorder"
|
||||
drag.mouse_default_cursor_shape = Control.CURSOR_MOVE
|
||||
drag.gui_input.connect(_on_drag_handle_gui_input.bind(index))
|
||||
row.add_child(drag)
|
||||
|
||||
# Parent the row into the list and apply the theme font/size overrides. Without
|
||||
# add_child the row never renders and drag-reorder has no geometry to work with.
|
||||
_list.add_child(panel)
|
||||
_apply_font_recursive(panel, _row_font_size)
|
||||
return panel
|
||||
|
||||
|
||||
## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by
|
||||
## the stage after add_child so the panel's UI is already built.
|
||||
func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
_ui_font = ui_font
|
||||
_emoji_font = emoji_font
|
||||
_base_font_size = int(sizes.get("rule_panel", 18))
|
||||
_row_font_size = int(sizes.get("panel_row", 16))
|
||||
_title_font_size = int(sizes.get("panel_title", 18))
|
||||
var title_bold := bool(sizes.get("panel_title_bold", true))
|
||||
var bold_font: Font = sizes.get("bold_font", null)
|
||||
_title_font = bold_font if title_bold and bold_font != null else ui_font
|
||||
_apply_font_recursive(self, _base_font_size)
|
||||
if _title_label != null:
|
||||
_apply_font_to(_title_label, _title_font_size, _title_font)
|
||||
if _empty_label != null:
|
||||
_apply_font_to(_empty_label, _row_font_size)
|
||||
|
||||
func apply_theme(sandbox_theme: Variant) -> void:
|
||||
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
|
||||
|
||||
|
||||
func _apply_font_recursive(node: Node, size: int) -> void:
|
||||
for child: Node in node.get_children():
|
||||
if child is Control:
|
||||
_apply_font_to(child as Control, size)
|
||||
_apply_font_recursive(child, size)
|
||||
|
||||
|
||||
func _apply_font_to(c: Control, size: int, font: Font = null) -> void:
|
||||
if c == null:
|
||||
return
|
||||
if font != null:
|
||||
c.add_theme_font_override("font", font)
|
||||
elif _ui_font != null:
|
||||
c.add_theme_font_override("font", _ui_font)
|
||||
elif _emoji_font != null:
|
||||
c.add_theme_font_override("font", _emoji_font)
|
||||
if size > 0:
|
||||
c.add_theme_font_size_override("font_size", size)
|
||||
|
||||
|
||||
func _make_label(text: String, dim: bool = false) -> Label:
|
||||
var lbl := Label.new()
|
||||
lbl.text = text
|
||||
if dim:
|
||||
lbl.modulate = Color(1.0, 1.0, 1.0, 0.7)
|
||||
return lbl
|
||||
|
||||
|
||||
func _trigger_summary(rule: Dictionary) -> String:
|
||||
var trigger: Dictionary = rule.get("trigger", {})
|
||||
var type := String(trigger.get("type", ""))
|
||||
return "%s %s %s" % [
|
||||
TRIGGER_REGISTRY.icon(type),
|
||||
_actor_name(int(trigger.get("source", -1))),
|
||||
TRIGGER_REGISTRY.label(type),
|
||||
]
|
||||
|
||||
|
||||
func _action_summaries(rule: Dictionary) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for a: Dictionary in rule.get("actions", []):
|
||||
out.append("→ %s %s %s" % [
|
||||
ACTION_REGISTRY.icon(String(a.get("type", ""))),
|
||||
_actor_name(int(a.get("target", -1))),
|
||||
ACTION_REGISTRY.summarize(a),
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
func _row_style(bg: Color) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
sb.set_corner_radius_all(4)
|
||||
sb.content_margin_left = 6.0
|
||||
sb.content_margin_right = 6.0
|
||||
sb.content_margin_top = 2.0
|
||||
sb.content_margin_bottom = 2.0
|
||||
return sb
|
||||
|
||||
|
||||
func _on_drag_handle_gui_input(event: InputEvent, index: int) -> void:
|
||||
if event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
|
||||
if (event as InputEventMouseButton).pressed:
|
||||
_drag_index = index
|
||||
_drag_target = index
|
||||
_refresh_drag_highlight()
|
||||
else:
|
||||
_commit_drag()
|
||||
elif event is InputEventMouseMotion and _drag_index >= 0:
|
||||
_update_drag_target()
|
||||
|
||||
|
||||
func _update_drag_target() -> void:
|
||||
var mouse_y := _list.get_local_mouse_position().y
|
||||
var best := _drag_target
|
||||
var best_d := INF
|
||||
for i: int in _rows.size():
|
||||
var row := _rows[i]
|
||||
var d := absf((row.position.y + row.size.y * 0.5) - mouse_y)
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = i
|
||||
if best != _drag_target:
|
||||
_drag_target = best
|
||||
_refresh_drag_highlight()
|
||||
|
||||
|
||||
func _refresh_drag_highlight() -> void:
|
||||
for i: int in _rows.size():
|
||||
var bg := Color(0.0, 0.0, 0.0, 0.0)
|
||||
if _drag_index >= 0 and i == _drag_target:
|
||||
bg = Color(0.15, 0.4, 0.9, 0.4)
|
||||
(_rows[i] as PanelContainer).add_theme_stylebox_override("panel", _row_style(bg))
|
||||
|
||||
|
||||
func _commit_drag() -> void:
|
||||
var from := _drag_index
|
||||
var to := _drag_target
|
||||
_drag_index = -1
|
||||
_drag_target = -1
|
||||
_refresh_drag_highlight()
|
||||
if from < 0 or to < 0 or from == to or from >= _rules.size() or to >= _rules.size():
|
||||
return
|
||||
var moved: Dictionary = _rules[from]
|
||||
_rules.remove_at(from)
|
||||
_rules.insert(to, moved)
|
||||
var ordered: Array[int] = []
|
||||
for r: Dictionary in _rules:
|
||||
ordered.append(int(r.get("id", -1)))
|
||||
refresh()
|
||||
reorder_requested.emit(ordered)
|
||||
|
||||
|
||||
func _actor_name(id: int) -> String:
|
||||
if id <= 0:
|
||||
# -1 is the "no source / no actor" sentinel; instance_from_id(-1) would
|
||||
# spam an engine error, so resolve the display name only for real ids.
|
||||
return "?"
|
||||
var node := instance_from_id(id)
|
||||
if node is Node and is_instance_valid(node):
|
||||
return String((node as Node).name)
|
||||
return "?"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmiropsijs8g2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dkhhg81ft70mm
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://dlee4y2wnkupm
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://blq30ou2f1boe
|
||||
@@ -0,0 +1,687 @@
|
||||
extends Control
|
||||
## StickmanEditor - Main controller for the Stickman Studio editor.
|
||||
##
|
||||
## Manages menu actions (save/load/clear), coordinates all body part panels
|
||||
## and the whole stickman preview, and handles JSON serialization/deserialization.
|
||||
##
|
||||
## Phase 2: FILE_VERSION "1.1", backward-compatible loading of "1.0" files,
|
||||
## cross-panel selection coordination.
|
||||
## Phase 3: Edit/View menus, grid configuration, settings persistence,
|
||||
## snap-to-grid, Reset Views.
|
||||
## Phase 4: v1.2 JSON with multi-shape shapes[] array, rotation, scale per part.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const ContentPathsScript := preload("res://scripts/shared/content_paths.gd")
|
||||
|
||||
const BODY_PART_NAMES: PackedStringArray = [
|
||||
"head", "torso",
|
||||
"left_upper_arm", "left_lower_arm",
|
||||
"right_upper_arm", "right_lower_arm",
|
||||
"left_upper_leg", "left_lower_leg",
|
||||
"right_upper_leg", "right_lower_leg"
|
||||
]
|
||||
|
||||
const BODY_PART_DISPLAY: Dictionary = {
|
||||
"head": "Head",
|
||||
"torso": "Torso",
|
||||
"left_upper_arm": "Left Upper Arm",
|
||||
"left_lower_arm": "Left Lower Arm",
|
||||
"right_upper_arm": "Right Upper Arm",
|
||||
"right_lower_arm": "Right Lower Arm",
|
||||
"left_upper_leg": "Left Upper Leg",
|
||||
"left_lower_leg": "Left Lower Leg",
|
||||
"right_upper_leg": "Right Upper Leg",
|
||||
"right_lower_leg": "Right Lower Leg",
|
||||
}
|
||||
|
||||
const DEFAULT_POSITIONS: Dictionary = {
|
||||
"head": Vector2(150, 40),
|
||||
"torso": Vector2(150, 100),
|
||||
"left_upper_arm": Vector2(140, 105),
|
||||
"left_lower_arm": Vector2(130, 150),
|
||||
"right_upper_arm": Vector2(160, 105),
|
||||
"right_lower_arm": Vector2(170, 150),
|
||||
"left_upper_leg": Vector2(142, 160),
|
||||
"left_lower_leg": Vector2(135, 210),
|
||||
"right_upper_leg": Vector2(158, 160),
|
||||
"right_lower_leg": Vector2(165, 210),
|
||||
}
|
||||
|
||||
const FILE_VERSION := "1.5"
|
||||
const FILE_FILTER := "*.stk ; Stickman Files"
|
||||
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]
|
||||
|
||||
# Phase 8: rig proportions (scenes/characters/stickman/stickman_rig.tscn rest pose — see spec §2).
|
||||
const PROPORTIONS: Dictionary = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
"upper_leg_length": 200.0,
|
||||
"lower_leg_length": 200.0,
|
||||
"torso_length": 391.5,
|
||||
}
|
||||
|
||||
const X_AXIS_PARTS: PackedStringArray = [
|
||||
"left_upper_arm", "left_lower_arm", "right_upper_arm", "right_lower_arm",
|
||||
]
|
||||
|
||||
# Phase 9 Round 5: map each body part to its silhouette-guide joint (see
|
||||
# whole_stickman_preview.gd GUIDE_JOINTS). Used to compute the per-part
|
||||
# guide_offset (bbox center − guide joint, preview space) at save time.
|
||||
const GUIDE_JOINT_FOR_PART: Dictionary = {
|
||||
"head": "Neck",
|
||||
"torso": "Hips",
|
||||
"left_upper_arm": "LeftShoulder",
|
||||
"left_lower_arm": "LeftElbow",
|
||||
"right_upper_arm": "RightShoulder",
|
||||
"right_lower_arm": "RightElbow",
|
||||
"left_upper_leg": "Hips",
|
||||
"left_lower_leg": "LeftKnee",
|
||||
"right_upper_leg": "Hips",
|
||||
"right_lower_leg": "RightKnee",
|
||||
}
|
||||
|
||||
const SETTINGS_PATH := "user://settings.json"
|
||||
const SETTINGS_VERSION := "1.0"
|
||||
const DEFAULT_GRID_SIZE := 15
|
||||
const MIN_GRID_SIZE := 1
|
||||
const MAX_GRID_SIZE := 100
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# On-ready node references
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@onready var _stickman_name_edit: LineEdit = %StickmanNameEdit
|
||||
@onready var _menu_bar: MenuBar = %MenuBar
|
||||
@onready var _body_part_panels: Dictionary = {} ## { String : BodyPartPanel }
|
||||
@onready var _whole_preview: WholeStickmanPreview = %WholeStickmanPreview
|
||||
@onready var _save_dialog: FileDialog = %SaveDialog
|
||||
@onready var _load_dialog: FileDialog = %LoadDialog
|
||||
@onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog
|
||||
@onready var _error_dialog: AcceptDialog = %ErrorDialog
|
||||
@onready var _grid_config_dialog: ConfirmationDialog = %GridConfigDialog
|
||||
@onready var _grid_size_spin_box: SpinBox = %GridSizeSpinBox
|
||||
@onready var _status_cursor_coords: Label = %CursorCoords
|
||||
@onready var _status_snap_status: Label = %SnapStatus
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grid / snap state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _grid_size: int = DEFAULT_GRID_SIZE
|
||||
var _snap_enabled: bool = false
|
||||
|
||||
# Phase 5: shape clipboard
|
||||
var _shape_clipboard: Dictionary = {}
|
||||
|
||||
# Phase 6: recent colors
|
||||
var _recent_colors: Array[String] = []
|
||||
|
||||
# Phase 7: pose silhouette guide (default ON per user decision)
|
||||
var _show_guide: bool = true
|
||||
|
||||
var _edit_menu: PopupMenu
|
||||
var _view_menu: PopupMenu
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_menu_bar()
|
||||
_setup_dialogs()
|
||||
_populate_body_part_panels()
|
||||
_whole_preview.part_moved.connect(_on_preview_part_moved)
|
||||
_whole_preview.set_body_parts(_collect_all_shape_data())
|
||||
|
||||
_load_settings()
|
||||
_update_snap_status_label()
|
||||
_broadcast_settings()
|
||||
_broadcast_recent_colors()
|
||||
|
||||
if not _grid_config_dialog.confirmed.is_connected(_on_grid_config_confirmed):
|
||||
_grid_config_dialog.confirmed.connect(_on_grid_config_confirmed)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
var mouse_pos := get_global_mouse_position()
|
||||
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
if panel and panel.is_cursor_over_drawing(mouse_pos):
|
||||
var world_pos := panel.global_to_world(mouse_pos)
|
||||
_status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
|
||||
return
|
||||
|
||||
if _whole_preview.is_cursor_over_preview(mouse_pos):
|
||||
var world_pos := _whole_preview.global_to_world(mouse_pos)
|
||||
_status_cursor_coords.text = "X: %d Y: %d" % [int(world_pos.x), int(world_pos.y)]
|
||||
return
|
||||
|
||||
_status_cursor_coords.text = ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Menu & Dialog setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _setup_menu_bar() -> void:
|
||||
var file_menu: PopupMenu = PopupMenu.new()
|
||||
file_menu.name = "FileMenu"
|
||||
file_menu.add_item("Save", 0)
|
||||
file_menu.add_item("Load", 1)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("Clear", 2)
|
||||
file_menu.id_pressed.connect(_on_file_menu_id_pressed)
|
||||
_menu_bar.add_child(file_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "File")
|
||||
|
||||
_edit_menu = PopupMenu.new()
|
||||
_edit_menu.name = "EditMenu"
|
||||
_edit_menu.add_item("Configure Grid...", 0)
|
||||
_edit_menu.add_separator()
|
||||
_edit_menu.add_item(_snap_menu_label(), 1)
|
||||
_edit_menu.id_pressed.connect(_on_edit_menu_id_pressed)
|
||||
_edit_menu.about_to_popup.connect(_on_edit_menu_about_to_popup)
|
||||
_menu_bar.add_child(_edit_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "Edit")
|
||||
|
||||
var view_menu: PopupMenu = PopupMenu.new()
|
||||
view_menu.name = "ViewMenu"
|
||||
view_menu.add_item("Reset Views", 0)
|
||||
view_menu.add_separator()
|
||||
view_menu.add_item(_guide_menu_label(), 1)
|
||||
view_menu.id_pressed.connect(_on_view_menu_id_pressed)
|
||||
view_menu.about_to_popup.connect(_on_view_menu_about_to_popup)
|
||||
_menu_bar.add_child(view_menu)
|
||||
_menu_bar.set_menu_title(_menu_bar.get_menu_count() - 1, "View")
|
||||
_view_menu = view_menu
|
||||
|
||||
|
||||
func _setup_dialogs() -> void:
|
||||
var content_dir: String = ProjectSettings.globalize_path(ContentPathsScript.STICKMEN_DIR)
|
||||
_save_dialog.current_dir = content_dir
|
||||
_load_dialog.current_dir = content_dir
|
||||
_save_dialog.file_selected.connect(_on_save_file_selected)
|
||||
_load_dialog.file_selected.connect(_on_load_file_selected)
|
||||
_clear_confirm.confirmed.connect(_on_clear_confirmed)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Body-part panel population
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _populate_body_part_panels() -> void:
|
||||
var left_col: VBoxContainer = %LeftColumn
|
||||
var center_col: VBoxContainer = %CenterColumn
|
||||
|
||||
var body_part_scene: PackedScene = load("res://scenes/editors/stickman/body_part_panel.tscn")
|
||||
|
||||
var left_names: Array[String] = ["head", "left_upper_arm", "left_lower_arm", "left_upper_leg", "left_lower_leg"]
|
||||
var center_names: Array[String] = ["torso", "right_upper_arm", "right_lower_arm", "right_upper_leg", "right_lower_leg"]
|
||||
|
||||
for part_name: String in left_names:
|
||||
var panel: BodyPartPanel = body_part_scene.instantiate() as BodyPartPanel
|
||||
panel.part_name = part_name
|
||||
panel.display_name = BODY_PART_DISPLAY[part_name]
|
||||
panel.shape_changed.connect(_on_body_part_shape_changed.bind(part_name))
|
||||
panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name))
|
||||
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
|
||||
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
|
||||
panel.color_selected.connect(_on_color_selected.bind(part_name))
|
||||
_body_part_panels[part_name] = panel
|
||||
left_col.add_child(panel)
|
||||
|
||||
for part_name: String in center_names:
|
||||
var panel: BodyPartPanel = body_part_scene.instantiate() as BodyPartPanel
|
||||
panel.part_name = part_name
|
||||
panel.display_name = BODY_PART_DISPLAY[part_name]
|
||||
panel.shape_changed.connect(_on_body_part_shape_changed.bind(part_name))
|
||||
panel.shape_selected.connect(_on_body_part_shape_selected.bind(part_name))
|
||||
panel.shape_copy_requested.connect(_on_shape_copy_requested.bind(part_name))
|
||||
panel.shape_paste_requested.connect(_on_shape_paste_requested.bind(part_name))
|
||||
panel.color_selected.connect(_on_color_selected.bind(part_name))
|
||||
_body_part_panels[part_name] = panel
|
||||
center_col.add_child(panel)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handlers - menus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_file_menu_id_pressed(id: int) -> void:
|
||||
match id:
|
||||
0: # Save
|
||||
_save_dialog.popup_centered_ratio(0.6)
|
||||
1: # Load
|
||||
_load_dialog.popup_centered_ratio(0.6)
|
||||
2: # Clear
|
||||
_clear_confirm.popup_centered()
|
||||
|
||||
|
||||
func _on_edit_menu_id_pressed(id: int) -> void:
|
||||
match id:
|
||||
0: # Configure Grid...
|
||||
_grid_size_spin_box.value = float(_grid_size)
|
||||
_grid_config_dialog.popup_centered()
|
||||
1: # Snap to Grid (checkable)
|
||||
_snap_enabled = not _snap_enabled
|
||||
if _edit_menu:
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_snap_status_label()
|
||||
|
||||
|
||||
func _on_edit_menu_about_to_popup() -> void:
|
||||
if _edit_menu:
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
|
||||
|
||||
func _on_view_menu_id_pressed(id: int) -> void:
|
||||
match id:
|
||||
0: # Reset Views
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).reset_view()
|
||||
_whole_preview.reset_view()
|
||||
1: # Hide/Show Pose Guide
|
||||
_show_guide = not _show_guide
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _on_view_menu_about_to_popup() -> void:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _update_guide_menu_item() -> void:
|
||||
if _view_menu:
|
||||
var idx := _view_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_view_menu.set_item_text(idx, _guide_menu_label())
|
||||
|
||||
|
||||
func _guide_menu_label() -> String:
|
||||
return "Hide Pose Guide" if _show_guide else "Show Pose Guide"
|
||||
|
||||
|
||||
func _on_grid_config_confirmed() -> void:
|
||||
_grid_size = int(_grid_size_spin_box.value)
|
||||
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||||
_save_settings()
|
||||
_broadcast_settings()
|
||||
|
||||
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
path = ContentPathsScript.canonicalize(path)
|
||||
if not path.ends_with(".stk"):
|
||||
path += ".stk"
|
||||
|
||||
var data := _build_json_data()
|
||||
var json_text := JSON.stringify(data, "\t", false)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
_show_error("Failed to save file: " + str(FileAccess.get_open_error()))
|
||||
return
|
||||
|
||||
file.store_string(json_text)
|
||||
file.close()
|
||||
|
||||
|
||||
func _on_load_file_selected(path: String) -> void:
|
||||
path = ContentPathsScript.canonicalize(path)
|
||||
if not FileAccess.file_exists(path):
|
||||
_show_error("File not found: " + path)
|
||||
return
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_show_error("Failed to open file: " + str(FileAccess.get_open_error()))
|
||||
return
|
||||
|
||||
var json_text := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json: Variant = JSON.parse_string(json_text)
|
||||
if json == null:
|
||||
_show_error("Failed to parse JSON from file: " + path)
|
||||
return
|
||||
|
||||
var err: String = _apply_json_data(json)
|
||||
if not err.is_empty():
|
||||
_show_error(err)
|
||||
return
|
||||
|
||||
|
||||
func _on_clear_confirmed() -> void:
|
||||
_clear_all()
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
_error_dialog.dialog_text = message
|
||||
_error_dialog.popup_centered()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handlers - body-part panels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_body_part_shape_changed(_new_data: Array, part_name: String) -> void:
|
||||
_whole_preview.set_body_parts(_collect_all_shape_data())
|
||||
if _new_data.is_empty():
|
||||
_whole_preview.set_part_position(part_name, DEFAULT_POSITIONS.get(part_name, Vector2.ZERO))
|
||||
_whole_preview.set_part_rotation(part_name, 0.0)
|
||||
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
|
||||
_broadcast_clipboard_state()
|
||||
|
||||
|
||||
func _on_body_part_shape_selected(part_name: String) -> void:
|
||||
for other_name: String in BODY_PART_NAMES:
|
||||
if other_name != part_name:
|
||||
var panel: BodyPartPanel = _body_part_panels.get(other_name) as BodyPartPanel
|
||||
if panel:
|
||||
panel.deselect()
|
||||
|
||||
|
||||
func _on_preview_part_moved(_part_name: String, _new_position: Vector2) -> void:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data collection / JSON helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _compute_part_pivot_length(shapes_arr: Array, part_name: String) -> Dictionary:
|
||||
var min_x := INF
|
||||
var min_y := INF
|
||||
var max_x := -INF
|
||||
var max_y := -INF
|
||||
for sd in shapes_arr:
|
||||
if not sd is Dictionary:
|
||||
continue
|
||||
for p in (sd as Dictionary).get("points", []):
|
||||
if not p is Dictionary:
|
||||
continue
|
||||
var px: float = float((p as Dictionary).get("x", 0.0))
|
||||
var py: float = float((p as Dictionary).get("y", 0.0))
|
||||
min_x = minf(min_x, px)
|
||||
min_y = minf(min_y, py)
|
||||
max_x = maxf(max_x, px)
|
||||
max_y = maxf(max_y, py)
|
||||
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return { "pivot": { "x": 0.0, "y": 0.0 }, "length": 0.0 }
|
||||
|
||||
var pivot := { "x": (min_x + max_x) * 0.5, "y": (min_y + max_y) * 0.5 }
|
||||
var length: float
|
||||
if X_AXIS_PARTS.has(part_name):
|
||||
length = max_x - min_x
|
||||
else:
|
||||
length = max_y - min_y
|
||||
return { "pivot": pivot, "length": length }
|
||||
|
||||
|
||||
func _collect_all_shape_data() -> Dictionary:
|
||||
var all_data: Dictionary = {}
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
var panel = _body_part_panels.get(part_name)
|
||||
if panel:
|
||||
var shapes_arr: Array = panel.get_shape_data()
|
||||
var pos := _whole_preview.get_part_position(part_name)
|
||||
var rot := _whole_preview.get_part_rotation(part_name)
|
||||
var scl := _whole_preview.get_part_scale(part_name)
|
||||
var pl := _compute_part_pivot_length(shapes_arr, part_name)
|
||||
var pivot_vector := Vector2(float(pl["pivot"]["x"]), float(pl["pivot"]["y"]))
|
||||
var joint_preview := _whole_preview.get_guide_joint_preview(GUIDE_JOINT_FOR_PART[part_name])
|
||||
var guide_offset := (pos + pivot_vector) - joint_preview
|
||||
all_data[part_name] = {
|
||||
"shapes": shapes_arr,
|
||||
"position": {"x": pos.x, "y": pos.y},
|
||||
"rotation": rot,
|
||||
"scale": {"x": scl.x, "y": scl.y},
|
||||
"pivot": pl["pivot"],
|
||||
"length": pl["length"],
|
||||
"guide_offset": {"x": guide_offset.x, "y": guide_offset.y},
|
||||
}
|
||||
return all_data
|
||||
|
||||
|
||||
func _build_json_data() -> Dictionary:
|
||||
var time_str := Time.get_datetime_string_from_system()
|
||||
var body_parts: Dictionary = _collect_all_shape_data()
|
||||
|
||||
return {
|
||||
"version": FILE_VERSION,
|
||||
"stickman_name": _stickman_name_edit.text.strip_edges(),
|
||||
"part_order": _whole_preview.get_part_order(),
|
||||
"proportions": PROPORTIONS.duplicate(),
|
||||
"body_parts": body_parts,
|
||||
"metadata": {
|
||||
"created_at": time_str,
|
||||
"modified_at": time_str,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func _apply_json_data(json: Variant) -> String:
|
||||
if not json is Dictionary:
|
||||
return "Invalid file format: root must be a dictionary."
|
||||
|
||||
var dict: Dictionary = json as Dictionary
|
||||
|
||||
var version: String = dict.get("version", "")
|
||||
if version not in SUPPORTED_VERSIONS:
|
||||
return "Unsupported file version: '%s' (expected '1.0', '1.1', '1.2', '1.3', '1.4', or '1.5')" % version
|
||||
|
||||
_stickman_name_edit.text = dict.get("stickman_name", "")
|
||||
|
||||
var body_parts: Variant = dict.get("body_parts")
|
||||
if not body_parts is Dictionary:
|
||||
return "Invalid file format: 'body_parts' must be a dictionary."
|
||||
|
||||
var body_dict: Dictionary = body_parts as Dictionary
|
||||
|
||||
_clear_all_panels()
|
||||
|
||||
var positions: Dictionary = {}
|
||||
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
var part_data: Variant = body_dict.get(part_name)
|
||||
var panel = _body_part_panels.get(part_name) as BodyPartPanel
|
||||
|
||||
if part_data is Dictionary:
|
||||
var pd: Dictionary = part_data as Dictionary
|
||||
|
||||
if pd.has("shapes"):
|
||||
# v1.2 format: shapes array already in correct structure
|
||||
if panel:
|
||||
panel.set_shape_data(pd["shapes"])
|
||||
else:
|
||||
# v1.0/v1.1 format: single shape dict at top level, wrap in array
|
||||
if version == "1.0":
|
||||
if not pd.has("closed"):
|
||||
var st: String = str(pd.get("shape_type", ""))
|
||||
pd["closed"] = (st == "rectangle" or st == "circle")
|
||||
if not pd.has("vertex_flags"):
|
||||
pd["vertex_flags"] = []
|
||||
if panel:
|
||||
panel.set_shape_data(pd)
|
||||
|
||||
# Position (all versions)
|
||||
var pos_variant: Variant = pd.get("position")
|
||||
if pos_variant is Dictionary:
|
||||
var pos_dict: Dictionary = pos_variant as Dictionary
|
||||
positions[part_name] = Vector2(
|
||||
float(pos_dict.get("x", 0)),
|
||||
float(pos_dict.get("y", 0))
|
||||
)
|
||||
else:
|
||||
positions[part_name] = DEFAULT_POSITIONS.get(part_name, Vector2.ZERO)
|
||||
|
||||
# Rotation (v1.2+, defaults to 0 for older versions)
|
||||
_whole_preview.set_part_rotation(part_name, float(pd.get("rotation", 0.0)))
|
||||
|
||||
# Scale (v1.2+, defaults to 1.0 for older versions)
|
||||
var scl_variant: Variant = pd.get("scale")
|
||||
if scl_variant is Dictionary:
|
||||
var sd: Dictionary = scl_variant as Dictionary
|
||||
_whole_preview.set_part_scale(part_name, Vector2(
|
||||
float(sd.get("x", 1.0)), float(sd.get("y", 1.0))))
|
||||
else:
|
||||
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
|
||||
else:
|
||||
positions[part_name] = DEFAULT_POSITIONS.get(part_name, Vector2.ZERO)
|
||||
_whole_preview.set_part_rotation(part_name, 0.0)
|
||||
_whole_preview.set_part_scale(part_name, Vector2(1.0, 1.0))
|
||||
|
||||
var loaded_order: Variant = dict.get("part_order")
|
||||
if loaded_order is Array:
|
||||
_whole_preview.set_part_order(loaded_order as Array)
|
||||
|
||||
_whole_preview.reset_view()
|
||||
_whole_preview.set_all_part_positions(positions)
|
||||
_whole_preview.set_body_parts(_collect_all_shape_data())
|
||||
|
||||
return ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clear helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _clear_all_panels() -> void:
|
||||
for part_name: String in BODY_PART_NAMES:
|
||||
var panel = _body_part_panels.get(part_name)
|
||||
if panel:
|
||||
panel.clear_shape()
|
||||
|
||||
|
||||
func _clear_all() -> void:
|
||||
_clear_all_panels()
|
||||
_whole_preview.clear_all()
|
||||
_whole_preview.set_all_part_positions(DEFAULT_POSITIONS.duplicate())
|
||||
_stickman_name_edit.text = ""
|
||||
_shape_clipboard.clear()
|
||||
_broadcast_clipboard_state()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 5: Shape clipboard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_shape_copy_requested(shape_dict: Dictionary, _part_name: String) -> void:
|
||||
_shape_clipboard = shape_dict.duplicate(true)
|
||||
_broadcast_clipboard_state()
|
||||
|
||||
|
||||
func _broadcast_clipboard_state() -> void:
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).set_has_clipboard(not _shape_clipboard.is_empty())
|
||||
|
||||
|
||||
func _broadcast_recent_colors() -> void:
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
(panel as BodyPartPanel).set_recent_colors(_recent_colors)
|
||||
|
||||
|
||||
func _paste_shape_into_panel(part_name: String, world_pos: Vector2) -> void:
|
||||
if _shape_clipboard.is_empty():
|
||||
return
|
||||
var panel = _body_part_panels.get(part_name)
|
||||
if panel:
|
||||
panel.paste_shape(_shape_clipboard, world_pos)
|
||||
|
||||
|
||||
func _on_shape_paste_requested(world_pos: Vector2, part_name: String) -> void:
|
||||
_paste_shape_into_panel(part_name, world_pos)
|
||||
|
||||
|
||||
func _on_color_selected(color: Color, _part_name: String) -> void:
|
||||
var hex_str := color.to_html()
|
||||
var idx := _recent_colors.find(hex_str)
|
||||
if idx >= 0:
|
||||
_recent_colors.remove_at(idx)
|
||||
_recent_colors.insert(0, hex_str)
|
||||
while _recent_colors.size() > 8:
|
||||
_recent_colors.pop_back()
|
||||
_save_settings()
|
||||
_broadcast_recent_colors()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3: Settings persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _load_settings() -> void:
|
||||
if not FileAccess.file_exists(SETTINGS_PATH):
|
||||
return
|
||||
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
|
||||
if file == null:
|
||||
return
|
||||
|
||||
var json_text := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var json: Variant = JSON.parse_string(json_text)
|
||||
if not json is Dictionary:
|
||||
return
|
||||
|
||||
var d := json as Dictionary
|
||||
_grid_size = int(d.get("grid_size", DEFAULT_GRID_SIZE))
|
||||
_snap_enabled = bool(d.get("snap_to_grid", false))
|
||||
_show_guide = bool(d.get("show_pose_guide", true))
|
||||
_grid_size = clampi(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE)
|
||||
|
||||
var raw_colors: Variant = d.get("recent_colors", [])
|
||||
if raw_colors is Array:
|
||||
var arr: Array = raw_colors as Array
|
||||
for c in arr:
|
||||
if c is String:
|
||||
_recent_colors.append(c as String)
|
||||
while _recent_colors.size() > 8:
|
||||
_recent_colors.pop_back()
|
||||
|
||||
if _edit_menu:
|
||||
var idx := _edit_menu.get_item_index(1)
|
||||
if idx >= 0:
|
||||
_edit_menu.set_item_text(idx, _snap_menu_label())
|
||||
if _view_menu:
|
||||
_update_guide_menu_item()
|
||||
|
||||
|
||||
func _save_settings() -> void:
|
||||
var data := {
|
||||
"version": SETTINGS_VERSION,
|
||||
"grid_size": _grid_size,
|
||||
"snap_to_grid": _snap_enabled,
|
||||
"recent_colors": _recent_colors,
|
||||
"show_pose_guide": _show_guide,
|
||||
}
|
||||
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(JSON.stringify(data, "\t", false))
|
||||
file.close()
|
||||
|
||||
|
||||
func _broadcast_settings() -> void:
|
||||
for panel in _body_part_panels.values():
|
||||
if panel is BodyPartPanel:
|
||||
var p := panel as BodyPartPanel
|
||||
p.set_grid_size(_grid_size)
|
||||
p.set_snap_enabled(_snap_enabled)
|
||||
_whole_preview.set_grid_size(_grid_size)
|
||||
_whole_preview.set_snap_enabled(_snap_enabled)
|
||||
_whole_preview.set_show_guide(_show_guide)
|
||||
|
||||
|
||||
func _snap_menu_label() -> String:
|
||||
return "[√] Snap to Grid" if _snap_enabled else "Snap to Grid"
|
||||
|
||||
|
||||
func _update_snap_status_label() -> void:
|
||||
_status_snap_status.text = "SNAP: ON" if _snap_enabled else "SNAP: OFF"
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlacmxk257ro
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://codv3g6qw1tef
|
||||
Reference in New Issue
Block a user