Implement Phase 17a Prop Roles UI/UX Specification and associated functionality

- Added UI/UX specification for prop roles and set down mechanics in phase 17a.
- Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings.
- Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors.
- Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior.
- Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
2026-10-09 23:11:34 -04:00
parent 3f66cc3195
commit 1d4a71525a
52 changed files with 20051 additions and 109 deletions
@@ -69,8 +69,9 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _locomotion != null and _locomotion.consume_walk_completed():
_finish_action()
if _locomotion != null:
if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed():
_finish_action()
ActionPhase.SPEAKING:
if _primary_speech.is_empty():
_finish_action()
@@ -398,7 +399,7 @@ func _advance_to_next_action() -> void:
action.erase("speed")
_current_run_item["action"] = action
var action_type := String(action.get("type", ""))
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop"
var requires_animated := action_type == ACTION_REGISTRY.MOVE_TO or action_type == "pick_up_prop" or action_type == "drop_prop" or action_type == "throw_prop" or action_type == "set_down_prop"
if requires_animated and _rig != null:
if _rig.state == StickmanRig.RigState.RECOVERING:
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
@@ -442,7 +443,7 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
"recover":
_action_phase = ActionPhase.RECOVERING
recovery_requested.emit()
"pick_up_prop", "drop_prop", "throw_prop":
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
else:
@@ -4,6 +4,7 @@ extends Node
## StickmanRig schedules this component and exposes its public facade.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
@@ -69,6 +70,7 @@ const ARM_COLLISION_RADIUS: float = 12.0
const LEG_COLLISION_RADIUS: float = 14.0
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
const COLLISION_LAYER: int = 1
const WALK_BLOCKED_TIMEOUT: float = 0.45
const COLLISION_PARTS: Dictionary = {
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
@@ -140,6 +142,8 @@ var _arrival_emitted: bool = false
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _last_grounded_walk_velocity_x: float = 0.0
var _blocked_walk_elapsed: float = 0.0
var _walk_failed: bool = false
var collision_bodies: Dictionary = {}
var collision_lengths: Dictionary = {}
var _collision_visuals: Dictionary = {}
@@ -190,8 +194,8 @@ func setup_collision_bodies() -> void:
continue
var proxy := AnimatableBody2D.new()
proxy.name = "CollisionBody"
proxy.collision_layer = COLLISION_LAYER
proxy.collision_mask = COLLISION_LAYER
proxy.collision_layer = STAGE_PHYSICS.ACTOR_LAYER
proxy.collision_mask = STAGE_PHYSICS.actor_mask()
proxy.sync_to_physics = false
proxy.set_meta("stickman_rig", _rig)
proxy.set_meta("part_name", key)
@@ -254,11 +258,12 @@ func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
func set_collision_bodies_enabled(enabled: bool) -> void:
var layer: int = COLLISION_LAYER if enabled else 0
var layer: int = STAGE_PHYSICS.ACTOR_LAYER if enabled else 0
var mask: int = STAGE_PHYSICS.actor_mask() if enabled else 0
for proxy: AnimatableBody2D in collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
proxy.collision_layer = layer
proxy.collision_mask = layer
proxy.collision_mask = mask
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
@@ -378,6 +383,8 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
_walk_speed_current = speed_override if speed_override > 0.0 else default_pace
_nav_agent.max_speed = _walk_speed_current
_walk_done = false
_walk_failed = false
_blocked_walk_elapsed = 0.0
_arrival_emitted = false
_settling = false
_settle_waiting_for_ground = false
@@ -387,6 +394,10 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
_reset_foot_contacts()
var target_root: Vector2 = target + FOOT_OFFSET
if (target - (_rig.global_position - FOOT_OFFSET)).length() <= ARRIVE_DISTANCE:
var close_motion: Vector2 = target_root - _rig.global_position
var close_sweep: Dictionary = _sweep_actor_blocking_props(close_motion)
if float(close_sweep.get("fraction", 1.0)) < 0.999:
return false
_rig.global_position = target_root
_walking = false
_walk_done = true
@@ -411,6 +422,8 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
func cancel_walk(restore_idle_pose: bool = true) -> void:
if _rig == null:
return
_walk_failed = false
_blocked_walk_elapsed = 0.0
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
_rig._anim_player.stop(false)
@@ -457,6 +470,12 @@ func consume_walk_completed() -> bool:
_walk_done = false
return true
func consume_walk_failed() -> bool:
if not _walk_failed:
return false
_walk_failed = false
return true
func _update_walking(delta: float) -> void:
if not _walking:
return
@@ -485,6 +504,12 @@ func _update_walking(delta: float) -> void:
var root_target: Vector2
if _walk_mode == "nav":
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
var snap_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
var snap_sweep: Dictionary = _sweep_actor_blocking_props(snap_motion)
if float(snap_sweep.get("fraction", 1.0)) < 0.999:
_update_blocked_walk(delta)
return
_blocked_walk_elapsed = 0.0
_rig.global_position.x = final_root.x
_finish_walk()
return
@@ -499,12 +524,68 @@ func _update_walking(delta: float) -> void:
else:
root_target = Vector2(final_root.x, _rig.global_position.y)
var before_x: float = _rig.global_position.x
_rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta)
var requested_motion := _rig.global_position.move_toward(root_target, _walk_speed_current * delta) - _rig.global_position
var sweep: Dictionary = _sweep_actor_blocking_props(requested_motion)
var safe_fraction: float = float(sweep.get("fraction", 1.0))
_rig.global_position += requested_motion * safe_fraction
if safe_fraction < 0.999:
_update_blocked_walk(delta)
else:
_blocked_walk_elapsed = 0.0
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
if _walk_failed:
return
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
var arrival_motion := Vector2(final_root.x - _rig.global_position.x, 0.0)
var arrival_sweep: Dictionary = _sweep_actor_blocking_props(arrival_motion)
if float(arrival_sweep.get("fraction", 1.0)) < 0.999:
_update_blocked_walk(delta)
return
_rig.global_position.x = final_root.x
_finish_walk()
func _update_blocked_walk(delta: float) -> void:
_blocked_walk_elapsed += delta
if _blocked_walk_elapsed < WALK_BLOCKED_TIMEOUT:
return
cancel_walk(false)
_walk_failed = true
_blocked_walk_elapsed = 0.0
func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
if _rig == null or is_zero_approx(motion.x):
return {"fraction": 1.0}
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
return {"fraction": 1.0}
var excluded: Array[RID] = []
for own_proxy: AnimatableBody2D in collision_bodies.values():
if own_proxy != null and is_instance_valid(own_proxy):
excluded.append(own_proxy.get_rid())
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
if held != null and is_instance_valid(held):
excluded.append(held.get_rid())
var safe_fraction: float = 1.0
for part_name: String in ["torso", "head"]:
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.motion = motion
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1:
safe_fraction = minf(safe_fraction, cast[0])
return {"fraction": safe_fraction}
func _settle_walk_markers(delta: float) -> void:
if not _settling or _rig == null:
return
@@ -984,7 +1065,7 @@ func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.motion = motion
query.collision_mask = COLLISION_LAYER
query.collision_mask = STAGE_PHYSICS.ACTOR_QUERY_MASK
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1:
@@ -4,14 +4,18 @@ extends Node
const ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
enum Hand { LEFT, RIGHT }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING }
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING }
const MAX_REACH: float = 390.0
const APPROACH_GAP: float = 90.0
const APPROACH_REPLAN_LIMIT: int = 1
const REACH_DURATION: float = 0.34
const SET_DOWN_REACH_DURATION: float = 0.22
const SET_DOWN_RECOVER_DURATION: float = 0.24
const PICKUP_RECOVER_DURATION: float = 0.32
const LOW_PICKUP_THRESHOLD: float = 110.0
const LOW_PICKUP_MAX_DROP: float = 110.0
@@ -29,6 +33,7 @@ const THROW_SPIN_SPEED: float = 2.4
const THROW_TRAJECTORY_SEGMENTS: int = 24
const THROW_HEAD_CLEARANCE: float = 36.0
const THROW_REACH_MARGIN: float = 8.0
const PROP_TRACE_ENABLED: bool = true
signal action_completed
signal prop_event(event_type: String, prop: PropBlock, reason: String)
@@ -69,9 +74,33 @@ var _throw_torso_windup_rotation: float = 0.0
var _approach_replans: int = 0
var _marker_start_position: Vector2 = Vector2.ZERO
var _saved_body_state: Dictionary = {}
var _pickup_transform: Transform2D = Transform2D.IDENTITY
var _pickup_transform_valid: bool = false
var _last_transform: Transform2D = Transform2D.IDENTITY
var _sampled_linear_velocity: Vector2 = Vector2.ZERO
var _sampled_angular_velocity: float = 0.0
var _set_down_target: Vector2 = Vector2.ZERO
var _set_down_grip_target: Vector2 = Vector2.ZERO
var _set_down_approach_root: Vector2 = Vector2.ZERO
var _set_down_facing_left: bool = false
var _set_down_release_transform: Transform2D = Transform2D.IDENTITY
var _set_down_reach_start_transform: Transform2D = Transform2D.IDENTITY
var _set_down_recover_elapsed: float = 0.0
var _set_down_recover_hand_start: Vector2 = Vector2.ZERO
var _trace_released_prop: PropBlock
var _trace_released_elapsed: float = 0.0
var _trace_released_watch_remaining: float = 0.0
func _process(delta: float) -> void:
if not PROP_TRACE_ENABLED or _trace_released_prop == null or not is_instance_valid(_trace_released_prop) or _trace_released_watch_remaining <= 0.0:
return
_trace_released_elapsed += delta
_trace_released_watch_remaining -= delta
if _trace_released_elapsed < 0.5:
return
_trace_released_elapsed = 0.0
_trace_prop_state("released_watch", _trace_released_prop)
func _exit_tree() -> void:
@@ -135,6 +164,8 @@ func start_action(action: Dictionary) -> bool:
_throw_released = false
_phase = Phase.THROWING
return true
"set_down_prop":
return _begin_set_down(action)
return false
@@ -143,10 +174,24 @@ func tick(delta: float) -> void:
return
match _phase:
Phase.APPROACHING:
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
_fail_pickup_approach("Too far to reach")
return
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
_on_approach_arrived()
Phase.SET_DOWN_APPROACHING:
_update_held_transform(delta)
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
_fail_set_down("Too far to reach")
return
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
_on_set_down_approach_arrived()
Phase.REACHING:
_update_reach(delta)
Phase.SET_DOWN_REACHING:
_update_set_down_reach(delta)
Phase.SET_DOWN_RECOVERING:
_update_set_down_recovery(delta)
Phase.PICKUP_RECOVERING:
_update_pickup_recovery(delta)
Phase.HELD:
@@ -156,8 +201,14 @@ func tick(delta: float) -> void:
func cancel_action() -> void:
if _phase == Phase.APPROACHING and _locomotion != null:
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING) and _locomotion != null:
_locomotion.call("cancel_walk")
if _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING:
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.HELD if _held_prop != null and is_instance_valid(_held_prop) else Phase.IDLE
_active_action.clear()
return
if _phase == Phase.APPROACHING or _phase == Phase.REACHING:
_clear_reservation()
_restore_hand_target()
@@ -224,6 +275,33 @@ func preview_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dic
return _clip_preview_to_collision(solution, prop)
func preview_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if not target.is_finite():
return {"known": false, "valid": false, "reason": "Prop depends on earlier actions"}
var context := _build_throw_preview_context(queue_prefix)
if not bool(context.get("available", false)) or bool(context.get("uncertain", false)):
return {
"known": false,
"valid": false,
"reason": "Prop depends on earlier actions",
"target_position": target,
}
var prop: PropBlock = context["prop"]
var result := _solve_set_down_target(
target,
prop,
context.get("anchor", {}),
int(context.get("hand", Hand.RIGHT)),
context.get("root_position", _rig.global_position),
context.get("root_offset", Vector2.ZERO),
bool(context.get("facing_left", _is_facing_left())),
true
)
result["known"] = true
result["prop"] = prop
return result
func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary:
if _rig == null or not is_instance_valid(_rig):
return {"available": false, "reason": "The acting stickman is unavailable."}
@@ -297,7 +375,7 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
has_prop = true
planned = true
plan_step = int(entry.get("step", 0))
"drop_prop", "throw_prop":
"drop_prop", "throw_prop", "set_down_prop":
if has_prop:
prop = null
anchor.clear()
@@ -315,6 +393,7 @@ func _build_throw_preview_context(queue_prefix: Array[Dictionary]) -> Dictionary
"hand": hand_value,
"root_position": root_position,
"root_offset": root_offset,
"facing_left": predicted_facing_left,
"planned": planned,
"uncertain": uncertain,
"plan_step": plan_step,
@@ -479,19 +558,22 @@ func _invalid_throw_preview(effective_target: Vector2, start_position: Vector2)
func disarm_for_ragdoll() -> void:
var interrupted_pickup := _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING
var interrupted_set_down := _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING
var interrupted_windup := _phase == Phase.THROWING and not _throw_released
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING:
if interrupted_pickup or interrupted_set_down:
cancel_action()
elif interrupted_windup:
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("ragdoll", false)
if interrupted_pickup or interrupted_windup:
if interrupted_pickup or interrupted_set_down or interrupted_windup:
action_completed.emit()
func reset_for_scene_exit() -> void:
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
if PROP_TRACE_ENABLED:
print("[PropTrace] reset_for_scene_exit actor=%s phase=%s held=%s" % [_trace_actor_name(), Phase.keys()[int(_phase)], _trace_prop_id(_held_prop)])
if _phase == Phase.APPROACHING or _phase == Phase.REACHING or _phase == Phase.PICKUP_RECOVERING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.SET_DOWN_REACHING or _phase == Phase.SET_DOWN_RECOVERING or (_phase == Phase.THROWING and not _throw_released):
cancel_action()
if _held_prop != null and is_instance_valid(_held_prop):
_release_held("scene_reset", false)
@@ -501,6 +583,8 @@ func reset_for_scene_exit() -> void:
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
if PROP_TRACE_ENABLED:
print("[PropTrace] pickup_begin actor=%s target_id=%s hand=%s phase=%s" % [_trace_actor_name(), prop_id, requested_hand, Phase.keys()[int(_phase)]])
if _phase != Phase.IDLE or _held_prop != null or _rig == null:
return false
if int(_rig.get("state")) != 0:
@@ -511,8 +595,11 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary)
if not resolved is PropBlock:
return false
var prop := resolved as PropBlock
_trace_prop_state("pickup_resolved", prop)
if not is_instance_valid(prop) or prop.is_queued_for_deletion():
return false
if not prop.can_be_picked_up():
return false
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
return false
var picked := _choose_grip_and_hand(prop, requested_hand)
@@ -567,6 +654,506 @@ func _choose_grip_and_hand(prop: PropBlock, requested_hand: String) -> Dictionar
return candidates[0]
func _begin_set_down(action: Dictionary) -> bool:
if _held_prop == null or not is_instance_valid(_held_prop):
prop_event.emit("set_down_failed", null, "No prop is held")
return false
var raw_target: Variant = action.get("target_position", Vector2.INF)
if not raw_target is Vector2 or not (raw_target as Vector2).is_finite():
prop_event.emit("set_down_failed", _held_prop, "Choose a release place")
return false
if _phase != Phase.HELD and _phase != Phase.IDLE:
return false
var target := raw_target as Vector2
var preview := _solve_set_down_target(target, _held_prop, _held_anchor, int(_hand), _rig.global_position, Vector2.ZERO, _is_facing_left(), true)
if PROP_TRACE_ENABLED:
var resolved_transform: Transform2D = preview.get("release_transform", Transform2D.IDENTITY)
print("[PropTrace] set_down_begin actor=%s prop=%s requested=%s valid=%s reason=%s resolved=%s approach=%s grip=%s" % [_trace_actor_name(), _trace_prop_id(_held_prop), str(target), str(preview.get("valid", false)), String(preview.get("reason", "")), str(resolved_transform.origin), str(preview.get("approach_position", Vector2.INF)), str(preview.get("grip_position", Vector2.INF))])
if not bool(preview.get("valid", false)):
prop_event.emit("set_down_failed", _held_prop, String(preview.get("reason", "Too far to reach")))
return false
_set_down_target = target
_set_down_grip_target = preview["grip_position"]
_set_down_approach_root = preview["approach_position"]
_set_down_facing_left = bool(preview["facing_left"])
_set_down_release_transform = preview["release_transform"]
_active_action = action.duplicate(true)
if absf(_set_down_approach_root.x - _rig.global_position.x) > 8.0:
if _set_down_facing_left != _is_facing_left():
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var target_feet := _set_down_approach_root - foot_offset
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)):
_active_action.clear()
prop_event.emit("set_down_failed", _held_prop, "Too far to reach")
return false
_phase = Phase.SET_DOWN_APPROACHING
_trace_prop_state("set_down_approaching", _held_prop)
return true
_begin_set_down_reach()
return true
func _solve_set_down_target(target: Vector2, prop: PropBlock, anchor: Dictionary, hand_value: int, actor_root: Vector2, root_offset: Vector2, facing_left: bool, check_occupancy: bool) -> Dictionary:
if not target.is_finite() or prop == null or not is_instance_valid(prop):
return {"valid": false, "reason": "Too far to reach"}
var release_transform := _set_down_transform_for_target(prop, target)
var clearance_lift := 0.0
if check_occupancy:
var resolved := _resolve_set_down_release_transform(prop, target)
if not bool(resolved.get("valid", false)):
return {"valid": false, "reason": String(resolved.get("reason", "No room to let go here")), "release_transform": release_transform}
release_transform = resolved["release_transform"]
clearance_lift = float(resolved.get("clearance_lift", 0.0))
var anchor_position: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
var prop_geometry := _set_down_collision_geometry(prop)
var prop_shape: Shape2D = prop_geometry.get("shape") as Shape2D
var prop_bounds := _set_down_shape_bounds(prop_shape, release_transform * (prop_geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)) if prop_shape != null else Rect2(target, Vector2.ZERO)
var candidates: Array[Dictionary] = []
var current_grip_local := anchor_position
if facing_left:
current_grip_local.x = -current_grip_local.x
var current_grip := release_transform * current_grip_local
var current_wrist := _preview_wrist_position(hand_value, root_offset, facing_left)
if current_wrist.distance_to(current_grip) <= MAX_REACH and not _set_down_actor_overlaps_release(prop_bounds, actor_root, facing_left):
candidates.append({
"root": actor_root,
"facing_left": facing_left,
"grip": current_grip,
"distance": current_wrist.distance_to(current_grip),
})
for actor_on_right: bool in [false, true]:
var candidate_facing_left := actor_on_right
var grip_local := anchor_position
if candidate_facing_left:
grip_local.x = -grip_local.x
var grip_world := release_transform * grip_local
var side := 1.0 if actor_on_right else -1.0
var approach_root := Vector2(grip_world.x + side * APPROACH_GAP, actor_root.y)
for body_rect: Rect2 in _set_down_actor_body_rects_relative(candidate_facing_left):
var body_world := body_rect
body_world.position += actor_root
if body_world.end.y <= prop_bounds.position.y or body_world.position.y >= prop_bounds.end.y:
continue
if actor_on_right:
approach_root.x = maxf(approach_root.x, prop_bounds.end.x - body_rect.position.x + 8.0)
else:
approach_root.x = minf(approach_root.x, prop_bounds.position.x - body_rect.end.x - 8.0)
var candidate_offset := root_offset + (approach_root - actor_root)
var wrist := _preview_wrist_position(hand_value, candidate_offset, candidate_facing_left)
var distance := wrist.distance_to(grip_world)
if distance <= MAX_REACH and _set_down_route_is_clear(actor_root, approach_root, root_offset):
candidates.append({
"root": approach_root,
"facing_left": candidate_facing_left,
"grip": grip_world,
"distance": distance,
})
if candidates.is_empty():
return {"valid": false, "reason": "Too far to reach", "release_transform": release_transform}
candidates.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return float(a.get("distance", INF)) < float(b.get("distance", INF))
)
var chosen: Dictionary = candidates[0]
if not _set_down_route_is_clear(actor_root, chosen["root"], root_offset):
return {"valid": false, "reason": "Too far to reach", "release_transform": release_transform}
return {
"valid": true,
"reason": "",
"release_transform": release_transform,
"requested_target": target,
"clearance_lift": clearance_lift,
"target_adjusted": clearance_lift > 0.01,
"grip_position": chosen["grip"],
"approach_position": chosen["root"],
"facing_left": bool(chosen["facing_left"]),
"distance": float(chosen["distance"]),
}
func _set_down_route_is_clear(start_root: Vector2, target_root: Vector2, root_offset: Vector2) -> bool:
if _rig == null or not is_instance_valid(_rig):
return false
var nav_agent := _rig.get_node_or_null(NodePath("NavigationAgentAnchor/NavigationAgent2D")) as NavigationAgent2D
if nav_agent != null:
var nav_map: RID = nav_agent.get_navigation_map()
if NavigationServer2D.map_get_iteration_id(nav_map) > 0:
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var path: PackedVector2Array = NavigationServer2D.map_get_path(nav_map, start_root - foot_offset, target_root - foot_offset, true)
if path.is_empty():
return false
var delta := Vector2(target_root.x - start_root.x, 0.0)
if is_zero_approx(delta.x):
return true
var space_state := _rig.get_world_2d().direct_space_state
if space_state == null:
return true
var excluded: Array[RID] = []
for rid: RID in _rig.call("get_collision_body_rids"):
excluded.append(rid)
if _held_prop != null and is_instance_valid(_held_prop):
excluded.append(_held_prop.get_rid())
var collision_bodies: Dictionary = _locomotion.get("collision_bodies") if _locomotion != null else {}
for part_name: String in ["torso", "head"]:
var proxy := collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.transform.origin += root_offset
query.motion = delta
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if not cast.is_empty() and cast[0] < 0.999:
return false
return true
func _resolve_set_down_release_transform(prop: PropBlock, target: Vector2) -> Dictionary:
if prop == null or not is_instance_valid(prop):
return {"valid": false, "reason": "No room to let go here"}
var release_transform := _set_down_transform_for_target(prop, target)
var total_lift := 0.0
for _attempt: int in 8:
var overlap_info := _set_down_target_overlap_info(prop, release_transform)
var overlaps: Array = overlap_info.get("overlaps", [])
if overlaps.is_empty():
return {
"valid": true,
"release_transform": release_transform,
"clearance_lift": total_lift,
}
if bool(overlap_info.get("blocked", false)):
if PROP_TRACE_ENABLED:
for entry: Dictionary in overlaps:
var blocker: Object = entry.get("collider")
var blocker_position := (blocker as Node2D).global_position if blocker is Node2D else Vector2.INF
var part_name := String((blocker as Node).get_meta("part_name", "")) if blocker is Node else ""
print("[PropTrace] set_down_blocker prop=%s collider=%s class=%s part=%s adjustable=%s position=%s" % [_trace_prop_id(prop), String((blocker as Node).name) if blocker is Node else "<unknown>", blocker.get_class() if blocker is Object else "<unknown>", part_name, str(entry.get("adjustable", false)), str(blocker_position)])
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
var highest_support_top := INF
for entry: Dictionary in overlaps:
var collider: Object = entry.get("collider")
var support_top := _set_down_support_top_y(collider)
if not is_finite(support_top):
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
highest_support_top = minf(highest_support_top, support_top)
var geometry: Dictionary = _set_down_collision_geometry(prop)
var shape: Shape2D = geometry.get("shape") as Shape2D
if shape == null:
return {"valid": true, "release_transform": release_transform, "clearance_lift": total_lift}
var shape_transform: Transform2D = release_transform * (geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)
var shape_bounds := _set_down_shape_bounds(shape, shape_transform)
var lift := shape_bounds.end.y - highest_support_top + 2.0
if not is_finite(lift) or lift <= 0.0:
lift = 2.0
total_lift += lift
if total_lift > 8192.0:
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
release_transform.origin.y -= lift
return {"valid": false, "reason": "No room to let go here", "release_transform": release_transform}
func _set_down_collision_geometry(prop: PropBlock) -> Dictionary:
var collision_shape: Shape2D = null
var local_transform := Transform2D.IDENTITY
var polygon_node := prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if polygon_node != null and not polygon_node.disabled and polygon_node.polygon.size() >= 3:
var points := Geometry2D.convex_hull(polygon_node.polygon)
if points.size() >= 3:
var convex := ConvexPolygonShape2D.new()
convex.points = points
collision_shape = convex
local_transform = prop.global_transform.affine_inverse() * polygon_node.global_transform
var circle_node := prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if collision_shape == null and circle_node != null and circle_node.shape != null and not circle_node.disabled:
collision_shape = circle_node.shape
local_transform = prop.global_transform.affine_inverse() * circle_node.global_transform
if collision_shape == null:
return {}
return {"shape": collision_shape, "local_transform": local_transform}
func _set_down_target_overlap_info(prop: PropBlock, release_transform: Transform2D) -> Dictionary:
var geometry := _set_down_collision_geometry(prop)
var collision_shape: Shape2D = geometry.get("shape") as Shape2D
if collision_shape == null:
return {"overlaps": [], "blocked": false}
var space_state: PhysicsDirectSpaceState2D = prop.get_world_2d().direct_space_state
if space_state == null:
return {"overlaps": [], "blocked": false}
var query := PhysicsShapeQueryParameters2D.new()
query.shape = collision_shape
query.transform = release_transform * (geometry.get("local_transform", Transform2D.IDENTITY) as Transform2D)
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER | STAGE_PHYSICS.PROP_LAYER
if bool(prop.stage_roles.get("released_blocks_stickmen", true)):
query.collision_mask |= STAGE_PHYSICS.ACTOR_LAYER
query.exclude = [prop.get_rid()]
var overlaps: Array[Dictionary] = space_state.intersect_shape(query, 64)
var blocking_overlaps: Array[Dictionary] = []
var blocked := false
for overlap: Dictionary in overlaps:
var collider: Object = overlap.get("collider")
if collider == null:
continue
if collider is TerrainBlock:
blocking_overlaps.append({"collider": collider, "adjustable": true})
continue
if collider is PropBlock:
var other := collider as PropBlock
if PROP_STAGE_ROLES.props_should_collide(prop.stage_roles, PropBlock.RuntimeRole.RELEASED, other.stage_roles, int(other.runtime_role)):
var adjustable := bool(other.stage_roles.get("holds_other_props", false))
blocking_overlaps.append({"collider": collider, "adjustable": adjustable})
blocked = blocked or not adjustable
continue
if collider is CollisionObject2D and ((collider as CollisionObject2D).collision_layer & STAGE_PHYSICS.ACTOR_LAYER) != 0:
if collider is Node and (collider as Node).has_meta("stickman_rig") and (collider as Node).get_meta("stickman_rig") == _rig:
var part_name := String((collider as Node).get_meta("part_name", ""))
if part_name != "head" and part_name != "torso":
continue
blocking_overlaps.append({"collider": collider, "adjustable": false})
blocked = true
return {"overlaps": blocking_overlaps, "blocked": blocked}
func _set_down_actor_body_rects_relative(facing_left: bool) -> Array[Rect2]:
var result: Array[Rect2] = []
if _rig == null or not is_instance_valid(_rig) or _locomotion == null:
return result
var collision_bodies: Dictionary = _locomotion.get("collision_bodies")
for part_name: String in ["torso", "head"]:
var proxy := collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node := proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var bounds := _set_down_shape_bounds(shape_node.shape, shape_node.global_transform)
var local_rect := Rect2(bounds.position - _rig.global_position, bounds.size)
if facing_left != _is_facing_left():
local_rect.position.x = -local_rect.end.x
result.append(local_rect)
return result
func _set_down_actor_overlaps_release(prop_bounds: Rect2, actor_root: Vector2, facing_left: bool) -> bool:
for body_rect: Rect2 in _set_down_actor_body_rects_relative(facing_left):
var body_world := body_rect
body_world.position += actor_root
if body_world.intersects(prop_bounds):
return true
return false
func _set_down_shape_bounds(shape: Shape2D, shape_transform: Transform2D) -> Rect2:
var local_rect := shape.get_rect()
var corners := [
local_rect.position,
Vector2(local_rect.end.x, local_rect.position.y),
local_rect.end,
Vector2(local_rect.position.x, local_rect.end.y),
]
var first := true
var bounds := Rect2()
for corner: Vector2 in corners:
var point := shape_transform * corner
if first:
bounds = Rect2(point, Vector2.ZERO)
first = false
else:
bounds = bounds.expand(point)
return bounds
func _set_down_transform_for_target(prop: PropBlock, target: Vector2) -> Transform2D:
var transform := prop.global_transform
if prop == _held_prop and _pickup_transform_valid:
transform = _pickup_transform
transform.origin = target
return transform
func _set_down_support_top_y(collider: Object) -> float:
var collision_polygon: CollisionPolygon2D = null
if collider is TerrainBlock:
collision_polygon = (collider as TerrainBlock).get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
elif collider is PropBlock:
collision_polygon = (collider as PropBlock).get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if collision_polygon != null and not collision_polygon.disabled and collision_polygon.polygon.size() >= 3:
var top_y := INF
for point: Vector2 in collision_polygon.polygon:
top_y = minf(top_y, (collision_polygon.global_transform * point).y)
return top_y
if collider is PropBlock:
var circle_node := (collider as PropBlock).get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if circle_node != null and circle_node.shape != null and not circle_node.disabled:
return _set_down_shape_bounds(circle_node.shape, circle_node.global_transform).position.y
return INF
func _begin_set_down_reach() -> void:
var marker := _hand_marker()
if marker == null or _held_prop == null or not is_instance_valid(_held_prop):
_fail_set_down("Too far to reach")
return
if _set_down_facing_left != _is_facing_left():
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
_set_down_grip_target = _set_down_release_transform * _scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left)
_reach_start = marker.global_position
_reach_target = _set_down_grip_target
_reach_elapsed = 0.0
_set_down_reach_start_transform = _held_prop.global_transform
_prepare_set_down_crouch(_set_down_grip_target)
_phase = Phase.SET_DOWN_REACHING
_trace_prop_state("set_down_reaching", _held_prop)
func _on_set_down_approach_arrived() -> void:
_trace_prop_state("set_down_approach_arrived", _held_prop)
if _held_prop == null or not is_instance_valid(_held_prop):
_fail_set_down("No prop is held")
return
if not _refresh_set_down_release_target():
_fail_set_down("No room to let go here")
return
if _solved_wrist_transform().origin.distance_to(_set_down_grip_target) > MAX_REACH:
_fail_set_down("Too far to reach")
return
_begin_set_down_reach()
func _update_set_down_reach(delta: float) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_fail_set_down("No prop is held")
return
var marker := _hand_marker()
if marker == null:
_fail_set_down("Too far to reach")
return
_reach_elapsed += delta
var weight := smoothstep(0.0, 1.0, clampf(_reach_elapsed / SET_DOWN_REACH_DURATION, 0.0, 1.0))
marker.global_position = _reach_start.lerp(_set_down_grip_target, weight)
_update_pickup_crouch(weight)
_update_held_transform(delta, true)
if _pickup_transform_valid:
_held_prop.global_transform = _set_down_reach_attachment_transform(marker.global_position, weight)
if weight < 1.0:
return
if _solved_wrist_transform().origin.distance_to(_set_down_grip_target) > MAX_REACH:
_fail_set_down("Too far to reach")
return
var previous_release_position := _set_down_release_transform.origin
if not _refresh_set_down_release_target():
_fail_set_down("No room to let go here")
return
if previous_release_position.distance_to(_set_down_release_transform.origin) > 1.0:
_reach_start = marker.global_position
_reach_target = _set_down_grip_target
_reach_elapsed = 0.0
_set_down_reach_start_transform = _held_prop.global_transform
return
marker.global_position = _set_down_grip_target
_held_prop.global_transform = _set_down_release_transform
_set_down_recover_hand_start = marker.position
_set_down_recover_elapsed = 0.0
if PROP_TRACE_ENABLED:
print("[PropTrace] set_down_release requested=%s final=%s" % [str(_set_down_target), str(_set_down_release_transform.origin)])
_release_held("set_down", false, Vector2.ZERO, 0.0, true, false)
_phase = Phase.SET_DOWN_RECOVERING
func _refresh_set_down_release_target() -> bool:
if _held_prop == null or not is_instance_valid(_held_prop):
return false
var result := _resolve_set_down_release_transform(_held_prop, _set_down_target)
if not bool(result.get("valid", false)):
return false
_set_down_release_transform = result["release_transform"]
_set_down_grip_target = _set_down_release_transform * _scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left)
return true
func _set_down_reach_attachment_transform(grip_position: Vector2, weight: float) -> Transform2D:
var start_rotation := _set_down_reach_start_transform.get_rotation()
var target_rotation := _pickup_transform.get_rotation()
var start_scale := _set_down_reach_start_transform.get_scale()
var target_scale := _pickup_transform.get_scale()
var clamped_weight := clampf(weight, 0.0, 1.0)
var transform := Transform2D(
lerp_angle(start_rotation, target_rotation, clamped_weight),
Vector2.ZERO
).scaled(start_scale.lerp(target_scale, clamped_weight))
transform.origin = grip_position - transform.basis_xform(_scaled_anchor_position(_held_anchor, _held_prop, _set_down_facing_left))
return transform
func _prepare_set_down_crouch(grip_position: Vector2) -> void:
var torso_marker := _torso_marker()
_pickup_uses_crouch = false
if torso_marker == null:
return
_pickup_torso_start = torso_marker.position
_pickup_torso_start_rotation = torso_marker.rotation
_pickup_torso_crouched = _pickup_torso_start
_pickup_torso_crouched_rotation = _pickup_torso_start_rotation
var grip_local := _rig.to_local(grip_position)
var low_amount := clampf((grip_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0)
if low_amount <= 0.0:
return
var toward_prop := signf(grip_local.x - _pickup_torso_start.x)
if is_zero_approx(toward_prop):
toward_prop = -1.0 if _set_down_facing_left else 1.0
_pickup_torso_crouched += Vector2(toward_prop * LOW_PICKUP_LEAN * low_amount, LOW_PICKUP_MAX_DROP * low_amount)
_pickup_torso_crouched_rotation -= toward_prop * 0.10 * low_amount
_pickup_uses_crouch = true
_rig.call("_set_limb_mods_enabled", true)
func _update_set_down_recovery(delta: float) -> void:
_set_down_recover_elapsed += delta
var weight := smoothstep(0.0, 1.0, clampf(_set_down_recover_elapsed / SET_DOWN_RECOVER_DURATION, 0.0, 1.0))
var marker := _hand_marker()
if marker != null:
marker.position = _set_down_recover_hand_start.lerp(_marker_start_position, weight)
_update_pickup_crouch(1.0 - weight)
if weight < 1.0:
return
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.IDLE
_active_action.clear()
action_completed.emit()
func _fail_set_down(reason: String) -> void:
if _locomotion != null and _phase == Phase.SET_DOWN_APPROACHING:
_locomotion.call("cancel_walk")
var prop := _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null
if PROP_TRACE_ENABLED:
print("[PropTrace] set_down_failed actor=%s prop=%s reason=%s phase=%s actor_position=%s" % [_trace_actor_name(), _trace_prop_id(prop), reason, Phase.keys()[int(_phase)], str(_rig.global_position if _rig != null else Vector2.INF)])
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.HELD if prop != null else Phase.IDLE
_active_action.clear()
prop_event.emit("set_down_failed", prop, reason)
action_completed.emit()
func _fail_pickup_approach(reason: String) -> void:
_clear_reservation()
_restore_hand_target()
_restore_pickup_torso()
_phase = Phase.IDLE
_active_action.clear()
prop_event.emit("pickup_failed", null, reason)
action_completed.emit()
func _start_approach(grip_position: Vector2) -> bool:
if _locomotion == null:
return false
@@ -683,13 +1270,12 @@ func _commit_pickup(marker: Marker2D) -> void:
"linear_velocity": _held_prop.linear_velocity,
"angular_velocity": _held_prop.angular_velocity,
}
_pickup_transform = _held_prop.global_transform
_pickup_transform_valid = true
_held_prop.interaction_reserved_by = 0
_held_prop.interaction_owner_id = _rig.get_instance_id()
_held_prop.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
_held_prop.freeze = true
_held_prop.sleeping = false
_held_prop.collision_layer = 0
_held_prop.collision_mask = 0
_held_prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
_trace_prop_state("pickup_committed", _held_prop)
if _pickup_uses_crouch:
_pickup_recover_elapsed = 0.0
_phase = Phase.PICKUP_RECOVERING
@@ -986,45 +1572,66 @@ func _quadratic_bezier(from: Vector2, control: Vector2, to: Vector2, weight: flo
return from * inverse * inverse + control * (2.0 * inverse * t) + to * t * t
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0) -> void:
func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vector2.ZERO, throw_spin: float = 0.0, zero_velocity: bool = false, restore_hand: bool = true) -> void:
if _held_prop == null or not is_instance_valid(_held_prop):
_clear_held_state()
return
var prop := _held_prop
var release_velocity := throw_velocity if throwing else _sampled_linear_velocity
var release_angular := _sampled_angular_velocity
_trace_prop_state("release_begin:%s" % reason, prop)
var force_still_release: bool = zero_velocity or reason == "scene_reset"
var release_velocity := Vector2.ZERO if force_still_release else (throw_velocity if throwing else _sampled_linear_velocity)
var release_angular := 0.0 if force_still_release else (throw_spin if throwing else _sampled_angular_velocity)
if not release_velocity.is_finite():
release_velocity = Vector2.ZERO
if not is_finite(release_angular):
release_angular = 0.0
prop.interaction_owner_id = 0
prop.interaction_reserved_by = 0
prop.collision_layer = int(_saved_body_state.get("collision_layer", 1))
prop.collision_mask = int(_saved_body_state.get("collision_mask", 1))
prop.z_index = int(_saved_body_state.get("z_index", prop.z_index))
prop.z_as_relative = bool(_saved_body_state.get("z_as_relative", prop.z_as_relative))
prop.freeze_mode = int(_saved_body_state.get("freeze_mode", RigidBody2D.FREEZE_MODE_KINEMATIC))
prop.freeze = false if throwing else bool(_saved_body_state.get("freeze", false))
if throwing:
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = throw_spin
elif not prop.freeze:
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
var target_role := PropBlock.RuntimeRole.RELEASED
if reason == "scene_reset":
target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL
prop.apply_runtime_role(target_role)
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
_held_prop = null
_held_anchor.clear()
_saved_body_state.clear()
_pickup_transform_valid = false
_sampled_linear_velocity = Vector2.ZERO
_sampled_angular_velocity = 0.0
if not throwing:
if not throwing and restore_hand:
_restore_hand_target()
_phase = Phase.IDLE if not throwing else Phase.THROWING
if reason == "manual" or reason == "ragdoll":
if reason == "manual" or reason == "ragdoll" or reason == "set_down":
prop_event.emit("prop_dropped", prop, reason)
elif reason == "throw":
prop_event.emit("prop_thrown", prop, "")
if PROP_TRACE_ENABLED:
_trace_released_prop = prop
_trace_released_elapsed = 0.0
_trace_released_watch_remaining = 5.0
_trace_prop_state("release_complete:%s" % reason, prop)
func _trace_actor_name() -> String:
return String(_rig.name) if _rig != null and is_instance_valid(_rig) else "<no actor>"
func _trace_prop_id(prop: PropBlock) -> String:
if prop == null or not is_instance_valid(prop):
return "<none>"
return prop.stage_object_id if not prop.stage_object_id.is_empty() else String(prop.name)
func _trace_prop_state(event: String, prop: PropBlock) -> void:
if not PROP_TRACE_ENABLED:
return
var timestamp_ms := Time.get_ticks_msec()
if prop == null or not is_instance_valid(prop):
print("[PropTrace t=%dms] %s actor=%s prop=<none> phase=%s" % [timestamp_ms, event, _trace_actor_name(), Phase.keys()[int(_phase)]])
return
print("[PropTrace t=%dms] %s actor=%s prop=%s phase=%s role=%d freeze=%s layer=%d mask=%d position=%s rotation=%.3f velocity=%s owner=%d reserved=%d" % [timestamp_ms, event, _trace_actor_name(), _trace_prop_id(prop), Phase.keys()[int(_phase)], int(prop.runtime_role), str(prop.freeze), prop.collision_layer, prop.collision_mask, str(prop.global_position), prop.global_rotation, str(prop.linear_velocity), prop.interaction_owner_id, prop.interaction_reserved_by])
func _clear_reservation() -> void:
@@ -1041,6 +1648,7 @@ func _clear_held_state() -> void:
_held_prop = null
_held_anchor.clear()
_saved_body_state.clear()
_pickup_transform_valid = false
_phase = Phase.IDLE
_active_action.clear()
@@ -1091,6 +1699,13 @@ func _scaled_anchor(anchor: Dictionary, prop: PropBlock) -> Dictionary:
return result
func _scaled_anchor_position(anchor: Dictionary, prop: PropBlock, facing_left: bool) -> Vector2:
var local_position: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2) * prop.get_interaction_scale()
if facing_left:
local_position.x = -local_position.x
return local_position
func _grip_world_position(prop: PropBlock, anchor: Dictionary) -> Vector2:
return _grip_world_position_for_facing(prop, anchor, _is_facing_left())
@@ -21,6 +21,7 @@ const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0)
const GROUND_PROBE_START_OFFSET := 50.0
const GROUND_PROBE_END_OFFSET := 1500.0
const GROUND_PROBE_TOLERANCE := 200.0
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
const STAND_UP_DURATION := 2.0
const STAND_POSE: Dictionary = {
@@ -818,7 +819,7 @@ func _on_stand_up_finished(generation: int) -> void:
func _build_body(entry: Dictionary) -> void:
var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = 1; body.collision_mask = 1
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = STAGE_PHYSICS.ACTOR_LAYER; body.collision_mask = STAGE_PHYSICS.actor_mask()
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
var shape := CollisionShape2D.new()
if str(entry["kind"]) == "visual":
@@ -538,6 +538,12 @@ func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -
return _prop_interactor_component.call("preview_throw", target, queue_prefix)
func preview_prop_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
if _prop_interactor_component == null:
return {"known": false, "valid": false, "reason": "Prop depends on earlier actions"}
return _prop_interactor_component.call("preview_set_down", target, queue_prefix)
func reset_prop_interactions_for_scene_exit() -> void:
if _prop_interactor_component != null:
_prop_interactor_component.call("reset_for_scene_exit")
+13 -5
View File
@@ -120,6 +120,13 @@ func set_throw_target(pos: Vector2) -> void:
popup_centered()
func set_down_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()
@@ -294,14 +301,14 @@ func _rebuild_params(type: String) -> void:
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":
"throw_prop", "set_down_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.text = ("📍 Change release place" if _has_throw_target else "📍 Choose release place") if type == "set_down_prop" else ("🎯 Change target…" if _has_throw_target else "🎯 Click target…")
_set_target_btn.pressed.connect(func() -> void: target_requested.emit())
throw_row.add_child(_set_target_btn)
_update_throw_target_label()
@@ -410,10 +417,11 @@ func _update_prop_target_label() -> void:
func _update_throw_target_label() -> void:
if _target_label == null:
return
var label_prefix: String = "Release place" if _current_type() == "set_down_prop" else "Target"
if _has_throw_target:
_target_label.text = "Target: (%d, %d)" % [int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
_target_label.text = "%s: (%d, %d)" % [label_prefix, int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))]
else:
_target_label.text = "Target: not set"
_target_label.text = "%s: not set" % label_prefix
func _on_ok_pressed() -> void:
@@ -434,7 +442,7 @@ func _on_ok_pressed() -> void:
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":
"throw_prop", "set_down_prop":
if not _has_throw_target:
target_requested.emit()
return
@@ -0,0 +1,47 @@
class_name PropSettingsDepthCue
extends Control
## Small vector picture showing whether a prop is drawn behind or in front.
var _in_front: bool = false
var _stickman_color: Color = Color("#F5F7FA")
var _prop_color: Color = Color("#B7824D")
var _outline_color: Color = Color("#171B24")
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(40.0, 34.0)
queue_redraw()
func configure(in_front: bool, stickman_color: Color, prop_color: Color, outline_color: Color) -> void:
_in_front = in_front
_stickman_color = stickman_color
_prop_color = prop_color
_outline_color = outline_color
queue_redraw()
func _draw() -> void:
if _in_front:
_draw_stickman()
_draw_prop()
else:
_draw_prop()
_draw_stickman()
func _draw_prop() -> void:
var box := Rect2(Vector2(18.0, 10.0), Vector2(19.0, 19.0))
draw_rect(box, _prop_color)
draw_rect(box, _outline_color, false, 1.5)
func _draw_stickman() -> void:
var head := Vector2(17.0, 5.5)
draw_circle(head, 4.0, _stickman_color)
draw_line(Vector2(17.0, 10.0), Vector2(17.0, 21.0), _stickman_color, 2.5, true)
draw_line(Vector2(17.0, 13.5), Vector2(9.0, 18.0), _stickman_color, 2.5, true)
draw_line(Vector2(17.0, 13.5), Vector2(25.0, 17.5), _stickman_color, 2.5, true)
draw_line(Vector2(17.0, 21.0), Vector2(11.5, 30.0), _stickman_color, 2.5, true)
draw_line(Vector2(17.0, 21.0), Vector2(23.0, 30.0), _stickman_color, 2.5, true)
@@ -0,0 +1 @@
uid://dm8lgubovno3v
File diff suppressed because it is too large Load Diff
+95
View File
@@ -12,6 +12,8 @@ extends RigidBody2D
const PropVisuals := preload("res://scripts/props/prop_visuals.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -20,6 +22,7 @@ const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
enum RuntimeRole { EDIT, START_STILL, START_PHYSICS, HELD, RELEASED }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
@@ -30,6 +33,8 @@ var stage_object_id: String = ""
var interaction_anchors: Dictionary = {}
var interaction_reserved_by: int = 0
var interaction_owner_id: int = 0
var stage_roles: Dictionary = {}
var runtime_role: RuntimeRole = RuntimeRole.EDIT
# ---------------------------------------------------------------------------
# Child node names
@@ -115,6 +120,7 @@ var _play_scale_active: bool = false
## `other` is the body that entered contact (Phase 4 trigger source).
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
signal separated(other: Node)
signal physics_profile_changed
# ---------------------------------------------------------------------------
# Lifecycle
@@ -124,6 +130,9 @@ func _ready() -> void:
_ensure_children()
_apply_shape()
_apply_style()
if stage_roles.is_empty():
stage_roles = PROP_STAGE_ROLES.new_placement(false)
apply_runtime_role(RuntimeRole.EDIT)
# Enable contact reporting so the body_entered signal fires (Phase 4).
contact_monitor = true
max_contacts_reported = MAX_CONTACTS_REPORTED
@@ -134,6 +143,84 @@ func _ready() -> void:
body_exited.connect(_on_body_exited)
func set_stage_roles(roles: Dictionary) -> void:
stage_roles = PROP_STAGE_ROLES.normalize(roles, PROP_STAGE_ROLES.new_placement(has_usable_grip()))
apply_stage_depth()
if runtime_role != RuntimeRole.EDIT:
apply_runtime_role(runtime_role)
else:
physics_profile_changed.emit()
func apply_stage_depth() -> void:
var normalized: Dictionary = PROP_STAGE_ROLES.normalize(stage_roles)
stage_roles = normalized
z_as_relative = false
if bool(normalized.get("legacy_draw_order", false)):
z_index = 0
else:
z_index = -1 if String(normalized.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else 1
func apply_runtime_role(role: RuntimeRole) -> void:
runtime_role = role
match role:
RuntimeRole.EDIT:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
freeze = true
collision_layer = 0
collision_mask = 0
RuntimeRole.START_STILL:
freeze_mode = RigidBody2D.FREEZE_MODE_STATIC
freeze = true
collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking())
collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking())
RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
freeze = false
collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking())
collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking())
RuntimeRole.HELD:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
freeze = true
collision_layer = 0
collision_mask = 0
if role != RuntimeRole.HELD:
apply_stage_depth()
# A frozen RigidBody2D can push its cached EDIT transform back onto the
# node during the next physics sync. When PLAY scale has been moved into the
# child geometry, keep the physics server's body transform at unit scale too
# so that a START_STILL prop is not scaled twice on its first physics tick.
if _play_scale_active and role != RuntimeRole.EDIT:
_sync_transform_to_physics_server()
physics_profile_changed.emit()
func has_usable_grip() -> bool:
var anchors: Dictionary = get_interaction_anchors()
return not PropAnchorDataScript.compatible_grip_ids(anchors, 0).is_empty() or not PropAnchorDataScript.compatible_grip_ids(anchors, 1).is_empty()
func can_be_picked_up() -> bool:
return bool(stage_roles.get("can_pick_up", false)) and has_usable_grip()
func is_actor_blocking() -> bool:
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
return false
if runtime_role == RuntimeRole.RELEASED:
return bool(stage_roles.get("released_blocks_stickmen", true))
return bool(stage_roles.get("blocks_stickmen", false))
func can_receive_prop_contact() -> bool:
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
return false
if runtime_role == RuntimeRole.RELEASED:
return bool(stage_roles.get("released_bumps_other_props", true))
return runtime_role == RuntimeRole.START_PHYSICS
func set_interaction_anchors(value: Dictionary) -> void:
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
@@ -328,6 +415,7 @@ func begin_play_instance_scale() -> void:
_play_scale_active = true
scale = Vector2.ONE
_apply_play_scale()
_sync_transform_to_physics_server()
func end_play_instance_scale() -> void:
if not _play_scale_active:
@@ -364,6 +452,13 @@ func _set_visual_scale(value: Vector2) -> void:
if visuals != null:
visuals.scale = value
func _sync_transform_to_physics_server() -> void:
var rid := get_rid()
if not rid.is_valid():
return
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, global_transform)
reset_physics_interpolation()
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
+76
View File
@@ -0,0 +1,76 @@
class_name PropStageRoles
extends RefCounted
## Normalized, per-placement behavior for a prop in the Sandbox Stage.
const DEPTH_BEHIND: String = "behind"
const DEPTH_FRONT: String = "front"
const START_STILL: String = "still"
const START_PHYSICS: String = "physics"
const ROLE_KEYS: Array[String] = [
"depth", "start_motion", "holds_other_props", "blocks_stickmen",
"can_pick_up", "released_blocks_stickmen", "released_bumps_other_props",
"legacy_draw_order",
]
static func new_placement(has_grip: bool = false) -> Dictionary:
return {
"depth": DEPTH_BEHIND,
"start_motion": START_STILL,
"holds_other_props": false,
"blocks_stickmen": false,
"can_pick_up": has_grip,
"released_blocks_stickmen": true,
"released_bumps_other_props": true,
"legacy_draw_order": false,
}
static func legacy_placement(has_grip: bool = true) -> Dictionary:
return {
"depth": DEPTH_BEHIND,
"start_motion": START_PHYSICS,
"holds_other_props": false,
"blocks_stickmen": true,
"can_pick_up": has_grip,
"released_blocks_stickmen": true,
"released_bumps_other_props": true,
"legacy_draw_order": true,
}
static func normalize(raw: Variant, fallback: Dictionary = {}) -> Dictionary:
var roles: Dictionary = new_placement(bool(fallback.get("can_pick_up", false)))
if not fallback.is_empty():
roles.merge(fallback, true)
if raw is Dictionary:
roles.merge((raw as Dictionary).duplicate(true), true)
if String(roles.get("depth", DEPTH_BEHIND)) not in [DEPTH_BEHIND, DEPTH_FRONT]:
roles["depth"] = DEPTH_BEHIND
if String(roles.get("start_motion", START_STILL)) not in [START_STILL, START_PHYSICS]:
roles["start_motion"] = START_STILL
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
roles[key] = bool(roles.get(key, false))
return roles
static func props_should_collide(a_roles: Dictionary, a_state: int, b_roles: Dictionary, b_state: int) -> bool:
if a_state == 3 or b_state == 3 or a_state == 0 or b_state == 0:
return false
var a_still: bool = a_state == 1
var b_still: bool = b_state == 1
if a_still and b_still:
return false
if a_still:
return bool(a_roles.get("holds_other_props", false)) and _accepts_prop_contact(b_roles, b_state)
if b_still:
return bool(b_roles.get("holds_other_props", false)) and _accepts_prop_contact(a_roles, a_state)
return _accepts_prop_contact(a_roles, a_state) and _accepts_prop_contact(b_roles, b_state)
static func _accepts_prop_contact(roles: Dictionary, state: int) -> bool:
if state == 2:
return true
if state == 4:
return bool(roles.get("released_bumps_other_props", true))
return false
+1
View File
@@ -0,0 +1 @@
uid://c1dsjejgn2r1v
@@ -0,0 +1,238 @@
class_name StageInspectorPanel
extends PanelContainer
## Reusable shell for an in-stage selection inspector.
signal close_requested
var _stage_theme: SandboxTheme = null
var _safe_rect := Rect2()
var _fit_pending: bool = false
var _margin: MarginContainer
var _layout: VBoxContainer
var _header: HBoxContainer
var _icon: Label
var _title: Label
var _close_button: Button
var _scroll: ScrollContainer
var _body: VBoxContainer
func _ready() -> void:
_build_shell()
if _stage_theme != null:
apply_theme(_stage_theme)
request_fit()
func _unhandled_key_input(event: InputEvent) -> void:
if not visible or not event is InputEventKey:
return
var key_event := event as InputEventKey
if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE:
close_requested.emit()
get_viewport().set_input_as_handled()
func apply_theme(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
if not is_node_ready():
return
theme = stage_theme.control_theme() if stage_theme != null else null
var shell_bg := _color("stage_panels.shell.surface_color", Color("#252B38FA"))
var shell_border := _color("stage_panels.shell.border_color", Color("#FFFFFF33"))
var panel_style := _make_style(shell_bg, shell_border, 1, 0, 0)
panel_style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
add_theme_stylebox_override("panel", panel_style)
var padding := _integer("stage_panels.shell.padding", 12, 4, 28)
for side: String in ["left", "right", "top", "bottom"]:
_margin.add_theme_constant_override("margin_" + side, padding)
_layout.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
_header.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 8, 0, 32))
_close_button.custom_minimum_size = Vector2(
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
)
_title.add_theme_font_size_override("font_size", _integer("stage_panels.type.title_size", 24, 12, 48))
_title.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if stage_theme != null:
var ui_font := stage_theme.font()
if ui_font != null:
_title.add_theme_font_override("font", ui_font)
_close_button.add_theme_font_override("font", ui_font)
var emoji_font := stage_theme.emoji_font()
if emoji_font != null:
_icon.add_theme_font_override("font", emoji_font)
_close_button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_style_action_button(_close_button, false)
request_fit()
func set_header(title: String, icon_text: String = "") -> void:
if _title == null:
return
_title.text = title
_title.tooltip_text = title
_icon.text = icon_text
_icon.visible = not icon_text.is_empty()
request_fit()
func body() -> VBoxContainer:
return _body
func set_safe_rect(rect: Rect2) -> void:
_safe_rect = rect
request_fit()
func request_fit() -> void:
if _fit_pending:
return
_fit_pending = true
call_deferred("_fit_to_content")
func reset_scroll() -> void:
if _scroll != null:
_scroll.scroll_vertical = 0
func focus_first_control() -> void:
if _body == null:
return
for node: Node in _body.find_children("*", "Control", true, false):
var control := node as Control
if control == null or not control.visible or not control.is_visible_in_tree():
continue
if control.focus_mode == Control.FOCUS_NONE or control is Button and (control as Button).disabled:
continue
control.grab_focus()
return
_close_button.grab_focus()
func style_action_button(button: Button, prominent: bool = false) -> void:
_style_action_button(button, prominent)
func _build_shell() -> void:
if _body != null:
return
set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
var safe := MarginContainer.new()
add_child(safe)
_margin = safe
_layout = VBoxContainer.new()
_layout.add_theme_constant_override("separation", 8)
safe.add_child(_layout)
_header = HBoxContainer.new()
_header.add_theme_constant_override("separation", 8)
_layout.add_child(_header)
_icon = Label.new()
_icon.custom_minimum_size.x = 28.0
_icon.add_theme_font_size_override("font_size", 24)
_icon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_icon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE
_header.add_child(_icon)
_title = Label.new()
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_title.max_lines_visible = 2
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_header.add_child(_title)
_close_button = Button.new()
_close_button.text = "✕ Close"
_close_button.custom_minimum_size = Vector2(
float(_integer("stage_panels.action.close_width", 88, 72, 180)),
float(_integer("stage_panels.action.min_target_size", 48, 44, 96))
)
_close_button.focus_mode = Control.FOCUS_ALL
_close_button.pressed.connect(func() -> void: close_requested.emit())
_header.add_child(_close_button)
_scroll = ScrollContainer.new()
_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_layout.add_child(_scroll)
_body = VBoxContainer.new()
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body.add_theme_constant_override("separation", 8)
_scroll.add_child(_body)
func _fit_to_content() -> void:
_fit_pending = false
if not is_inside_tree() or _body == null:
return
var rect := _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
rect = get_viewport_rect()
var inset := float(_integer("stage_panels.shell.edge_inset", 12, 0, 48))
var preferred_width := float(_integer("stage_panels.shell.preferred_width", 380, 220, 720))
var card_width := minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
custom_minimum_size.x = card_width
size.x = card_width
await get_tree().process_frame
if not is_inside_tree():
return
var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28))
var gap := float(_integer("stage_panels.shell.header_gap", 8, 0, 32))
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
var body_height := _body.get_combined_minimum_size().y
var fixed_height := header_height + gap + padding * 2.0
var available_height := maxf(72.0, rect.size.y - inset * 2.0)
var natural_height := fixed_height + body_height
var card_height := minf(natural_height, available_height)
var scroll_height := maxf(0.0, card_height - fixed_height)
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
_scroll.custom_minimum_size.y = scroll_height
_scroll.update_minimum_size()
func _style_action_button(button: Button, prominent: bool) -> void:
if button == null:
return
var accent := _color("mode_colors.edit", Color("#22C6FF"))
var neutral := _color("stage_panels.action.surface_color", Color("#171B24"))
var hover := _color("stage_panels.action.hover_surface", Color("#303A4B"))
var selected := _color("stage_panels.action.selected_surface", Color("#12394A"))
var border := _color("global.border_color", Color("#FFFFFF33"))
button.add_theme_stylebox_override("normal", _make_style(selected if prominent else neutral, accent if prominent else border, 1, 10, 6))
button.add_theme_stylebox_override("hover", _make_style(selected.lightened(0.06) if prominent else hover, accent, 2, 10, 6))
button.add_theme_stylebox_override("pressed", _make_style(selected, accent, 2, 10, 6))
button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 2, 10, 6))
button.add_theme_stylebox_override("disabled", _make_style(_color("stage_panels.choice.disabled_surface", Color("#252B38")), border, 1, 10, 6))
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2, 0, 0))
button.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
button.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
if _stage_theme != null:
var ui_font := _stage_theme.font()
if ui_font != null:
button.add_theme_font_override("font", ui_font)
func _make_style(bg: Color, border: Color, border_width: int, hpad: int, vpad: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = bg
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
style.content_margin_left = hpad
style.content_margin_right = hpad
style.content_margin_top = vpad
style.content_margin_bottom = vpad
return style
func _integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://dxpgiyw4mxljt
@@ -0,0 +1,126 @@
class_name StagePanelChoiceGroup
extends VBoxContainer
## Exclusive, full-width choices with optional decorative cues.
signal choice_selected(id: StringName)
var _stage_theme: SandboxTheme = null
var _group := ButtonGroup.new()
var _buttons: Dictionary = {}
var _selected_id: StringName = &""
func _ready() -> void:
_group.allow_unpress = true
add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32))
func add_choice(id: StringName, caption: String, cue: Control = null) -> Button:
var button := Button.new()
button.name = "Choice_%s" % String(id)
button.toggle_mode = true
button.button_group = _group
button.custom_minimum_size.y = float(_integer("stage_panels.choice.minimum_height", 56, 44, 112))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.focus_mode = Control.FOCUS_ALL
button.text = ""
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 4)
margin.add_theme_constant_override("margin_bottom", 4)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
button.add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
if cue != null:
cue.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(cue)
var label := Label.new()
label.text = caption
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(label)
button.pressed.connect(_on_choice_pressed.bind(id, button))
add_child(button)
_buttons[id] = {"button": button, "label": label}
if _stage_theme != null:
_apply_choice_theme(button, label)
return button
func select_no_signal(id: StringName) -> void:
if not _buttons.has(id):
clear_no_signal()
return
_selected_id = id
for key: Variant in _buttons:
var button: Button = _buttons[key]["button"]
button.set_pressed_no_signal(StringName(key) == id)
func clear_no_signal() -> void:
_selected_id = &""
for entry: Dictionary in _buttons.values():
(entry["button"] as Button).set_pressed_no_signal(false)
func apply_theme(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
add_theme_constant_override("separation", _integer("stage_panels.choice.row_gap", 6, 0, 32))
for entry: Dictionary in _buttons.values():
_apply_choice_theme(entry["button"] as Button, entry["label"] as Label)
func _on_choice_pressed(id: StringName, button: Button) -> void:
if not button.button_pressed:
if _buttons.has(_selected_id):
(_buttons[_selected_id]["button"] as Button).set_pressed_no_signal(true)
return
_selected_id = id
choice_selected.emit(id)
func _apply_choice_theme(button: Button, label: Label) -> void:
if _stage_theme == null:
return
var font := _stage_theme.font()
if font != null:
label.add_theme_font_override("font", font)
label.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
label.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
var neutral := _color("stage_panels.choice.neutral_surface", Color("#171B24"))
var hover := _color("stage_panels.choice.hover_surface", Color("#303A4B"))
var selected := _color("stage_panels.choice.selected_surface", Color("#12394A"))
var disabled := _color("stage_panels.choice.disabled_surface", Color("#252B38"))
var border := _color("stage_panels.choice.border_color", Color("#FFFFFF33"))
var accent := _color("mode_colors.edit", Color("#22C6FF"))
button.add_theme_stylebox_override("normal", _make_style(neutral, border, 1))
button.add_theme_stylebox_override("hover", _make_style(hover, border, 1))
button.add_theme_stylebox_override("pressed", _make_style(selected, accent, 2))
button.add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 2))
button.add_theme_stylebox_override("disabled", _make_style(disabled, border, 1))
button.add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2))
func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = bg
style.border_color = border
style.set_border_width_all(width)
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
return style
func _integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://du3kqlsq5t10
@@ -0,0 +1,65 @@
class_name StagePanelSwitchVisual
extends Control
## Draw-only switch. Input belongs to its containing toggle row.
var _enabled: bool = false
var _unavailable: bool = false
var _stage_theme: SandboxTheme = null
var _track_size := Vector2(64.0, 34.0)
var _knob_size: float = 26.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_apply_metrics()
queue_redraw()
func set_state(enabled: bool, unavailable: bool) -> void:
_enabled = enabled
_unavailable = unavailable
queue_redraw()
func apply_theme(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
_track_size.x = float(_integer("stage_panels.toggle.switch_width", 64, 48, 112))
_track_size.y = float(_integer("stage_panels.toggle.switch_height", 34, 28, 64))
_knob_size = float(_integer("stage_panels.toggle.knob_size", 26, 20, 52))
_apply_metrics()
queue_redraw()
func _draw() -> void:
if size.x <= 0.0 or size.y <= 0.0:
return
var on_color := _color("stage_panels.toggle.switch_on_color", Color("#22C6FF"))
var off_color := _color("stage_panels.toggle.switch_off_color", Color("#4A5362"))
var disabled_color := _color("stage_panels.toggle.switch_disabled_color", Color("#3B414D"))
var knob_color := _color("stage_panels.toggle.knob_color", Color("#F5F7FA"))
var track_color := disabled_color if _unavailable else (on_color if _enabled else off_color)
var center_y := size.y * 0.5
var radius := center_y
draw_rect(Rect2(radius, 0.0, maxf(0.0, size.x - radius * 2.0), size.y), track_color)
draw_circle(Vector2(radius, center_y), radius, track_color)
draw_circle(Vector2(size.x - radius, center_y), radius, track_color)
var knob_radius := minf(_knob_size, size.y - 4.0) * 0.5
var knob_center := Vector2(size.x - radius, center_y) if _enabled else Vector2(radius, center_y)
draw_circle(knob_center, knob_radius, knob_color)
if _enabled and not _unavailable:
var check_color := on_color.darkened(0.25)
draw_line(knob_center + Vector2(-4.5, 0.0), knob_center + Vector2(-1.0, 3.5), check_color, 2.0, true)
draw_line(knob_center + Vector2(-1.0, 3.5), knob_center + Vector2(5.0, -4.0), check_color, 2.0, true)
func _apply_metrics() -> void:
custom_minimum_size = _track_size
size_flags_vertical = Control.SIZE_SHRINK_BEGIN
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://d24xh8pdqpyyn
@@ -0,0 +1,189 @@
class_name StagePanelToggleRow
extends Button
## Full-row toggle with stable switch placement and optional help.
signal preferred_height_changed
const SWITCH_VISUAL := preload("res://scripts/shared/stage_ui/stage_panel_switch_visual.gd")
var _stage_theme: SandboxTheme = null
var _caption: String = ""
var _normal_help: String = ""
var _disabled_reason: String = ""
var _caption_label: Label
var _help_label: Label
var _label_stack: VBoxContainer
var _state_label: Label
var _switch_visual: StagePanelSwitchVisual
var _content_margin: MarginContainer
func _ready() -> void:
text = ""
toggle_mode = true
focus_mode = Control.FOCUS_ALL
_build_content()
toggled.connect(_on_toggled)
if _stage_theme != null:
apply_theme(_stage_theme)
_sync_text()
_sync_switch()
func configure(caption: String) -> void:
_caption = caption
_sync_text()
func set_help_text(text_value: String) -> void:
_normal_help = text_value
_sync_text()
func set_disabled_state(value: bool, reason: String = "") -> void:
if value:
_disabled_reason = reason
else:
_disabled_reason = ""
disabled = value
_sync_text()
_sync_switch()
func set_checked_no_signal(value: bool) -> void:
set_pressed_no_signal(value)
_sync_switch()
func apply_theme(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
if _caption_label == null:
return
var font := stage_theme.font() if stage_theme != null else null
if font != null:
for label: Label in [_caption_label, _help_label, _state_label]:
label.add_theme_font_override("font", font)
_caption_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.body_size", 20, 14, 40))
_help_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.helper_size", 16, 12, 32))
_state_label.add_theme_font_size_override("font_size", _integer("stage_panels.type.helper_size", 16, 12, 32))
_content_margin.add_theme_constant_override("margin_left", _integer("stage_panels.toggle.row_padding", 12, 4, 28))
_content_margin.add_theme_constant_override("margin_right", _integer("stage_panels.toggle.row_padding", 12, 4, 28))
_switch_visual.apply_theme(stage_theme)
var neutral := _color("stage_panels.toggle.neutral_surface", Color("#202633"))
var hover := _color("stage_panels.toggle.hover_surface", Color("#2B3442"))
var selected := _color("stage_panels.toggle.selected_surface", Color("#173443"))
var disabled_surface := _color("stage_panels.toggle.disabled_surface", Color("#252B38"))
var border := _color("stage_panels.toggle.border_color", Color("#FFFFFF33"))
var accent := _color("mode_colors.edit", Color("#22C6FF"))
add_theme_stylebox_override("normal", _make_style(neutral, border, 1))
add_theme_stylebox_override("hover", _make_style(hover, border, 1))
add_theme_stylebox_override("pressed", _make_style(selected, accent, 1))
add_theme_stylebox_override("hover_pressed", _make_style(selected.lightened(0.06), accent, 1))
add_theme_stylebox_override("disabled", _make_style(disabled_surface, border, 1))
add_theme_stylebox_override("focus", _make_style(Color(0, 0, 0, 0), accent, 2))
_sync_text()
func _build_content() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 12)
margin.add_theme_constant_override("margin_right", 12)
margin.add_theme_constant_override("margin_top", 6)
margin.add_theme_constant_override("margin_bottom", 6)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
_content_margin = margin
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
_label_stack = VBoxContainer.new()
_label_stack.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_label_stack.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
_label_stack.add_theme_constant_override("separation", 2)
_label_stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(_label_stack)
_caption_label = Label.new()
_caption_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_caption_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_caption_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_label_stack.add_child(_caption_label)
_help_label = Label.new()
_help_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_help_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
_label_stack.add_child(_help_label)
_switch_visual = SWITCH_VISUAL.new()
_switch_visual.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
row.add_child(_switch_visual)
_state_label = Label.new()
_state_label.custom_minimum_size.x = 28.0
_state_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_state_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_state_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
_state_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(_state_label)
# Connect after all labels exist: adding the first label can trigger a resize
# synchronously, before the help and state labels have been constructed.
_caption_label.resized.connect(_update_preferred_height)
_help_label.resized.connect(_update_preferred_height)
func _sync_text() -> void:
var help := _disabled_reason if disabled and not _disabled_reason.is_empty() else _normal_help
var base_height := float(_integer("stage_panels.toggle.minimum_height", 64, 48, 120))
var help_height := float(_integer("stage_panels.toggle.help_minimum_height", 72, 56, 144))
if _caption_label == null or _help_label == null or _label_stack == null or _state_label == null:
custom_minimum_size.y = help_height if not help.is_empty() else base_height
return
_caption_label.text = _caption
_help_label.text = help
_help_label.visible = not help.is_empty()
_sync_label_colors()
var content_height := _label_stack.get_combined_minimum_size().y
var next_height := maxf(help_height if not help.is_empty() else base_height, content_height + 12.0)
if not is_equal_approx(custom_minimum_size.y, next_height):
custom_minimum_size.y = next_height
update_minimum_size()
preferred_height_changed.emit()
func _update_preferred_height() -> void:
_sync_text()
func _sync_switch() -> void:
if _switch_visual == null or _state_label == null:
return
_switch_visual.set_state(button_pressed, disabled)
_state_label.text = "ON" if button_pressed else "OFF"
_sync_label_colors()
func _sync_label_colors() -> void:
if _caption_label == null or _help_label == null or _state_label == null:
return
_caption_label.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
_help_label.add_theme_color_override("font_color", _color("stage_panels.type.muted_text_color", Color("#AAB2C0")))
_state_label.add_theme_color_override("font_color", _color("stage_panels.type.muted_text_color", Color("#AAB2C0")))
func _on_toggled(_value: bool) -> void:
_sync_switch()
func _make_style(bg: Color, border: Color, width: int) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = bg
style.border_color = border
style.set_border_width_all(width)
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
return style
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://4so64df7s3if
+16
View File
@@ -75,6 +75,14 @@ const ACTION_TEMPLATES := {
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"set_down_prop": {
"label": "Set Down Prop",
"icon": "🫳",
"params": [
{ "key": "target_position", "type": "position", "required": true },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
}
@@ -157,6 +165,8 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
params["hand"] = String(action.get("hand", "auto"))
"throw_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
"set_down_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
return {
"type": type,
"target": target_id,
@@ -187,6 +197,8 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
return { "type": "drop_prop" }
"throw_prop":
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
"set_down_prop":
return { "type": "set_down_prop", "target_position": params.get("target_position", Vector2.ZERO) }
_:
return { "type": type }
@@ -224,6 +236,10 @@ static func summarize(action: Dictionary) -> String:
var raw_position: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var target_position: Vector2 = raw_position as Vector2 if raw_position is Vector2 else Vector2.ZERO
return "Throw Prop (%d, %d)" % [int(roundf(target_position.x)), int(roundf(target_position.y))]
"set_down_prop":
var raw_release: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var release_position: Vector2 = raw_release as Vector2 if raw_release is Vector2 else Vector2.ZERO
return "Set Down Prop (%d, %d)" % [int(roundf(release_position.x)), int(roundf(release_position.y))]
_:
return type
+45 -1
View File
@@ -14,7 +14,15 @@ const DEFAULT_CONFIG: Dictionary = {
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"stage_panels": {
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
"type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"},
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"},
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"}
},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
}
@@ -222,12 +230,48 @@ func _apply_legacy_aliases(source: Dictionary) -> void:
var modes: Dictionary = source.get("mode_colors", {})
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
var old_panel_colors: Dictionary = source.get("stage_prop_settings", {})
if old_panel_colors.has("edit_accent") and not modes.has("edit"):
modes["edit"] = old_panel_colors["edit_accent"]
var fonts: Dictionary = source.get("fonts", {})
var sizes: Dictionary = fonts.get("sizes", {})
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
var legacy: String = name + "_font_size"
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
var old_panel: Dictionary = source.get("stage_prop_settings", {})
if not old_panel.is_empty():
var stage_panels: Dictionary = source.get("stage_panels", {})
for mapping: Array in [
["card_width", "shell", "preferred_width"], ["padding", "shell", "padding"],
["corner_radius", "shell", "corner_radius"], ["title_size", "type", "title_size"],
["section_size", "type", "section_size"], ["body_size", "type", "body_size"],
["helper_font_size", "type", "helper_size"], ["choice_text_color", "type", "text_color"],
["helper_color", "type", "muted_text_color"], ["choice_min_height", "choice", "minimum_height"],
["button_min_height", "action", "min_target_size"],
["choice_neutral_surface", "choice", "neutral_surface"], ["choice_hover_surface", "choice", "hover_surface"],
["choice_selected_surface", "choice", "selected_surface"], ["neutral_border", "choice", "border_color"],
["disabled_surface", "choice", "disabled_surface"],
["toggle_row_min_height", "toggle", "minimum_height"], ["toggle_help_min_height", "toggle", "help_minimum_height"],
["row_padding", "toggle", "row_padding"], ["switch_width", "toggle", "switch_width"],
["switch_height", "toggle", "switch_height"], ["switch_knob_size", "toggle", "knob_size"],
["toggle_surface", "toggle", "neutral_surface"], ["toggle_hover_surface", "toggle", "hover_surface"],
["toggle_selected_surface", "toggle", "selected_surface"], ["switch_on_color", "toggle", "switch_on_color"],
["disabled_surface", "toggle", "disabled_surface"], ["neutral_border", "toggle", "border_color"],
["switch_off_color", "toggle", "switch_off_color"], ["switch_disabled_color", "toggle", "switch_disabled_color"],
["switch_knob_color", "toggle", "knob_color"], ["stickman_cue_color", "prop", "stickman_cue_color"],
["prop_cue_color", "prop", "prop_cue_color"], ["cue_outline_color", "prop", "cue_outline_color"]
]:
var old_key: String = mapping[0]
var group_key: String = mapping[1]
var new_key: String = mapping[2]
if not old_panel.has(old_key):
continue
var group: Dictionary = stage_panels.get(group_key, {})
if not group.has(new_key):
group[new_key] = old_panel[old_key]
stage_panels[group_key] = group
source["stage_panels"] = stage_panels
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
+22
View File
@@ -0,0 +1,22 @@
class_name StagePhysicsPolicy
extends RefCounted
## Named physics bits shared by Stage objects and stickman collision probes.
const TERRAIN_LAYER: int = 1 << 0
const ACTOR_LAYER: int = 1 << 1
const PROP_LAYER: int = 1 << 2
const ACTOR_BLOCKING_PROP_LAYER: int = 1 << 3
const ACTOR_QUERY_MASK: int = TERRAIN_LAYER | ACTOR_LAYER | ACTOR_BLOCKING_PROP_LAYER
const PROP_QUERY_MASK: int = TERRAIN_LAYER | ACTOR_LAYER | PROP_LAYER
static func prop_layer(blocks_stickmen: bool) -> int:
return PROP_LAYER | (ACTOR_BLOCKING_PROP_LAYER if blocks_stickmen else 0)
static func prop_mask(blocks_stickmen: bool) -> int:
return TERRAIN_LAYER | PROP_LAYER | (ACTOR_LAYER if blocks_stickmen else 0)
static func actor_mask() -> int:
return TERRAIN_LAYER | ACTOR_LAYER | ACTOR_BLOCKING_PROP_LAYER
@@ -0,0 +1 @@
uid://gpw1hqukuefb
+77 -1
View File
@@ -29,8 +29,17 @@ var action_show_collision: bool = false
var action_preview_unavailable: bool = false
var action_preview_planned: bool = false
var action_preview_uncertain: bool = false
var set_down_preview_visible: bool = false
var set_down_preview_known: bool = false
var set_down_prop: PropBlock = null
var set_down_transform: Transform2D = Transform2D.IDENTITY
var set_down_approach: Vector2 = Vector2.ZERO
var set_down_grip: Vector2 = Vector2.ZERO
var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3)
var set_down_unknown_color: Color = Color(0.77, 0.81, 0.87, 0.9)
var set_down_reach_color: Color = Color(1.0, 0.7, 0.1, 0.9)
var set_down_ghost_alpha: float = 0.36
var line_width_px: float = 2.0
var dash_length_px: float = 8.0
var dash_gap_px: float = 5.0
@@ -44,6 +53,10 @@ func apply_style(style: Dictionary) -> void:
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
var set_down_style: Dictionary = style.get("set_down", {})
set_down_unknown_color = Color.from_string(String(set_down_style.get("unknown_color", "")), set_down_unknown_color)
set_down_reach_color = Color.from_string(String(set_down_style.get("reach_color", "")), set_down_reach_color)
set_down_ghost_alpha = clampf(float(set_down_style.get("ghost_alpha", set_down_ghost_alpha)), 0.0, 1.0)
var defaults: Dictionary = style.get("style_defaults", {})
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
@@ -78,6 +91,8 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
set_down_prop = null
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
@@ -94,6 +109,8 @@ func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_ta
action_preview_unavailable = false
action_preview_planned = planned
action_preview_uncertain = uncertain
set_down_preview_visible = false
set_down_prop = null
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
@@ -108,6 +125,24 @@ func set_throw_preview_unavailable(requested_target: Vector2) -> void:
action_preview_unavailable = true
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid: bool, prop: PropBlock = null, release_transform: Transform2D = Transform2D.IDENTITY, approach: Vector2 = Vector2.ZERO, grip: Vector2 = Vector2.ZERO) -> void:
action_visible = true
action_origin = origin
action_target = target
action_curve_points.clear()
action_valid = valid
action_preview_unavailable = not known
action_show_requested_target = false
action_show_collision = false
set_down_preview_visible = true
set_down_preview_known = known
set_down_prop = prop
set_down_transform = release_transform
set_down_approach = approach
set_down_grip = grip
func clear_action() -> void:
@@ -118,6 +153,8 @@ func clear_action() -> void:
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
set_down_prop = null
queue_redraw()
@@ -127,7 +164,9 @@ func _draw() -> void:
_draw_cell_marker(terrain_anchor, guide_line_color)
_draw_cell_marker(terrain_target, guide_line_color)
if action_visible:
if action_preview_unavailable:
if set_down_preview_visible:
_draw_set_down_preview()
elif action_preview_unavailable:
var zoom := _zoom()
var unavailable_color := Color(0.74, 0.79, 0.85, 0.9)
draw_arc(action_target, MARKER_RADIUS_PX / zoom, 0.0, TAU, 24, unavailable_color, LINE_WIDTH_PX / zoom, true)
@@ -152,6 +191,43 @@ func _draw() -> void:
_draw_ghost_marker(action_target, color)
func _draw_set_down_preview() -> void:
var zoom := _zoom()
var color := valid_color if action_valid else invalid_color
if not set_down_preview_known:
color = set_down_unknown_color
_draw_dashed(action_origin, set_down_approach if set_down_approach.is_finite() and set_down_approach != Vector2.ZERO else action_target, color)
if set_down_preview_known and set_down_prop != null and is_instance_valid(set_down_prop):
var polygon_node := set_down_prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if polygon_node != null and polygon_node.polygon.size() >= 3:
var local_transform: Transform2D = set_down_prop.global_transform.affine_inverse() * polygon_node.global_transform
var ghost_transform: Transform2D = set_down_transform * local_transform
var ghost_points := PackedVector2Array()
for point: Vector2 in polygon_node.polygon:
ghost_points.append(ghost_transform * point)
var fill := set_down_prop.fill_color
fill.a = set_down_ghost_alpha
draw_colored_polygon(ghost_points, fill)
var outline_points := ghost_points.duplicate()
outline_points.append(ghost_points[0])
draw_polyline(outline_points, color, line_width_px / zoom, true)
else:
_draw_ghost_marker(action_target, color)
else:
_draw_ghost_marker(action_target, color)
var release_origin := set_down_transform.origin
var target_adjusted := set_down_preview_known and release_origin.distance_to(action_target) > 1.0
if target_adjusted:
_draw_dashed(action_target, release_origin, set_down_reach_color)
draw_arc(release_origin, 4.0 / zoom, 0.0, TAU, 16, set_down_reach_color, LINE_WIDTH_PX / zoom, true)
var cross_radius := 9.0 / zoom
var target_color := set_down_reach_color if target_adjusted else color
draw_line(action_target + Vector2(-cross_radius, 0.0), action_target + Vector2(cross_radius, 0.0), target_color, line_width_px / zoom, true)
draw_line(action_target + Vector2(0.0, -cross_radius), action_target + Vector2(0.0, cross_radius), target_color, line_width_px / zoom, true)
if set_down_grip.is_finite() and set_down_grip != Vector2.ZERO:
draw_line(set_down_approach, set_down_grip, set_down_reach_color, line_width_px / zoom, true)
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
var zoom := _zoom()
var dash := dash_length_px / zoom
+58 -4
View File
@@ -3,11 +3,12 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.1"
const FILE_VERSION: String = "1.2"
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
@@ -157,8 +158,13 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
static func _migrate_to_current(data: Dictionary) -> Dictionary:
var version := String(data.get("version", ""))
if version == FILE_VERSION:
return data
if version != "1.0":
var current: Dictionary = data.duplicate(true)
for raw: Variant in current.get("objects", []):
if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop" and not (raw as Dictionary).has("roles"):
(raw as Dictionary)["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1"]:
return {}
var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []):
@@ -166,10 +172,39 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
continue
var object: Dictionary = raw
if String(object.get("type", "")) == "prop":
object["stage_object_id"] = String(object.get("file_id", ""))
if version == "1.0":
object["stage_object_id"] = String(object.get("file_id", ""))
if not object.has("roles"):
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
static func _assign_legacy_depths(raw_objects: Variant) -> void:
if not raw_objects is Array:
return
var objects: Array = raw_objects
for index: int in objects.size():
if not objects[index] is Dictionary:
continue
var entry: Dictionary = objects[index]
if String(entry.get("type", "")) != "prop" or not entry.get("roles", {}) is Dictionary:
continue
var roles: Dictionary = entry["roles"]
if not bool(roles.get("legacy_draw_order", false)):
continue
var characters_before: int = 0
var characters_after: int = 0
for other_index: int in objects.size():
if not objects[other_index] is Dictionary or String((objects[other_index] as Dictionary).get("type", "")) != "stickman":
continue
if other_index < index:
characters_before += 1
else:
characters_after += 1
roles["depth"] = PROP_STAGE_ROLES.DEPTH_FRONT if characters_before > characters_after else PROP_STAGE_ROLES.DEPTH_BEHIND
static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
@@ -187,6 +222,17 @@ static func _validate_root(data: Dictionary) -> Dictionary:
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
seen_stage_ids[stage_id] = true
if entry.has("roles"):
var roles_value: Variant = entry["roles"]
if not roles_value is Dictionary:
return {"ok": false, "error": "Prop roles must be an object."}
var roles: Dictionary = roles_value
for key: String in ["depth", "start_motion"]:
if roles.has(key) and (not roles[key] is String or (key == "depth" and not [PROP_STAGE_ROLES.DEPTH_BEHIND, PROP_STAGE_ROLES.DEPTH_FRONT].has(roles[key])) or (key == "start_motion" and not [PROP_STAGE_ROLES.START_STILL, PROP_STAGE_ROLES.START_PHYSICS].has(roles[key]))):
return {"ok": false, "error": "Prop role '%s' is invalid." % key}
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
if roles.has(key) and not roles[key] is bool:
return {"ok": false, "error": "Prop role '%s' must be true or false." % key}
return {"ok": true, "error": ""}
static func _recover_backup(path: String) -> void:
@@ -220,6 +266,11 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
var target_position: Vector2 = action["target_position"]
if not target_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(target_position)}
"set_down_prop":
if not action.get("target_position") is Vector2: return {}
var release_position: Vector2 = action["target_position"]
if not release_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(release_position)}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
@@ -243,6 +294,9 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
"throw_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
"set_down_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
+26 -18
View File
@@ -97,7 +97,30 @@ func is_selected(node: Node2D) -> bool:
## Returns the frontmost selectable node under `world_pos`, or null.
func hit_test(world_pos: Vector2) -> Node2D:
return _frontmost_at(world_pos)
var hits: Array[Node2D] = hit_test_all(world_pos)
return hits[0] if not hits.is_empty() else null
## Returns every geometrically selectable object under the point, frontmost first.
func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
var hits: Array[Node2D] = []
for child: Node in _world.get_children():
var node := child as Node2D
if node == null or not _is_selectable(node):
continue
if get_world_aabb(node).has_point(world_pos):
hits.append(node)
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
if ragdoll_hit != null and not hits.has(ragdoll_hit):
hits.append(ragdoll_hit)
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
if a.z_index != b.z_index:
return a.z_index > b.z_index
if a.get_index() != b.get_index():
return a.get_index() > b.get_index()
return get_world_aabb(a).get_area() < get_world_aabb(b).get_area()
)
return hits
## Refreshes the hovered node under `world_pos`, emitting hover_changed when it
@@ -223,20 +246,5 @@ func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
## Highest World child index wins; on equal area the frontmost (first hit in
## reverse iteration) stays.
func _frontmost_at(world_pos: Vector2) -> Node2D:
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
if ragdoll_hit != null:
return ragdoll_hit
var children := _world.get_children()
var best: Node2D = null
var best_area := INF
for i: int in range(children.size() - 1, -1, -1):
var node := children[i] as Node2D
if node == null or not _is_selectable(node):
continue
var aabb := get_world_aabb(node)
if aabb.has_point(world_pos):
var area := aabb.get_area()
if best == null or area < best_area:
best = node
best_area = area
return best
var hits: Array[Node2D] = hit_test_all(world_pos)
return hits[0] if not hits.is_empty() else null
+2
View File
@@ -14,6 +14,7 @@ extends RefCounted
const TERRAIN_UTILS := preload("res://scripts/terrain/terrain_utils.gd")
const PROP_UTILS := preload("res://scripts/props/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
@@ -180,6 +181,7 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
prop.spawn_id = String(template.get("id", asset_id))
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
prop.ensure_default_grip()
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(prop.has_usable_grip()))
return prop