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:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user