Add Use Spot Overlay and Target Capture Bar functionality
- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots. - Implemented UI elements including buttons for changing props and canceling actions. - Added `PropUseSpotData` class for validation and serialization of prop use spots. - Created tests for validating use spot data migration, serialization, and rule matching. - Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
This commit is contained in:
@@ -399,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" or action_type == "set_down_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" or action_type == "use_prop"
|
||||
if requires_animated and _rig != null:
|
||||
if _rig.state == StickmanRig.RigState.RECOVERING:
|
||||
_run_queue.insert(_run_cursor, _current_run_item.duplicate(true))
|
||||
@@ -443,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", "set_down_prop":
|
||||
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop":
|
||||
if _rig == null or not _rig.start_prop_action(action):
|
||||
_finish_action()
|
||||
else:
|
||||
|
||||
@@ -6,9 +6,10 @@ 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")
|
||||
const USE_REACH_SOLVER := preload("res://scripts/characters/stickman/stickman_use_reach_solver.gd")
|
||||
|
||||
enum Hand { LEFT, RIGHT }
|
||||
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING }
|
||||
enum Phase { IDLE, APPROACHING, REACHING, PICKUP_RECOVERING, HELD, THROWING, SET_DOWN_APPROACHING, SET_DOWN_REACHING, SET_DOWN_RECOVERING, USE_APPROACHING, USE_REACHING, USE_RECOVERING }
|
||||
|
||||
const MAX_REACH: float = 390.0
|
||||
const APPROACH_GAP: float = 90.0
|
||||
@@ -16,6 +17,12 @@ 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 USE_REACH_DURATION: float = 0.28
|
||||
const USE_RECOVER_DURATION: float = 0.20
|
||||
const USE_MAX_REPLAN_DISPLACEMENT: float = 180.0
|
||||
const USE_REPLAN_LIMIT: int = 1
|
||||
## Temporary diagnostics for the Use reach failure report. Remove after diagnosis.
|
||||
const USE_REACH_TRACE_ENABLED: bool = true
|
||||
const PICKUP_RECOVER_DURATION: float = 0.32
|
||||
const LOW_PICKUP_THRESHOLD: float = 110.0
|
||||
const LOW_PICKUP_MAX_DROP: float = 110.0
|
||||
@@ -38,6 +45,8 @@ const PROP_TRACE_ENABLED: bool = false
|
||||
|
||||
signal action_completed
|
||||
signal prop_event(event_type: String, prop: PropBlock, reason: String)
|
||||
signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)
|
||||
signal use_failed(reason_code: String, prop: PropBlock, spot_id: String)
|
||||
|
||||
var _rig: Node2D
|
||||
var _locomotion: Node
|
||||
@@ -91,6 +100,16 @@ 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
|
||||
var _use_prop: PropBlock
|
||||
var _use_spot_id: String = ""
|
||||
var _use_planned_spot_world: Vector2 = Vector2.INF
|
||||
var _use_approach_root: Vector2 = Vector2.ZERO
|
||||
var _use_approach_replans: int = 0
|
||||
var _use_reach_elapsed: float = 0.0
|
||||
var _use_recover_elapsed: float = 0.0
|
||||
var _use_marker_start: Vector2 = Vector2.ZERO
|
||||
var _use_marker_recover_start: Vector2 = Vector2.ZERO
|
||||
var _use_hand: Hand = Hand.RIGHT
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
@@ -167,6 +186,8 @@ func start_action(action: Dictionary) -> bool:
|
||||
return true
|
||||
"set_down_prop":
|
||||
return _begin_set_down(action)
|
||||
"use_prop":
|
||||
return _begin_use(action)
|
||||
return false
|
||||
|
||||
|
||||
@@ -193,6 +214,25 @@ func tick(delta: float) -> void:
|
||||
_update_set_down_reach(delta)
|
||||
Phase.SET_DOWN_RECOVERING:
|
||||
_update_set_down_recovery(delta)
|
||||
Phase.USE_APPROACHING:
|
||||
if not _validate_use_target():
|
||||
return
|
||||
var live_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
|
||||
if not live_spot.is_finite():
|
||||
_fail_use("spot_missing")
|
||||
return
|
||||
if live_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT:
|
||||
_request_use_replan("target_moved")
|
||||
return
|
||||
if _locomotion != null and bool(_locomotion.call("consume_walk_failed")):
|
||||
_fail_use("route_blocked")
|
||||
return
|
||||
if _locomotion != null and bool(_locomotion.call("consume_walk_completed")):
|
||||
_on_use_approach_arrived()
|
||||
Phase.USE_REACHING:
|
||||
_update_use_reach(delta)
|
||||
Phase.USE_RECOVERING:
|
||||
_update_use_recovery(delta)
|
||||
Phase.PICKUP_RECOVERING:
|
||||
_update_pickup_recovery(delta)
|
||||
Phase.HELD:
|
||||
@@ -202,7 +242,7 @@ func tick(delta: float) -> void:
|
||||
|
||||
|
||||
func cancel_action() -> void:
|
||||
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING) and _locomotion != null:
|
||||
if (_phase == Phase.APPROACHING or _phase == Phase.SET_DOWN_APPROACHING or _phase == Phase.USE_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()
|
||||
@@ -232,6 +272,10 @@ func cancel_action() -> void:
|
||||
_throw_elapsed = 0.0
|
||||
_restore_hand_target()
|
||||
_restore_throw_torso()
|
||||
if _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING:
|
||||
_restore_hand_target()
|
||||
_restore_pickup_torso()
|
||||
_clear_use_state()
|
||||
if _phase != Phase.HELD:
|
||||
_phase = Phase.IDLE
|
||||
_active_action.clear()
|
||||
@@ -560,27 +604,327 @@ 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_use := _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING
|
||||
var interrupted_windup := _phase == Phase.THROWING and not _throw_released
|
||||
if interrupted_pickup or interrupted_set_down:
|
||||
if interrupted_pickup or interrupted_set_down or interrupted_use:
|
||||
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_set_down or interrupted_windup:
|
||||
if interrupted_pickup or interrupted_set_down or interrupted_use or interrupted_windup:
|
||||
action_completed.emit()
|
||||
|
||||
|
||||
func reset_for_scene_exit() -> void:
|
||||
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):
|
||||
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.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_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)
|
||||
_phase = Phase.IDLE
|
||||
_active_action.clear()
|
||||
_restore_hand_target()
|
||||
_clear_use_state()
|
||||
|
||||
|
||||
func get_held_prop() -> PropBlock:
|
||||
return _held_prop if _held_prop != null and is_instance_valid(_held_prop) else null
|
||||
|
||||
|
||||
func preview_use(prop: PropBlock, spot_id: String, actor_root: Vector2 = Vector2.INF, predicted_facing_left: bool = false) -> Dictionary:
|
||||
if _rig == null or not is_instance_valid(_rig):
|
||||
return {"known": true, "valid": false, "reason_code": "out_of_reach"}
|
||||
var root: Vector2 = _rig.global_position if not actor_root.is_finite() else actor_root
|
||||
return USE_REACH_SOLVER.solve(_rig, prop, spot_id, root, predicted_facing_left)
|
||||
|
||||
|
||||
func _begin_use(action: Dictionary) -> bool:
|
||||
var prop_id: String = String(action.get("target_prop_id", ""))
|
||||
var spot_id: String = String(action.get("target_spot_id", ""))
|
||||
if _phase != Phase.IDLE or _rig == null or int(_rig.get("state")) != StickmanRig.RigState.ANIMATED:
|
||||
use_failed.emit("out_of_reach", null, spot_id)
|
||||
return false
|
||||
if _held_prop != null and is_instance_valid(_held_prop):
|
||||
use_failed.emit("hands_full", null, spot_id)
|
||||
return false
|
||||
if prop_id.is_empty() or not _resolver.is_valid():
|
||||
use_failed.emit("prop_missing", null, spot_id)
|
||||
return false
|
||||
var resolved: Variant = _resolver.call(prop_id)
|
||||
if not resolved is PropBlock or not is_instance_valid(resolved as PropBlock) or (resolved as PropBlock).is_queued_for_deletion():
|
||||
use_failed.emit("prop_missing", null, spot_id)
|
||||
return false
|
||||
var prop: PropBlock = resolved as PropBlock
|
||||
var preview: Dictionary = USE_REACH_SOLVER.solve(_rig, prop, spot_id, _rig.global_position, _is_facing_left())
|
||||
if not bool(preview.get("valid", false)):
|
||||
use_failed.emit(String(preview.get("reason_code", "out_of_reach")), prop, spot_id)
|
||||
return false
|
||||
_use_prop = prop
|
||||
_use_spot_id = spot_id
|
||||
_use_planned_spot_world = preview.get("spot_world", Vector2.INF)
|
||||
_use_approach_root = preview.get("approach_root", _rig.global_position)
|
||||
_use_approach_replans = 0
|
||||
_use_hand = int(preview.get("hand", Hand.RIGHT))
|
||||
_hand = _use_hand
|
||||
_active_action = action.duplicate(true)
|
||||
_trace_use_reach("begin", "", preview)
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker == null:
|
||||
_clear_use_state()
|
||||
use_failed.emit("out_of_reach", prop, spot_id)
|
||||
return false
|
||||
_marker_start_position = marker.position
|
||||
if absf(_use_approach_root.x - _rig.global_position.x) > 8.0:
|
||||
return _start_use_approach(preview)
|
||||
_start_use_reach(preview)
|
||||
return _phase == Phase.USE_REACHING
|
||||
|
||||
|
||||
func _start_use_approach(preview: Dictionary) -> bool:
|
||||
if _locomotion == null or _use_prop == null or not is_instance_valid(_use_prop):
|
||||
_fail_use("prop_missing")
|
||||
return false
|
||||
_use_approach_root = preview.get("approach_root", _use_approach_root)
|
||||
_use_planned_spot_world = preview.get("spot_world", _use_planned_spot_world)
|
||||
_use_hand = int(preview.get("hand", _use_hand))
|
||||
_hand = _use_hand
|
||||
var facing_left: bool = bool(preview.get("facing_left", _is_facing_left()))
|
||||
if facing_left != _is_facing_left():
|
||||
_rig.call("set_facing_profile", StickmanRig.FacingProfile.LEFT if facing_left else StickmanRig.FacingProfile.RIGHT)
|
||||
var target_feet: Vector2 = _use_approach_root - _locomotion.call("get_foot_offset")
|
||||
if not bool(_locomotion.call("walk_to", target_feet, -1.0)):
|
||||
_fail_use("route_blocked")
|
||||
return false
|
||||
_phase = Phase.USE_APPROACHING
|
||||
return true
|
||||
|
||||
|
||||
func _on_use_approach_arrived() -> void:
|
||||
if not _validate_use_target():
|
||||
return
|
||||
var current_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
|
||||
if not current_spot.is_finite():
|
||||
_fail_use("spot_missing")
|
||||
return
|
||||
if current_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT:
|
||||
_request_use_replan("target_moved")
|
||||
return
|
||||
var preview: Dictionary = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left())
|
||||
_trace_use_reach("approach_arrived", String(preview.get("reason_code", "")), preview)
|
||||
if not bool(preview.get("valid", false)):
|
||||
_request_use_replan(String(preview.get("reason_code", "out_of_reach")))
|
||||
return
|
||||
if absf(Vector2(preview.get("approach_root", _rig.global_position)).x - _rig.global_position.x) > 14.0:
|
||||
_request_use_replan("target_moved", preview)
|
||||
return
|
||||
_use_planned_spot_world = current_spot
|
||||
_start_use_reach(preview)
|
||||
|
||||
|
||||
func _request_use_replan(failure_reason: String, preview: Dictionary = {}) -> void:
|
||||
if _phase == Phase.USE_APPROACHING and _locomotion != null:
|
||||
_locomotion.call("cancel_walk")
|
||||
if _use_approach_replans >= USE_REPLAN_LIMIT:
|
||||
_fail_use("target_moved" if failure_reason == "target_moved" else failure_reason)
|
||||
return
|
||||
_use_approach_replans += 1
|
||||
if preview.is_empty() and _use_prop != null and is_instance_valid(_use_prop):
|
||||
preview = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left())
|
||||
if preview.is_empty() or not bool(preview.get("valid", false)):
|
||||
_fail_use(failure_reason)
|
||||
return
|
||||
var new_spot: Vector2 = preview.get("spot_world", Vector2.INF)
|
||||
if not new_spot.is_finite() or new_spot.distance_to(_use_planned_spot_world) > USE_MAX_REPLAN_DISPLACEMENT:
|
||||
_fail_use("target_moved")
|
||||
return
|
||||
_use_planned_spot_world = new_spot
|
||||
_use_approach_root = preview.get("approach_root", _rig.global_position)
|
||||
_use_hand = int(preview.get("hand", _use_hand))
|
||||
_hand = _use_hand
|
||||
if absf(_use_approach_root.x - _rig.global_position.x) > 14.0:
|
||||
_start_use_approach(preview)
|
||||
else:
|
||||
_start_use_reach(preview)
|
||||
|
||||
|
||||
func _start_use_reach(preview: Dictionary) -> void:
|
||||
_use_hand = int(preview.get("hand", _use_hand))
|
||||
_hand = _use_hand
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker == null:
|
||||
_fail_use("out_of_reach")
|
||||
return
|
||||
# Locomotion replaces hand targets with its standing pose at arrival. Save
|
||||
# that current pose as the recovery target instead of restoring the stale
|
||||
# marker captured before the approach walk.
|
||||
_marker_start_position = marker.position
|
||||
_use_marker_start = marker.global_position
|
||||
_use_reach_elapsed = 0.0
|
||||
var torso_marker: Marker2D = _torso_marker()
|
||||
_pickup_uses_crouch = false
|
||||
if torso_marker != null:
|
||||
_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 spot_world: Vector2 = preview.get("spot_world", _use_prop.use_spot_world_position(_use_spot_id))
|
||||
var spot_local: Vector2 = _rig.to_local(spot_world)
|
||||
var low_amount: float = clampf((spot_local.y - _pickup_torso_start.y - LOW_PICKUP_THRESHOLD) / LOW_PICKUP_MAX_DROP, 0.0, 1.0)
|
||||
if low_amount > 0.0:
|
||||
var toward_prop: float = signf(spot_local.x - _pickup_torso_start.x)
|
||||
if is_zero_approx(toward_prop):
|
||||
toward_prop = -1.0 if _is_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)
|
||||
_phase = Phase.USE_REACHING
|
||||
_trace_use_reach("reach_started", "", preview)
|
||||
|
||||
|
||||
func _update_use_reach(delta: float) -> void:
|
||||
if not _validate_use_target():
|
||||
return
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker == null:
|
||||
_fail_use("out_of_reach")
|
||||
return
|
||||
var spot_world: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
|
||||
if not spot_world.is_finite():
|
||||
_fail_use("spot_missing")
|
||||
return
|
||||
# Once reaching starts, do not drag the hand across a moving target. The
|
||||
# approach gets one bounded replan; the reach phase only tolerates contact
|
||||
# scale motion and then fails so DIRECT can be corrected.
|
||||
if spot_world.distance_to(_use_planned_spot_world) > USE_REACH_SOLVER.CONTACT_TOLERANCE:
|
||||
_fail_use("target_moved")
|
||||
return
|
||||
_use_reach_elapsed += delta
|
||||
var weight: float = clampf(_use_reach_elapsed / USE_REACH_DURATION, 0.0, 1.0)
|
||||
var eased_weight: float = smoothstep(0.0, 1.0, weight)
|
||||
marker.global_position = _use_marker_start.lerp(spot_world, eased_weight)
|
||||
_update_pickup_crouch(eased_weight)
|
||||
if weight < 1.0:
|
||||
return
|
||||
var final_preview: Dictionary = USE_REACH_SOLVER.solve(_rig, _use_prop, _use_spot_id, _rig.global_position, _is_facing_left())
|
||||
if not bool(final_preview.get("valid", false)) or int(final_preview.get("hand", -1)) != _use_hand:
|
||||
_trace_use_reach("reach_check_failed", String(final_preview.get("reason_code", "out_of_reach")), final_preview)
|
||||
_fail_use(String(final_preview.get("reason_code", "out_of_reach")))
|
||||
return
|
||||
var wrist_position: Vector2 = _solved_wrist_transform().origin
|
||||
if wrist_position.distance_to(spot_world) > USE_REACH_SOLVER.CONTACT_TOLERANCE:
|
||||
_trace_use_reach("contact_failed", "out_of_reach", final_preview)
|
||||
_fail_use("out_of_reach")
|
||||
return
|
||||
use_completed.emit(_use_prop, _use_spot_id, spot_world)
|
||||
_use_marker_recover_start = marker.global_position
|
||||
_use_recover_elapsed = 0.0
|
||||
_phase = Phase.USE_RECOVERING
|
||||
|
||||
|
||||
func _update_use_recovery(delta: float) -> void:
|
||||
_use_recover_elapsed += delta
|
||||
var weight: float = smoothstep(0.0, 1.0, clampf(_use_recover_elapsed / USE_RECOVER_DURATION, 0.0, 1.0))
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker != null:
|
||||
marker.global_position = _use_marker_recover_start.lerp(_use_marker_start, weight)
|
||||
_update_pickup_crouch(1.0 - weight)
|
||||
if weight < 1.0:
|
||||
return
|
||||
_restore_hand_target()
|
||||
_restore_pickup_torso()
|
||||
_clear_use_state()
|
||||
_phase = Phase.IDLE
|
||||
_active_action.clear()
|
||||
action_completed.emit()
|
||||
|
||||
|
||||
func _validate_use_target() -> bool:
|
||||
if _use_prop == null or not is_instance_valid(_use_prop) or _use_prop.is_queued_for_deletion():
|
||||
_fail_use("prop_missing")
|
||||
return false
|
||||
if _use_prop.get_use_spot(_use_spot_id).is_empty():
|
||||
_fail_use("spot_missing")
|
||||
return false
|
||||
if _use_prop.interaction_owner_id != 0 or _use_prop.interaction_reserved_by != 0:
|
||||
_fail_use("prop_held")
|
||||
return false
|
||||
if _held_prop != null and is_instance_valid(_held_prop):
|
||||
_fail_use("hands_full")
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _fail_use(reason_code: String) -> void:
|
||||
var action_was_active: bool = _phase == Phase.USE_APPROACHING or _phase == Phase.USE_REACHING or _phase == Phase.USE_RECOVERING
|
||||
_trace_use_reach("failed", reason_code)
|
||||
if _phase == Phase.USE_APPROACHING and _locomotion != null:
|
||||
_locomotion.call("cancel_walk")
|
||||
var prop: PropBlock = _use_prop if _use_prop != null and is_instance_valid(_use_prop) else null
|
||||
var spot_id: String = _use_spot_id
|
||||
_restore_hand_target()
|
||||
_restore_pickup_torso()
|
||||
_clear_use_state()
|
||||
_phase = Phase.IDLE
|
||||
_active_action.clear()
|
||||
use_failed.emit(reason_code, prop, spot_id)
|
||||
if action_was_active:
|
||||
action_completed.emit()
|
||||
|
||||
|
||||
func _trace_use_reach(event_name: String, reason_code: String = "", preview: Dictionary = {}) -> void:
|
||||
if not USE_REACH_TRACE_ENABLED:
|
||||
return
|
||||
var details: Dictionary = {
|
||||
"event": event_name,
|
||||
"reason": reason_code,
|
||||
"phase": Phase.keys()[int(_phase)],
|
||||
"actor": String(_rig.name) if _rig != null and is_instance_valid(_rig) else "<none>",
|
||||
"root": _rig.global_position if _rig != null and is_instance_valid(_rig) else Vector2.INF,
|
||||
"rig_transform": _rig.global_transform if _rig != null and is_instance_valid(_rig) else Transform2D.IDENTITY,
|
||||
"rig_scale": _rig.global_scale if _rig != null and is_instance_valid(_rig) else Vector2.ZERO,
|
||||
"facing_left": _is_facing_left(),
|
||||
"hand": _use_hand,
|
||||
"approach_root": _use_approach_root,
|
||||
"planned_spot": _use_planned_spot_world,
|
||||
"preview": preview,
|
||||
}
|
||||
var marker: Marker2D = _hand_marker()
|
||||
if marker != null:
|
||||
details["hand_marker_world"] = marker.global_position
|
||||
var wrist_position: Vector2 = _solved_wrist_transform().origin if _rig != null and is_instance_valid(_rig) else Vector2.INF
|
||||
details["wrist_world"] = wrist_position
|
||||
if _use_prop != null and is_instance_valid(_use_prop):
|
||||
var spot: Dictionary = _use_prop.get_use_spot(_use_spot_id)
|
||||
var live_spot: Vector2 = _use_prop.use_spot_world_position(_use_spot_id)
|
||||
details["prop_id"] = _use_prop.stage_object_id
|
||||
details["prop_transform"] = _use_prop.global_transform
|
||||
details["spot_id"] = _use_spot_id
|
||||
details["spot_local"] = spot.get("position", Vector2.INF)
|
||||
details["spot_world"] = live_spot
|
||||
details["wrist_to_spot"] = wrist_position.distance_to(live_spot) if wrist_position.is_finite() and live_spot.is_finite() else INF
|
||||
var suffix: String = "Left" if _use_hand == Hand.LEFT else "Right"
|
||||
var upper: Bone2D = _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
|
||||
var lower: Bone2D = _rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
|
||||
if upper != null:
|
||||
details["shoulder_world"] = upper.global_position
|
||||
if lower != null:
|
||||
details["elbow_world"] = lower.global_position
|
||||
print("[UseReachTrace] %s" % str(details))
|
||||
|
||||
|
||||
func _clear_use_state() -> void:
|
||||
_use_prop = null
|
||||
_use_spot_id = ""
|
||||
_use_planned_spot_world = Vector2.INF
|
||||
_use_approach_root = Vector2.ZERO
|
||||
_use_approach_replans = 0
|
||||
_use_reach_elapsed = 0.0
|
||||
_use_recover_elapsed = 0.0
|
||||
_use_marker_start = Vector2.ZERO
|
||||
_use_marker_recover_start = Vector2.ZERO
|
||||
|
||||
|
||||
func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) -> bool:
|
||||
|
||||
@@ -183,6 +183,8 @@ signal queue_finished # queue ran to completion (not
|
||||
signal queue_changed # any queue mutation
|
||||
signal speech_finished # bubble auto-hid after speak()
|
||||
signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String)
|
||||
signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2)
|
||||
signal use_failed(reason_code: String, prop: PropBlock, spot_id: String)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
@@ -262,6 +264,8 @@ func _ready() -> void:
|
||||
if _prop_interactor_component != null:
|
||||
_prop_interactor_component.call("configure", self, _locomotion_component, Callable())
|
||||
_prop_interactor_component.connect("prop_event", _on_prop_interaction_event)
|
||||
_prop_interactor_component.connect("use_completed", _on_prop_use_completed)
|
||||
_prop_interactor_component.connect("use_failed", _on_prop_use_failed)
|
||||
if _action_runner_component != null:
|
||||
_action_runner_component.configure(self, _locomotion_component, _prop_interactor_component)
|
||||
_action_runner_component.action_queue = _pending_action_queue
|
||||
@@ -532,6 +536,12 @@ func cancel_prop_action() -> void:
|
||||
_prop_interactor_component.call("cancel_action")
|
||||
|
||||
|
||||
func get_held_prop() -> PropBlock:
|
||||
if _prop_interactor_component == null:
|
||||
return null
|
||||
return _prop_interactor_component.call("get_held_prop") as PropBlock
|
||||
|
||||
|
||||
func preview_prop_throw(target: Vector2, queue_prefix: Array[Dictionary] = []) -> Dictionary:
|
||||
if _prop_interactor_component == null:
|
||||
return {}
|
||||
@@ -544,6 +554,12 @@ func preview_prop_set_down(target: Vector2, queue_prefix: Array[Dictionary] = []
|
||||
return _prop_interactor_component.call("preview_set_down", target, queue_prefix)
|
||||
|
||||
|
||||
func preview_prop_use(prop: PropBlock, spot_id: String, actor_root: Vector2 = Vector2.INF, predicted_facing_left: bool = false) -> Dictionary:
|
||||
if _prop_interactor_component == null:
|
||||
return {"known": true, "valid": false, "reason_code": "out_of_reach"}
|
||||
return _prop_interactor_component.call("preview_use", prop, spot_id, actor_root, predicted_facing_left)
|
||||
|
||||
|
||||
func reset_prop_interactions_for_scene_exit() -> void:
|
||||
if _prop_interactor_component != null:
|
||||
_prop_interactor_component.call("reset_for_scene_exit")
|
||||
@@ -553,6 +569,14 @@ func _on_prop_interaction_event(event_type: String, prop: PropBlock, reason: Str
|
||||
prop_interaction_event.emit(event_type, prop, reason)
|
||||
|
||||
|
||||
func _on_prop_use_completed(prop: PropBlock, spot_id: String, world_position: Vector2) -> void:
|
||||
use_completed.emit(prop, spot_id, world_position)
|
||||
|
||||
|
||||
func _on_prop_use_failed(reason_code: String, prop: PropBlock, spot_id: String) -> void:
|
||||
use_failed.emit(reason_code, prop, spot_id)
|
||||
|
||||
|
||||
## Public stand-up request. No-op unless the rig is in RAGDOLL.
|
||||
func request_recovery() -> void:
|
||||
if state == RigState.RAGDOLL and _ragdoll_component != null:
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
class_name StickmanUseReachSolver
|
||||
extends RefCounted
|
||||
## Shared, nonmutating reach estimate for DIRECT previews and PLAY actions.
|
||||
|
||||
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||
const APPROACH_GAP: float = 110.0
|
||||
const CONTACT_TOLERANCE: float = 34.0
|
||||
const MAX_ROUTE_LENGTH: float = 5000.0
|
||||
|
||||
|
||||
static func solve(rig: StickmanRig, prop: PropBlock, spot_id: String, actor_root: Vector2, predicted_facing_left: bool, _options: Dictionary = {}) -> Dictionary:
|
||||
var result: Dictionary = {"known": true, "valid": false, "reason_code": "out_of_reach", "actor_root": actor_root, "spot_world": Vector2.INF, "approach_root": actor_root, "approach_feet": Vector2.INF, "facing_left": predicted_facing_left, "hand": 1, "reach_start": actor_root, "reach_end": Vector2.INF}
|
||||
if rig == null or not is_instance_valid(rig) or not actor_root.is_finite():
|
||||
result["reason_code"] = "out_of_reach"
|
||||
return result
|
||||
if not _transform_is_finite_invertible(rig.global_transform):
|
||||
result["reason_code"] = "out_of_reach"
|
||||
return result
|
||||
if not _has_ground_support(rig, actor_root):
|
||||
result["reason_code"] = "route_blocked"
|
||||
return result
|
||||
if prop == null or not is_instance_valid(prop) or prop.is_queued_for_deletion():
|
||||
result["reason_code"] = "prop_missing"
|
||||
return result
|
||||
if not _transform_is_finite_invertible(prop.global_transform):
|
||||
result["reason_code"] = "spot_missing"
|
||||
return result
|
||||
if prop.get_use_spot(spot_id).is_empty():
|
||||
result["reason_code"] = "spot_missing"
|
||||
return result
|
||||
var held_prop: PropBlock = rig.get_held_prop() if rig.has_method("get_held_prop") else null
|
||||
if held_prop != null and is_instance_valid(held_prop):
|
||||
result["reason_code"] = "hands_full"
|
||||
return result
|
||||
if prop.interaction_owner_id != 0 or prop.interaction_reserved_by != 0:
|
||||
result["reason_code"] = "prop_held"
|
||||
return result
|
||||
var spot_world: Vector2 = prop.use_spot_world_position(spot_id)
|
||||
if not spot_world.is_finite():
|
||||
result["reason_code"] = "spot_missing"
|
||||
return result
|
||||
result["spot_world"] = spot_world
|
||||
result["reach_end"] = spot_world
|
||||
var candidates: Array[Dictionary] = []
|
||||
var saw_ground_route: bool = false
|
||||
var saw_clear_standing: bool = false
|
||||
for hand_value: int in [0, 1]:
|
||||
var current_wrist: Vector2 = _wrist_world(rig, hand_value)
|
||||
var shoulder: Vector2 = _shoulder_world(rig, hand_value)
|
||||
var arm_reach: float = _arm_reach(rig, hand_value)
|
||||
if not current_wrist.is_finite() or not shoulder.is_finite() or arm_reach <= 0.0:
|
||||
continue
|
||||
# Reach is measured from the shoulder joint. The wrist's current pose may
|
||||
# be far from the spot even though IK can rotate the arm to touch it; this
|
||||
# is especially important after locomotion changes the hand-marker pose.
|
||||
if shoulder.distance_to(spot_world) <= arm_reach and _hand_path_is_clear(rig, prop, shoulder, spot_world) and _has_ground_support(rig, actor_root) and _standing_clear(rig, actor_root):
|
||||
candidates.append({"root": actor_root, "hand": hand_value, "facing_left": predicted_facing_left, "wrist": current_wrist, "distance": 0.0})
|
||||
continue
|
||||
for side: float in [-1.0, 1.0]:
|
||||
var approach_root: Vector2 = Vector2(spot_world.x + side * APPROACH_GAP, actor_root.y)
|
||||
var facing_left: bool = approach_root.x > spot_world.x
|
||||
var shoulder_offset: Vector2 = _shoulder_offset(rig, hand_value, predicted_facing_left)
|
||||
var wrist_offset: Vector2 = _wrist_offset(rig, hand_value, predicted_facing_left)
|
||||
if predicted_facing_left != facing_left:
|
||||
shoulder_offset.x = -shoulder_offset.x
|
||||
wrist_offset.x = -wrist_offset.x
|
||||
var candidate_shoulder: Vector2 = approach_root + shoulder_offset
|
||||
var candidate_wrist: Vector2 = approach_root + wrist_offset
|
||||
if candidate_shoulder.distance_to(spot_world) > arm_reach or not _hand_path_is_clear(rig, prop, candidate_shoulder, spot_world):
|
||||
continue
|
||||
var route: Dictionary = _grounded_route(rig, actor_root, approach_root)
|
||||
if not bool(route.get("valid", false)):
|
||||
continue
|
||||
saw_ground_route = true
|
||||
if not _has_ground_support(rig, approach_root) or not _standing_clear(rig, approach_root):
|
||||
continue
|
||||
saw_clear_standing = true
|
||||
candidates.append({"root": approach_root, "hand": hand_value, "facing_left": facing_left, "wrist": candidate_wrist, "distance": float(route.get("distance", INF))})
|
||||
if candidates.is_empty():
|
||||
if not saw_ground_route and not _has_any_ground_route(rig, actor_root, spot_world):
|
||||
result["reason_code"] = "route_blocked"
|
||||
elif saw_ground_route and not saw_clear_standing:
|
||||
result["reason_code"] = "no_standing_room"
|
||||
else:
|
||||
result["reason_code"] = "out_of_reach"
|
||||
return result
|
||||
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]
|
||||
var approach_root: Vector2 = chosen["root"]
|
||||
result["valid"] = true
|
||||
result["reason_code"] = ""
|
||||
result["approach_root"] = approach_root
|
||||
result["approach_feet"] = approach_root - StickmanLocomotion.FOOT_OFFSET
|
||||
result["facing_left"] = bool(chosen["facing_left"])
|
||||
result["hand"] = int(chosen["hand"])
|
||||
result["reach_start"] = chosen["wrist"]
|
||||
return result
|
||||
|
||||
|
||||
static func _wrist_world(rig: StickmanRig, hand_value: int) -> Vector2:
|
||||
var suffix: String = "Left" if hand_value == 0 else "Right"
|
||||
var lower_visual: Node2D = rig.get_node_or_null(NodePath("Body/%sLowerArm" % suffix)) as Node2D
|
||||
var lower_bone: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
|
||||
if lower_visual == null or lower_bone == null:
|
||||
var hand_marker: Marker2D = rig.get_node_or_null(NodePath("IK_Targets/%s_Hand" % suffix)) as Marker2D
|
||||
return hand_marker.global_position if hand_marker != null else Vector2.INF
|
||||
return lower_visual.to_global(Vector2(0.0, lower_bone.length))
|
||||
|
||||
|
||||
static func _shoulder_world(rig: StickmanRig, hand_value: int) -> Vector2:
|
||||
var suffix: String = "Left" if hand_value == 0 else "Right"
|
||||
var upper: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
|
||||
return upper.global_position if upper != null else rig.global_position
|
||||
|
||||
|
||||
static func _arm_reach(rig: StickmanRig, hand_value: int) -> float:
|
||||
var suffix: String = "Left" if hand_value == 0 else "Right"
|
||||
var upper: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm" % suffix)) as Bone2D
|
||||
var lower: Bone2D = rig.get_node_or_null(NodePath("Skeleton2D/Torso/%sUpperArm/%sLowerArm" % [suffix, suffix])) as Bone2D
|
||||
if upper == null or lower == null:
|
||||
return 0.0
|
||||
if not is_finite(upper.length) or not is_finite(lower.length):
|
||||
return 0.0
|
||||
# Bone lengths are authored in local space. Measure their world-space
|
||||
# endpoints so resized, mirrored, and nonuniformly scaled rigs use the same
|
||||
# reach that the IK solver can actually produce.
|
||||
var shoulder: Vector2 = upper.global_position
|
||||
var elbow: Vector2 = lower.global_position
|
||||
var wrist: Vector2 = _wrist_world(rig, hand_value)
|
||||
if not shoulder.is_finite() or not elbow.is_finite() or not wrist.is_finite():
|
||||
return 0.0
|
||||
return shoulder.distance_to(elbow) + elbow.distance_to(wrist)
|
||||
|
||||
|
||||
static func _transform_is_finite_invertible(value: Transform2D) -> bool:
|
||||
return value.origin.is_finite() and value.x.is_finite() and value.y.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.0001
|
||||
|
||||
|
||||
static func _shoulder_offset(rig: StickmanRig, hand_value: int, facing_left: bool) -> Vector2:
|
||||
var local: Vector2 = rig.to_local(_shoulder_world(rig, hand_value))
|
||||
if rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT:
|
||||
local.x = -local.x
|
||||
if facing_left:
|
||||
local.x = -local.x
|
||||
return rig.global_transform.basis_xform(local)
|
||||
|
||||
|
||||
static func _wrist_offset(rig: StickmanRig, hand_value: int, facing_left: bool) -> Vector2:
|
||||
var local: Vector2 = rig.to_local(_wrist_world(rig, hand_value))
|
||||
if rig.get_facing_profile() == StickmanRig.FacingProfile.LEFT:
|
||||
local.x = -local.x
|
||||
if facing_left:
|
||||
local.x = -local.x
|
||||
return rig.global_transform.basis_xform(local)
|
||||
|
||||
|
||||
static func _hand_path_is_clear(rig: StickmanRig, prop: PropBlock, start: Vector2, finish: Vector2) -> bool:
|
||||
if not start.is_finite() or not finish.is_finite():
|
||||
return false
|
||||
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
|
||||
if state == null:
|
||||
return true
|
||||
var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(start, finish)
|
||||
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER | STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||
var excluded: Array[RID] = rig.get_collision_body_rids()
|
||||
if prop.get_rid().is_valid():
|
||||
excluded.append(prop.get_rid())
|
||||
query.exclude = excluded
|
||||
return state.intersect_ray(query).is_empty()
|
||||
|
||||
|
||||
static func _grounded_route(rig: StickmanRig, from_root: Vector2, to_root: Vector2) -> Dictionary:
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
if locomotion == null or locomotion._nav_agent == null:
|
||||
return {"valid": false, "distance": INF}
|
||||
var nav: NavigationAgent2D = locomotion._nav_agent
|
||||
var map: RID = nav.get_navigation_map()
|
||||
if not map.is_valid() or NavigationServer2D.map_get_iteration_id(map) == 0:
|
||||
return {"valid": false, "distance": INF}
|
||||
var from_feet: Vector2 = from_root - StickmanLocomotion.FOOT_OFFSET
|
||||
var to_feet: Vector2 = to_root - StickmanLocomotion.FOOT_OFFSET
|
||||
var path: PackedVector2Array = NavigationServer2D.map_get_path(map, from_feet, to_feet, true)
|
||||
if path.is_empty() or path[0].distance_to(from_feet) > 180.0 or path[path.size() - 1].distance_to(to_feet) > 180.0:
|
||||
return {"valid": false, "distance": INF}
|
||||
if not _actor_path_is_clear(rig, from_feet, path, from_root - rig.global_position):
|
||||
return {"valid": false, "distance": INF}
|
||||
var distance: float = 0.0
|
||||
for index: int in range(1, path.size()):
|
||||
distance += path[index - 1].distance_to(path[index])
|
||||
if distance > MAX_ROUTE_LENGTH:
|
||||
return {"valid": false, "distance": INF}
|
||||
return {"valid": true, "distance": distance}
|
||||
|
||||
|
||||
static func _actor_path_is_clear(rig: StickmanRig, from_feet: Vector2, path: PackedVector2Array, initial_offset: Vector2) -> bool:
|
||||
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
|
||||
if state == null:
|
||||
return false
|
||||
var excluded: Array[RID] = rig.get_collision_body_rids()
|
||||
var bodies: Dictionary = rig.get_collision_bodies()
|
||||
var route_points: PackedVector2Array = PackedVector2Array()
|
||||
route_points.append(from_feet)
|
||||
route_points.append_array(path)
|
||||
var offset: Vector2 = initial_offset
|
||||
for index: int in range(1, route_points.size()):
|
||||
var motion: Vector2 = route_points[index] - route_points[index - 1]
|
||||
if absf(motion.x) <= 0.001:
|
||||
offset += motion
|
||||
continue
|
||||
for part_name: String in ["torso", "head"]:
|
||||
var proxy: AnimatableBody2D = bodies.get(part_name) as AnimatableBody2D
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
continue
|
||||
var shape_node: CollisionShape2D = 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 += offset
|
||||
query.motion = motion
|
||||
query.collision_mask = STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||
query.exclude = excluded
|
||||
var cast: PackedFloat32Array = state.cast_motion(query)
|
||||
if cast.size() > 0 and cast[0] < 0.999:
|
||||
return false
|
||||
offset += motion
|
||||
return true
|
||||
|
||||
|
||||
static func _has_ground_support(rig: StickmanRig, root_position: Vector2) -> bool:
|
||||
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
|
||||
if state == null:
|
||||
return false
|
||||
var feet: Vector2 = root_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(feet + Vector2(0.0, -24.0), feet + Vector2(0.0, 88.0))
|
||||
query.collision_mask = STAGE_PHYSICS.TERRAIN_LAYER
|
||||
query.exclude = rig.get_collision_body_rids()
|
||||
var hit: Dictionary = state.intersect_ray(query)
|
||||
return not hit.is_empty() and (hit.get("normal", Vector2.UP) as Vector2).dot(Vector2.UP) >= 0.7
|
||||
|
||||
|
||||
static func _standing_clear(rig: StickmanRig, root_position: Vector2) -> bool:
|
||||
var state: PhysicsDirectSpaceState2D = rig.get_world_2d().direct_space_state
|
||||
if state == null:
|
||||
return false
|
||||
var motion: Vector2 = root_position - rig.global_position
|
||||
var excluded: Array[RID] = rig.get_collision_body_rids()
|
||||
for proxy: AnimatableBody2D in rig.get_collision_bodies().values():
|
||||
if proxy == null or not is_instance_valid(proxy):
|
||||
continue
|
||||
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
|
||||
if shape_node == null or shape_node.shape == null:
|
||||
continue
|
||||
var query: PhysicsShapeQueryParameters2D = PhysicsShapeQueryParameters2D.new()
|
||||
query.shape = shape_node.shape
|
||||
var candidate_transform: Transform2D = shape_node.global_transform
|
||||
candidate_transform.origin += motion
|
||||
query.transform = candidate_transform
|
||||
query.collision_mask = STAGE_PHYSICS.ACTOR_LAYER | STAGE_PHYSICS.ACTOR_BLOCKING_PROP_LAYER
|
||||
query.exclude = excluded
|
||||
query.margin = 2.0
|
||||
if not state.intersect_shape(query, 8).is_empty():
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _has_any_ground_route(rig: StickmanRig, actor_root: Vector2, spot_world: Vector2) -> bool:
|
||||
for side: float in [-1.0, 1.0]:
|
||||
var candidate: Vector2 = Vector2(spot_world.x + side * APPROACH_GAP, actor_root.y)
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
if locomotion == null or locomotion._nav_agent == null:
|
||||
continue
|
||||
var nav: NavigationAgent2D = locomotion._nav_agent
|
||||
var map: RID = nav.get_navigation_map()
|
||||
if not map.is_valid() or NavigationServer2D.map_get_iteration_id(map) == 0:
|
||||
continue
|
||||
var from_feet: Vector2 = actor_root - StickmanLocomotion.FOOT_OFFSET
|
||||
var to_feet: Vector2 = candidate - StickmanLocomotion.FOOT_OFFSET
|
||||
var path: PackedVector2Array = NavigationServer2D.map_get_path(map, from_feet, to_feet, true)
|
||||
if not path.is_empty() and path[0].distance_to(from_feet) <= 180.0 and path[path.size() - 1].distance_to(to_feet) <= 180.0 and _actor_path_is_clear(rig, from_feet, path, actor_root - rig.global_position):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://doq1r0f3qgr2y
|
||||
@@ -31,12 +31,18 @@ var _surface_color: Color = Color("#252B38")
|
||||
var _border_color: Color = Color("#FFFFFF33")
|
||||
var _prop_target_label: Label = null
|
||||
var _set_prop_target_btn: Button = null
|
||||
var _use_target_label: Label = null
|
||||
var _set_use_target_btn: Button = null
|
||||
var _hand_option: OptionButton = null
|
||||
var _stop_current_check: CheckBox = null
|
||||
var _show_reactive_options: bool = false
|
||||
var _has_target: bool = false
|
||||
var _target_pos: Vector2 = Vector2.ZERO
|
||||
var _prop_target_id: String = ""
|
||||
var _use_prop_target_id: String = ""
|
||||
var _use_spot_target_id: String = ""
|
||||
var _use_target_summary: String = ""
|
||||
var _use_target_summary_resolver: Callable = Callable()
|
||||
var _has_throw_target: bool = false
|
||||
var _throw_target_pos: Vector2 = Vector2.ZERO
|
||||
|
||||
@@ -68,6 +74,9 @@ func open_new(show_reactive_options: bool = false) -> void:
|
||||
_target_pos = Vector2.ZERO
|
||||
_move_style = ACTION_REGISTRY.WALK
|
||||
_prop_target_id = ""
|
||||
_use_prop_target_id = ""
|
||||
_use_spot_target_id = ""
|
||||
_use_target_summary = ""
|
||||
_has_throw_target = false
|
||||
_throw_target_pos = Vector2.ZERO
|
||||
_select_type(ACTION_REGISTRY.MOVE_TO)
|
||||
@@ -88,6 +97,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
|
||||
_has_target = action.has("target") and action["target"] is Vector2
|
||||
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
|
||||
_prop_target_id = String(action.get("target_prop_id", ""))
|
||||
_use_prop_target_id = String(action.get("target_prop_id", "")) if type == "use_prop" else ""
|
||||
_use_spot_target_id = String(action.get("target_spot_id", "")) if type == "use_prop" else ""
|
||||
_use_target_summary = "Use spot chosen" if not _use_spot_target_id.is_empty() else ""
|
||||
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
|
||||
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
|
||||
_move_style = ACTION_REGISTRY.checked_style(action.get("style", null))
|
||||
@@ -113,6 +125,25 @@ func set_prop_target(prop_id: String) -> void:
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_use_target(prop_id: String, spot_id: String, summary: String) -> void:
|
||||
_use_prop_target_id = prop_id
|
||||
_use_spot_target_id = spot_id
|
||||
_use_target_summary = summary
|
||||
_update_use_target_label()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_use_target_summary_resolver(resolver: Callable) -> void:
|
||||
_use_target_summary_resolver = resolver
|
||||
refresh_use_target_summary()
|
||||
|
||||
|
||||
func refresh_use_target_summary() -> void:
|
||||
if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty():
|
||||
_use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id))
|
||||
_update_use_target_label()
|
||||
|
||||
|
||||
func set_throw_target(pos: Vector2) -> void:
|
||||
_has_throw_target = true
|
||||
_throw_target_pos = pos
|
||||
@@ -250,6 +281,8 @@ func _rebuild_params(type: String) -> void:
|
||||
_set_target_btn = null
|
||||
_prop_target_label = null
|
||||
_set_prop_target_btn = null
|
||||
_use_target_label = null
|
||||
_set_use_target_btn = null
|
||||
_hand_option = null
|
||||
_stop_current_check = null
|
||||
_walk_style_btn = null
|
||||
@@ -301,6 +334,19 @@ 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)
|
||||
"use_prop":
|
||||
var use_row := VBoxContainer.new()
|
||||
use_row.add_theme_constant_override("separation", 6)
|
||||
_params_box.add_child(use_row)
|
||||
_use_target_label = Label.new()
|
||||
_use_target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
use_row.add_child(_use_target_label)
|
||||
_set_use_target_btn = Button.new()
|
||||
_set_use_target_btn.text = "Choose prop and spot"
|
||||
_set_use_target_btn.custom_minimum_size.y = 48.0
|
||||
_set_use_target_btn.pressed.connect(func() -> void: target_requested.emit())
|
||||
use_row.add_child(_set_use_target_btn)
|
||||
_update_use_target_label()
|
||||
"throw_prop", "set_down_prop":
|
||||
var throw_row := HBoxContainer.new()
|
||||
_params_box.add_child(throw_row)
|
||||
@@ -414,6 +460,21 @@ func _update_prop_target_label() -> void:
|
||||
_prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set")
|
||||
|
||||
|
||||
func _update_use_target_label() -> void:
|
||||
if _use_target_label == null:
|
||||
return
|
||||
if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty():
|
||||
_use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id))
|
||||
if not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty():
|
||||
_use_target_label.text = "Using: %s" % (_use_target_summary if not _use_target_summary.is_empty() else "Use spot chosen")
|
||||
if _set_use_target_btn != null:
|
||||
_set_use_target_btn.text = "Change spot"
|
||||
else:
|
||||
_use_target_label.text = "Choose a prop and one exact Use spot."
|
||||
if _set_use_target_btn != null:
|
||||
_set_use_target_btn.text = "Choose prop and spot"
|
||||
|
||||
|
||||
func _update_throw_target_label() -> void:
|
||||
if _target_label == null:
|
||||
return
|
||||
@@ -442,6 +503,15 @@ 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"
|
||||
"use_prop":
|
||||
if _use_prop_target_id.is_empty() or _use_spot_target_id.is_empty():
|
||||
target_requested.emit()
|
||||
return
|
||||
if _use_target_summary == "Choose a new spot":
|
||||
target_requested.emit()
|
||||
return
|
||||
action["target_prop_id"] = _use_prop_target_id
|
||||
action["target_spot_id"] = _use_spot_target_id
|
||||
"throw_prop", "set_down_prop":
|
||||
if not _has_throw_target:
|
||||
target_requested.emit()
|
||||
|
||||
@@ -36,6 +36,7 @@ var _base_font_size: int = 0
|
||||
var _row_font_size: int = 0
|
||||
var _title_font_size: int = 0
|
||||
var _title_font: Font = null
|
||||
var _use_target_summary_resolver: Callable = Callable()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -56,6 +57,11 @@ func setup(r: StickmanRig) -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func set_use_target_summary_resolver(resolver: Callable) -> void:
|
||||
_use_target_summary_resolver = resolver
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _list == null:
|
||||
return
|
||||
@@ -145,7 +151,14 @@ func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
|
||||
|
||||
var summary := Label.new()
|
||||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
|
||||
var action_summary: String = ACTION_REGISTRY.summarize(action)
|
||||
if String(action.get("type", "")) == "use_prop" and _use_target_summary_resolver.is_valid():
|
||||
action_summary = String(_use_target_summary_resolver.call(
|
||||
String(action.get("target_prop_id", "")),
|
||||
"specific",
|
||||
String(action.get("target_spot_id", ""))
|
||||
))
|
||||
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), action_summary]
|
||||
row.add_child(summary)
|
||||
var status_label := Label.new()
|
||||
status_label.text = _status_label(status)
|
||||
|
||||
@@ -34,6 +34,7 @@ var _action_type_option: OptionButton = null
|
||||
var _trigger_readonly_label: Label = null
|
||||
var _actions_box: VBoxContainer = null
|
||||
var _empty_actions_label: Label = null
|
||||
var _use_target_summary_resolver: Callable = Callable()
|
||||
|
||||
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
|
||||
## override (engine default); Font null = no override.
|
||||
@@ -79,6 +80,28 @@ func set_trigger_target(target_id: int, params: Dictionary) -> void:
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_trigger_use_target(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void:
|
||||
var params: Dictionary = {"target_prop_id": target_prop_id, "spot_scope": spot_scope}
|
||||
if spot_scope == "specific":
|
||||
params["target_spot_id"] = target_spot_id
|
||||
_trigger["type"] = "prop_used"
|
||||
_trigger["target"] = -1
|
||||
_trigger["params"] = params
|
||||
_refresh_trigger()
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_use_target_summary_resolver(resolver: Callable) -> void:
|
||||
_use_target_summary_resolver = resolver
|
||||
_refresh_trigger()
|
||||
_refresh_actions()
|
||||
|
||||
|
||||
func refresh_use_target_summaries() -> void:
|
||||
_refresh_trigger()
|
||||
_refresh_actions()
|
||||
|
||||
|
||||
## Called by the stage after an action is collected. `index` < 0 appends.
|
||||
func set_action(index: int, action: Dictionary) -> void:
|
||||
if index < 0:
|
||||
@@ -272,7 +295,15 @@ func _refresh_trigger() -> void:
|
||||
_target_row.visible = full
|
||||
|
||||
if not full:
|
||||
_trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), TRIGGER_REGISTRY.summarize(_trigger)]
|
||||
var read_only_summary: String = TRIGGER_REGISTRY.summarize(_trigger)
|
||||
if type == "prop_used":
|
||||
var params: Dictionary = _trigger.get("params", {})
|
||||
read_only_summary = "uses %s" % _resolve_use_target_summary(
|
||||
String(params.get("target_prop_id", "")),
|
||||
String(params.get("spot_scope", "any")),
|
||||
String(params.get("target_spot_id", ""))
|
||||
)
|
||||
_trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), read_only_summary]
|
||||
return
|
||||
|
||||
# Show only the target control relevant to this trigger type.
|
||||
@@ -296,6 +327,13 @@ func _refresh_trigger() -> void:
|
||||
"prop":
|
||||
_target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1)))
|
||||
_set_target_btn.visible = true
|
||||
"use_prop":
|
||||
var use_params: Dictionary = _trigger.get("params", {})
|
||||
var prop_id: String = String(use_params.get("target_prop_id", ""))
|
||||
var scope: String = String(use_params.get("spot_scope", "any"))
|
||||
_target_label.text = _resolve_use_target_summary(prop_id, scope, String(use_params.get("target_spot_id", "")))
|
||||
_set_target_btn.text = "Choose prop"
|
||||
_set_target_btn.visible = true
|
||||
_:
|
||||
pass
|
||||
|
||||
@@ -346,10 +384,14 @@ func _make_action_row(index: int, action: Dictionary) -> Control:
|
||||
|
||||
var summary := Label.new()
|
||||
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var action_summary: String = ACTION_REGISTRY.summarize(action)
|
||||
if String(action.get("type", "")) == "use_prop":
|
||||
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action)
|
||||
action_summary = _resolve_use_target_summary(String(flat.get("target_prop_id", "")), "specific", String(flat.get("target_spot_id", "")))
|
||||
summary.text = "%s %s %s" % [
|
||||
ACTION_REGISTRY.icon(String(action.get("type", ""))),
|
||||
_actor_name(int(action.get("target", -1))),
|
||||
ACTION_REGISTRY.summarize(action),
|
||||
action_summary,
|
||||
]
|
||||
row.add_child(summary)
|
||||
|
||||
@@ -378,6 +420,17 @@ func _on_ok_pressed() -> void:
|
||||
if _current_type() == "action_finished":
|
||||
_sync_action_type()
|
||||
_trigger["type"] = _current_type()
|
||||
if String(_trigger.get("type", "")) == "prop_used":
|
||||
var use_params: Dictionary = _trigger.get("params", {})
|
||||
var target_prop_id: String = String(use_params.get("target_prop_id", ""))
|
||||
var spot_scope: String = String(use_params.get("spot_scope", ""))
|
||||
if target_prop_id.is_empty() or spot_scope not in ["any", "specific"] or (spot_scope == "specific" and String(use_params.get("target_spot_id", "")).is_empty()) or _resolve_use_target_summary(target_prop_id, spot_scope, String(use_params.get("target_spot_id", ""))) == "Choose a new spot":
|
||||
return
|
||||
for action: Dictionary in _actions:
|
||||
if String(action.get("type", "")) == "use_prop":
|
||||
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action)
|
||||
if _resolve_use_target_summary(String(flat.get("target_prop_id", "")), "specific", String(flat.get("target_spot_id", ""))) == "Choose a new spot":
|
||||
return
|
||||
var rule := {
|
||||
"id": _rule_id,
|
||||
"trigger": _trigger.duplicate(true),
|
||||
@@ -386,6 +439,14 @@ func _on_ok_pressed() -> void:
|
||||
committed.emit(rule)
|
||||
|
||||
|
||||
func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String:
|
||||
if prop_id.is_empty():
|
||||
return "Choose a new spot"
|
||||
if not _use_target_summary_resolver.is_valid():
|
||||
return "Choose a new spot"
|
||||
return String(_use_target_summary_resolver.call(prop_id, scope, spot_id))
|
||||
|
||||
|
||||
func _actor_name(id: int) -> String:
|
||||
if id <= 0:
|
||||
# -1 is the "no source / no actor" sentinel; instance_from_id(-1) would
|
||||
|
||||
@@ -34,6 +34,7 @@ var _base_font_size: int = 0
|
||||
var _row_font_size: int = 0
|
||||
var _title_font_size: int = 0
|
||||
var _title_font: Font = null
|
||||
var _use_target_summary_resolver: Callable = Callable()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -55,6 +56,11 @@ func set_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func set_use_target_summary_resolver(resolver: Callable) -> void:
|
||||
_use_target_summary_resolver = resolver
|
||||
refresh()
|
||||
|
||||
|
||||
## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up.
|
||||
func show_rules(rules: Array[Dictionary], title_hint: String) -> void:
|
||||
set_rules(rules, title_hint)
|
||||
@@ -231,6 +237,14 @@ func _make_label(text: String, dim: bool = false) -> Label:
|
||||
func _trigger_summary(rule: Dictionary) -> String:
|
||||
var trigger: Dictionary = rule.get("trigger", {})
|
||||
var type := String(trigger.get("type", ""))
|
||||
if type == "prop_used":
|
||||
var params: Dictionary = trigger.get("params", {})
|
||||
var target_summary: String = _resolve_use_target_summary(
|
||||
String(params.get("target_prop_id", "")),
|
||||
String(params.get("spot_scope", "any")),
|
||||
String(params.get("target_spot_id", ""))
|
||||
)
|
||||
return "%s When %s uses %s" % [TRIGGER_REGISTRY.icon(type), _actor_name(int(trigger.get("source", -1))), target_summary]
|
||||
return "%s %s %s" % [
|
||||
TRIGGER_REGISTRY.icon(type),
|
||||
_actor_name(int(trigger.get("source", -1))),
|
||||
@@ -241,14 +255,28 @@ func _trigger_summary(rule: Dictionary) -> String:
|
||||
func _action_summaries(rule: Dictionary) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
for a: Dictionary in rule.get("actions", []):
|
||||
var action_summary: String = ACTION_REGISTRY.summarize(a)
|
||||
if String(a.get("type", "")) == "use_prop":
|
||||
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(a)
|
||||
action_summary = _resolve_use_target_summary(
|
||||
String(flat.get("target_prop_id", "")),
|
||||
"specific",
|
||||
String(flat.get("target_spot_id", ""))
|
||||
)
|
||||
out.append("→ %s %s %s" % [
|
||||
ACTION_REGISTRY.icon(String(a.get("type", ""))),
|
||||
_actor_name(int(a.get("target", -1))),
|
||||
ACTION_REGISTRY.summarize(a),
|
||||
action_summary,
|
||||
])
|
||||
return out
|
||||
|
||||
|
||||
func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String:
|
||||
if not _use_target_summary_resolver.is_valid():
|
||||
return "Choose a new spot"
|
||||
return String(_use_target_summary_resolver.call(prop_id, scope, spot_id))
|
||||
|
||||
|
||||
func _row_style(bg: Color) -> StyleBoxFlat:
|
||||
var sb := StyleBoxFlat.new()
|
||||
sb.bg_color = bg
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -266,6 +266,11 @@ func set_prop_target(stage_id: String) -> void:
|
||||
_action_editor.set_prop_target(stage_id)
|
||||
|
||||
|
||||
func set_use_target(prop_id: String, spot_id: String, summary: String) -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.set_use_target(prop_id, spot_id, summary)
|
||||
|
||||
|
||||
func set_throw_target(position: Vector2) -> void:
|
||||
if _action_editor != null:
|
||||
_action_editor.set_throw_target(position)
|
||||
@@ -286,7 +291,7 @@ func request_action_target_capture() -> void:
|
||||
return
|
||||
_action_editor.hide()
|
||||
var action_type: String = _action_editor.current_action_type()
|
||||
if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop"]:
|
||||
if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop", "use_prop"]:
|
||||
_emit_capture_request(
|
||||
&"actor",
|
||||
"🎯 Click the stickman who will act (Esc or RMB to cancel)",
|
||||
@@ -300,6 +305,8 @@ func request_action_target_capture() -> void:
|
||||
_emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
|
||||
"pick_up_prop":
|
||||
_emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture"))
|
||||
"use_prop":
|
||||
_emit_capture_request(&"use_prop", "Click a prop, then choose its Use spot", Callable(self, "set_use_target_from_capture"), Callable(self, "cancel_target_capture"))
|
||||
"throw_prop":
|
||||
_emit_capture_request(&"throw_position", "🏹 Click where to throw (Esc or RMB to cancel)", Callable(self, "set_throw_target"), Callable(self, "cancel_target_capture"))
|
||||
"set_down_prop":
|
||||
@@ -330,5 +337,14 @@ func accept_prop_target(value: Node2D) -> void:
|
||||
show_action_editor()
|
||||
|
||||
|
||||
func set_use_target_from_capture(value: Variant) -> void:
|
||||
if not value is Dictionary:
|
||||
show_action_editor()
|
||||
return
|
||||
var target: Dictionary = value
|
||||
set_use_target(String(target.get("prop_id", "")), String(target.get("spot_id", "")), String(target.get("summary", "")))
|
||||
show_action_editor()
|
||||
|
||||
|
||||
func cancel_target_capture() -> void:
|
||||
show_action_editor()
|
||||
|
||||
@@ -68,6 +68,7 @@ func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_fo
|
||||
_action_popup.add_item("💥 Ragdoll", int(action_ids.get("ragdoll", 3)))
|
||||
_action_popup.add_item("🔄 Recover", int(action_ids.get("recover", 4)))
|
||||
_action_popup.add_item("🫴 Pick Up Prop…", int(action_ids.get("pick_up_prop", 9)))
|
||||
_action_popup.add_item("🖐️ Use…", int(action_ids.get("use_prop", 13)))
|
||||
_action_popup.add_item("⬇️ Drop Prop", int(action_ids.get("drop_prop", 10)))
|
||||
_action_popup.add_item("🏹 Throw Prop…", int(action_ids.get("throw_prop", 11)))
|
||||
_action_popup.add_item("🫳 Set Down…", int(action_ids.get("set_down_prop", 12)))
|
||||
|
||||
@@ -10,6 +10,11 @@ const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
|
||||
signal role_choice_requested(key: String, value: String)
|
||||
signal role_toggle_requested(enabled: bool, key: String)
|
||||
signal spot_add_requested
|
||||
signal spot_move_requested(spot_id: String)
|
||||
signal spot_rename_requested(spot_id: String, name: String)
|
||||
signal spot_erase_requested(spot_id: String)
|
||||
signal spot_selected(spot_id: String)
|
||||
signal close_requested
|
||||
|
||||
var _stage_theme: SandboxTheme
|
||||
@@ -22,6 +27,8 @@ var _notice: Label
|
||||
var _depth_group: StagePanelChoiceGroup
|
||||
var _motion_group: StagePanelChoiceGroup
|
||||
var _controls: Dictionary = {}
|
||||
var _spot_rows: VBoxContainer
|
||||
var _spot_empty_label: Label
|
||||
var _active_selection_key: String = ""
|
||||
var _dismissed_selection_key: String = ""
|
||||
|
||||
@@ -46,7 +53,8 @@ func show_prop(
|
||||
title: String,
|
||||
roles: Dictionary,
|
||||
safe_rect: Rect2,
|
||||
selection: Array[Node2D]
|
||||
selection: Array[Node2D],
|
||||
selected_spot_id: String = ""
|
||||
) -> void:
|
||||
if prop == null or not is_instance_valid(prop):
|
||||
hide_card()
|
||||
@@ -62,6 +70,7 @@ func show_prop(
|
||||
_fields.visible = true
|
||||
_card.set_header(title, "📦")
|
||||
_sync_roles(prop, roles)
|
||||
_sync_spots(prop.get_use_spots(), selected_spot_id)
|
||||
|
||||
|
||||
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
|
||||
@@ -184,6 +193,7 @@ func _build() -> void:
|
||||
_controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen")
|
||||
_controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up")
|
||||
_add_release_options()
|
||||
_add_use_spots_section()
|
||||
|
||||
_reopen_button = Button.new()
|
||||
_reopen_button.name = "ReopenPropSettings"
|
||||
@@ -196,6 +206,105 @@ func _build() -> void:
|
||||
add_child(_reopen_button)
|
||||
|
||||
|
||||
func _add_use_spots_section() -> void:
|
||||
var section := VBoxContainer.new()
|
||||
section.name = "UseSpotsSection"
|
||||
section.add_theme_constant_override("separation", _stage_panel_integer("spots.row_gap", 8, 4, 24))
|
||||
_fields.add_child(section)
|
||||
_add_heading(section, "Use spots")
|
||||
var helper := Label.new()
|
||||
helper.text = "Stickmen can touch these places without picking up the prop."
|
||||
helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||
helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||
_apply_ui_font(helper)
|
||||
section.add_child(helper)
|
||||
var add_button := Button.new()
|
||||
add_button.name = "AddUseSpot"
|
||||
add_button.text = "+ Add a spot"
|
||||
add_button.custom_minimum_size.y = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||
_card.style_action_button(add_button, true)
|
||||
add_button.pressed.connect(func() -> void: spot_add_requested.emit())
|
||||
section.add_child(add_button)
|
||||
_spot_empty_label = Label.new()
|
||||
_spot_empty_label.text = "No Use spots yet. Add one where a stickman should reach."
|
||||
_spot_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_spot_empty_label.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
|
||||
_apply_ui_font(_spot_empty_label)
|
||||
section.add_child(_spot_empty_label)
|
||||
_spot_rows = VBoxContainer.new()
|
||||
_spot_rows.name = "UseSpotRows"
|
||||
_spot_rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_spot_rows.add_theme_constant_override("separation", _stage_panel_integer("spots.row_gap", 8, 4, 24))
|
||||
section.add_child(_spot_rows)
|
||||
|
||||
|
||||
func _sync_spots(spots: Array[Dictionary], selected_spot_id: String) -> void:
|
||||
if _spot_rows == null:
|
||||
return
|
||||
for child: Node in _spot_rows.get_children():
|
||||
child.queue_free()
|
||||
_spot_empty_label.visible = spots.is_empty()
|
||||
var button_height: float = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
|
||||
for spot: Dictionary in spots:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
var number: int = int(spot.get("number", 0))
|
||||
var row := VBoxContainer.new()
|
||||
row.name = "UseSpot_%d" % number
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_theme_constant_override("separation", 4)
|
||||
_spot_rows.add_child(row)
|
||||
var header := HBoxContainer.new()
|
||||
row.add_child(header)
|
||||
var select_button := Button.new()
|
||||
select_button.text = "%d" % number
|
||||
select_button.tooltip_text = "Show spot %d on the prop" % number
|
||||
select_button.custom_minimum_size = Vector2(button_height, button_height)
|
||||
select_button.pressed.connect(func() -> void: spot_selected.emit(spot_id))
|
||||
_card.style_action_button(select_button, spot_id == selected_spot_id)
|
||||
header.add_child(select_button)
|
||||
var name_column := VBoxContainer.new()
|
||||
name_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
header.add_child(name_column)
|
||||
var name_label := Label.new()
|
||||
name_label.text = "Spot name"
|
||||
name_label.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
|
||||
_apply_ui_font(name_label)
|
||||
name_column.add_child(name_label)
|
||||
var name_edit := LineEdit.new()
|
||||
name_edit.text = String(spot.get("name", "Spot %d" % number))
|
||||
name_edit.placeholder_text = "Spot %d" % number
|
||||
name_edit.custom_minimum_size.y = button_height
|
||||
name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_apply_ui_font(name_edit)
|
||||
name_edit.text_submitted.connect(func(_text: String) -> void: _commit_spot_name(name_edit, spot_id))
|
||||
name_edit.focus_exited.connect(func() -> void: _commit_spot_name(name_edit, spot_id))
|
||||
name_column.add_child(name_edit)
|
||||
var actions := FlowContainer.new()
|
||||
actions.add_theme_constant_override("h_separation", 8)
|
||||
actions.add_theme_constant_override("v_separation", 4)
|
||||
row.add_child(actions)
|
||||
var move_button := Button.new()
|
||||
move_button.text = "Move"
|
||||
move_button.custom_minimum_size = Vector2(88.0, button_height)
|
||||
move_button.pressed.connect(func() -> void: spot_move_requested.emit(spot_id))
|
||||
_card.style_action_button(move_button, false)
|
||||
actions.add_child(move_button)
|
||||
var erase_button := Button.new()
|
||||
erase_button.text = "Erase"
|
||||
erase_button.custom_minimum_size = Vector2(88.0, button_height)
|
||||
erase_button.pressed.connect(func() -> void: spot_erase_requested.emit(spot_id))
|
||||
_card.style_action_button(erase_button, false)
|
||||
actions.add_child(erase_button)
|
||||
_card.request_fit()
|
||||
|
||||
|
||||
func _commit_spot_name(edit: LineEdit, spot_id: String) -> void:
|
||||
if edit == null or not is_instance_valid(edit):
|
||||
return
|
||||
spot_rename_requested.emit(spot_id, edit.text)
|
||||
|
||||
|
||||
func _add_legacy_order_cue() -> void:
|
||||
var legacy_cue := Label.new()
|
||||
legacy_cue.name = "LegacyOrderCue"
|
||||
|
||||
@@ -18,6 +18,7 @@ const TRIG_BACK: int = 5
|
||||
const TRIG_PROP_PICKED_UP: int = 6
|
||||
const TRIG_PROP_DROPPED: int = 7
|
||||
const TRIG_PROP_THROWN: int = 8
|
||||
const TRIG_PROP_USED: int = 9
|
||||
const RULE_MORE_ADD: int = 0
|
||||
const RULE_MORE_DONE: int = 1
|
||||
const RULE_ACTION_DONE: int = 6
|
||||
@@ -90,6 +91,7 @@ func build_ui(
|
||||
_trigger_popup.add_item("🫴 Picks up a prop", int(popup_ids.get("trigger_prop_picked_up", TRIG_PROP_PICKED_UP)))
|
||||
_trigger_popup.add_item("⬇️ Drops a prop", int(popup_ids.get("trigger_prop_dropped", TRIG_PROP_DROPPED)))
|
||||
_trigger_popup.add_item("🏹 Throws a prop", int(popup_ids.get("trigger_prop_thrown", TRIG_PROP_THROWN)))
|
||||
_trigger_popup.add_item("🖐️ Used prop", int(popup_ids.get("trigger_prop_used", TRIG_PROP_USED)))
|
||||
_trigger_popup.add_separator()
|
||||
_trigger_popup.add_item("⬅ Back to actions", int(popup_ids.get("trigger_back", TRIG_BACK)))
|
||||
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
|
||||
@@ -105,6 +107,7 @@ func build_ui(
|
||||
_action_popup.add_item("⬇️ Drop Prop", int(popup_ids.get("action_drop_prop", 10)))
|
||||
_action_popup.add_item("🏹 Throw Prop…", int(popup_ids.get("action_throw_prop", 11)))
|
||||
_action_popup.add_item("🫳 Set Down…", int(popup_ids.get("action_set_down_prop", 12)))
|
||||
_action_popup.add_item("🖐️ Use…", int(popup_ids.get("action_use_prop", 13)))
|
||||
_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
|
||||
parent.add_child(_action_popup)
|
||||
|
||||
@@ -155,6 +158,19 @@ func get_rule_editor() -> RuleEditor:
|
||||
return _rule_editor
|
||||
|
||||
|
||||
func set_use_target_summary_resolver(resolver: Callable) -> void:
|
||||
if _rule_panel != null:
|
||||
_rule_panel.set_use_target_summary_resolver(resolver)
|
||||
if _rule_editor != null:
|
||||
_rule_editor.set_use_target_summary_resolver(resolver)
|
||||
|
||||
|
||||
func refresh_use_target_summaries(rules: Array[Dictionary]) -> void:
|
||||
refresh_rule_panel(rules)
|
||||
if _rule_editor != null:
|
||||
_rule_editor.refresh_use_target_summaries()
|
||||
|
||||
|
||||
func edit_rule_by_id(rules: Array[Dictionary], rule_id: int) -> bool:
|
||||
for rule: Dictionary in rules:
|
||||
if int(rule.get("id", -1)) == rule_id:
|
||||
@@ -186,6 +202,8 @@ func trigger_target_capture_hint(target_type: String) -> String:
|
||||
return "🎯 Click the trigger area (Esc or RMB to cancel)"
|
||||
"prop":
|
||||
return "💥 Click the prop (Esc or RMB to cancel)"
|
||||
"use_prop":
|
||||
return "Click a prop, then click a named spot (Esc or RMB to cancel)"
|
||||
return ""
|
||||
|
||||
|
||||
@@ -302,7 +320,7 @@ func on_trigger_selected(id: int) -> void:
|
||||
var trigger: Dictionary = builder.get("trigger", {})
|
||||
trigger["type"] = trigger_type_string(id)
|
||||
builder["trigger"] = trigger
|
||||
if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN]:
|
||||
if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN, TRIG_PROP_USED]:
|
||||
step = Step.TRIGGER_TARGET
|
||||
match id:
|
||||
TRIG_ARRIVED:
|
||||
@@ -313,6 +331,8 @@ func on_trigger_selected(id: int) -> void:
|
||||
hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
|
||||
TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
|
||||
hint = "Click the prop to watch (Esc or RMB to cancel)"
|
||||
TRIG_PROP_USED:
|
||||
hint = "Click a prop, then click a named spot"
|
||||
else:
|
||||
step = Step.SELECT_ACTION
|
||||
open_rule_action_popup()
|
||||
@@ -359,6 +379,8 @@ func trigger_type_string(id: int) -> String:
|
||||
return "prop_dropped"
|
||||
TRIG_PROP_THROWN:
|
||||
return "prop_thrown"
|
||||
TRIG_PROP_USED:
|
||||
return "prop_used"
|
||||
_:
|
||||
return ""
|
||||
|
||||
@@ -383,6 +405,8 @@ func action_type_string(id: int) -> String:
|
||||
return "throw_prop"
|
||||
12:
|
||||
return "set_down_prop"
|
||||
13:
|
||||
return "use_prop"
|
||||
_:
|
||||
return ""
|
||||
|
||||
@@ -407,6 +431,8 @@ func action_verb(id: int) -> String:
|
||||
return "throw a prop"
|
||||
12:
|
||||
return "set down a prop"
|
||||
13:
|
||||
return "use a prop"
|
||||
_:
|
||||
return "act"
|
||||
|
||||
@@ -438,6 +464,23 @@ func accept_trigger_prop(prop: PropBlock) -> void:
|
||||
open_rule_action_popup()
|
||||
|
||||
|
||||
func accept_trigger_use_prop(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void:
|
||||
var trigger: Dictionary = builder.get("trigger", {})
|
||||
trigger["target"] = -1
|
||||
var params: Dictionary = {"target_prop_id": target_prop_id, "spot_scope": spot_scope}
|
||||
if spot_scope == "specific":
|
||||
params["target_spot_id"] = target_spot_id
|
||||
trigger["params"] = params
|
||||
builder["trigger"] = trigger
|
||||
step = Step.SELECT_ACTION
|
||||
open_rule_action_popup()
|
||||
|
||||
|
||||
func set_rule_editor_trigger_use_target(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void:
|
||||
if _rule_editor != null:
|
||||
_rule_editor.set_trigger_use_target(target_prop_id, spot_scope, target_spot_id)
|
||||
|
||||
|
||||
func set_trigger_target_miss_hint(trigger_type: String) -> void:
|
||||
match trigger_type:
|
||||
"arrived_at_waypoint":
|
||||
|
||||
@@ -9,6 +9,7 @@ const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman
|
||||
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
|
||||
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
|
||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
||||
|
||||
|
||||
func collect(
|
||||
@@ -57,6 +58,8 @@ func collect(
|
||||
entry["asset_id"] = prop.spawn_id
|
||||
entry["stage_object_id"] = prop.stage_object_id
|
||||
entry["roles"] = prop.stage_roles.duplicate(true)
|
||||
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
|
||||
entry["next_use_spot_number"] = prop.next_use_spot_number
|
||||
var prop_scale: Vector2 = authored_state.get("scale", prop.scale)
|
||||
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
|
||||
entry["material_preset"] = prop.material_preset
|
||||
@@ -172,6 +175,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
|
||||
if node is PropBlock:
|
||||
var prop: PropBlock = node as PropBlock
|
||||
prop.stage_object_id = String(entry.get("stage_object_id", ""))
|
||||
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
|
||||
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
|
||||
var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(prop.has_usable_grip())
|
||||
if entry.get("roles", null) is Dictionary:
|
||||
prop.set_stage_roles(PROP_STAGE_ROLES.normalize(entry["roles"], default_roles))
|
||||
|
||||
@@ -4,7 +4,7 @@ extends RefCounted
|
||||
|
||||
const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||
|
||||
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION }
|
||||
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT }
|
||||
|
||||
var kind: int = Kind.NONE
|
||||
var hint: String = ""
|
||||
@@ -16,9 +16,62 @@ var throw_preview_cache: Dictionary = {}
|
||||
var set_down_capture_rig: StickmanRig
|
||||
var set_down_preview_prefix: Array[Dictionary] = []
|
||||
var set_down_preview_cache: Dictionary = {}
|
||||
var spot_edit_prop: PropBlock
|
||||
var spot_edit_id: String = ""
|
||||
var spot_edit_active: bool = false
|
||||
var spot_edit_ghost_world: Vector2 = Vector2.INF
|
||||
var use_target_active: bool = false
|
||||
var use_target_prop: PropBlock
|
||||
var use_target_actor: StickmanRig
|
||||
var use_target_context: String = ""
|
||||
var use_target_step: int = 0
|
||||
var use_target_choices: Array[Dictionary] = []
|
||||
var use_target_preview_cache: Dictionary = {}
|
||||
var use_target_preview_prefix: Array[Dictionary] = []
|
||||
var use_target_prefix_known: bool = false
|
||||
|
||||
|
||||
func is_stage_custom_capture() -> bool:
|
||||
return kind in [Kind.SPOT_ADD, Kind.SPOT_MOVE, Kind.USE_PROP, Kind.USE_SPOT]
|
||||
|
||||
|
||||
func begin_spot_edit(prop: PropBlock, spot_id: String, ghost_world: Vector2) -> void:
|
||||
finish()
|
||||
spot_edit_prop = prop if prop != null and is_instance_valid(prop) else null
|
||||
spot_edit_id = spot_id
|
||||
spot_edit_active = spot_edit_prop != null
|
||||
spot_edit_ghost_world = ghost_world
|
||||
kind = Kind.SPOT_MOVE if not spot_id.is_empty() else Kind.SPOT_ADD
|
||||
|
||||
|
||||
func begin_use_target(context: String, actor: StickmanRig, queue_prefix: Array[Dictionary], prefix_known: bool) -> void:
|
||||
finish()
|
||||
use_target_active = true
|
||||
use_target_context = context
|
||||
use_target_actor = actor if actor != null and is_instance_valid(actor) else null
|
||||
use_target_preview_prefix = queue_prefix.duplicate(true)
|
||||
use_target_prefix_known = prefix_known
|
||||
kind = Kind.USE_PROP
|
||||
|
||||
|
||||
func select_use_target_prop(prop: PropBlock) -> void:
|
||||
use_target_prop = prop if prop != null and is_instance_valid(prop) else null
|
||||
use_target_step = 1 if use_target_prop != null else 0
|
||||
use_target_choices.clear()
|
||||
use_target_preview_cache.clear()
|
||||
kind = Kind.USE_SPOT if use_target_step == 1 else Kind.USE_PROP
|
||||
|
||||
|
||||
func reset_use_target_prop() -> void:
|
||||
use_target_prop = null
|
||||
use_target_step = 0
|
||||
use_target_choices.clear()
|
||||
use_target_preview_cache.clear()
|
||||
kind = Kind.USE_PROP if use_target_active else Kind.NONE
|
||||
|
||||
|
||||
func begin(kind_value: int, hint_value: String, on_resolve: Callable, on_cancel: Callable) -> void:
|
||||
finish()
|
||||
kind = kind_value
|
||||
hint = hint_value
|
||||
resolve_callback = on_resolve
|
||||
@@ -54,6 +107,19 @@ func finish() -> void:
|
||||
set_down_capture_rig = null
|
||||
set_down_preview_prefix.clear()
|
||||
set_down_preview_cache.clear()
|
||||
spot_edit_prop = null
|
||||
spot_edit_id = ""
|
||||
spot_edit_active = false
|
||||
spot_edit_ghost_world = Vector2.INF
|
||||
use_target_active = false
|
||||
use_target_prop = null
|
||||
use_target_actor = null
|
||||
use_target_context = ""
|
||||
use_target_step = 0
|
||||
use_target_choices.clear()
|
||||
use_target_preview_cache.clear()
|
||||
use_target_preview_prefix.clear()
|
||||
use_target_prefix_known = false
|
||||
|
||||
|
||||
func update_preview(camera: Camera2D, snap_enabled: bool, grid_size: float) -> void:
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
class_name StageUseSpotOverlay
|
||||
extends Control
|
||||
## Screen-space presentation for authored Use spots and short runtime cues.
|
||||
|
||||
const DEFAULT_PIN_HIT_SIZE: float = 44.0
|
||||
const DEFAULT_PIN_RADIUS: float = 15.0
|
||||
const DEFAULT_PULSE_DURATION: float = 0.6
|
||||
|
||||
var _theme: SandboxTheme
|
||||
var _pin_hit_size: float = DEFAULT_PIN_HIT_SIZE
|
||||
var _pin_radius: float = DEFAULT_PIN_RADIUS
|
||||
var _pin_outline_width: float = 2.0
|
||||
var _pill_padding: float = 10.0
|
||||
var _pulse_duration: float = DEFAULT_PULSE_DURATION
|
||||
var _prop: PropBlock
|
||||
var _visible_spots: Array[Dictionary] = []
|
||||
var _selected_spot_id: String = ""
|
||||
var _moving_spot_id: String = ""
|
||||
var _ghost_world: Vector2 = Vector2.INF
|
||||
var _instruction: String = ""
|
||||
var _show_ghost: bool = false
|
||||
var _guide: Dictionary = {}
|
||||
var _success_prop: PropBlock
|
||||
var _success_spot_id: String = ""
|
||||
var _success_remaining: float = 0.0
|
||||
var _failure_text: String = ""
|
||||
var _failure_prop: PropBlock
|
||||
var _failure_spot_id: String = ""
|
||||
var _failure_remaining: float = 0.0
|
||||
|
||||
|
||||
func configure(theme: SandboxTheme) -> void:
|
||||
_theme = theme
|
||||
_pin_hit_size = float(_theme.integer("stage_panels.spots.pin_hit_size", 44, 44, 128)) if _theme != null else DEFAULT_PIN_HIT_SIZE
|
||||
_pin_radius = float(_theme.integer("stage_panels.spots.pin_visual_size", 30, 12, 64)) * 0.5 if _theme != null else DEFAULT_PIN_RADIUS
|
||||
_pin_outline_width = float(_theme.integer("stage_panels.spots.pin_outline_width", 2, 1, 8)) if _theme != null else 2.0
|
||||
_pill_padding = float(_theme.integer("stage_panels.spots.name_pill_padding", 10, 4, 32)) if _theme != null else 10.0
|
||||
_pulse_duration = _theme.number("overlays.use_preview.completion_duration", 0.6, 0.1, 2.0) if _theme != null else DEFAULT_PULSE_DURATION
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
set_process(true)
|
||||
|
||||
|
||||
func show_edit(prop: PropBlock, ghost_world: Vector2 = Vector2.INF, instruction: String = "", selected_spot_id: String = "", moving_spot_id: String = "") -> void:
|
||||
_set_visible_prop(prop)
|
||||
_selected_spot_id = selected_spot_id
|
||||
_moving_spot_id = moving_spot_id
|
||||
_ghost_world = ghost_world
|
||||
_instruction = instruction
|
||||
_show_ghost = instruction != ""
|
||||
_guide.clear()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func show_targeting(prop: PropBlock, guide: Dictionary = {}, instruction: String = "") -> void:
|
||||
_set_visible_prop(prop)
|
||||
_selected_spot_id = ""
|
||||
_moving_spot_id = ""
|
||||
_ghost_world = Vector2.INF
|
||||
_instruction = instruction
|
||||
_show_ghost = false
|
||||
_guide = guide.duplicate(true)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _set_visible_prop(prop: PropBlock) -> void:
|
||||
_prop = prop if prop != null and is_instance_valid(prop) else null
|
||||
_visible_spots.clear()
|
||||
if _prop != null:
|
||||
_visible_spots.assign(_prop.get_use_spots())
|
||||
|
||||
|
||||
func show_success(prop: PropBlock, spot_id: String) -> void:
|
||||
_success_prop = prop if prop != null and is_instance_valid(prop) else null
|
||||
_success_spot_id = spot_id
|
||||
_success_remaining = _pulse_duration
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func show_failure(prop: PropBlock, spot_id: String, reason: String) -> void:
|
||||
_failure_prop = prop if prop != null and is_instance_valid(prop) else null
|
||||
_failure_spot_id = spot_id
|
||||
_failure_text = reason
|
||||
_failure_remaining = 1.4
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func hit_spots(screen_position: Vector2) -> Array[String]:
|
||||
var label_hits: Array[String] = []
|
||||
if _prop == null or not is_instance_valid(_prop):
|
||||
return label_hits
|
||||
for spot: Dictionary in _visible_spots:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
var world_position: Vector2 = _prop.use_spot_world_position(spot_id)
|
||||
if world_position.is_finite() and _spot_label_rect(_prop, spot).has_point(screen_position):
|
||||
label_hits.append(spot_id)
|
||||
if not label_hits.is_empty():
|
||||
return label_hits
|
||||
var result: Array[String] = []
|
||||
for spot: Dictionary in _visible_spots:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
var world_position: Vector2 = _prop.use_spot_world_position(spot_id)
|
||||
if world_position.is_finite() and _screen_position(world_position).distance_to(screen_position) <= _pin_hit_size * 0.5:
|
||||
result.append(spot_id)
|
||||
return result
|
||||
|
||||
|
||||
func closest_hit_spot(screen_position: Vector2) -> String:
|
||||
if _prop == null or not is_instance_valid(_prop):
|
||||
return ""
|
||||
var closest_label_id: String = ""
|
||||
var closest_label_distance: float = INF
|
||||
var closest_pin_id: String = ""
|
||||
var closest_pin_distance: float = _pin_hit_size * 0.5
|
||||
for spot: Dictionary in _visible_spots:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
var world_position: Vector2 = _prop.use_spot_world_position(spot_id)
|
||||
if not world_position.is_finite():
|
||||
continue
|
||||
var distance: float = _screen_position(world_position).distance_to(screen_position)
|
||||
if _spot_label_rect(_prop, spot).has_point(screen_position):
|
||||
if distance < closest_label_distance:
|
||||
closest_label_id = spot_id
|
||||
closest_label_distance = distance
|
||||
elif distance <= closest_pin_distance:
|
||||
closest_pin_id = spot_id
|
||||
closest_pin_distance = distance
|
||||
return closest_label_id if not closest_label_id.is_empty() else closest_pin_id
|
||||
|
||||
|
||||
func clear_session() -> void:
|
||||
_prop = null
|
||||
_visible_spots.clear()
|
||||
_selected_spot_id = ""
|
||||
_moving_spot_id = ""
|
||||
_ghost_world = Vector2.INF
|
||||
_instruction = ""
|
||||
_show_ghost = false
|
||||
_guide.clear()
|
||||
_success_prop = null
|
||||
_success_spot_id = ""
|
||||
_success_remaining = 0.0
|
||||
_failure_prop = null
|
||||
_failure_spot_id = ""
|
||||
_failure_text = ""
|
||||
_failure_remaining = 0.0
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_paused(paused: bool) -> void:
|
||||
set_process(not paused)
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if _success_remaining > 0.0:
|
||||
_success_remaining = maxf(0.0, _success_remaining - delta)
|
||||
if _failure_remaining > 0.0:
|
||||
_failure_remaining = maxf(0.0, _failure_remaining - delta)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if _prop != null and is_instance_valid(_prop):
|
||||
for spot: Dictionary in _visible_spots:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
if spot_id == _moving_spot_id:
|
||||
_draw_moving_source(_prop, spot)
|
||||
else:
|
||||
_draw_pin(_prop, spot, spot_id == _selected_spot_id)
|
||||
if _visible_spots.is_empty() and not _instruction.is_empty():
|
||||
var local_bounds: Rect2 = _prop.get_visual_local_bounds()
|
||||
var prompt_world: Vector2 = _prop.global_transform * local_bounds.get_center()
|
||||
_draw_pill(_screen_position(prompt_world) + Vector2(0.0, -42.0), _instruction, _theme_color("mode_colors.direct", Color("#FFB300")))
|
||||
if _show_ghost and _ghost_world.is_finite():
|
||||
var ghost_position: Vector2 = _screen_position(_ghost_world)
|
||||
var moving_spot: Dictionary = _find_visible_spot(_moving_spot_id)
|
||||
if not moving_spot.is_empty() and _prop != null and is_instance_valid(_prop):
|
||||
var source_position: Vector2 = _screen_position(_prop.use_spot_world_position(_moving_spot_id))
|
||||
var guide_color := Color("#FFB300")
|
||||
guide_color.a = 0.72
|
||||
draw_line(source_position, ghost_position, guide_color, 2.0, true)
|
||||
_draw_pin_at(ghost_position, int(moving_spot.get("number", 0)), Color("#FFB300"), true)
|
||||
else:
|
||||
_draw_pin_at(ghost_position, 0, Color("#FFB300"), false)
|
||||
if not _instruction.is_empty():
|
||||
_draw_pill(ghost_position + Vector2(26.0, -24.0), _instruction, Color("#FFB300"))
|
||||
if bool(_guide.get("known", false)) or _guide.has("reason_code"):
|
||||
var start: Vector2 = _guide.get("approach_root", Vector2.INF)
|
||||
var end: Vector2 = _guide.get("spot_world", Vector2.INF)
|
||||
if start.is_finite() and end.is_finite():
|
||||
var known: bool = bool(_guide.get("known", true))
|
||||
var valid: bool = bool(_guide.get("valid", false))
|
||||
var end_screen: Vector2 = _screen_position(end)
|
||||
var guide_color: Color
|
||||
if not known:
|
||||
guide_color = _theme_color("overlays.use_preview.uncertain_color", Color("#FFB300"))
|
||||
elif valid:
|
||||
guide_color = _theme_color("overlays.use_preview.valid_color", Color("#42D887"))
|
||||
else:
|
||||
guide_color = _theme_color("overlays.use_preview.invalid_color", Color("#F06464"))
|
||||
if not known:
|
||||
var uncertain_reason: String = String(_guide.get("reason", "We'll check when you play."))
|
||||
_draw_reach_marker(end_screen + Vector2(24.0, 0.0), "?", guide_color)
|
||||
_draw_pill(end_screen + Vector2(0.0, 40.0), uncertain_reason, guide_color)
|
||||
else:
|
||||
var reason: String = String(_guide.get("reason", _reason_copy(String(_guide.get("reason_code", "")), valid)))
|
||||
_draw_reach_marker(end_screen + Vector2(24.0, 0.0), "✓" if valid else "!", guide_color)
|
||||
_draw_pill(end_screen + Vector2(0.0, -58.0), reason, guide_color)
|
||||
if _success_remaining > 0.0 and _success_prop != null and is_instance_valid(_success_prop):
|
||||
var success_spot: Vector2 = _success_prop.use_spot_world_position(_success_spot_id)
|
||||
if success_spot.is_finite():
|
||||
var progress: float = 1.0 - _success_remaining / _pulse_duration
|
||||
var alpha: float = clampf(_success_remaining / _pulse_duration, 0.0, 1.0)
|
||||
var pulse_color: Color = _theme_color("overlays.use_preview.valid_color", Color("#42D887"))
|
||||
pulse_color.a = alpha
|
||||
draw_arc(_screen_position(success_spot), 15.0 + progress * 24.0, 0.0, TAU, 32, pulse_color, 4.0, true)
|
||||
if _failure_remaining > 0.0 and _failure_prop != null and is_instance_valid(_failure_prop):
|
||||
var failure_spot: Vector2 = _failure_prop.use_spot_world_position(_failure_spot_id)
|
||||
if failure_spot.is_finite():
|
||||
_draw_pill(_screen_position(failure_spot) + Vector2(0.0, -42.0), _failure_text, Color("#F06464"))
|
||||
|
||||
|
||||
func _draw_pin(prop: PropBlock, spot: Dictionary, selected: bool) -> void:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
var point: Vector2 = prop.use_spot_world_position(spot_id)
|
||||
if not point.is_finite():
|
||||
return
|
||||
var color: Color = _theme_color("mode_colors.direct", Color("#FFB300")) if selected else _theme_color("stage_panels.spots.pin_surface_color", Color("#F4F7FB"))
|
||||
_draw_pin_at(_screen_position(point), int(spot.get("number", 0)), color, selected)
|
||||
_draw_pill(_spot_label_center(_screen_position(point)), _spot_label_text(spot), _theme_color("mode_colors.direct", Color("#FFB300")))
|
||||
|
||||
|
||||
func _spot_label_text(spot: Dictionary) -> String:
|
||||
var number: int = int(spot.get("number", 0))
|
||||
var spot_name: String = String(spot.get("name", "Spot %d" % number)).strip_edges()
|
||||
if spot_name.is_empty():
|
||||
spot_name = "Spot %d" % number
|
||||
return "%d %s" % [number, spot_name]
|
||||
|
||||
|
||||
func _spot_label_center(screen_point: Vector2) -> Vector2:
|
||||
return screen_point + Vector2(0.0, -28.0)
|
||||
|
||||
|
||||
func _spot_label_rect(prop: PropBlock, spot: Dictionary) -> Rect2:
|
||||
var world_position: Vector2 = prop.use_spot_world_position(String(spot.get("id", "")))
|
||||
if not world_position.is_finite():
|
||||
return Rect2()
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
var text_size: Vector2 = font.get_string_size(_spot_label_text(spot), HORIZONTAL_ALIGNMENT_LEFT, -1, 16)
|
||||
var center: Vector2 = _spot_label_center(_screen_position(world_position))
|
||||
return Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0))
|
||||
|
||||
|
||||
func _draw_moving_source(prop: PropBlock, spot: Dictionary) -> void:
|
||||
var spot_id: String = String(spot.get("id", ""))
|
||||
var point: Vector2 = prop.use_spot_world_position(spot_id)
|
||||
if not point.is_finite():
|
||||
return
|
||||
var screen_point: Vector2 = _screen_position(point)
|
||||
var amber := Color("#FFB300")
|
||||
var ghost_fill := amber
|
||||
ghost_fill.a = 0.30
|
||||
draw_circle(screen_point, _pin_radius + 4.0, _theme_color("stage_panels.spots.pin_outline_color", Color("#171B24")))
|
||||
draw_circle(screen_point, _pin_radius, ghost_fill)
|
||||
draw_arc(screen_point, _pin_radius, 0.0, TAU, 32, amber, maxf(_pin_outline_width, 3.0), true)
|
||||
draw_arc(screen_point, _pin_radius + 5.0, 0.0, TAU, 32, amber, 2.0, true)
|
||||
draw_circle(screen_point, 3.0, amber)
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
var number_text: String = str(int(spot.get("number", 0)))
|
||||
var text_size: Vector2 = font.get_string_size(number_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 15)
|
||||
draw_string(font, screen_point + Vector2(-text_size.x * 0.5, 5.0), number_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#171B24"))
|
||||
_draw_pill(screen_point + Vector2(0.0, -28.0), "Moving " + String(spot.get("name", "Spot")), amber)
|
||||
|
||||
|
||||
func _find_visible_spot(spot_id: String) -> Dictionary:
|
||||
if spot_id.is_empty():
|
||||
return {}
|
||||
for spot: Dictionary in _visible_spots:
|
||||
if String(spot.get("id", "")) == spot_id:
|
||||
return spot
|
||||
return {}
|
||||
|
||||
|
||||
func _draw_pin_at(point: Vector2, number: int, accent: Color, selected: bool) -> void:
|
||||
draw_circle(point, _pin_radius + 3.0, _theme_color("stage_panels.spots.pin_outline_color", Color("#171B24")))
|
||||
draw_circle(point, _pin_radius, accent)
|
||||
draw_arc(point, _pin_radius, 0.0, TAU, 32, Color.WHITE, _pin_outline_width, true)
|
||||
draw_circle(point, 3.0, Color("#171B24"))
|
||||
if number > 0:
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
var label: String = str(number)
|
||||
var label_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15)
|
||||
draw_string(font, point + Vector2(-label_size.x * 0.5, 5.0), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#171B24"))
|
||||
|
||||
|
||||
func _draw_pill(center: Vector2, text_value: String, accent: Color) -> void:
|
||||
if text_value.is_empty():
|
||||
return
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
var text_size: Vector2 = font.get_string_size(text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16)
|
||||
var rect := Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0))
|
||||
draw_rect(rect, Color("#171B24"), true)
|
||||
draw_rect(rect, accent, false, 2.0)
|
||||
draw_string(font, rect.position + Vector2(10.0, 18.0), text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE)
|
||||
|
||||
|
||||
func _draw_reach_marker(center: Vector2, glyph: String, accent: Color) -> void:
|
||||
draw_circle(center, 13.0, Color("#171B24"))
|
||||
draw_circle(center, 10.0, accent)
|
||||
var font: Font = ThemeDB.fallback_font
|
||||
var font_size := 16
|
||||
var glyph_size: Vector2 = font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
draw_string(font, center + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.35), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color("#171B24"))
|
||||
|
||||
|
||||
func _screen_position(world_position: Vector2) -> Vector2:
|
||||
return get_viewport().get_canvas_transform() * world_position
|
||||
|
||||
|
||||
func _reason_copy(reason_code: String, valid: bool) -> String:
|
||||
if valid:
|
||||
return "Ready to use"
|
||||
match reason_code:
|
||||
"route_blocked":
|
||||
return "Can't walk there"
|
||||
"no_standing_room":
|
||||
return "No room to stand"
|
||||
"hands_full":
|
||||
return "Hands must be empty"
|
||||
"prop_held":
|
||||
return "Prop is busy"
|
||||
"earlier_actions":
|
||||
return "We'll check when you play."
|
||||
_:
|
||||
return "Too far to reach"
|
||||
|
||||
|
||||
func _theme_color(path: String, fallback: Color) -> Color:
|
||||
return fallback if _theme == null else _theme.color(path, fallback)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1ocurcl8fpiu
|
||||
@@ -0,0 +1,227 @@
|
||||
class_name StageUseTargetCaptureBar
|
||||
extends PanelContainer
|
||||
## Clear, screen-space instructions and actions for choosing a Use target.
|
||||
|
||||
signal cancel_requested
|
||||
signal change_prop_requested
|
||||
|
||||
const MAX_BAR_WIDTH: float = 800.0
|
||||
const VIEWPORT_WIDTH_RATIO: float = 0.68
|
||||
const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0
|
||||
const ACTION_ROW_MIN_WIDTH: float = 480.0
|
||||
const ACTION_BUTTON_WIDTH: float = 200.0
|
||||
const DEFAULT_BUTTON_HEIGHT: float = 56.0
|
||||
|
||||
var _stage_theme: SandboxTheme
|
||||
var _top_bar_height: float = 64.0
|
||||
var _horizontal_layout: HBoxContainer
|
||||
var _vertical_layout: VBoxContainer
|
||||
var _active_layout: Container
|
||||
var _actions_row: HBoxContainer
|
||||
var _actions_column: VBoxContainer
|
||||
var _actions_parent: BoxContainer
|
||||
var _prompt: Label
|
||||
var _change_prop_button: Button
|
||||
var _cancel_button: Button
|
||||
|
||||
|
||||
func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void:
|
||||
_stage_theme = stage_theme
|
||||
_top_bar_height = maxf(0.0, top_bar_height)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_controls()
|
||||
apply_theme()
|
||||
z_index = 100
|
||||
visible = false
|
||||
set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||
get_viewport().size_changed.connect(_layout_capture_bar)
|
||||
_layout_capture_bar()
|
||||
|
||||
|
||||
func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void:
|
||||
if _prompt == null:
|
||||
return
|
||||
if not prop_selected:
|
||||
_prompt.text = "Choose a prop to use"
|
||||
elif prop_has_use_spots:
|
||||
_prompt.text = "Choose a spot on the prop"
|
||||
else:
|
||||
_prompt.text = "This prop has no Use spots. Add one in EDIT."
|
||||
_change_prop_button.visible = prop_selected
|
||||
visible = true
|
||||
_layout_capture_bar()
|
||||
|
||||
|
||||
func hide_capture() -> void:
|
||||
visible = false
|
||||
|
||||
|
||||
func apply_theme() -> void:
|
||||
if not is_node_ready() or _prompt == null:
|
||||
return
|
||||
theme = _stage_theme.control_theme() if _stage_theme != null else null
|
||||
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
|
||||
var shell: StyleBoxFlat = StyleBoxFlat.new()
|
||||
shell.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA"))
|
||||
shell.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 10, 24))
|
||||
shell.border_color = accent
|
||||
shell.border_width_left = 8
|
||||
shell.border_width_right = 0
|
||||
shell.border_width_top = 0
|
||||
shell.border_width_bottom = 0
|
||||
shell.content_margin_left = 20.0
|
||||
shell.content_margin_right = 18.0
|
||||
shell.content_margin_top = 14.0
|
||||
shell.content_margin_bottom = 14.0
|
||||
shell.shadow_color = Color(0.0, 0.0, 0.0, 0.38)
|
||||
shell.shadow_size = 8
|
||||
shell.shadow_offset = Vector2(0.0, 4.0)
|
||||
add_theme_stylebox_override("panel", shell)
|
||||
var prompt_size: int = clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34)
|
||||
_prompt.add_theme_font_size_override("font_size", prompt_size)
|
||||
_prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
|
||||
var body_font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font
|
||||
if body_font != null:
|
||||
_prompt.add_theme_font_override("font", body_font)
|
||||
_change_prop_button.add_theme_font_override("font", body_font)
|
||||
_cancel_button.add_theme_font_override("font", body_font)
|
||||
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
|
||||
_change_prop_button.add_theme_font_size_override("font_size", body_size)
|
||||
_cancel_button.add_theme_font_size_override("font_size", body_size)
|
||||
_style_button(_change_prop_button, false)
|
||||
_style_button(_cancel_button, true)
|
||||
var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96)))
|
||||
_change_prop_button.custom_minimum_size.y = button_height
|
||||
_cancel_button.custom_minimum_size.y = button_height
|
||||
_actions_row.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 12, 8, 24))
|
||||
_actions_column.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 10, 8, 24))
|
||||
_layout_capture_bar()
|
||||
|
||||
|
||||
func _build_controls() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
_horizontal_layout = HBoxContainer.new()
|
||||
_horizontal_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_horizontal_layout.add_theme_constant_override("separation", 20)
|
||||
_vertical_layout = VBoxContainer.new()
|
||||
_vertical_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_vertical_layout.add_theme_constant_override("separation", 12)
|
||||
_active_layout = _horizontal_layout
|
||||
add_child(_active_layout)
|
||||
_prompt = Label.new()
|
||||
_prompt.name = "Prompt"
|
||||
_prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_prompt.max_lines_visible = 2
|
||||
_prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
_prompt.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_active_layout.add_child(_prompt)
|
||||
_actions_row = HBoxContainer.new()
|
||||
_actions_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_actions_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
_actions_column = VBoxContainer.new()
|
||||
_actions_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_actions_parent = _actions_row
|
||||
_active_layout.add_child(_actions_parent)
|
||||
_change_prop_button = Button.new()
|
||||
_change_prop_button.name = "ChangeProp"
|
||||
_change_prop_button.text = "Change prop"
|
||||
_change_prop_button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
|
||||
_change_prop_button.focus_mode = Control.FOCUS_ALL
|
||||
_change_prop_button.pressed.connect(func() -> void: change_prop_requested.emit())
|
||||
_actions_row.add_child(_change_prop_button)
|
||||
_cancel_button = Button.new()
|
||||
_cancel_button.name = "Cancel"
|
||||
_cancel_button.text = "✕ Cancel"
|
||||
_cancel_button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
|
||||
_cancel_button.focus_mode = Control.FOCUS_ALL
|
||||
_cancel_button.pressed.connect(func() -> void: cancel_requested.emit())
|
||||
_actions_row.add_child(_cancel_button)
|
||||
|
||||
|
||||
func _style_button(button: Button, prominent: bool) -> void:
|
||||
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
|
||||
var text_color: Color = Color("#171B24") if prominent else _color("stage_panels.type.text_color", Color("#F5F7FA"))
|
||||
var normal_bg: Color = accent if prominent else _color("stage_panels.action.surface_color", Color("#171B24"))
|
||||
var hover_bg: Color = accent.lightened(0.08) if prominent else _color("stage_panels.action.hover_surface", Color("#303A4B"))
|
||||
var pressed_bg: Color = accent.darkened(0.12) if prominent else _color("stage_panels.action.selected_surface", Color("#12394A"))
|
||||
var border: Color = accent if prominent else _color("stage_panels.choice.border_color", Color("#FFFFFF55"))
|
||||
button.add_theme_stylebox_override("normal", _button_style(normal_bg, border, 1))
|
||||
button.add_theme_stylebox_override("hover", _button_style(hover_bg, accent, 2))
|
||||
button.add_theme_stylebox_override("pressed", _button_style(pressed_bg, accent, 2))
|
||||
button.add_theme_stylebox_override("hover_pressed", _button_style(hover_bg, accent, 2))
|
||||
button.add_theme_stylebox_override("focus", _button_style(Color(0.0, 0.0, 0.0, 0.0), accent, 2))
|
||||
button.add_theme_color_override("font_color", text_color)
|
||||
button.add_theme_color_override("font_hover_color", text_color)
|
||||
button.add_theme_color_override("font_pressed_color", text_color)
|
||||
button.add_theme_color_override("font_focus_color", text_color)
|
||||
|
||||
|
||||
func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
|
||||
var style: StyleBoxFlat = StyleBoxFlat.new()
|
||||
style.bg_color = background
|
||||
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 = 10.0
|
||||
style.content_margin_right = 10.0
|
||||
style.content_margin_top = 6.0
|
||||
style.content_margin_bottom = 6.0
|
||||
return style
|
||||
|
||||
|
||||
func _layout_capture_bar() -> void:
|
||||
if not is_node_ready() or _prompt == null:
|
||||
return
|
||||
var viewport_size: Vector2 = get_viewport_rect().size
|
||||
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
|
||||
var available_width: float = maxf(1.0, viewport_size.x - inset * 2.0)
|
||||
var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
|
||||
custom_minimum_size.x = width
|
||||
size.x = width
|
||||
position = Vector2((viewport_size.x - width) * 0.5, _top_bar_height + 56.0)
|
||||
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH, width < ACTION_ROW_MIN_WIDTH)
|
||||
call_deferred("_fit_height_to_content")
|
||||
|
||||
|
||||
func _set_layout(compact: bool, stack_actions: bool) -> void:
|
||||
var target_layout: Container = _vertical_layout if compact else _horizontal_layout
|
||||
var target_actions: BoxContainer = _actions_column if stack_actions else _actions_row
|
||||
if _actions_parent != target_actions:
|
||||
var previous_actions: BoxContainer = _actions_parent
|
||||
previous_actions.remove_child(_change_prop_button)
|
||||
previous_actions.remove_child(_cancel_button)
|
||||
_active_layout.remove_child(previous_actions)
|
||||
_actions_parent = target_actions
|
||||
_active_layout.add_child(_actions_parent)
|
||||
_actions_parent.add_child(_change_prop_button)
|
||||
_actions_parent.add_child(_cancel_button)
|
||||
if _active_layout != target_layout:
|
||||
_active_layout.remove_child(_prompt)
|
||||
_active_layout.remove_child(_actions_parent)
|
||||
remove_child(_active_layout)
|
||||
_active_layout = target_layout
|
||||
add_child(_active_layout)
|
||||
_active_layout.add_child(_prompt)
|
||||
_active_layout.add_child(_actions_parent)
|
||||
_change_prop_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH
|
||||
_cancel_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH
|
||||
_change_prop_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL
|
||||
_cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL
|
||||
|
||||
|
||||
func _fit_height_to_content() -> void:
|
||||
if not is_inside_tree() or not visible:
|
||||
return
|
||||
custom_minimum_size.y = get_combined_minimum_size().y
|
||||
size.y = custom_minimum_size.y
|
||||
|
||||
|
||||
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://gnnheadxlx6r
|
||||
@@ -13,6 +13,7 @@ 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 PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
||||
const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -33,6 +34,8 @@ var stage_object_id: String = ""
|
||||
var interaction_anchors: Dictionary = {}
|
||||
var interaction_reserved_by: int = 0
|
||||
var interaction_owner_id: int = 0
|
||||
var use_spots: Array[Dictionary] = []
|
||||
var next_use_spot_number: int = 1
|
||||
var stage_roles: Dictionary = {}
|
||||
var runtime_role: RuntimeRole = RuntimeRole.EDIT
|
||||
|
||||
@@ -400,6 +403,103 @@ func get_visual_local_bounds() -> Rect2:
|
||||
return _visual_local_bounds
|
||||
|
||||
|
||||
func get_use_spots() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for spot: Dictionary in use_spots:
|
||||
result.append(spot.duplicate(true))
|
||||
return result
|
||||
|
||||
|
||||
func get_use_spot(spot_id: String) -> Dictionary:
|
||||
return PROP_USE_SPOT_DATA.find(use_spots, spot_id)
|
||||
|
||||
|
||||
func get_use_spot_local_bounds() -> Rect2:
|
||||
if _custom_shapes:
|
||||
return _visual_local_bounds
|
||||
if shape_type == ShapeType.CIRCLE:
|
||||
return Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0))
|
||||
if polygon_points.is_empty():
|
||||
return Rect2()
|
||||
var minimum: Vector2 = polygon_points[0]
|
||||
var maximum: Vector2 = polygon_points[0]
|
||||
for point: Vector2 in polygon_points:
|
||||
minimum = minimum.min(point)
|
||||
maximum = maximum.max(point)
|
||||
return Rect2(minimum, maximum - minimum)
|
||||
|
||||
|
||||
func add_use_spot(local_position: Vector2) -> String:
|
||||
if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position):
|
||||
return ""
|
||||
var number: int = maxi(1, next_use_spot_number)
|
||||
var spot_id: String = "spot_%d" % number
|
||||
if not get_use_spot(spot_id).is_empty():
|
||||
return ""
|
||||
use_spots.append({"id": spot_id, "number": number, "name": PROP_USE_SPOT_DATA.fallback_name(number), "position": local_position})
|
||||
next_use_spot_number = number + 1
|
||||
return spot_id
|
||||
|
||||
|
||||
func rename_use_spot(spot_id: String, spot_name: String) -> bool:
|
||||
for index: int in use_spots.size():
|
||||
if String(use_spots[index].get("id", "")) != spot_id:
|
||||
continue
|
||||
var number: int = int(use_spots[index].get("number", 1))
|
||||
var normalized_name: String = spot_name.strip_edges()
|
||||
use_spots[index]["name"] = normalized_name if not normalized_name.is_empty() else PROP_USE_SPOT_DATA.fallback_name(number)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func move_use_spot(spot_id: String, local_position: Vector2) -> bool:
|
||||
if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position):
|
||||
return false
|
||||
for index: int in use_spots.size():
|
||||
if String(use_spots[index].get("id", "")) == spot_id:
|
||||
use_spots[index]["position"] = local_position
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func erase_use_spot(spot_id: String) -> bool:
|
||||
for index: int in use_spots.size():
|
||||
if String(use_spots[index].get("id", "")) == spot_id:
|
||||
use_spots.remove_at(index)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func use_spot_world_position(spot_id: String) -> Vector2:
|
||||
var spot: Dictionary = get_use_spot(spot_id)
|
||||
if spot.is_empty():
|
||||
return Vector2.INF
|
||||
var local_position: Vector2 = spot.get("position", Vector2.ZERO)
|
||||
var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE
|
||||
return global_transform * (local_position * baked_scale)
|
||||
|
||||
|
||||
func use_spot_local_from_world(world_position: Vector2) -> Vector2:
|
||||
if not world_position.is_finite() or not _transform_is_invertible(global_transform):
|
||||
return Vector2.INF
|
||||
var local_position: Vector2 = to_local(world_position)
|
||||
return local_position if get_use_spot_local_bounds().has_point(local_position) else Vector2.INF
|
||||
|
||||
|
||||
func set_use_spots(spots: Array[Dictionary], next_number: int) -> void:
|
||||
use_spots.clear()
|
||||
for spot: Dictionary in spots:
|
||||
use_spots.append(spot.duplicate(true))
|
||||
var highest: int = 0
|
||||
for spot: Dictionary in use_spots:
|
||||
highest = maxi(highest, int(spot.get("number", 0)))
|
||||
next_use_spot_number = maxi(highest + 1, next_number)
|
||||
|
||||
|
||||
func _transform_is_invertible(value: Transform2D) -> bool:
|
||||
return value.x.is_finite() and value.y.is_finite() and value.origin.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.000001
|
||||
|
||||
|
||||
func get_interaction_scale() -> Vector2:
|
||||
return _play_instance_scale if _play_scale_active else scale.abs()
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
class_name PropUseSpotData
|
||||
extends RefCounted
|
||||
## Pure validation and conversion helpers for placed-prop Use spots.
|
||||
|
||||
static func validate_saved(raw_spots: Variant, raw_next: Variant) -> String:
|
||||
if not raw_spots is Array:
|
||||
return "Prop Use spots must be an array."
|
||||
if not _is_integer_number(raw_next):
|
||||
return "Prop Use spot counter must be an integer."
|
||||
var next_value: float = float(raw_next)
|
||||
if not is_finite(next_value) or next_value < 1.0:
|
||||
return "Prop Use spot counter must be a positive integer."
|
||||
var ids: Dictionary = {}
|
||||
var numbers: Dictionary = {}
|
||||
var highest: int = 0
|
||||
for raw: Variant in raw_spots as Array:
|
||||
if not raw is Dictionary:
|
||||
return "Prop Use spot entries must be objects."
|
||||
var spot: Dictionary = raw
|
||||
var raw_id: Variant = spot.get("id", null)
|
||||
var raw_number: Variant = spot.get("number", null)
|
||||
var raw_name: Variant = spot.get("name", null)
|
||||
if not raw_id is String or not _is_integer_number(raw_number) or not raw_name is String:
|
||||
return "Prop Use spot fields are invalid."
|
||||
var numeric_number: float = float(raw_number)
|
||||
if not is_finite(numeric_number) or numeric_number < 1.0:
|
||||
return "Prop Use spot numbers must be positive integers."
|
||||
var number: int = int(numeric_number)
|
||||
var spot_id: String = String(raw_id)
|
||||
if spot_id != "spot_%d" % number or ids.has(spot_id) or numbers.has(number):
|
||||
return "Prop Use spot IDs and numbers must be unique and canonical."
|
||||
if not _is_finite_point(spot.get("position", null)):
|
||||
return "Prop Use spot positions must be finite points."
|
||||
ids[spot_id] = true
|
||||
numbers[number] = true
|
||||
highest = maxi(highest, number)
|
||||
if int(next_value) <= highest:
|
||||
return "Prop Use spot counter must exceed every existing spot number."
|
||||
return ""
|
||||
|
||||
|
||||
static func decode(raw_spots: Array) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raw: Variant in raw_spots:
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
var spot: Dictionary = raw
|
||||
var number: int = int(spot.get("number", 0))
|
||||
var name: String = String(spot.get("name", "")).strip_edges()
|
||||
result.append({
|
||||
"id": String(spot.get("id", "spot_%d" % number)),
|
||||
"number": number,
|
||||
"name": name if not name.is_empty() else fallback_name(number),
|
||||
"position": _point_from_data(spot.get("position", null)),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
static func encode(spots: Array[Dictionary]) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for spot: Dictionary in spots:
|
||||
var number: int = int(spot.get("number", 0))
|
||||
var position: Variant = spot.get("position", Vector2.ZERO)
|
||||
if not position is Vector2:
|
||||
continue
|
||||
var name: String = String(spot.get("name", "")).strip_edges()
|
||||
result.append({
|
||||
"id": String(spot.get("id", "spot_%d" % number)),
|
||||
"number": number,
|
||||
"name": name if not name.is_empty() else fallback_name(number),
|
||||
"position": {"x": position.x, "y": position.y},
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
static func fallback_name(number: int) -> String:
|
||||
return "Spot %d" % maxi(1, number)
|
||||
|
||||
|
||||
static func find(spots: Array[Dictionary], spot_id: String) -> Dictionary:
|
||||
for spot: Dictionary in spots:
|
||||
if String(spot.get("id", "")) == spot_id:
|
||||
return spot.duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
static func _is_finite_point(value: Variant) -> bool:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var point: Dictionary = value
|
||||
var x: Variant = point.get("x", null)
|
||||
var y: Variant = point.get("y", null)
|
||||
return (x is int or x is float) and (y is int or y is float) and is_finite(float(x)) and is_finite(float(y))
|
||||
|
||||
|
||||
static func _is_integer_number(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return true
|
||||
if not value is float:
|
||||
return false
|
||||
var number: float = value
|
||||
return is_finite(number) and absf(number) <= 9007199254740991.0 and floorf(number) == number
|
||||
|
||||
|
||||
static func _point_from_data(value: Variant) -> Vector2:
|
||||
if not _is_finite_point(value):
|
||||
return Vector2.ZERO
|
||||
var point: Dictionary = value
|
||||
return Vector2(float(point["x"]), float(point["y"]))
|
||||
@@ -0,0 +1 @@
|
||||
uid://vdr0sanaei05
|
||||
@@ -83,6 +83,15 @@ const ACTION_TEMPLATES := {
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"use_prop": {
|
||||
"label": "Use…",
|
||||
"icon": "🖐️",
|
||||
"params": [
|
||||
{ "key": "target_prop_id", "type": "prop", "required": true },
|
||||
{ "key": "target_spot_id", "type": "spot", "required": true },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +176,9 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
||||
params["target_position"] = action.get("target_position", Vector2.ZERO)
|
||||
"set_down_prop":
|
||||
params["target_position"] = action.get("target_position", Vector2.ZERO)
|
||||
"use_prop":
|
||||
params["target_prop_id"] = String(action.get("target_prop_id", ""))
|
||||
params["target_spot_id"] = String(action.get("target_spot_id", ""))
|
||||
return {
|
||||
"type": type,
|
||||
"target": target_id,
|
||||
@@ -199,6 +211,8 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
||||
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) }
|
||||
"use_prop":
|
||||
return { "type": "use_prop", "target_prop_id": String(params.get("target_prop_id", "")), "target_spot_id": String(params.get("target_spot_id", "")) }
|
||||
_:
|
||||
return { "type": type }
|
||||
|
||||
@@ -240,6 +254,10 @@ static func summarize(action: Dictionary) -> String:
|
||||
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))]
|
||||
"use_prop":
|
||||
var prop_id: String = String(action.get("target_prop_id", params.get("target_prop_id", "")))
|
||||
var spot_id: String = String(action.get("target_spot_id", params.get("target_spot_id", "")))
|
||||
return "Use (%s · %s)" % [prop_id, spot_id] if not prop_id.is_empty() and not spot_id.is_empty() else "Use (Choose a new spot)"
|
||||
_:
|
||||
return type
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ const DEFAULT_CONFIG: Dictionary = {
|
||||
"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"}
|
||||
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"},
|
||||
"spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6}
|
||||
},
|
||||
"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}},
|
||||
"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", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}},
|
||||
"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"}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ const BADGE_STACK_STEP := Vector2(0.0, -28.0)
|
||||
## Phase 4 rule rendering constants.
|
||||
const WAYPOINT_HIT_RADIUS_PX := 14.0
|
||||
const RULE_TRIGGER_COLOR := Color(0.2, 0.85, 0.3)
|
||||
const RULE_ACTION_COLOR := Color(1.0, 0.55, 0.15)
|
||||
const RULE_ACTION_COLOR := Color("#7E57C2")
|
||||
const RULE_CONNECTOR_COLOR := Color("#AAB2C0")
|
||||
const RULE_LABEL_BG := Color(0.0, 0.0, 0.0, 0.7)
|
||||
const RULE_LABEL_BORDER := Color(1.0, 1.0, 1.0, 0.25)
|
||||
const RULE_LABEL_FONT_SIZE_PX := 14.0
|
||||
@@ -47,6 +48,8 @@ var badge_radius: float = RULE_BADGE_RADIUS_PX
|
||||
var completed_path_alpha: float = 0.20
|
||||
var completed_path_color: Color = Color(0.58, 0.62, 0.68)
|
||||
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
|
||||
var rule_action_color: Color = RULE_ACTION_COLOR
|
||||
var rule_connector_color: Color = RULE_CONNECTOR_COLOR
|
||||
var emoji_font: Font = null
|
||||
|
||||
## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is
|
||||
@@ -341,13 +344,13 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
|
||||
if not action_anchor.is_finite():
|
||||
action_anchor = trigger_anchor
|
||||
|
||||
# Dashed white connector (trigger -> action).
|
||||
# Quiet gray connector keeps the rule relationship distinct from reach guides.
|
||||
if trigger_anchor.distance_to(action_anchor) > 0.5:
|
||||
_draw_dashed(trigger_anchor, action_anchor, zoom)
|
||||
_draw_dashed(trigger_anchor, action_anchor, zoom, rule_connector_color)
|
||||
|
||||
# Badges.
|
||||
_draw_rule_badge(trigger_anchor, zoom, "⚡", RULE_TRIGGER_COLOR)
|
||||
_draw_rule_badge(action_anchor, zoom, "→", RULE_ACTION_COLOR)
|
||||
_draw_rule_badge(action_anchor, zoom, "→", rule_action_color)
|
||||
|
||||
# Label at the line midpoint on a dark rounded rect.
|
||||
var summary := rule_summary(rule)
|
||||
|
||||
@@ -50,6 +50,21 @@ func matches_rule(rule: Dictionary, event: Dictionary) -> bool:
|
||||
return ids_match(source_id, source)
|
||||
"entered_area", "collided", "prop_picked_up", "prop_dropped", "prop_thrown":
|
||||
return ids_match(source_id, source) and ids_match(target_id, target)
|
||||
"prop_used":
|
||||
if not ids_match(source_id, source) or not target is PropBlock:
|
||||
return false
|
||||
var params: Dictionary = trigger.get("params", {})
|
||||
var target_prop_id: String = String(params.get("target_prop_id", ""))
|
||||
var prop: PropBlock = target as PropBlock
|
||||
if target_prop_id.is_empty() or prop.stage_object_id != target_prop_id:
|
||||
return false
|
||||
var scope: String = String(params.get("spot_scope", ""))
|
||||
if scope == "any":
|
||||
return not String(event.get("spot_id", "")).is_empty()
|
||||
if scope != "specific":
|
||||
return false
|
||||
var spot_id: String = String(params.get("target_spot_id", ""))
|
||||
return not spot_id.is_empty() and spot_id == String(event.get("spot_id", ""))
|
||||
_:
|
||||
return false
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ 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.2"
|
||||
const FILE_VERSION: String = "1.3"
|
||||
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
|
||||
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
|
||||
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
|
||||
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
|
||||
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
|
||||
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
|
||||
|
||||
static func vector_to_data(value: Vector2) -> Dictionary:
|
||||
return {"x": value.x, "y": value.y}
|
||||
@@ -160,11 +161,15 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
if version == FILE_VERSION:
|
||||
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)
|
||||
if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop":
|
||||
var prop_entry: Dictionary = raw
|
||||
if not prop_entry.has("roles"):
|
||||
prop_entry["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
|
||||
prop_entry["use_spots"] = prop_entry.get("use_spots", [])
|
||||
prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1)
|
||||
_assign_legacy_depths(current.get("objects", []))
|
||||
return current
|
||||
if version not in ["1.0", "1.1"]:
|
||||
if version not in ["1.0", "1.1", "1.2"]:
|
||||
return {}
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
for raw: Variant in migrated.get("objects", []):
|
||||
@@ -176,6 +181,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
object["stage_object_id"] = String(object.get("file_id", ""))
|
||||
if not object.has("roles"):
|
||||
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
|
||||
object["use_spots"] = object.get("use_spots", [])
|
||||
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
|
||||
migrated["version"] = FILE_VERSION
|
||||
_assign_legacy_depths(migrated.get("objects", []))
|
||||
return migrated
|
||||
@@ -222,6 +229,9 @@ 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
|
||||
var use_spot_error: String = PROP_USE_SPOT_DATA.validate_saved(entry.get("use_spots", []), entry.get("next_use_spot_number", 1))
|
||||
if not use_spot_error.is_empty():
|
||||
return {"ok": false, "error": use_spot_error}
|
||||
if entry.has("roles"):
|
||||
var roles_value: Variant = entry["roles"]
|
||||
if not roles_value is Dictionary:
|
||||
@@ -271,6 +281,11 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
var release_position: Vector2 = action["target_position"]
|
||||
if not release_position.is_finite(): return {}
|
||||
return {"type": type, "target_position": vector_to_data(release_position)}
|
||||
"use_prop":
|
||||
var prop_id: String = String(action.get("target_prop_id", ""))
|
||||
var spot_id: String = String(action.get("target_spot_id", ""))
|
||||
if prop_id.strip_edges().is_empty() or spot_id.strip_edges().is_empty(): return {}
|
||||
return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
@@ -297,6 +312,11 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
"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)}
|
||||
"use_prop":
|
||||
var prop_id: String = String(action.get("target_prop_id", ""))
|
||||
var spot_id: String = String(action.get("target_spot_id", ""))
|
||||
if prop_id.strip_edges().is_empty() or spot_id.strip_edges().is_empty(): return {}
|
||||
return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||
@@ -417,6 +437,11 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary
|
||||
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
|
||||
if source == null and int(trigger.get("source", -1)) != -1: return {}
|
||||
if target == null and int(trigger.get("target", -1)) != -1: return {}
|
||||
if type == "prop_used":
|
||||
if not trigger.has("source") or int(trigger.get("source", -1)) == -1 or source == null:
|
||||
return {}
|
||||
if not trigger.has("target") or int(trigger.get("target", -1)) != -1 or target != null:
|
||||
return {}
|
||||
var params: Dictionary = (trigger.get("params", {}) as Dictionary).duplicate(true)
|
||||
if type == "arrived_at_waypoint":
|
||||
if not params.get("waypoint_pos") is Vector2: return {}
|
||||
@@ -425,10 +450,23 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary
|
||||
var filter: String = String(params.get("action_type", ""))
|
||||
if not filter.is_empty():
|
||||
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
|
||||
elif type == "prop_used":
|
||||
var prop_id: String = String(params.get("target_prop_id", ""))
|
||||
var scope: String = String(params.get("spot_scope", ""))
|
||||
if prop_id.strip_edges().is_empty() or scope not in ["any", "specific"]: return {}
|
||||
if scope == "specific":
|
||||
var spot_id: String = String(params.get("target_spot_id", ""))
|
||||
if spot_id.strip_edges().is_empty(): return {}
|
||||
params = {"target_prop_id": prop_id, "spot_scope": scope, "target_spot_id": spot_id}
|
||||
else:
|
||||
if params.has("target_spot_id"): return {}
|
||||
params = {"target_prop_id": prop_id, "spot_scope": scope}
|
||||
return {"type": type, "source_id": source, "target_id": target, "params": params}
|
||||
|
||||
static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
||||
var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
|
||||
if type == "prop_used" and (not value.has("source_id") or value.get("source_id") == null or not value.has("target_id") or value.get("target_id") != null):
|
||||
return {}
|
||||
var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable)
|
||||
if source == -2 or target == -2: return {}
|
||||
var params: Dictionary = (value.get("params", {}) as Dictionary).duplicate(true)
|
||||
@@ -439,6 +477,18 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di
|
||||
var filter: String = String(params.get("action_type", ""))
|
||||
if not filter.is_empty():
|
||||
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
|
||||
elif type == "prop_used":
|
||||
var prop_id: String = String(params.get("target_prop_id", ""))
|
||||
var scope: String = String(params.get("spot_scope", ""))
|
||||
if prop_id.strip_edges().is_empty() or scope not in ["any", "specific"]: return {}
|
||||
if scope == "specific":
|
||||
var spot_id: String = String(params.get("target_spot_id", ""))
|
||||
if spot_id.strip_edges().is_empty(): return {}
|
||||
params = {"target_prop_id": prop_id, "spot_scope": scope, "target_spot_id": spot_id}
|
||||
else:
|
||||
if params.has("target_spot_id"): return {}
|
||||
params = {"target_prop_id": prop_id, "spot_scope": scope}
|
||||
if source == -1 or target != -1: return {}
|
||||
return {"type": type, "source": source, "target": target, "params": params}
|
||||
|
||||
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
||||
|
||||
@@ -47,6 +47,11 @@ const TRIGGER_TEMPLATES := {
|
||||
"icon": "🏹",
|
||||
"target_type": "prop",
|
||||
},
|
||||
"prop_used": {
|
||||
"label": "Used prop",
|
||||
"icon": "🖐️",
|
||||
"target_type": "use_prop",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user