feat(stage): complete phase 20b action plans

This commit is contained in:
2026-11-04 01:19:57 -05:00
parent 87a33b0abc
commit a9c0886348
70 changed files with 14234 additions and 469 deletions
@@ -5,11 +5,18 @@ extends Node
## locomotion and signals requests back to the facade.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd")
const STAGE_ACTION_IDENTITY := preload("res://scripts/stage/stage_action_identity.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
const PROP_INTERACTION_REACH: float = 110.0
const PROP_PURSUIT_MIN_SHIFT: float = 16.0
const PROP_PURSUIT_RETARGET_INTERVAL: float = 0.1
const PROP_PURSUIT_BUDGET: float = 12.0
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/speech/stickman_speech_bubble.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION, GROUP_BARRIER, PURSUIT_WAIT }
enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING }
signal action_started(action: Dictionary, index: int)
@@ -20,8 +27,12 @@ signal speech_finished
signal ragdoll_requested(enabled: bool)
signal recovery_requested
signal active_action_cancelled(action: Dictionary)
signal planned_action_started(action_id: String, action: Dictionary, step_index: int)
signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)
signal plan_changed
var _authored_queue: Array[Dictionary] = []
var _authored_steps: Array[Dictionary] = []
var action_queue: Array[Dictionary]:
get:
return _authored_queue.duplicate(true)
@@ -43,7 +54,26 @@ var _speech_bubble: Node2D = null
var _speech_preview_suppressed: bool = false
var _action_ids: Array[int] = []
var _next_action_id: int = 1
var _stable_to_numeric_ids: Dictionary = {}
var _stable_status: Dictionary = {}
var _current_action_id: int = -1
var _current_stable_action_id: String = ""
var _current_step_index: int = -1
var _active_step_entry: Dictionary = {}
var _group_say_action_id: String = ""
var _group_say_numeric_id: int = -1
var _group_say_step_index: int = -1
var _group_say_action: Dictionary = {}
var _group_say_barrier: bool = false
var _group_primary_done: bool = false
var _parallel_active_numeric_ids: Dictionary = {}
var _pursuit_action_id: String = ""
var _pursuit_prop_id: String = ""
var _pursuit_offset: Vector2 = Vector2.ZERO
var _pursuit_last_prop_position: Vector2 = Vector2.ZERO
var _pursuit_approach: Vector2 = Vector2.INF
var _pursuit_elapsed: float = 0.0
var _pursuit_retarget_elapsed: float = 0.0
var _completed_ids: Dictionary = {}
var _interrupted_ids: Dictionary = {}
var _paused: bool = false
@@ -70,8 +100,14 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_advance_to_next_action()
ActionPhase.WALKING:
if _locomotion != null:
if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed():
_finish_action()
if _locomotion.consume_walk_failed():
_end_prop_pursuit()
_finish_action(&"failed")
elif _locomotion.consume_walk_completed():
if _pursuit_action_id == _current_stable_action_id:
_check_prop_pursuit_arrival()
else:
_finish_action(&"succeeded")
ActionPhase.SPEAKING:
if _primary_speech.is_empty():
_finish_action()
@@ -79,6 +115,11 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.GROUP_BARRIER:
if not _group_say_barrier or _group_say_action_id.is_empty():
_complete_active_step()
ActionPhase.PURSUIT_WAIT:
pass
ActionPhase.RAGDOLLING:
if ragdoll_at_rest:
_finish_action()
@@ -91,6 +132,48 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_finish_action()
func pre_movement(delta: float) -> void:
if _paused or _pursuit_action_id.is_empty() or _action_phase not in [ActionPhase.WALKING, ActionPhase.PURSUIT_WAIT]:
return
_pursuit_elapsed += delta
_pursuit_retarget_elapsed += delta
if _pursuit_elapsed >= PROP_PURSUIT_BUDGET:
if _locomotion != null:
_locomotion.cancel_walk(false)
_end_prop_pursuit()
_finish_action(&"failed")
return
var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id)
if prop == null:
if _locomotion != null:
_locomotion.cancel_walk(false)
_end_prop_pursuit()
_finish_action(&"failed")
return
var prop_position: Vector2 = prop.global_position
if prop_position.distance_to(_pursuit_last_prop_position) < PROP_PURSUIT_MIN_SHIFT or _pursuit_retarget_elapsed < PROP_PURSUIT_RETARGET_INTERVAL:
return
var projected: Vector2 = _locomotion.project_target_to_navigation(prop_position + _pursuit_offset) if _locomotion != null else Vector2.INF
var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK)))
var retargeted: bool = false
if projected.is_finite() and _locomotion != null:
retargeted = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style)
if not projected.is_finite() or not retargeted:
if _locomotion != null:
_locomotion.cancel_walk(false)
_end_prop_pursuit()
_finish_action(&"failed")
return
_pursuit_last_prop_position = prop_position
_pursuit_approach = projected
_pursuit_retarget_elapsed = 0.0
_action_phase = ActionPhase.WALKING
func is_prop_pursuit_active() -> bool:
return not _pursuit_action_id.is_empty()
func speak(text: String, duration: float) -> void:
speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)})
@@ -134,111 +217,286 @@ func sync_facing(profile: int) -> void:
func set_authored_queue(actions: Array[Dictionary]) -> bool:
if _session_started:
return false
_authored_queue = actions.duplicate(true)
_action_ids.clear()
for _action: Dictionary in _authored_queue:
_action_ids.append(_allocate_action_id())
var steps: Array[Dictionary] = []
for source_action: Dictionary in actions:
var action: Dictionary = source_action.duplicate(true)
action["action_id"] = _allocate_stable_action_id()
if ACTION_REGISTRY.is_move_action(action) and action.get("target", null) is Vector2:
action["destination"] = {"kind": "place", "position": action["target"]}
action.erase("target")
steps.append({"actions": [action]})
_authored_steps = steps
_sync_legacy_queue()
queue_changed.emit()
plan_changed.emit()
return true
func get_action_plan() -> Array[Dictionary]:
return _authored_steps.duplicate(true)
func set_action_plan(steps: Array[Dictionary]) -> bool:
if _session_started or not STAGE_ACTION_PLAN.validate_steps(steps).is_empty():
return false
_authored_steps = steps.duplicate(true)
_sync_legacy_queue()
queue_changed.emit()
plan_changed.emit()
return true
func find_action(action_id: String) -> Dictionary:
return STAGE_ACTION_PLAN.find_action(_authored_steps, action_id)
func get_plan_view() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for step_index: int in _authored_steps.size():
var actions_value: Variant = _authored_steps[step_index].get("actions", [])
if not actions_value is Array:
continue
for raw_action: Variant in actions_value:
if not raw_action is Dictionary:
continue
var action: Dictionary = raw_action as Dictionary
var action_id: String = String(action.get("action_id", ""))
result.append({
"step_index": step_index,
"action_id": action_id,
"action": action.duplicate(true),
"status": _status_for_stable_id(action_id),
"numeric_id": _numeric_for_stable_id(action_id),
})
return result
func add_plan_step(action: Dictionary) -> String:
if _session_started and not _paused:
return ""
var created: Dictionary = _prepare_new_plan_action(action)
var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_step(_authored_steps, created)
return String(created.get("action_id", "")) if _commit_plan_edit(next) else ""
func add_plan_action_together(step_index: int, action: Dictionary) -> String:
if _session_started and not _paused:
return ""
if _session_started and _paused and not _plan_step_is_future(step_index):
return ""
var created: Dictionary = _prepare_new_plan_action(action)
var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_together(_authored_steps, step_index, created)
return String(created.get("action_id", "")) if _commit_plan_edit(next) else ""
func replace_plan_action(action_id: String, action: Dictionary) -> bool:
if _session_started and not _paused:
return false
var status: StringName = _status_for_stable_id(action_id)
if _session_started and _paused and status in [&"succeeded", &"failed", &"cancelled"]:
return false
var next: Array[Dictionary] = STAGE_ACTION_PLAN.replace_action(_authored_steps, action_id, action)
if next.is_empty() and not _authored_steps.is_empty():
return false
if _session_started and _paused and status == &"active":
_cancel_stable_action(action_id)
_stable_status[action_id] = &"pending"
return _commit_plan_edit(next)
func remove_plan_action(action_id: String) -> bool:
if _session_started and not _paused:
return false
var location: Dictionary = find_action(action_id)
if location.is_empty():
return false
if _status_for_stable_id(action_id) == &"active":
_cancel_stable_action(action_id)
var next: Array[Dictionary] = STAGE_ACTION_PLAN.remove_action(_authored_steps, action_id)
_stable_status.erase(action_id)
return _commit_plan_edit(next, true)
func move_plan_step(from_index: int, to_index: int) -> bool:
if _session_started and not _paused:
return false
if _session_started and _paused and (not _plan_step_is_future(from_index) or not _plan_step_is_future(to_index)):
return false
var next: Array[Dictionary] = STAGE_ACTION_PLAN.move_step(_authored_steps, from_index, to_index)
return _commit_plan_edit(next) if from_index != to_index else true
func can_add_plan_action_together(step_index: int, action_type: String) -> bool:
if _session_started and _paused and not _plan_step_is_future(step_index):
return false
if _session_started and not _paused:
return false
return step_index >= 0 and step_index < _authored_steps.size() and STAGE_ACTION_PLAN.can_add_together(_authored_steps[step_index], action_type)
func can_reorder_plan_step(step_index: int) -> bool:
if step_index < 0 or step_index >= _authored_steps.size():
return false
if _session_started and not _paused:
return false
return not _session_started or _plan_step_is_future(step_index)
func _prepare_new_plan_action(source_action: Dictionary) -> Dictionary:
var action: Dictionary = source_action.duplicate(true)
action["action_id"] = _allocate_stable_action_id()
if ACTION_REGISTRY.is_move_action(action) and not action.has("destination") and action.get("target", null) is Vector2:
action["destination"] = {"kind": "place", "position": action["target"]}
action.erase("target")
return action
func _commit_plan_edit(next: Array[Dictionary], allow_empty: bool = false) -> bool:
if next.is_empty() and not allow_empty:
return false
if not STAGE_ACTION_PLAN.validate_steps(next).is_empty():
return false
_authored_steps = next.duplicate(true)
_sync_legacy_queue()
if _session_started:
_rebuild_pending_runtime_queue()
queue_changed.emit()
plan_changed.emit()
return true
func _sync_legacy_queue() -> void:
_authored_queue = STAGE_ACTION_PLAN.flatten(_authored_steps)
_action_ids.clear()
for action: Dictionary in _authored_queue:
var stable_id: String = String(action.get("action_id", ""))
if not _stable_to_numeric_ids.has(stable_id):
_stable_to_numeric_ids[stable_id] = _allocate_action_id()
_action_ids.append(int(_stable_to_numeric_ids[stable_id]))
func _allocate_stable_action_id() -> String:
var identity := STAGE_ACTION_IDENTITY.new() as StageActionIdentity
var world: Node2D = _rig.get_parent() as Node2D if _rig != null else null
return identity.allocate(world)
func _numeric_for_stable_id(action_id: String) -> int:
if not _stable_to_numeric_ids.has(action_id):
_stable_to_numeric_ids[action_id] = _allocate_action_id()
return int(_stable_to_numeric_ids[action_id])
func _status_for_stable_id(action_id: String) -> StringName:
if _stable_status.has(action_id):
return StringName(String(_stable_status[action_id]))
return &"pending" if _session_started else &"not_started"
func _plan_step_is_future(step_index: int) -> bool:
if step_index < 0 or step_index >= _authored_steps.size():
return false
var actions: Array = _authored_steps[step_index].get("actions", [])
for raw_action: Variant in actions:
if not raw_action is Dictionary:
return false
if _status_for_stable_id(String((raw_action as Dictionary).get("action_id", ""))) != &"pending":
return false
return true
func _cancel_stable_action(action_id: String) -> void:
if action_id == _current_stable_action_id:
_cancel_current_primary(true)
_stable_status[action_id] = &"cancelled"
elif action_id == _group_say_action_id:
_overlay_speech.clear()
_parallel_active_numeric_ids.erase(_group_say_numeric_id)
_stable_status[action_id] = &"cancelled"
planned_action_ended.emit(action_id, &"cancelled", _group_say_step_index)
_group_say_action_id = ""
_group_say_numeric_id = -1
_group_say_action.clear()
if _group_say_barrier:
_group_say_barrier = false
func queue_action(action: Dictionary) -> void:
insert_action(action_queue.size(), action)
func clear_queue() -> void:
_authored_queue.clear()
_action_ids.clear()
if not _current_run_item.is_empty():
_cancel_current_primary(true)
_current_action_id = -1
_current_stable_action_id = ""
_group_say_action_id = ""
_group_say_action.clear()
_overlay_speech.clear()
_authored_steps.clear()
_sync_legacy_queue()
_run_queue.clear()
_run_cursor = 0
if _session_started:
_runner_state = RunnerState.IDLE
queue_changed.emit()
plan_changed.emit()
func get_queue() -> Array[Dictionary]:
return action_queue.duplicate(true)
return _authored_queue.duplicate(true)
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
if index < 0 or index >= _action_ids.size():
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
return
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
var status: int = _status_for_action_id(action_id)
if _session_started and _paused and status == QueueStatus.ACTIVE:
_cancel_current_primary(true)
_current_action_id = -1
_runner_state = RunnerState.IDLE
_authored_queue.remove_at(index)
if index < _action_ids.size():
_action_ids.remove_at(index)
_rebuild_pending_runtime_queue()
queue_changed.emit()
var stable_id: String = _stable_for_numeric_id(_action_ids[index])
if not stable_id.is_empty():
remove_plan_action(stable_id)
func insert_action(index: int, action: Dictionary) -> void:
if _session_started and not _paused:
return
var target: int = clampi(index, 0, _authored_queue.size())
if _session_started and _paused:
if target <= _paused_history_floor():
return
var action_id: int = _allocate_action_id()
_authored_queue.insert(target, action.duplicate(true))
_action_ids.insert(target, action_id)
if _session_started and _paused:
_rebuild_pending_runtime_queue()
queue_changed.emit()
var step_target: int = _step_boundary_for_flat_index(target)
if _session_started and _paused and not _plan_step_is_future(step_target):
return
var created: Dictionary = _prepare_new_plan_action(action)
var next: Array[Dictionary] = _authored_steps.duplicate(true)
next.insert(clampi(step_target, 0, next.size()), {"actions": [created]})
_commit_plan_edit(next)
func replace_action(index: int, action: Dictionary) -> bool:
if index < 0 or index >= _authored_queue.size():
return false
var action_id: int = _action_ids[index]
var status: int = _status_for_action_id(action_id)
if _session_started and _paused and (status == QueueStatus.COMPLETED or status == QueueStatus.INTERRUPTED):
return false
if _session_started and _paused and status == QueueStatus.ACTIVE:
_cancel_current_primary(true)
_current_action_id = -1
_runner_state = RunnerState.IDLE
_authored_queue[index] = action.duplicate(true)
if _session_started and _paused:
_completed_ids.erase(action_id)
_interrupted_ids.erase(action_id)
_rebuild_pending_runtime_queue()
queue_changed.emit()
return true
var stable_id: String = String(_authored_queue[index].get("action_id", ""))
var replacement: Dictionary = action.duplicate(true)
if ACTION_REGISTRY.is_move_action(replacement) and replacement.get("target", null) is Vector2:
replacement["destination"] = {"kind": "place", "position": replacement["target"]}
replacement.erase("target")
return replace_plan_action(stable_id, replacement)
func move_action(from_index: int, to_index: int) -> bool:
if from_index < 0 or from_index >= _authored_queue.size() or to_index < 0 or to_index >= _authored_queue.size():
return false
if _session_started and _paused:
var id: int = _action_ids[from_index]
if _status_for_action_id(id) != QueueStatus.PENDING:
return false
if to_index <= _paused_history_floor():
return false
if from_index == to_index:
return true
var action: Dictionary = _authored_queue.pop_at(from_index)
var action_id: int = _action_ids.pop_at(from_index)
_authored_queue.insert(to_index, action)
_action_ids.insert(to_index, action_id)
if _session_started and _paused:
_rebuild_pending_runtime_queue()
queue_changed.emit()
return true
var source_id: String = String(_authored_queue[from_index].get("action_id", ""))
var target_id: String = String(_authored_queue[to_index].get("action_id", ""))
var source: Dictionary = find_action(source_id)
var target: Dictionary = find_action(target_id)
if source.is_empty() or target.is_empty():
return false
return move_plan_step(int(source["step_index"]), int(target["step_index"]))
func get_queue_view() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for index: int in _authored_queue.size():
var action_id: int = _action_ids[index] if index < _action_ids.size() else -1
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id})
result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id, "stable_action_id": String(_authored_queue[index].get("action_id", "")), "step_index": int(_authored_queue[index].get("step_index", -1))})
return result
@@ -266,7 +524,10 @@ func clear_pause_state() -> void:
_paused = false
_completed_ids.clear()
_interrupted_ids.clear()
_stable_status.clear()
_parallel_active_numeric_ids.clear()
_current_action_id = -1
_current_stable_action_id = ""
_session_started = false
@@ -360,10 +621,17 @@ func stop_queue() -> void:
_action_phase = ActionPhase.NONE
_current_index = -1
_current_action_id = -1
_current_stable_action_id = ""
_current_step_index = -1
_active_step_entry.clear()
_group_say_action_id = ""
_group_say_action.clear()
_parallel_active_numeric_ids.clear()
_paused = false
_session_started = false
_completed_ids.clear()
_interrupted_ids.clear()
_stable_status.clear()
func is_queue_running() -> bool:
@@ -381,8 +649,12 @@ func _advance_to_next_action() -> void:
_current_index = -1
queue_finished.emit()
return
_current_run_item = _run_queue[_run_cursor]
var next_item: Dictionary = _run_queue[_run_cursor]
_run_cursor += 1
if next_item.has("actions"):
_start_plan_step(next_item)
return
_current_run_item = next_item
_current_index = int(_current_run_item.get("queue_index", -1))
_current_action_id = int(_current_run_item.get("action_id", -1))
_current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1
@@ -418,6 +690,121 @@ func _advance_to_next_action() -> void:
_begin_action(action, legacy_speed_override)
func _start_plan_step(step_entry: Dictionary) -> void:
_active_step_entry = step_entry.duplicate(true)
_current_step_index = int(step_entry.get("step_index", -1))
_group_primary_done = false
_group_say_barrier = false
_group_say_action_id = ""
_group_say_numeric_id = -1
_group_say_action.clear()
var raw_actions: Array = step_entry.get("actions", [])
var primary_action: Dictionary = {}
var primary_id: String = ""
var primary_numeric_id: int = -1
var speech_started: bool = false
for action_index: int in raw_actions.size():
var raw_action: Variant = raw_actions[action_index]
if not raw_action is Dictionary:
continue
var authored_action: Dictionary = raw_action as Dictionary
var action_id: String = String(authored_action.get("action_id", ""))
var numeric_id: int = _numeric_for_stable_id(action_id)
var runtime_action: Dictionary = _runtime_action_for_plan(authored_action)
_stable_status[action_id] = &"active"
_parallel_active_numeric_ids[numeric_id] = true
var flat_index: int = _flat_index_for_action(action_id)
planned_action_started.emit(action_id, runtime_action.duplicate(true), _current_step_index)
action_started.emit(runtime_action.duplicate(true), flat_index)
if String(authored_action.get("type", "")) == "speak" and raw_actions.size() == 2:
speech_started = true
speak_overlay_action(runtime_action)
if bool(authored_action.get("legacy_nonblocking", false)):
_stable_status[action_id] = &"succeeded"
_completed_ids[numeric_id] = true
_parallel_active_numeric_ids.erase(numeric_id)
planned_action_ended.emit(action_id, &"succeeded", _current_step_index)
action_finished.emit(runtime_action.duplicate(true), flat_index)
else:
_group_say_action_id = action_id
_group_say_numeric_id = numeric_id
_group_say_step_index = _current_step_index
_group_say_action = runtime_action.duplicate(true)
_group_say_barrier = true
elif primary_action.is_empty():
primary_action = runtime_action
primary_id = action_id
primary_numeric_id = numeric_id
if primary_action.is_empty():
_complete_active_step()
return
_current_run_item = {
"action": primary_action.duplicate(true),
"queue_index": _flat_index_for_action(primary_id),
"action_id": primary_numeric_id,
"stable_action_id": primary_id,
"step_index": _current_step_index,
"reactive": false,
}
_current_action_id = primary_numeric_id
_current_stable_action_id = primary_id
_current_index = int(_current_run_item.get("queue_index", -1))
var action: Dictionary = primary_action.duplicate(true)
if ACTION_REGISTRY.is_move_action(action):
action["type"] = ACTION_REGISTRY.MOVE_TO
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
action["style"] = style if not style.is_empty() else ACTION_REGISTRY.WALK
_begin_action(action)
if not speech_started and String(primary_action.get("type", "")) == "speak":
# Singleton Say is executed by the primary speech state, not an overlay.
_group_say_action_id = ""
func _runtime_action_for_plan(action: Dictionary) -> Dictionary:
var runtime_action: Dictionary = action.duplicate(true)
if ACTION_REGISTRY.is_move_action(runtime_action):
var destination_value: Variant = runtime_action.get("destination", {})
if destination_value is Dictionary:
var destination: Dictionary = destination_value
var point_value: Variant = destination.get("position", destination.get("last_known_approach", Vector2.ZERO))
runtime_action["target"] = point_value
return runtime_action
func _complete_active_step() -> void:
_active_step_entry.clear()
_current_step_index = -1
_group_primary_done = false
_group_say_barrier = false
_action_phase = ActionPhase.NONE
func _flat_index_for_action(action_id: String) -> int:
for index: int in _authored_queue.size():
if String(_authored_queue[index].get("action_id", "")) == action_id:
return index
return -1
func _stable_for_numeric_id(numeric_id: int) -> String:
for stable_id: Variant in _stable_to_numeric_ids:
if int(_stable_to_numeric_ids[stable_id]) == numeric_id:
return String(stable_id)
return ""
func _step_boundary_for_flat_index(flat_index: int) -> int:
var cursor: int = 0
for step_index: int in _authored_steps.size():
var actions: Array = _authored_steps[step_index].get("actions", [])
if flat_index <= cursor:
return step_index
if flat_index < cursor + actions.size():
return step_index + 1
cursor += actions.size()
return _authored_steps.size()
func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> void:
match String(action.get("type", "")):
"move_to":
@@ -425,8 +812,15 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
if style.is_empty():
style = ACTION_REGISTRY.WALK
if _locomotion == null or not _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override):
_finish_action()
var destination_value: Variant = action.get("destination", {})
var move_started: bool = false
if destination_value is Dictionary and String((destination_value as Dictionary).get("kind", "")) == "prop":
move_started = _start_prop_pursuit(action, destination_value as Dictionary, style, legacy_speed_override)
elif _locomotion != null:
move_started = _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override)
if not move_started:
_end_prop_pursuit()
_finish_action(&"failed")
"speak":
if bool(action.get("keep_acting", false)):
speak_overlay_action(action)
@@ -445,34 +839,142 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v
recovery_requested.emit()
"pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop":
if _rig == null or not _rig.start_prop_action(action):
_finish_action()
_finish_action(&"failed")
else:
_prop_action_finished = false
_action_phase = ActionPhase.PROP_INTERACTION
_:
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
_finish_action(&"failed")
func _finish_action() -> void:
func _start_prop_pursuit(action: Dictionary, destination: Dictionary, style: String, speed_override: float) -> bool:
var prop_id_value: Variant = destination.get("target_prop_id", null)
var offset_value: Variant = destination.get("approach_offset", null)
if not prop_id_value is String or not offset_value is Vector2 or not (offset_value as Vector2).is_finite():
return false
var prop: PropBlock = _resolve_pursuit_prop(String(prop_id_value))
if prop == null or _locomotion == null:
return false
var approach: Vector2 = prop.global_position + (offset_value as Vector2)
var projected: Vector2 = _locomotion.project_target_to_navigation(approach)
if not projected.is_finite():
return false
_pursuit_action_id = String(action.get("action_id", _current_stable_action_id))
_pursuit_prop_id = String(prop_id_value)
_pursuit_offset = offset_value as Vector2
_pursuit_last_prop_position = prop.global_position
_pursuit_approach = projected
_pursuit_elapsed = 0.0
_pursuit_retarget_elapsed = 0.0
return _locomotion.move_to(projected, style, speed_override)
func _resolve_pursuit_prop(prop_id: String) -> PropBlock:
if prop_id.is_empty() or _rig == null or not is_instance_valid(_rig):
return null
var world: Node = _rig.get_parent()
if world == null:
return null
for child: Node in world.get_children():
if child is PropBlock and not child.is_queued_for_deletion() and String((child as PropBlock).stage_object_id) == prop_id:
return child as PropBlock
if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == prop_id:
return null
return null
func _check_prop_pursuit_arrival() -> void:
var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id)
if prop == null or _locomotion == null or _rig == null:
_end_prop_pursuit()
_finish_action(&"failed")
return
var projected: Vector2 = _locomotion.project_target_to_navigation(prop.global_position + _pursuit_offset)
if not projected.is_finite():
_end_prop_pursuit()
_finish_action(&"failed")
return
var actor_feet: Vector2 = _rig.global_position - _locomotion.get_foot_offset()
if actor_feet.distance_to(projected) <= 28.0 and actor_feet.distance_to(prop.global_position) <= PROP_INTERACTION_REACH:
_end_prop_pursuit()
_finish_action(&"succeeded")
return
if actor_feet.distance_to(projected) > 28.0:
var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK)))
var restarted: bool = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style)
if restarted:
_pursuit_approach = projected
_pursuit_last_prop_position = prop.global_position
_pursuit_retarget_elapsed = 0.0
_action_phase = ActionPhase.WALKING
return
_end_prop_pursuit()
_finish_action(&"failed")
return
_locomotion.cancel_walk(false)
_pursuit_approach = projected
_pursuit_last_prop_position = prop.global_position
_pursuit_retarget_elapsed = 0.0
_action_phase = ActionPhase.PURSUIT_WAIT
func _end_prop_pursuit() -> void:
_pursuit_action_id = ""
_pursuit_prop_id = ""
_pursuit_offset = Vector2.ZERO
_pursuit_last_prop_position = Vector2.ZERO
_pursuit_approach = Vector2.INF
_pursuit_elapsed = 0.0
_pursuit_retarget_elapsed = 0.0
func _finish_action(outcome: StringName = &"succeeded") -> void:
var finished: Dictionary = _current_run_item.get("action", {})
var index: int = int(_current_run_item.get("queue_index", -1))
var action_id: int = int(_current_run_item.get("action_id", -1))
var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id))
if stable_id == _pursuit_action_id:
_end_prop_pursuit()
if action_id >= 0:
_completed_ids[action_id] = true
index = _index_for_action_id(action_id) if action_id >= 0 else -1
if outcome == &"cancelled":
_interrupted_ids[action_id] = true
else:
_completed_ids[action_id] = true
if not stable_id.is_empty():
_stable_status[stable_id] = outcome
_parallel_active_numeric_ids.erase(action_id)
planned_action_ended.emit(stable_id, outcome, int(_current_run_item.get("step_index", _current_step_index)))
index = _flat_index_for_action(stable_id) if not stable_id.is_empty() else (_index_for_action_id(action_id) if action_id >= 0 else -1)
_action_phase = ActionPhase.NONE
if not finished.is_empty():
action_finished.emit(finished, index)
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
if not finished.is_empty():
action_finished.emit(finished, index)
_current_stable_action_id = ""
if not _active_step_entry.is_empty():
_group_primary_done = true
if _group_say_barrier and not _group_say_action_id.is_empty():
_action_phase = ActionPhase.GROUP_BARRIER
else:
_complete_active_step()
func _cancel_current_primary(notify: bool = false) -> void:
var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id))
var step_index: int = int(_current_run_item.get("step_index", _current_step_index))
var numeric_id: int = int(_current_run_item.get("action_id", _current_action_id))
if stable_id == _pursuit_action_id:
_end_prop_pursuit()
if notify and not _current_run_item.is_empty():
var action: Dictionary = _current_run_item.get("action", {})
active_action_cancelled.emit(action.duplicate(true))
if not stable_id.is_empty():
_stable_status[stable_id] = &"cancelled"
_parallel_active_numeric_ids.erase(numeric_id)
_interrupted_ids[numeric_id] = true
planned_action_ended.emit(stable_id, &"cancelled", step_index)
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
if _rig != null:
@@ -484,6 +986,7 @@ func _cancel_current_primary(notify: bool = false) -> void:
_current_run_item.clear()
_current_index = -1
_current_action_id = -1
_current_stable_action_id = ""
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
@@ -502,13 +1005,25 @@ func _start_session_runtime() -> void:
_session_started = true
_completed_ids.clear()
_interrupted_ids.clear()
_action_ids.clear()
for _action: Dictionary in _authored_queue:
_action_ids.append(_allocate_action_id())
_stable_status.clear()
_parallel_active_numeric_ids.clear()
for action: Dictionary in _authored_queue:
_stable_status[String(action.get("action_id", ""))] = &"pending"
func _runtime_item(action_id: int, action: Dictionary, index: int) -> Dictionary:
return {"action": action.duplicate(true), "queue_index": index, "action_id": action_id, "reactive": false}
func _runtime_step_item(step_index: int, step: Dictionary) -> Dictionary:
var actions: Array[Dictionary] = []
var stable_ids: Array[String] = []
var numeric_ids: Array[int] = []
for raw_action: Variant in step.get("actions", []):
if not raw_action is Dictionary:
continue
var action: Dictionary = raw_action as Dictionary
var stable_id: String = String(action.get("action_id", ""))
actions.append(action.duplicate(true))
stable_ids.append(stable_id)
numeric_ids.append(_numeric_for_stable_id(stable_id))
return {"step_index": step_index, "actions": actions, "stable_ids": stable_ids, "numeric_ids": numeric_ids, "reactive": false}
func _rebuild_pending_runtime_queue() -> void:
@@ -518,11 +1033,19 @@ func _rebuild_pending_runtime_queue() -> void:
var runtime_item: Dictionary = _run_queue[runtime_index]
if bool(runtime_item.get("reactive", false)):
retained_reactive.append(runtime_item.duplicate(true))
for index: int in _authored_queue.size():
var action_id: int = _action_ids[index]
var status: int = _status_for_action_id(action_id)
if status == QueueStatus.PENDING or status == QueueStatus.NOT_STARTED:
rebuilt.append(_runtime_item(action_id, _authored_queue[index], index))
for step_index: int in _authored_steps.size():
var step: Dictionary = _authored_steps[step_index]
var all_pending: bool = true
for raw_action: Variant in step.get("actions", []):
if not raw_action is Dictionary:
all_pending = false
break
var status: StringName = _status_for_stable_id(String((raw_action as Dictionary).get("action_id", "")))
if status not in [&"pending", &"not_started"]:
all_pending = false
break
if all_pending:
rebuilt.append(_runtime_step_item(step_index, step))
_run_queue = retained_reactive + rebuilt
_run_cursor = 0
if not _run_queue.is_empty() and _session_started:
@@ -545,15 +1068,14 @@ func _index_for_action_id(action_id: int) -> int:
func _status_for_action_id(action_id: int) -> int:
if action_id < 0:
return QueueStatus.NOT_STARTED
if _completed_ids.has(action_id):
return QueueStatus.COMPLETED
if _interrupted_ids.has(action_id):
return QueueStatus.INTERRUPTED
if action_id == _current_action_id:
return QueueStatus.ACTIVE
if _session_started:
return QueueStatus.PENDING
return QueueStatus.NOT_STARTED
var stable_id: String = _stable_for_numeric_id(action_id)
var status: StringName = _status_for_stable_id(stable_id) if not stable_id.is_empty() else &"not_started"
match status:
&"succeeded", &"failed": return QueueStatus.COMPLETED
&"cancelled": return QueueStatus.INTERRUPTED
&"active": return QueueStatus.ACTIVE
&"pending": return QueueStatus.PENDING
_: return QueueStatus.NOT_STARTED
func _is_blocked() -> bool:
@@ -565,6 +1087,7 @@ func _update_speech(delta: float) -> void:
_overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta
if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)):
_overlay_speech.clear()
_finish_group_speech()
speech_finished.emit()
elif not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta
@@ -578,6 +1101,25 @@ func _update_speech(delta: float) -> void:
_show_visible_speech()
func _finish_group_speech() -> void:
if _group_say_action_id.is_empty():
return
var action_id: String = _group_say_action_id
var numeric_id: int = _group_say_numeric_id
var step_index: int = _group_say_step_index
var action: Dictionary = _group_say_action.duplicate(true)
_stable_status[action_id] = &"succeeded"
_completed_ids[numeric_id] = true
_parallel_active_numeric_ids.erase(numeric_id)
planned_action_ended.emit(action_id, &"succeeded", step_index)
action_finished.emit(action, _flat_index_for_action(action_id))
_group_say_action_id = ""
_group_say_numeric_id = -1
_group_say_step_index = -1
_group_say_action.clear()
_group_say_barrier = false
func _show_visible_speech() -> void:
if _rig == null:
return
@@ -161,6 +161,7 @@ var collision_lengths: Dictionary = {}
var collision_proxy_velocities: Dictionary = {}
var _collision_proxy_positions: Dictionary = {}
var _last_sweep_hit_tick: Dictionary = {}
var _owner_collision_grace_props: Dictionary = {}
var _actor_prop_debug_last_sample_frame: int = -1
var _actor_prop_debug_last_hit_tick: Dictionary = {}
var _collision_visuals: Dictionary = {}
@@ -196,6 +197,7 @@ func setup_collision_bodies() -> void:
collision_proxy_velocities.clear()
_collision_proxy_positions.clear()
_last_sweep_hit_tick.clear()
_owner_collision_grace_props.clear()
_collision_visuals.clear()
reaction_bones.clear()
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
@@ -470,6 +472,34 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri
_walk_latch_probe_frames = 0
return true
func project_target_to_navigation(target: Vector2) -> Vector2:
if _nav_agent == null or not target.is_finite():
return Vector2.INF
var navigation_map: RID = _nav_agent.get_navigation_map()
if not navigation_map.is_valid() or NavigationServer2D.map_get_iteration_id(navigation_map) == 0:
return Vector2.INF
var projected: Vector2 = NavigationServer2D.map_get_closest_point(navigation_map, target)
return projected if projected.is_finite() else Vector2.INF
func retarget_walk(target: Vector2) -> bool:
if _rig == null or _nav_agent == null or not target.is_finite() or (not _walking and not _settling):
return false
_walk_target_feet = target
_nav_agent.target_position = target
if _settling:
_settling = false
_walk_done = false
_arrival_emitted = false
_walk_settle_elapsed = 0.0
_walk_settle_start_pose.clear()
_walk_settle_end_pose.clear()
_walking = true
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name):
_rig._anim_player.play(_active_animation_name)
return true
func cancel_walk(restore_idle_pose: bool = true) -> void:
if _rig == null:
return
@@ -624,6 +654,15 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
for own_proxy: AnimatableBody2D in collision_bodies.values():
if own_proxy != null and is_instance_valid(own_proxy):
excluded.append(own_proxy.get_rid())
for prop_id: Variant in _owner_collision_grace_props.keys():
var prop_ref: WeakRef = _owner_collision_grace_props.get(prop_id) as WeakRef
var grace_prop: PropBlock = prop_ref.get_ref() as PropBlock if prop_ref != null else null
if grace_prop == null or not is_instance_valid(grace_prop) or not grace_prop.is_owner_collision_grace_active_for(_rig):
_owner_collision_grace_props.erase(prop_id)
continue
var grace_prop_rid: RID = grace_prop.get_rid()
if grace_prop_rid.is_valid() and not excluded.has(grace_prop_rid):
excluded.append(grace_prop_rid)
var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null
if held != null and is_instance_valid(held):
excluded.append(held.get_rid())
@@ -779,6 +818,18 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary:
return {"fraction": safe_fraction}
func register_owner_collision_grace_prop(prop: PropBlock) -> void:
if prop == null or not is_instance_valid(prop):
return
_owner_collision_grace_props[prop.get_instance_id()] = weakref(prop)
func unregister_owner_collision_grace_prop(prop: PropBlock) -> void:
if prop == null:
return
_owner_collision_grace_props.erase(prop.get_instance_id())
func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock:
# PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than
# the `collider` object key used by ray/shape intersection queries.
@@ -955,6 +955,7 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary)
_hand = int(picked["hand"])
_held_prop = prop
_held_prop.interaction_reserved_by = _rig.get_instance_id()
_held_prop.begin_owner_collision_grace(_rig, true)
_active_action = action.duplicate(true)
_approach_replans = 0
var marker := _hand_marker()
@@ -1028,7 +1029,7 @@ func _begin_set_down(action: Dictionary) -> bool:
_rig.call("set_facing_profile", 0 if _set_down_facing_left else 1)
var foot_offset: Vector2 = _locomotion.call("get_foot_offset")
var target_feet := _set_down_approach_root - foot_offset
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)):
if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.WALK)):
_active_action.clear()
prop_event.emit("set_down_failed", _held_prop, "Too far to reach")
return false
@@ -1930,12 +1931,18 @@ func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vec
release_velocity = Vector2.ZERO
if not is_finite(release_angular):
release_angular = 0.0
if reason == "scene_reset":
prop.clear_owner_collision_grace()
elif _rig != null and is_instance_valid(_rig) and prop.interaction_reserved_by == _rig.get_instance_id():
prop.release_owner_collision_grace(_rig)
prop.interaction_owner_id = 0
prop.interaction_reserved_by = 0
var target_role := PropBlock.RuntimeRole.RELEASED
if reason == "scene_reset":
target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL
prop.apply_runtime_role(target_role)
if reason == "set_down" and _rig != null and is_instance_valid(_rig):
prop.begin_owner_collision_grace(_rig)
prop.sleeping = false
prop.linear_velocity = release_velocity
prop.angular_velocity = release_angular
@@ -1983,6 +1990,7 @@ func _clear_reservation() -> void:
if _held_prop != null and is_instance_valid(_held_prop):
if _held_prop.interaction_reserved_by == _rig.get_instance_id():
_held_prop.interaction_reserved_by = 0
_held_prop.release_owner_collision_grace(_rig)
_held_prop = null
_held_anchor.clear()
_active_action.clear()
+68 -2
View File
@@ -182,6 +182,9 @@ signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished # queue ran to completion (not on stop)
signal queue_changed # any queue mutation
signal action_plan_changed
signal planned_action_started(action_id: String, action: Dictionary, step_index: int)
signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)
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)
@@ -215,6 +218,8 @@ var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lo
# ---------------------------------------------------------------------------
var _pending_action_queue: Array[Dictionary] = []
var _pending_action_plan: Array[Dictionary] = []
var _has_pending_action_plan: bool = false
var action_queue: Array[Dictionary]:
get:
return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue
@@ -262,7 +267,10 @@ func _ready() -> void:
push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.")
if _locomotion_component != null:
_locomotion_component.configure(self)
_locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target))
_locomotion_component.arrived.connect(func(target: Vector2) -> void:
if _action_runner_component == null or not _action_runner_component.is_prop_pursuit_active():
arrived.emit(target)
)
_locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit)
if _ragdoll_component != null:
_ragdoll_component.configure(self, _locomotion_component)
@@ -275,11 +283,17 @@ func _ready() -> void:
_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
if _has_pending_action_plan:
_action_runner_component.set_action_plan(_pending_action_plan)
else:
_action_runner_component.action_queue = _pending_action_queue
_action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index))
_action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index))
_action_runner_component.queue_finished.connect(func() -> void: queue_finished.emit())
_action_runner_component.queue_changed.connect(func() -> void: queue_changed.emit())
_action_runner_component.plan_changed.connect(func() -> void: action_plan_changed.emit())
_action_runner_component.planned_action_started.connect(func(action_id: String, action: Dictionary, step_index: int) -> void: planned_action_started.emit(action_id, action, step_index))
_action_runner_component.planned_action_ended.connect(func(action_id: String, outcome: StringName, step_index: int) -> void: planned_action_ended.emit(action_id, outcome, step_index))
_action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit())
_action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled))
_action_runner_component.recovery_requested.connect(func() -> void: request_recovery())
@@ -336,6 +350,8 @@ func _physics_process(delta: float) -> void:
_sample_ragdoll_velocity_baselines()
if _ragdoll_component != null:
_ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover)
if _action_runner_component != null:
_action_runner_component.pre_movement(delta)
if _locomotion_component != null:
_locomotion_component.tick_movement(delta, int(state))
if _prop_interactor_component != null:
@@ -947,6 +963,56 @@ func get_queue() -> Array[Dictionary]:
return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true)
func get_action_plan() -> Array[Dictionary]:
if _action_runner_component != null:
return _action_runner_component.get_action_plan()
return _pending_action_plan.duplicate(true)
func set_action_plan(steps: Array[Dictionary]) -> bool:
if _action_runner_component != null:
return _action_runner_component.set_action_plan(steps)
_pending_action_plan = steps.duplicate(true)
_has_pending_action_plan = true
return true
func find_action(action_id: String) -> Dictionary:
return _action_runner_component.find_action(action_id) if _action_runner_component != null else {}
func get_plan_view() -> Array[Dictionary]:
return _action_runner_component.get_plan_view() if _action_runner_component != null else []
func add_action_step(action: Dictionary) -> String:
return _action_runner_component.add_plan_step(action) if _action_runner_component != null else ""
func add_action_together(step_index: int, action: Dictionary) -> String:
return _action_runner_component.add_plan_action_together(step_index, action) if _action_runner_component != null else ""
func replace_planned_action(action_id: String, action: Dictionary) -> bool:
return _action_runner_component.replace_plan_action(action_id, action) if _action_runner_component != null else false
func remove_planned_action(action_id: String) -> bool:
return _action_runner_component.remove_plan_action(action_id) if _action_runner_component != null else false
func move_plan_step(from_index: int, to_index: int) -> bool:
return _action_runner_component.move_plan_step(from_index, to_index) if _action_runner_component != null else false
func can_add_action_together(step_index: int, action_type: String) -> bool:
return _action_runner_component.can_add_plan_action_together(step_index, action_type) if _action_runner_component != null else false
func can_reorder_plan_step(step_index: int) -> bool:
return _action_runner_component.can_reorder_plan_step(step_index) if _action_runner_component != null else false
func remove_action(index: int) -> void:
if _action_runner_component != null:
_action_runner_component.remove_action(index)
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -37,6 +37,7 @@ var _row_font_size: int = 0
var _title_font_size: int = 0
var _title_font: Font = null
var _use_target_summary_resolver: Callable = Callable()
var _prop_target_name_resolver: Callable = Callable()
func _ready() -> void:
@@ -62,6 +63,11 @@ func set_use_target_summary_resolver(resolver: Callable) -> void:
refresh()
func set_prop_target_name_resolver(resolver: Callable) -> void:
_prop_target_name_resolver = resolver
refresh()
func refresh() -> void:
if _list == null:
return
@@ -151,7 +157,7 @@ func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
var summary := Label.new()
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var action_summary: String = ACTION_REGISTRY.summarize(action)
var action_summary: String = ACTION_REGISTRY.summarize(action, _prop_target_name_resolver)
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", "")),
File diff suppressed because it is too large Load Diff
@@ -283,7 +283,7 @@ func set_down_target(position: Vector2) -> void:
func show_action_editor() -> void:
if _action_editor != null:
_action_editor.popup_centered()
_action_editor.resume_editor()
func request_action_target_capture() -> void:
@@ -304,7 +304,7 @@ func request_action_target_capture() -> void:
var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk"
_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"))
_emit_capture_request(&"pickup_prop", "Choose a prop to pick up", 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":
File diff suppressed because it is too large Load Diff
+75 -13
View File
@@ -27,7 +27,7 @@ signal reset_requested
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const COMPACT_TOP_BAR_WIDTH: float = 900.0
const COMPACT_TOP_BAR_WIDTH: float = 1100.0
var _stage_theme: SandboxTheme
var _current_mode: int = 0
@@ -39,6 +39,7 @@ var _accent_direct: Color = Color("#FFB300")
var _accent_play: Color = Color("#33DD77")
var _status_pill_font_size: int = 16
var _tooltip_font_size: int = 18
var _direct_hint_requested: bool = false
var _mode_buttons: Dictionary = {}
var _palette_buttons: Dictionary = {}
@@ -58,6 +59,7 @@ var _pause_button: Button
var _resume_button: Button
var _reset_button: Button
var _tooltip: Label
var _action_tooltip_avoid_rect: Rect2 = Rect2()
var _grid_check: CheckBox
var _snap_check: CheckBox
var _grid_size_spin: SpinBox
@@ -293,21 +295,76 @@ func set_action_tooltip(visible: bool, text_value: String = "") -> void:
_tooltip.visible = visible
if not visible:
return
_tooltip.text = text_value
_tooltip.text = _capture_tooltip_text(text_value)
var style: StyleBox = _tooltip.get_theme_stylebox("normal")
var extra: Vector2 = style.get_minimum_size() if style != null else Vector2.ZERO
_tooltip.size = _tooltip.get_minimum_size() + extra
var required_size: Vector2 = _tooltip.get_minimum_size() + extra
var tooltip_width: float = clampf(required_size.x, 320.0, 420.0)
_tooltip.size = Vector2(tooltip_width, maxf(required_size.y, 50.0))
var mouse: Vector2 = get_viewport().get_mouse_position()
var size: Vector2 = _tooltip.size
var position: Vector2 = mouse + Vector2(20.0, -20.0)
var viewport_size: Vector2 = get_viewport().get_visible_rect().size
if position.x + size.x > viewport_size.x:
position.x = mouse.x - size.x - 20.0
if position.y + size.y > viewport_size.y:
position.y = mouse.y - size.y - 20.0
if position.y < 0.0:
position.y = mouse.y + 20.0
_tooltip.position = position
var avoid_rects: Array[Rect2] = []
if _action_tooltip_avoid_rect.has_area():
avoid_rects.append(_action_tooltip_avoid_rect)
if _mode_badge != null and _mode_badge.visible:
avoid_rects.append(_mode_badge.get_global_rect().grow(6.0))
if _timestamp_badge != null and _timestamp_badge.visible:
avoid_rects.append(_timestamp_badge.get_global_rect().grow(6.0))
if _palette_dock != null and _palette_dock.visible:
avoid_rects.append(_palette_dock.get_global_rect().grow(6.0))
_tooltip.position = _find_action_tooltip_position(mouse, size, viewport_size, avoid_rects)
func _find_action_tooltip_position(mouse: Vector2, size: Vector2, viewport_size: Vector2, avoid_rects: Array[Rect2]) -> Vector2:
var min_x: float = 8.0
var max_x: float = maxf(min_x, viewport_size.x - size.x - 8.0)
var min_y: float = TOP_BAR_HEIGHT + 8.0
var max_y: float = maxf(min_y, viewport_size.y - size.y - 8.0)
var candidates: Array[Vector2] = [
mouse + Vector2(20.0, -20.0),
mouse + Vector2(-size.x - 20.0, -20.0),
mouse + Vector2(20.0, 20.0),
mouse + Vector2(-size.x - 20.0, 20.0),
]
for avoid_rect: Rect2 in avoid_rects:
candidates.append(Vector2(avoid_rect.position.x - size.x - 12.0, avoid_rect.get_center().y - size.y * 0.5))
candidates.append(Vector2(avoid_rect.end.x + 12.0, avoid_rect.get_center().y - size.y * 0.5))
candidates.append(Vector2(avoid_rect.get_center().x - size.x * 0.5, avoid_rect.position.y - size.y - 12.0))
candidates.append(Vector2(avoid_rect.get_center().x - size.x * 0.5, avoid_rect.end.y + 12.0))
candidates.append(Vector2(min_x, min_y))
candidates.append(Vector2(max_x, min_y))
candidates.append(Vector2(min_x, max_y))
candidates.append(Vector2(max_x, max_y))
for candidate: Vector2 in candidates:
var position: Vector2 = Vector2(clampf(candidate.x, min_x, max_x), clampf(candidate.y, min_y, max_y))
var tooltip_rect: Rect2 = Rect2(position, size)
var overlaps: bool = false
for avoid_rect: Rect2 in avoid_rects:
if tooltip_rect.intersects(avoid_rect):
overlaps = true
break
if not overlaps:
return position
return Vector2(min_x, min_y)
func set_action_tooltip_avoid_rect(rect: Rect2) -> void:
_action_tooltip_avoid_rect = rect
func _capture_tooltip_text(text_value: String) -> String:
var task: String = text_value.strip_edges()
for old_copy: String in ["(Esc or RMB to cancel)", "(Esc or right-click to cancel)", "Esc cancels."]:
task = task.replace(old_copy, "").strip_edges()
while task.ends_with("\n"):
task = task.substr(0, task.length() - 1).strip_edges()
var cancel_hint: String = "Press Escape or right-click to cancel"
if task.is_empty():
return cancel_hint
if task.contains(cancel_hint):
return task
return "%s\n%s" % [task, cancel_hint]
func set_edit_controls_visible(visible: bool) -> void:
@@ -326,8 +383,9 @@ func set_edit_controls_visible(visible: bool) -> void:
func set_direct_controls_visible(visible: bool) -> void:
_direct_hint_requested = visible
if _direct_hint_label != null:
_direct_hint_label.visible = visible
_direct_hint_label.visible = visible and get_viewport().get_visible_rect().size.x > COMPACT_TOP_BAR_WIDTH
func is_stage_frame_position(viewport_pos: Vector2) -> bool:
@@ -506,6 +564,8 @@ func _apply_responsive_top_bar_layout() -> void:
return
var viewport_width: float = get_viewport().get_visible_rect().size.x
var compact: bool = viewport_width <= COMPACT_TOP_BAR_WIDTH
if _direct_hint_label != null:
_direct_hint_label.visible = _direct_hint_requested and not compact
if compact:
_file_menu.custom_minimum_size = Vector2(64.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", 18)
@@ -656,7 +716,7 @@ func _build_timestamp_badge() -> void:
_timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_timestamp_badge.offset_left = -220.0
_timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0
_timestamp_badge.offset_right = -12.0
_timestamp_badge.offset_right = -_theme_number("stage_panels.shell.edge_inset", 12.0, 0.0, 48.0)
_timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0
var style := StyleBoxFlat.new()
style.bg_color = _theme_color("chrome.timestamp_badge.bg_color", Color("#171B24F2"))
@@ -683,6 +743,8 @@ func _build_action_tooltip() -> void:
_tooltip.name = "ActionTooltip"
_tooltip.visible = false
_tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE
_tooltip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tooltip.max_lines_visible = 3
var style := StyleBoxFlat.new()
style.bg_color = Color(0.1, 0.1, 0.1, 0.9)
style.border_color = _accent_direct
@@ -75,7 +75,7 @@ func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_fo
_action_popup.add_separator()
_action_popup.add_item("⚡ When...", int(action_ids.get("when", 5)))
_action_popup.add_separator()
_action_popup.add_item("📋 Edit Queue…", int(action_ids.get("edit_queue", 7)))
_action_popup.add_item("📋 Step Stack…", int(action_ids.get("edit_queue", 7)))
_action_popup.add_item("⚡ Edit Rules…", int(action_ids.get("edit_rules", 8)))
_action_popup.id_pressed.connect(_on_action_popup_id_pressed)
_action_popup.about_to_popup.connect(_on_action_popup_about_to_popup)
@@ -308,7 +308,7 @@ func refresh_action_popup_items(rules: Array[Dictionary]) -> void:
if _action_popup == null:
return
var valid: bool = context_rig != null and is_instance_valid(context_rig)
_disable_popup_item(_action_popup, 7, not valid or context_rig.get_queue().is_empty())
_disable_popup_item(_action_popup, 7, not valid)
_disable_popup_item(_action_popup, 8, not valid or not rig_has_rules(context_rig, rules))
@@ -316,7 +316,7 @@ func refresh_rig_context_items(rig: StickmanRig, popup: PopupMenu, rules: Array[
if popup == null:
return
var valid: bool = rig != null and is_instance_valid(rig)
_disable_popup_item(popup, 0, not valid or rig.get_queue().is_empty())
_disable_popup_item(popup, 0, not valid)
_disable_popup_item(popup, 1, not valid or not rig_has_rules(rig, rules))
@@ -455,10 +455,13 @@ func update_action_overlay(
if awaiting:
var origin: Vector2 = action_origin(capture_kind, set_down_rig, throw_rig, throw_cache, _action_queue.panel_rig if _action_queue != null else null)
var target: Vector2 = _camera.get_global_mouse_position() if _camera != null else Vector2.ZERO
var movement_target_capture: bool = capture_kind == "position" or pending_walk_target or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)
if snap_enabled and (capture_kind in ["position", "throw", "set_down"] or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)):
if grid_size > 0.0:
target = Vector2(roundf(target.x / grid_size) * grid_size, roundf(target.y / grid_size) * grid_size)
if capture_kind == "set_down":
if capture_kind in ["plan_place", "plan_prop"]:
_placement_overlay.set_action_marker(target)
elif capture_kind == "set_down":
var known: bool = bool(set_down_cache.get("known", false))
var valid: bool = bool(set_down_cache.get("valid", false))
if known:
@@ -487,11 +490,15 @@ func update_action_overlay(
throw_preview["start_position"], target, throw_preview["landing_position"], target_reachable,
trajectory_points, effective_target, collision_position, preview_planned, preview_uncertain
)
elif movement_target_capture:
_placement_overlay.set_grounded_action_trajectory(origin, target, is_target_valid(target))
else:
_placement_overlay.set_action_trajectory(origin, target, is_target_valid(target))
# Object/actor/rule target selection needs a cursor marker, not a
# movement-looking line from the actor to the hovered target.
_placement_overlay.set_action_marker(target)
elif direct_move_target.is_finite() and context_rig != null and is_instance_valid(context_rig):
var origin: Vector2 = context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET
_placement_overlay.set_action_trajectory(origin, direct_move_target, true)
_placement_overlay.set_grounded_action_trajectory(origin, direct_move_target, true)
else:
_placement_overlay.clear_action()
if _chrome != null:
@@ -0,0 +1,287 @@
class_name StageInspectorCaptureOverlay
extends Control
## Keeps an active world-target choice visible beside the inspector while
## dimming and blocking the inspector controls underneath it.
signal capture_action_requested(action_id: StringName)
const CAPTURE_STRIP_HEIGHT: float = 72.0
const CAPTURE_MULTI_ACTION_STRIP_HEIGHT: float = 124.0
const CAPTURE_BUTTON_HEIGHT: float = 48.0
const CAPTURE_CANCEL_WIDTH: float = 84.0
const CAPTURE_ACTION_WIDTH: float = 112.0
var _stage_theme: SandboxTheme
var _inspector: Control
var _inspector_scrim: Panel
var _capture_strip: PanelContainer
var _content_column: VBoxContainer
var _title_row: HBoxContainer
var _caption: Label
var _caption_base_font_size: int = 17
var _actions_row: HBoxContainer
var _action_buttons: Array[Button] = []
var _active_signature: String = ""
var _focus_before_capture: Control
func configure(stage_theme: SandboxTheme) -> void:
_stage_theme = stage_theme
func _ready() -> void:
name = "StageInspectorCaptureOverlay"
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
z_index = 120
visible = false
_build_controls()
get_viewport().size_changed.connect(_layout_capture_overlay)
func _process(_delta: float) -> void:
_layout_capture_overlay()
func show_capture(inspector: Control, prompt: String, actions: Array[Dictionary]) -> bool:
if inspector == null or not is_instance_valid(inspector) or not inspector.is_visible_in_tree():
hide_capture()
return false
var normalized_actions: Array[Dictionary] = actions.duplicate(true)
if normalized_actions.is_empty():
normalized_actions.append({"id": "cancel_capture", "label": "Cancel", "primary": true})
_set_stacked_actions(normalized_actions.size() > 1)
var action_signature: Array[String] = []
for action: Dictionary in normalized_actions:
action_signature.append("%s:%s" % [String(action.get("id", "")), String(action.get("label", ""))])
var signature: String = "%d|%s|%s" % [inspector.get_instance_id(), prompt, ";".join(action_signature)]
if _active_signature != signature:
if _active_signature.is_empty():
_focus_before_capture = get_viewport().gui_get_focus_owner()
_active_signature = signature
_inspector = inspector
_caption.text = prompt
_rebuild_actions(normalized_actions)
visible = true
if not _action_buttons.is_empty():
_action_buttons[_action_buttons.size() - 1].grab_focus()
_layout_capture_overlay()
return true
func hide_capture() -> void:
if _active_signature.is_empty():
return
_active_signature = ""
_inspector = null
visible = false
for button: Button in _action_buttons:
button.queue_free()
_action_buttons.clear()
if _focus_before_capture != null and is_instance_valid(_focus_before_capture) and _focus_before_capture.is_visible_in_tree():
_focus_before_capture.grab_focus()
_focus_before_capture = null
func has_capture() -> bool:
return not _active_signature.is_empty()
func get_inspector_rect() -> Rect2:
if not has_capture() or _inspector == null or not is_instance_valid(_inspector):
return Rect2()
return _inspector.get_global_rect()
func _build_controls() -> void:
_inspector_scrim = Panel.new()
_inspector_scrim.name = "InspectorInputBlocker"
_inspector_scrim.mouse_filter = Control.MOUSE_FILTER_STOP
_inspector_scrim.focus_mode = Control.FOCUS_NONE
_inspector_scrim.gui_input.connect(_on_capture_area_gui_input)
var scrim_style: StyleBoxFlat = StyleBoxFlat.new()
scrim_style.bg_color = Color(0.015, 0.02, 0.035, 0.56)
scrim_style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 8, 32))
_inspector_scrim.add_theme_stylebox_override("panel", scrim_style)
add_child(_inspector_scrim)
_capture_strip = PanelContainer.new()
_capture_strip.name = "CaptureCancelStrip"
_capture_strip.mouse_filter = Control.MOUSE_FILTER_STOP
_capture_strip.gui_input.connect(_on_capture_area_gui_input)
_capture_strip.add_theme_stylebox_override("panel", _strip_style())
add_child(_capture_strip)
var margin: MarginContainer = MarginContainer.new()
margin.add_theme_constant_override("margin_left", 10)
margin.add_theme_constant_override("margin_right", 10)
margin.add_theme_constant_override("margin_top", 7)
margin.add_theme_constant_override("margin_bottom", 7)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
_capture_strip.add_child(margin)
_content_column = VBoxContainer.new()
_content_column.name = "CaptureContent"
_content_column.add_theme_constant_override("separation", 4)
_content_column.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(_content_column)
_title_row = HBoxContainer.new()
_title_row.name = "CaptureTitleRow"
_title_row.add_theme_constant_override("separation", 8)
_title_row.alignment = BoxContainer.ALIGNMENT_CENTER
_title_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_title_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_content_column.add_child(_title_row)
var marker: Label = Label.new()
marker.text = "⊕"
marker.add_theme_font_size_override("font_size", 25)
marker.add_theme_color_override("font_color", Color("#22C6FF"))
marker.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title_row.add_child(marker)
_caption = Label.new()
_caption.name = "CapturePrompt"
_caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_caption.text = "Choosing a target"
_caption.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_caption.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_caption.clip_text = true
_caption.custom_minimum_size.y = 48.0
_caption_base_font_size = _integer("stage_panels.type.body_size", 17, 14, 28)
_caption.add_theme_font_size_override("font_size", _caption_base_font_size)
_caption.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
_caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL | Control.SIZE_SHRINK_BEGIN
_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title_row.add_child(_caption)
_actions_row = HBoxContainer.new()
_actions_row.name = "CaptureActions"
_actions_row.add_theme_constant_override("separation", 6)
_actions_row.alignment = BoxContainer.ALIGNMENT_END
_actions_row.size_flags_horizontal = Control.SIZE_SHRINK_END
_actions_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title_row.add_child(_actions_row)
func _set_stacked_actions(stacked: bool) -> void:
if _actions_row == null or _content_column == null or _title_row == null:
return
var target_parent: Node = _content_column if stacked else _title_row
if _actions_row.get_parent() != target_parent:
_actions_row.reparent(target_parent)
_actions_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stacked else Control.SIZE_SHRINK_END
_actions_row.alignment = BoxContainer.ALIGNMENT_END
func _rebuild_actions(actions: Array[Dictionary]) -> void:
for button: Button in _action_buttons:
button.queue_free()
_action_buttons.clear()
for action: Dictionary in actions:
var action_id: StringName = StringName(String(action.get("id", "")))
if action_id.is_empty():
continue
var button: Button = Button.new()
button.name = "Capture_%s" % String(action_id)
button.text = String(action.get("label", "Cancel"))
button.tooltip_text = button.text
button.focus_mode = Control.FOCUS_ALL
button.custom_minimum_size = Vector2(CAPTURE_CANCEL_WIDTH if action_id == &"cancel_capture" else CAPTURE_ACTION_WIDTH, CAPTURE_BUTTON_HEIGHT)
button.add_theme_font_size_override("font_size", _integer("stage_panels.action.label_size", 16, 14, 24))
_style_action_button(button, action_id == &"cancel_capture")
button.pressed.connect(_on_action_pressed.bind(action_id))
_actions_row.add_child(button)
_action_buttons.append(button)
_trap_action_focus()
func _trap_action_focus() -> void:
for index: int in _action_buttons.size():
var button: Button = _action_buttons[index]
button.set_focus_neighbor(SIDE_TOP, button.get_path())
button.set_focus_neighbor(SIDE_BOTTOM, button.get_path())
button.set_focus_neighbor(SIDE_LEFT, _action_buttons[maxi(0, index - 1)].get_path())
button.set_focus_neighbor(SIDE_RIGHT, _action_buttons[mini(_action_buttons.size() - 1, index + 1)].get_path())
func _on_action_pressed(action_id: StringName) -> void:
capture_action_requested.emit(action_id)
func _on_capture_area_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
var mouse_button: InputEventMouseButton = event as InputEventMouseButton
if mouse_button.pressed and mouse_button.button_index == MOUSE_BUTTON_RIGHT:
capture_action_requested.emit(&"cancel_capture")
accept_event()
func _layout_capture_overlay() -> void:
if not has_capture() or _inspector == null or not is_instance_valid(_inspector) or not _inspector.is_visible_in_tree():
hide_capture()
return
var viewport_rect: Rect2 = get_viewport().get_visible_rect()
var inspector_rect: Rect2 = _inspector.get_global_rect().intersection(viewport_rect)
if not inspector_rect.has_area():
hide_capture()
return
var caption_font_size: int = mini(_caption_base_font_size, 16) if inspector_rect.size.x < 340.0 else _caption_base_font_size
_caption.add_theme_font_size_override("font_size", caption_font_size)
_inspector_scrim.position = inspector_rect.position
_inspector_scrim.size = inspector_rect.size
_capture_strip.position = inspector_rect.position
var strip_height: float = CAPTURE_MULTI_ACTION_STRIP_HEIGHT if _actions_row.get_parent() == _content_column else CAPTURE_STRIP_HEIGHT
_capture_strip.size = Vector2(inspector_rect.size.x, strip_height)
_capture_strip.custom_minimum_size = Vector2(inspector_rect.size.x, strip_height)
func _strip_style() -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = _color("stage_panels.shell.surface_color", Color("#202634"))
style.border_color = _color("mode_colors.direct", Color("#FFB300"))
style.set_border_width_all(2)
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 8, 32))
style.content_margin_left = 8.0
style.content_margin_right = 8.0
style.content_margin_top = 4.0
style.content_margin_bottom = 4.0
return style
func _style_action_button(button: Button, is_cancel: bool) -> void:
var normal: Color = Color("#B83A43") if is_cancel else _color("stage_panels.action.surface_color", Color("#171B24"))
var hover: Color = Color("#CF4B54") if is_cancel else _color("stage_panels.action.hover_surface", Color("#303A4B"))
var pressed: Color = Color("#96323A") if is_cancel else _color("stage_panels.action.selected_surface", Color("#12394A"))
var border: Color = Color("#FF8790") if is_cancel else _color("stage_panels.choice.border_color", Color("#FFFFFF55"))
button.add_theme_stylebox_override("normal", _button_style(normal, border, 2))
button.add_theme_stylebox_override("hover", _button_style(hover, border.lightened(0.12), 2))
button.add_theme_stylebox_override("pressed", _button_style(pressed, border, 2))
button.add_theme_stylebox_override("focus", _button_style(normal, Color("#8BD9FF"), 3))
button.add_theme_color_override("font_color", Color("#FFFFFF"))
button.add_theme_color_override("font_hover_color", Color("#FFFFFF"))
button.add_theme_color_override("font_pressed_color", Color("#FFFFFF"))
button.add_theme_color_override("font_focus_color", Color("#FFFFFF"))
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 = 5.0
style.content_margin_bottom = 5.0
return style
func _color(path: String, fallback: Color) -> Color:
return _stage_theme.color(path, fallback) if _stage_theme != null else fallback
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return _stage_theme.integer(path, fallback, minimum, maximum) if _stage_theme != null else fallback
@@ -0,0 +1 @@
uid://71weltw84wr
+56 -2
View File
@@ -37,6 +37,7 @@ var waypoint_context: PopupMenu
var confirm_dialog: ConfirmationDialog
var queue_panel: QueuePanel
var action_editor: ActionEditor
var action_editor_backdrop: ColorRect
var speech_composer: Control
var speech_style_toolbar: Control
var speech_show_toolbar: Control
@@ -181,7 +182,7 @@ func build_context_and_editor_views(
action_size: Vector2i
) -> void:
rig_context_popup = PopupMenu.new()
rig_context_popup.add_item("📋 Edit Queue…", 0)
rig_context_popup.add_item("📋 Step Stack…", 0)
rig_context_popup.add_item("⚡ Edit Rules…", 1)
parent.add_child(rig_context_popup)
@@ -202,11 +203,29 @@ func build_context_and_editor_views(
queue_panel.apply_font(font, emoji_font, font_sizes)
queue_panel.min_size = queue_size
action_editor_backdrop = ColorRect.new()
action_editor_backdrop.name = "ActionEditorBackdrop"
action_editor_backdrop.color = _sandbox_theme.color("popups.confirmation.backdrop_color", Color("#080D15B8")) if _sandbox_theme != null else Color("#080D15B8")
action_editor_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
action_editor_backdrop.z_index = 100
action_editor_backdrop.z_as_relative = false
action_editor_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
action_editor_backdrop.visible = false
parent.add_child(action_editor_backdrop)
action_editor = ACTION_EDITOR_SCENE.instantiate() as ActionEditor
parent.add_child(action_editor)
action_editor.apply_font(font, emoji_font, font_sizes)
action_editor.apply_theme(_sandbox_theme)
action_editor.min_size = action_size
action_editor.set_preferred_size(action_size)
action_editor.about_to_popup.connect(func() -> void:
if is_instance_valid(action_editor_backdrop):
action_editor_backdrop.show()
)
action_editor.popup_hide.connect(func() -> void:
if is_instance_valid(action_editor_backdrop):
action_editor_backdrop.hide()
)
speech_composer = SPEECH_COMPOSER_SCENE.instantiate() as Control
parent.add_child(speech_composer)
@@ -394,6 +413,16 @@ func show_objects_here(
popup.window_input.connect(_on_objects_here_window_input.bind(popup))
popup.position = position
popup.popup()
popup.size_changed.connect(_fit_objects_here_popup_after_resize.bind(
popup,
pointer_position,
viewport_size,
avoid_rects.duplicate(),
edge_inset,
safe_top,
safe_bottom
))
_fit_objects_here_popup_after_resize(popup, pointer_position, viewport_size, avoid_rects, edge_inset, safe_top, safe_bottom)
cancel.call_deferred("grab_focus")
@@ -463,6 +492,31 @@ func _objects_here_popup_position(
return best_position
func _fit_objects_here_popup_after_resize(
popup: PopupPanel,
pointer_position: Vector2,
viewport_size: Vector2,
avoid_rects: Array[Rect2],
edge_inset: int,
safe_top: int,
safe_bottom: int
) -> void:
if popup == null or not is_instance_valid(popup) or not popup.visible:
return
var actual_size: Vector2i = popup.size
var safe_position: Vector2i = _objects_here_popup_position(
pointer_position,
viewport_size,
actual_size,
avoid_rects,
edge_inset,
safe_top,
safe_bottom
)
if popup.position != safe_position:
popup.position = safe_position
func _style_objects_here_candidate(button: Button, selected: bool) -> void:
if button == null:
return
+11 -13
View File
@@ -120,8 +120,8 @@ func collect(
entry["stage_name"] = rig.stage_name
var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile()))
entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile]
var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue())
entry["action_queue"] = queue_result["actions"]
var plan_result: Dictionary = STAGE_SCENE_IO.encode_plan(rig.get_action_plan())
entry["action_plan"] = plan_result["steps"]
if type == "stickman" and entry.has("stk_path"):
entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", "")))
elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"):
@@ -284,18 +284,16 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD")))
if facing_index >= 0:
(node as StickmanRig).set_facing_profile(facing_index)
var raw_queue: Variant = entry.get("action_queue", [])
if raw_queue is Array:
var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array)
for warning: String in queue.get("warnings", []):
var character_name: String = String(entry.get("stk_path", "")).get_file()
if character_name.is_empty():
character_name = file_id
warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning])
for action: Dictionary in queue["actions"]:
(node as StickmanRig).queue_action(action)
var raw_plan: Variant = entry.get("action_plan", [])
if raw_plan is Array:
var decoded_plan: Dictionary = STAGE_SCENE_IO.decode_plan(raw_plan as Array)
if bool(decoded_plan.get("ok", false)):
if not (node as StickmanRig).set_action_plan(decoded_plan.get("steps", [])):
warnings.append("%s: the action plan could not be restored." % String(entry.get("stage_name", file_id)))
else:
warnings.append("%s: %s" % [String(entry.get("stage_name", file_id)), String(decoded_plan.get("error", "Invalid action plan."))])
else:
warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id)
warnings.append("%s: invalid action plan." % String(entry.get("stage_name", file_id)))
(node as StickmanRig).reset_collision_proxy_velocity_baselines()
loaded_nodes.append(node)
if node is TerrainBlock:
@@ -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, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT }
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, PLAN_PLACE, PLAN_PROP, PICKUP_PROP, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT }
var kind: int = Kind.NONE
var hint: String = ""
@@ -31,6 +31,14 @@ var use_target_preview_prefix: Array[Dictionary] = []
var use_target_prefix_known: bool = false
static func normalize_plan_destination(value: Variant) -> Dictionary:
if value is Vector2:
return {"kind": "place", "position": value}
if value is Dictionary:
return (value as Dictionary).duplicate(true)
return {}
func is_stage_custom_capture() -> bool:
return kind in [Kind.SPOT_ADD, Kind.SPOT_MOVE, Kind.USE_PROP, Kind.USE_SPOT]
@@ -152,10 +160,40 @@ func resolve_target(world_pos: Vector2, selection: StageSelection, director_visu
var prop_hit: Node2D = selection.hit_test(world_pos)
if prop_hit is PropBlock:
value = prop_hit
Kind.PICKUP_PROP:
var pickup_hit: PropBlock = null
var ineligible_hit: PropBlock = null
for candidate: Node2D in selection.hit_test_all(world_pos):
if candidate is PropBlock and (candidate as PropBlock).can_be_picked_up():
pickup_hit = candidate as PropBlock
break
elif candidate is PropBlock and ineligible_hit == null:
ineligible_hit = candidate as PropBlock
if pickup_hit == null:
var reason: String = "Click a highlighted prop to pick it up."
if ineligible_hit != null:
reason = "This prop needs pickup enabled and a grip in Edit."
return {"valid": false, "value": null, "reason": reason}
value = pickup_hit
Kind.STICKMAN:
var stickman_hit: Node2D = selection.hit_test(world_pos)
if stickman_hit is StickmanRig:
value = stickman_hit
Kind.PLAN_PROP:
var plan_prop_hit: Node2D = selection.hit_test(world_pos)
if plan_prop_hit is PropBlock:
var prop: PropBlock = plan_prop_hit as PropBlock
var approach: Vector2 = world_pos
value = {
"kind": "prop",
"target_prop_id": prop.stage_object_id,
"approach_offset": approach - prop.global_position,
"last_known_approach": approach,
}
Kind.PLAN_PLACE:
var place_hit: Node2D = selection.hit_test(world_pos)
if place_hit == null or (not place_hit is PropBlock and not place_hit is StickmanRig and not place_hit is TriggerArea):
value = _snap_to_grid(world_pos, snap_enabled, grid_size)
Kind.POSITION, Kind.THROW_POSITION:
value = _snap_to_grid(world_pos, snap_enabled, grid_size)
Kind.SET_DOWN_POSITION:
@@ -302,11 +302,16 @@ 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))
var lines: PackedStringArray = text_value.split("\n", false)
var text_width: float = 0.0
for line: String in lines:
text_width = maxf(text_width, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, 16).x)
var rect_size: Vector2 = Vector2(text_width + _pill_padding * 2.0, maxf(26.0, float(lines.size()) * 18.0 + 8.0))
var rect := Rect2(center - rect_size * 0.5, rect_size)
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)
for index: int in lines.size():
draw_string(font, rect.position + Vector2(10.0, 17.0 + index * 18.0), lines[index], HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE)
func _draw_reach_marker(center: Vector2, glyph: String, accent: Color) -> void:
@@ -7,6 +7,7 @@ signal change_prop_requested
signal capture_action_requested(action_id: StringName)
const MAX_BAR_WIDTH: float = 800.0
const PLAN_CAPTURE_MAX_WIDTH: float = 440.0
const SCENERY_CAPTURE_MAX_WIDTH: float = 900.0
const VIEWPORT_WIDTH_RATIO: float = 0.68
const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0
@@ -53,6 +54,8 @@ var _safe_left: float = 0.0
var _safe_right: float = 0.0
var _scenery_edit_active: bool = false
var _scenery_capture_active: bool = false
var _plan_capture_active: bool = false
var _plan_target_cancel_only: bool = false
func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void:
@@ -73,6 +76,9 @@ func _ready() -> void:
func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void:
if _prompt == null:
return
_plan_capture_active = false
_plan_target_cancel_only = false
_prompt.visible = true
_show_standard_capture_layout()
_clear_custom_actions()
if not prop_selected:
@@ -88,10 +94,20 @@ func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) ->
func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
_plan_capture_active = true
_plan_target_cancel_only = true
_set_capture_actions(prompt, actions, false)
func set_target_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
_plan_capture_active = false
_plan_target_cancel_only = false
_set_capture_actions(prompt, actions, false)
func set_scenery_capture_actions(prompt: String, actions: Array[Dictionary]) -> void:
_plan_capture_active = false
_plan_target_cancel_only = false
_set_capture_actions(prompt, actions, true)
@@ -100,6 +116,7 @@ func _set_capture_actions(prompt: String, actions: Array[Dictionary], scenery_ca
return
_show_standard_capture_layout()
_prompt.text = prompt
_prompt.visible = not _plan_target_cancel_only
_change_prop_button.visible = false
_cancel_button.visible = false
_clear_custom_actions()
@@ -160,6 +177,9 @@ func set_scenery_edit_state(selected_count: int) -> void:
return
_scenery_capture_active = false
_scenery_edit_active = true
_plan_capture_active = false
_plan_target_cancel_only = false
_prompt.visible = true
_clear_custom_actions()
if _active_layout.get_parent() == self:
_active_layout.visible = false
@@ -198,6 +218,9 @@ func clear_horizontal_safe_bounds() -> void:
func hide_capture() -> void:
visible = false
_plan_capture_active = false
_plan_target_cancel_only = false
_prompt.visible = true
_show_standard_capture_layout()
@@ -245,7 +268,10 @@ func apply_theme() -> void:
if body_font != null:
button.add_theme_font_override("font", body_font)
button.add_theme_font_size_override("font_size", body_size)
_style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done"))
if _plan_target_cancel_only:
_style_plan_target_cancel_button(button)
else:
_style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done"))
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
@@ -628,6 +654,23 @@ func _style_button(button: Button, prominent: bool) -> void:
button.add_theme_color_override("font_focus_color", text_color)
func _style_plan_target_cancel_button(button: Button) -> void:
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
var surface: Color = _color("stage_panels.action.surface_color", Color("#171B24"))
var hover: Color = _color("stage_panels.action.hover_surface", Color("#303A4B"))
var pressed: Color = _color("stage_panels.action.selected_surface", Color("#12394A"))
var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA"))
button.add_theme_stylebox_override("normal", _button_style(surface, accent, 2))
button.add_theme_stylebox_override("hover", _button_style(hover, accent.lightened(0.1), 2))
button.add_theme_stylebox_override("pressed", _button_style(pressed, accent, 2))
button.add_theme_stylebox_override("hover_pressed", _button_style(hover, accent.lightened(0.1), 2))
button.add_theme_stylebox_override("focus", _button_style(surface, accent, 3))
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
@@ -651,6 +694,9 @@ func _layout_capture_bar() -> void:
if _scenery_capture_active:
_layout_scenery_capture_bar(viewport_size)
return
if _plan_target_cancel_only:
_layout_plan_target_cancel_button(viewport_size)
return
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var safe_left: float = inset
var safe_right: float = viewport_size.x - inset
@@ -658,25 +704,59 @@ func _layout_capture_bar() -> void:
safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset))
safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset))
var available_width: float = maxf(1.0, safe_right - safe_left)
var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
var maximum_width: float = PLAN_CAPTURE_MAX_WIDTH if _plan_capture_active else MAX_BAR_WIDTH
var width: float = minf(maximum_width, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
var narrow_compact: bool = width < NARROW_COMPACT_WIDTH
custom_minimum_size.x = width
if _plan_capture_active:
custom_minimum_size.y = 0.0
size.x = width
clip_contents = true
_prompt.add_theme_font_size_override("font_size", 18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34))
_prompt.max_lines_visible = 2 if narrow_compact else 3
_prompt.add_theme_font_size_override("font_size", 19 if _plan_capture_active else (18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34)))
_prompt.max_lines_visible = 2 if _plan_capture_active or narrow_compact else 3
_set_compact_panel_style(narrow_compact)
if _plan_capture_active:
_set_plan_capture_panel_style()
var left: float = safe_left + (available_width - width) * 0.5 if not _has_horizontal_safe_bounds else safe_left
position = Vector2(left, _top_bar_height + 56.0)
var many_actions: bool = _custom_action_buttons.size() > 3
var stack_actions: bool = width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0)
var wrapped: bool = (_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions)
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped, stack_actions, wrapped)
var stack_actions: bool = false if _plan_capture_active else (width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0))
var wrapped: bool = false if _plan_capture_active else ((_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions))
var compact_layout: bool = false if _plan_capture_active else (width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped)
_set_layout(compact_layout, stack_actions, wrapped)
_fit_custom_action_widths(width, wrapped)
_apply_compact_action_labels(narrow_compact)
call_deferred("_fit_height_to_content")
func _layout_plan_target_cancel_button(viewport_size: Vector2) -> void:
if _custom_action_buttons.is_empty():
return
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var safe_left: float = inset
var safe_right: float = viewport_size.x - inset
if _has_horizontal_safe_bounds:
safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset))
safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset))
var button: Button = _custom_action_buttons[0]
var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96)))
var available_width: float = maxf(1.0, safe_right - safe_left)
var button_width: float = minf(148.0, available_width)
button.custom_minimum_size = Vector2(button_width, button_height)
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
button.add_theme_font_size_override("font_size", 19)
_prompt.visible = false
_set_layout(false, false)
_actions_row.alignment = BoxContainer.ALIGNMENT_BEGIN
_actions_row.add_theme_constant_override("separation", 0)
custom_minimum_size = Vector2(button_width, 0.0)
size = Vector2(button_width, button_height)
position = Vector2(safe_left, _top_bar_height + 56.0)
clip_contents = false
_set_plan_capture_panel_style()
call_deferred("_fit_height_to_content")
func _layout_scenery_edit_capture_bar(viewport_size: Vector2) -> void:
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var safe_left: float = inset
@@ -871,14 +951,19 @@ func _fit_custom_action_widths(width: float, wrapped: bool) -> void:
var button_width: float = ACTION_BUTTON_WIDTH
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
var action_font_size: int = maxi(18, body_size - (4 if wrapped else 0))
if _plan_capture_active:
button_width = 124.0
action_font_size = 18
if width < NARROW_COMPACT_WIDTH:
button_width = 0.0
button_width = 124.0 if _plan_capture_active else 0.0
action_font_size = 16
if wrapped and not _custom_action_buttons.is_empty():
var gaps: float = float(maxi(0, _custom_action_buttons.size() - 1) * 12)
button_width = minf(ACTION_BUTTON_WIDTH, maxf(144.0, (width - 44.0 - gaps) / float(_custom_action_buttons.size())))
for button: Button in _custom_action_buttons:
button.custom_minimum_size.x = button_width
if _plan_capture_active:
button.custom_minimum_size.y = 48.0
button.add_theme_font_size_override("font_size", action_font_size)
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width < NARROW_COMPACT_WIDTH else Control.SIZE_FILL
@@ -918,9 +1003,32 @@ func _set_compact_panel_style(compact: bool) -> void:
panel_style.content_margin_bottom = 8.0 if compact else 14.0
func _set_plan_capture_panel_style() -> void:
if _plan_target_cancel_only:
var cancel_style: StyleBoxFlat = StyleBoxFlat.new()
cancel_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
cancel_style.set_border_width_all(0)
cancel_style.content_margin_left = 0.0
cancel_style.content_margin_right = 0.0
cancel_style.content_margin_top = 0.0
cancel_style.content_margin_bottom = 0.0
add_theme_stylebox_override("panel", cancel_style)
return
var panel_style: StyleBoxFlat = get_theme_stylebox("panel") as StyleBoxFlat
if panel_style == null:
return
panel_style.border_width_left = 4
panel_style.content_margin_left = 10.0
panel_style.content_margin_right = 10.0
panel_style.content_margin_top = 8.0
panel_style.content_margin_bottom = 8.0
func _fit_height_to_content() -> void:
if not is_inside_tree() or not visible:
return
if _plan_capture_active:
custom_minimum_size.y = 0.0
var target_height: float = _active_layout.get_combined_minimum_size().y
var panel_style: StyleBox = get_theme_stylebox("panel")
if panel_style != null:
+155
View File
@@ -60,6 +60,8 @@ const MAX_CONTACTS_REPORTED := 32
const DEFAULT_WOBBLE_DURATION: float = 0.48
const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0
const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0
const OWNER_COLLISION_GRACE_CLEAR_MARGIN: float = 24.0
const OWNER_COLLISION_GRACE_CLEAR_FRAMES: int = 2
# ---------------------------------------------------------------------------
# Exported properties
@@ -129,6 +131,15 @@ var _wobble_center: Vector2 = Vector2.ZERO
var _wobble_baselines: Dictionary = {}
var _last_physics_velocity: Vector2 = Vector2.ZERO
var _has_physics_velocity_sample: bool = false
var _owner_collision_grace_actor_id: int = 0
var _owner_collision_grace_actor_ref: WeakRef
var _owner_collision_grace_locomotion_ref: WeakRef
var _owner_collision_grace_body_refs: Array[WeakRef] = []
var _owner_collision_grace_role: RuntimeRole = RuntimeRole.EDIT
var _owner_collision_grace_while_reserved: bool = false
var _owner_collision_grace_clear_frames: int = 0
var _owner_collision_grace_started_frame: int = -1
var _owner_collision_grace_last_sample_frame: int = -1
# ---------------------------------------------------------------------------
# Signals
@@ -178,12 +189,14 @@ func _process(delta: float) -> void:
func _physics_process(_delta: float) -> void:
_update_owner_collision_grace()
if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]:
_last_physics_velocity = linear_velocity
_has_physics_velocity_sample = _last_physics_velocity.is_finite()
func _exit_tree() -> void:
clear_owner_collision_grace()
cancel_wobble()
@@ -320,6 +333,8 @@ func apply_stage_depth() -> void:
func apply_runtime_role(role: RuntimeRole) -> void:
runtime_role = role
if _owner_collision_grace_actor_id != 0 and (role != _owner_collision_grace_role or not is_actor_blocking()):
clear_owner_collision_grace()
match role:
RuntimeRole.EDIT:
freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
@@ -369,6 +384,146 @@ func is_actor_blocking() -> bool:
return bool(stage_roles.get("blocks_stickmen", false))
## Temporarily ignores contacts with one stickman. Pickup reservations can keep
## protection active until the reservation ends; Set Down uses exit clearance.
## The prop's authored role and contacts with supports or other stickmen remain.
func begin_owner_collision_grace(actor_rig: Node2D, while_reserved: bool = false) -> void:
clear_owner_collision_grace()
if actor_rig == null or not is_instance_valid(actor_rig) or not is_actor_blocking():
return
_owner_collision_grace_actor_id = actor_rig.get_instance_id()
_owner_collision_grace_actor_ref = weakref(actor_rig)
_owner_collision_grace_role = runtime_role
_owner_collision_grace_while_reserved = while_reserved
_owner_collision_grace_clear_frames = 0
_owner_collision_grace_started_frame = Engine.get_physics_frames()
_owner_collision_grace_last_sample_frame = -1
for body: PhysicsBody2D in _actor_collision_bodies(actor_rig):
body.add_collision_exception_with(self)
add_collision_exception_with(body)
_owner_collision_grace_body_refs.append(weakref(body))
if actor_rig.has_method("get_locomotion"):
var locomotion: Node = actor_rig.call("get_locomotion") as Node
if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("register_owner_collision_grace_prop"):
locomotion.call("register_owner_collision_grace_prop", self)
_owner_collision_grace_locomotion_ref = weakref(locomotion)
func release_owner_collision_grace(actor_rig: Node2D) -> void:
if not is_owner_collision_grace_active_for(actor_rig):
return
_owner_collision_grace_while_reserved = false
_owner_collision_grace_clear_frames = 0
_owner_collision_grace_started_frame = Engine.get_physics_frames()
_owner_collision_grace_last_sample_frame = -1
func is_owner_collision_grace_active_for(actor: Node) -> bool:
return (
actor != null
and is_instance_valid(actor)
and _owner_collision_grace_actor_id != 0
and actor.get_instance_id() == _owner_collision_grace_actor_id
)
func clear_owner_collision_grace() -> void:
for body_ref: WeakRef in _owner_collision_grace_body_refs:
var body: PhysicsBody2D = body_ref.get_ref() as PhysicsBody2D
if body != null and is_instance_valid(body):
body.remove_collision_exception_with(self)
remove_collision_exception_with(body)
_owner_collision_grace_body_refs.clear()
var locomotion: Node = _owner_collision_grace_locomotion_ref.get_ref() as Node if _owner_collision_grace_locomotion_ref != null else null
if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("unregister_owner_collision_grace_prop"):
locomotion.call("unregister_owner_collision_grace_prop", self)
_owner_collision_grace_locomotion_ref = null
_owner_collision_grace_actor_ref = null
_owner_collision_grace_actor_id = 0
_owner_collision_grace_role = RuntimeRole.EDIT
_owner_collision_grace_while_reserved = false
_owner_collision_grace_clear_frames = 0
_owner_collision_grace_started_frame = -1
_owner_collision_grace_last_sample_frame = -1
func _update_owner_collision_grace() -> void:
if _owner_collision_grace_actor_id == 0:
return
var actor: Node2D = _owner_collision_grace_actor_ref.get_ref() as Node2D if _owner_collision_grace_actor_ref != null else null
if actor == null or not is_instance_valid(actor) or runtime_role != _owner_collision_grace_role or not is_actor_blocking():
clear_owner_collision_grace()
return
if _owner_collision_grace_while_reserved:
return
var physics_frame: int = Engine.get_physics_frames()
if physics_frame <= _owner_collision_grace_started_frame or physics_frame == _owner_collision_grace_last_sample_frame:
return
_owner_collision_grace_last_sample_frame = physics_frame
if _actor_collision_shapes_overlap(actor):
_owner_collision_grace_clear_frames = 0
return
_owner_collision_grace_clear_frames += 1
if _owner_collision_grace_clear_frames >= OWNER_COLLISION_GRACE_CLEAR_FRAMES:
clear_owner_collision_grace()
func _actor_collision_shapes_overlap(actor_rig: Node2D) -> bool:
if not is_inside_tree() or get_world_2d() == null or collision_layer == 0:
return false
var space_state: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
if space_state == null:
return true
var active_shape_count: int = 0
for body: PhysicsBody2D in _actor_collision_bodies(actor_rig):
if body.collision_layer == 0:
continue
for child: Node in body.get_children():
var collision_shape: CollisionShape2D = child as CollisionShape2D
if collision_shape == null or collision_shape.disabled or collision_shape.shape == null:
continue
active_shape_count += 1
var query := PhysicsShapeQueryParameters2D.new()
query.shape = collision_shape.shape
query.transform = collision_shape.global_transform
query.collision_mask = collision_layer
query.margin = OWNER_COLLISION_GRACE_CLEAR_MARGIN
query.exclude = [body.get_rid()]
for hit: Dictionary in space_state.intersect_shape(query, 16):
if hit.get("collider") == self:
return true
return active_shape_count == 0
func _actor_collision_bodies(actor_rig: Node2D) -> Array[PhysicsBody2D]:
var result: Array[PhysicsBody2D] = []
var seen: Dictionary = {}
if actor_rig == null or not is_instance_valid(actor_rig):
return result
if actor_rig.has_method("get_collision_bodies"):
var collision_bodies: Variant = actor_rig.call("get_collision_bodies")
if collision_bodies is Dictionary:
for raw_body: Variant in (collision_bodies as Dictionary).values():
_append_actor_collision_body(raw_body, result, seen)
if actor_rig.has_method("get_ragdoll_bodies"):
var ragdoll_bodies: Variant = actor_rig.call("get_ragdoll_bodies")
if ragdoll_bodies is Array:
for raw_body: Variant in ragdoll_bodies as Array:
_append_actor_collision_body(raw_body, result, seen)
return result
func _append_actor_collision_body(raw_body: Variant, bodies: Array[PhysicsBody2D], seen: Dictionary) -> void:
var body: PhysicsBody2D = raw_body as PhysicsBody2D
if body == null or not is_instance_valid(body):
return
var body_id: int = body.get_instance_id()
if seen.has(body_id):
return
seen[body_id] = true
bodies.append(body)
func can_receive_prop_contact() -> bool:
if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT:
return false
@@ -0,0 +1,198 @@
class_name StageActionSummaryGlyph
extends Control
## Small, screen-sized glyphs for the Stage action-plan summary rows.
var _kind: String = "move"
var _tint: Color = Color("#8BD9FF")
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
focus_mode = Control.FOCUS_NONE
func configure(kind: String, tint: Color) -> void:
_kind = kind
_tint = tint
queue_redraw()
func _draw() -> void:
draw_in_canvas(self, _kind, Vector2.ZERO, 1.0, _tint)
## Draws the same vector glyph into another CanvasItem's active draw callback.
## `origin` is the top-left of the 28 x 30 glyph design space.
static func draw_in_canvas(canvas: CanvasItem, kind: String, origin: Vector2, scale_factor: float, tint: Color) -> void:
if canvas == null or scale_factor <= 0.0:
return
match kind:
"move":
_draw_move(canvas, origin, scale_factor, tint)
"speak":
_draw_speech(canvas, origin, scale_factor, tint)
"wait":
_draw_wait(canvas, origin, scale_factor, tint)
"ragdoll":
_draw_ragdoll(canvas, origin, scale_factor, tint)
"recover":
_draw_recover(canvas, origin, scale_factor, tint)
"drop_prop":
_draw_drop_prop(canvas, origin, scale_factor, tint)
"throw_prop":
_draw_throw_prop(canvas, origin, scale_factor, tint)
"set_down_prop":
_draw_set_down_prop(canvas, origin, scale_factor, tint)
"use_prop":
_draw_use_prop(canvas, origin, scale_factor, tint)
static func _draw_move(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
var route := PackedVector2Array([
origin + Vector2(4.0, 24.0) * scale_factor,
origin + Vector2(8.0, 15.0) * scale_factor,
origin + Vector2(13.0, 19.0) * scale_factor,
origin + Vector2(24.0, 7.0) * scale_factor,
])
canvas.draw_polyline(route, tint, 2.8 * scale_factor, true)
canvas.draw_circle(origin + Vector2(4.0, 24.0) * scale_factor, 2.5 * scale_factor, tint, true, -1.0, true)
var arrow_head := PackedVector2Array([
origin + Vector2(17.0, 7.0) * scale_factor,
origin + Vector2(25.0, 6.0) * scale_factor,
origin + Vector2(24.0, 14.0) * scale_factor,
])
canvas.draw_polyline(arrow_head, tint, 2.8 * scale_factor, true)
static func _draw_speech(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
var outline := PackedVector2Array([
origin + Vector2(5.0, 5.0) * scale_factor,
origin + Vector2(22.0, 5.0) * scale_factor,
origin + Vector2(26.0, 9.0) * scale_factor,
origin + Vector2(26.0, 18.0) * scale_factor,
origin + Vector2(22.0, 22.0) * scale_factor,
origin + Vector2(15.0, 22.0) * scale_factor,
origin + Vector2(8.0, 28.0) * scale_factor,
origin + Vector2(8.0, 22.0) * scale_factor,
origin + Vector2(5.0, 22.0) * scale_factor,
origin + Vector2(2.0, 19.0) * scale_factor,
origin + Vector2(2.0, 8.0) * scale_factor,
origin + Vector2(5.0, 5.0) * scale_factor,
])
canvas.draw_polyline(outline, tint, 2.2 * scale_factor, true)
static func _draw_wait(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
var outline := PackedVector2Array([
origin + Vector2(6.0, 5.0) * scale_factor,
origin + Vector2(24.0, 5.0) * scale_factor,
origin + Vector2(22.0, 9.0) * scale_factor,
origin + Vector2(17.0, 14.0) * scale_factor,
origin + Vector2(22.0, 19.0) * scale_factor,
origin + Vector2(24.0, 23.0) * scale_factor,
origin + Vector2(6.0, 23.0) * scale_factor,
origin + Vector2(8.0, 19.0) * scale_factor,
origin + Vector2(13.0, 14.0) * scale_factor,
origin + Vector2(8.0, 9.0) * scale_factor,
origin + Vector2(6.0, 5.0) * scale_factor,
])
canvas.draw_polyline(outline, tint, 2.2 * scale_factor, true)
canvas.draw_line(origin + Vector2(9.0, 8.0) * scale_factor, origin + Vector2(21.0, 8.0) * scale_factor, tint, 2.0 * scale_factor, true)
canvas.draw_line(origin + Vector2(9.0, 20.0) * scale_factor, origin + Vector2(21.0, 20.0) * scale_factor, tint, 2.0 * scale_factor, true)
static func _draw_ragdoll(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
var center: Vector2 = origin + Vector2(14.0, 15.0) * scale_factor
var points := PackedVector2Array([
center + Vector2(0.0, -13.0) * scale_factor,
center + Vector2(3.0, -5.0) * scale_factor,
center + Vector2(10.0, -10.0) * scale_factor,
center + Vector2(7.0, -2.0) * scale_factor,
center + Vector2(14.0, 1.0) * scale_factor,
center + Vector2(6.0, 4.0) * scale_factor,
center + Vector2(10.0, 11.0) * scale_factor,
center + Vector2(2.0, 7.0) * scale_factor,
center + Vector2(-2.0, 14.0) * scale_factor,
center + Vector2(-5.0, 6.0) * scale_factor,
center + Vector2(-12.0, 10.0) * scale_factor,
center + Vector2(-8.0, 2.0) * scale_factor,
center + Vector2(-14.0, -2.0) * scale_factor,
center + Vector2(-6.0, -5.0) * scale_factor,
center + Vector2(-10.0, -12.0) * scale_factor,
center + Vector2(-2.0, -7.0) * scale_factor,
])
canvas.draw_colored_polygon(points, tint)
static func _draw_recover(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
var center: Vector2 = origin + Vector2(14.0, 15.0) * scale_factor
var arc := PackedVector2Array()
for index: int in 16:
var angle: float = deg_to_rad(-55.0 + float(index) / 15.0 * 285.0)
arc.append(center + Vector2(cos(angle), sin(angle)) * 10.0 * scale_factor)
canvas.draw_polyline(arc, tint, 2.4 * scale_factor, true)
var end_angle: float = deg_to_rad(230.0)
var tip: Vector2 = center + Vector2(cos(end_angle), sin(end_angle)) * 10.0 * scale_factor
var tangent: Vector2 = Vector2(-sin(end_angle), cos(end_angle))
var base: Vector2 = tip - tangent * 7.0 * scale_factor
var wing: Vector2 = Vector2(-tangent.y, tangent.x) * 4.0 * scale_factor
canvas.draw_colored_polygon(PackedVector2Array([
tip,
base + wing,
base - wing,
]), tint)
static func _draw_drop_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
canvas.draw_rect(Rect2(origin + Vector2(4.0, 3.0) * scale_factor, Vector2(20.0, 9.0) * scale_factor), tint, false, 2.2 * scale_factor, true)
canvas.draw_line(origin + Vector2(14.0, 13.0) * scale_factor, origin + Vector2(14.0, 23.0) * scale_factor, tint, 2.5 * scale_factor, true)
canvas.draw_line(origin + Vector2(14.0, 23.0) * scale_factor, origin + Vector2(8.0, 18.0) * scale_factor, tint, 2.5 * scale_factor, true)
canvas.draw_line(origin + Vector2(14.0, 23.0) * scale_factor, origin + Vector2(20.0, 18.0) * scale_factor, tint, 2.5 * scale_factor, true)
canvas.draw_line(origin + Vector2(4.0, 28.0) * scale_factor, origin + Vector2(24.0, 28.0) * scale_factor, tint, 2.0 * scale_factor, true)
static func _draw_throw_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
var bow := PackedVector2Array([
origin + Vector2(17.0, 3.0) * scale_factor,
origin + Vector2(11.0, 5.0) * scale_factor,
origin + Vector2(6.0, 9.0) * scale_factor,
origin + Vector2(4.0, 15.0) * scale_factor,
origin + Vector2(6.0, 21.0) * scale_factor,
origin + Vector2(11.0, 25.0) * scale_factor,
origin + Vector2(17.0, 27.0) * scale_factor,
])
canvas.draw_polyline(bow, tint, 2.4 * scale_factor, true)
canvas.draw_line(origin + Vector2(17.0, 3.0) * scale_factor, origin + Vector2(17.0, 27.0) * scale_factor, tint, 1.6 * scale_factor, true)
canvas.draw_line(origin + Vector2(2.0, 15.0) * scale_factor, origin + Vector2(26.0, 15.0) * scale_factor, tint, 2.6 * scale_factor, true)
canvas.draw_polyline(PackedVector2Array([
origin + Vector2(20.0, 9.0) * scale_factor,
origin + Vector2(27.0, 15.0) * scale_factor,
origin + Vector2(20.0, 21.0) * scale_factor,
]), tint, 2.6 * scale_factor, true)
static func _draw_set_down_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
canvas.draw_line(origin + Vector2(14.0, 3.0) * scale_factor, origin + Vector2(14.0, 12.0) * scale_factor, tint, 2.4 * scale_factor, true)
canvas.draw_polyline(PackedVector2Array([
origin + Vector2(9.0, 8.0) * scale_factor,
origin + Vector2(14.0, 13.0) * scale_factor,
origin + Vector2(19.0, 8.0) * scale_factor,
]), tint, 2.4 * scale_factor, true)
canvas.draw_rect(Rect2(origin + Vector2(6.0, 14.0) * scale_factor, Vector2(16.0, 10.0) * scale_factor), tint, false, 2.2 * scale_factor, true)
canvas.draw_line(origin + Vector2(3.0, 27.0) * scale_factor, origin + Vector2(25.0, 27.0) * scale_factor, tint, 2.0 * scale_factor, true)
static func _draw_use_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void:
canvas.draw_rect(Rect2(origin + Vector2(3.0, 12.0) * scale_factor, Vector2(16.0, 13.0) * scale_factor), tint, false, 2.2 * scale_factor, true)
var spark := PackedVector2Array([
origin + Vector2(22.0, 3.0) * scale_factor,
origin + Vector2(24.0, 9.0) * scale_factor,
origin + Vector2(28.0, 11.0) * scale_factor,
origin + Vector2(23.0, 13.0) * scale_factor,
origin + Vector2(21.0, 18.0) * scale_factor,
origin + Vector2(19.0, 13.0) * scale_factor,
origin + Vector2(15.0, 11.0) * scale_factor,
origin + Vector2(20.0, 9.0) * scale_factor,
origin + Vector2(22.0, 3.0) * scale_factor,
])
canvas.draw_polyline(spark, tint, 2.0 * scale_factor, true)
@@ -0,0 +1 @@
uid://jv06t4nihqrb
@@ -18,6 +18,7 @@ var _rename_button: Button
var _close_button: Button
var _scroll: ScrollContainer
var _body: VBoxContainer
var _pinned: VBoxContainer
var _collapsible: bool = false
var _collapsed: bool = false
var _close_visible: bool = true
@@ -167,6 +168,10 @@ func body() -> VBoxContainer:
return _body
func pinned_content() -> VBoxContainer:
return _pinned
func set_safe_rect(rect: Rect2) -> void:
_safe_rect = rect
request_fit()
@@ -184,6 +189,22 @@ func reset_scroll() -> void:
_scroll.scroll_vertical = 0
func get_body_scroll_vertical() -> int:
return _scroll.scroll_vertical if _scroll != null else 0
func set_body_scroll_vertical(value: int) -> void:
if _scroll != null:
_scroll.scroll_vertical = maxi(0, value)
func scroll_to_control(control: Control) -> void:
if _scroll == null or control == null or not is_instance_valid(control):
return
var content_y: float = control.global_position.y - _scroll.get_global_rect().position.y + _scroll.scroll_vertical
_scroll.scroll_vertical = maxi(0, int(roundf(content_y)))
func focus_first_control() -> void:
if _body == null:
return
@@ -274,10 +295,10 @@ func _build_shell() -> void:
_header.add_child(_icon)
_title = Label.new()
_title.name = "InspectorTitle"
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_title.max_lines_visible = 2
_title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL | Control.SIZE_SHRINK_END
_title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_header.add_child(_title)
@@ -303,6 +324,10 @@ func _build_shell() -> void:
_close_button.focus_mode = Control.FOCUS_ALL
_close_button.pressed.connect(_on_close_button_pressed)
_header.add_child(_close_button)
_pinned = VBoxContainer.new()
_pinned.name = "InspectorPinnedContent"
_pinned.add_theme_constant_override("separation", 8)
_layout.add_child(_pinned)
_scroll = ScrollContainer.new()
_scroll.name = "InspectorBodyScroll"
@@ -326,7 +351,8 @@ func _fit_to_content() -> void:
rect = get_viewport_rect()
var inset := float(_integer("stage_panels.shell.edge_inset", 12, 0, 48))
var preferred_width := float(_integer("stage_panels.shell.preferred_width", 380, 220, 720))
var card_width := minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
var max_width := float(_integer("stage_panels.shell.max_width", 380, 220, 720))
var card_width := minf(minf(preferred_width, max_width), maxf(1.0, rect.size.x - inset * 2.0))
custom_minimum_size.x = card_width
size.x = card_width
await get_tree().process_frame
@@ -338,7 +364,9 @@ func _fit_to_content() -> void:
rect = _safe_rect
if rect.size.x <= 0.0 or rect.size.y <= 0.0:
rect = get_viewport_rect()
card_width = minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0))
preferred_width = float(_integer("stage_panels.shell.preferred_width", 380, 220, 720))
max_width = float(_integer("stage_panels.shell.max_width", 380, 220, 720))
card_width = minf(minf(preferred_width, max_width), maxf(1.0, rect.size.x - inset * 2.0))
custom_minimum_size.x = card_width
size.x = card_width
var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28))
@@ -346,7 +374,9 @@ func _fit_to_content() -> void:
var header_height := maxf(44.0, _header.get_combined_minimum_size().y)
var body_height := _body.get_combined_minimum_size().y
var show_body: bool = not _collapsed and _body_visible
var fixed_height := header_height + (gap if show_body else 0.0) + padding * 2.0
var pinned_visible: bool = _pinned.visible and _pinned.get_child_count() > 0
var pinned_height: float = _pinned.get_combined_minimum_size().y if pinned_visible else 0.0
var fixed_height := header_height + (gap if pinned_visible else 0.0) + pinned_height + (gap if show_body else 0.0) + padding * 2.0
var available_height := maxf(72.0, rect.size.y - inset * 2.0)
var natural_height := fixed_height + (body_height if show_body else 0.0)
var card_height := minf(natural_height, available_height)
@@ -355,8 +385,12 @@ func _fit_to_content() -> void:
# previous full-content minimum can clamp the new height until another layout.
_scroll.custom_minimum_size.y = scroll_height
_scroll.update_minimum_size()
position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset)
size = Vector2(card_width, card_height)
# Containers can raise the actual width to fit their children. Anchor from that
# final width so a content minimum cannot consume the right-edge gutter.
var left_limit := rect.position.x + inset
var right_limit := rect.position.x + rect.size.x - inset - size.x
position = Vector2(clampf(right_limit, left_limit, maxf(left_limit, right_limit)), rect.position.y + inset)
_fit_pending = false
+28 -3
View File
@@ -12,6 +12,7 @@ enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
const MOVE_TO: String = "move_to"
const WALK: String = "walk"
const RUN: String = "run"
const WAIT: String = "wait"
const ACTION_TEMPLATES := {
MOVE_TO: {
@@ -54,7 +55,7 @@ const ACTION_TEMPLATES := {
},
"pick_up_prop": {
"label": "Pick Up Prop",
"icon": "🫴",
"icon": "✋",
"params": [
{ "key": "target_prop_id", "type": "prop", "required": true },
{ "key": "hand", "type": "choice", "default": "auto", "choices": ["auto", "left", "right"] },
@@ -108,6 +109,12 @@ static func has_type(type: String) -> bool:
return ACTION_TEMPLATES.has(type)
static func has_editable_parameters(type: String) -> bool:
var template: Dictionary = ACTION_TEMPLATES.get(canonical_type(type), {})
var params: Variant = template.get("params", [])
return params is Array and not (params as Array).is_empty()
static func canonical_type(type: String) -> String:
return MOVE_TO if type == "walk_to" else type
@@ -126,6 +133,19 @@ static func is_move_action(action: Dictionary) -> bool:
return type == MOVE_TO or type == "walk_to"
static func can_add_authored_action_together(existing_actions: Array[Dictionary], candidate_type: String) -> bool:
var normalized_candidate: String = canonical_type(candidate_type)
if existing_actions.size() != 1 or not has_type(normalized_candidate):
return false
var existing_type: String = canonical_type(String(existing_actions[0].get("type", "")))
if not has_type(existing_type):
return false
# The runner supports one primary action with one speech overlay. Keep the
# pair explicit: Say may accompany any registered primary action, but two
# primary actions or two Say actions still need separate steps.
return (normalized_candidate == "speak" and existing_type != "speak") or (existing_type == "speak" and normalized_candidate != "speak")
static func label(type: String) -> String:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
return String(tpl.get("label", type))
@@ -219,7 +239,7 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
## Human-readable one-line summary for both flat queue actions and rule-action
## shapes (the get() fallbacks tolerate either key layout).
static func summarize(action: Dictionary) -> String:
static func summarize(action: Dictionary, prop_name_resolver: Callable = Callable()) -> String:
var type := canonical_type(String(action.get("type", "")))
var params: Dictionary = action.get("params", {})
match type:
@@ -243,7 +263,12 @@ static func summarize(action: Dictionary) -> String:
"recover":
return "Recover"
"pick_up_prop":
return "Pick Up Prop (%s)" % String(action.get("target_prop_id", params.get("target_prop_id", "")))
var prop_id: String = String(action.get("target_prop_id", params.get("target_prop_id", "")))
if prop_name_resolver.is_valid() and not prop_id.is_empty():
var prop_name: String = String(prop_name_resolver.call(prop_id)).strip_edges()
if not prop_name.is_empty():
return "Pick Up Prop · %s" % prop_name
return "Pick Up Prop"
"drop_prop":
return "Drop Prop"
"throw_prop":
+5 -3
View File
@@ -42,11 +42,11 @@ const DEFAULT_CONFIG: Dictionary = {
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[560,300], "font_size":22, "message_font_size":20}},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,460], "confirm_size":[560,300], "font_size":22, "message_font_size":20}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":8, "setup_title_size":30, "setup_heading_size":26, "setup_choice_font_size":22, "setup_choice_height":68, "setup_choice_corner_radius":22, "setup_choice_border_width":2, "tab_font_size":22, "tab_height":60, "filter_chip_font_size":20, "filter_chip_height":48, "filter_chip_gap":8, "setup_button_font_size":20, "button_border_width":2, "footer_gap":16, "surface_corner_radius":14, "look_corner_radius":8, "corner_radius":16, "compact_swatch_size":42, "color_group_gap":8, "action_color":"#087DBA", "separator_color":"#AAB2C0A0"}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"stage_panels": {
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
"shell": {"preferred_width": 380, "max_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
"type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"},
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
@@ -55,7 +55,7 @@ const DEFAULT_CONFIG: Dictionary = {
"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}
},
"stage_surface_colors": {"section_gap": 8, "swatch_gap": 8, "swatch_size": 48, "swatch_border": "#FFFFFFB8", "swatch_focus_border": "#FFFFFF", "swatch_corner_radius": 8, "swatch_border_width": 2, "swatch_check_size": 24, "selected_border_width": 4, "focus_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "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}},
"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", "director_plan": {"route_color": "#8BD9FF", "run_color": "#FFB300", "say_color": "#FFD84D", "wait_color": "#B69CFF", "completed_color": "#959EAD", "failed_color": "#F05B57", "candidate_valid_color": "#4DE666", "candidate_invalid_color": "#FF626B", "flag_size_px": 44, "flag_number_size_px": 18, "label_bg_color": "#101720E8", "label_border_alpha": 0.8, "spotlight_route_width_px": 4, "spotlight_route_shadow_width_px": 6, "spotlight_dash_length_px": 12, "spotlight_dash_gap_px": 7, "spotlight_route_alpha": 0.95, "unfocused_step_alpha": 0.24, "action_badge_font_size_px": 18, "action_badge_padding_px": [10, 6], "action_badge_radius_px": 11, "action_badge_border_width_px": 2, "action_badge_icon_slot_width_px": 22, "action_badge_icon_text_gap_px": 6, "action_badge_icon_scale": 0.72, "action_badge_stack_gap_px": 4, "action_badge_route_gap_px": 6, "actor_name_font_size_px": 20, "actor_name_max_width_px": 260, "actor_name_padding_px": [12, 7], "actor_name_radius_px": 12, "actor_name_border_width_px": 2, "actor_name_offset_px": 26, "route_badge_fraction": 0.32, "actor_name_border_color": "#FFB300", "action_badge_text_color": "#F5F7FA", "route_shadow_color": "#101720D9", "action_row_move_surface": "#17313D", "action_row_say_surface": "#2C281C", "action_row_wait_surface": "#29243A", "action_row_other_surface": "#202632", "action_row_icon_surface": "#252B38", "action_row_text_color": "#F5F7FA", "action_row_duration_color": "#AAB2C0", "action_row_options_color": "#8E9AAA", "action_row_font_size_px": 18, "action_row_duration_font_size_px": 14, "action_row_icon_font_size_px": 20, "action_row_height_px": 48, "action_row_icon_size_px": 32, "action_row_rail_width_px": 4, "action_row_options_width_px": 24, "action_row_options_font_size_px": 18, "action_row_radius_px": 8, "action_row_selected_border_width_px": 2}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36, "surface_line_width_px": 5, "surface_hover_line_width_px": 7, "surface_alpha": 0.75, "surface_hover_alpha": 0.95}, "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"}
}
@@ -230,6 +230,8 @@ func _apply_phase19_defaults() -> void:
"candidate_name_pill_border_width": 3.0,
"candidate_name_pill_padding_x": 12.0,
"candidate_name_pill_padding_y": 8.0,
"selected_name_pill_font_size_px": 20.0,
"selected_name_pill_max_width_px": 260.0,
}
for key: String in defaults:
if not gizmos.has(key):
+83
View File
@@ -0,0 +1,83 @@
class_name StageActionIdentity
extends RefCounted
## Scene-scoped action ID allocation for later event links.
static var _fallback_counter: int = 1
func allocate(world: Node2D) -> String:
var used: Dictionary = _collect_ids(world)
var candidate: String = ""
for _attempt: int in 16:
var unique_number: int = ResourceUID.create_id()
candidate = "action_%s" % String.num_int64(unique_number, 16)
if not used.has(candidate):
return candidate
while used.has(candidate) or candidate.is_empty():
candidate = "action_%d_%d" % [Time.get_ticks_usec(), _fallback_counter]
_fallback_counter += 1
return candidate
func validate_scene_ids(objects: Array) -> String:
var seen: Dictionary = {}
for raw_object: Variant in objects:
if not raw_object is Dictionary:
continue
var object: Dictionary = raw_object
if String(object.get("type", "")) != "stickman":
continue
var plan_value: Variant = object.get("action_plan", [])
if not plan_value is Array:
continue
for raw_step: Variant in plan_value:
if not raw_step is Dictionary:
continue
var actions_value: Variant = (raw_step as Dictionary).get("actions", [])
if not actions_value is Array:
continue
for raw_action: Variant in actions_value:
if not raw_action is Dictionary:
continue
var action_id: String = String((raw_action as Dictionary).get("action_id", ""))
if action_id.strip_edges().is_empty() or seen.has(action_id):
return "Action IDs must be unique and non-empty across the scene."
seen[action_id] = true
return ""
func _collect_ids(world: Node2D) -> Dictionary:
var used: Dictionary = {}
if world == null or not is_instance_valid(world):
return used
for child: Node in world.get_children():
if child is StickmanRig:
_collect_plan_ids((child as StickmanRig).get_action_plan(), used)
elif child.has_method("get"):
var original_value: Variant = child.get("original_entry")
if original_value is Dictionary:
_collect_entry_ids(original_value as Dictionary, used)
return used
func _collect_entry_ids(entry: Dictionary, used: Dictionary) -> void:
var plan_value: Variant = entry.get("action_plan", [])
if not plan_value is Array:
return
var plan: Array[Dictionary] = []
for raw_step: Variant in plan_value:
if raw_step is Dictionary:
plan.append(raw_step as Dictionary)
_collect_plan_ids(plan, used)
func _collect_plan_ids(plan: Array[Dictionary], used: Dictionary) -> void:
for step: Dictionary in plan:
var actions_value: Variant = step.get("actions", [])
if not actions_value is Array:
continue
for raw_action: Variant in actions_value:
if raw_action is Dictionary:
var action_id: String = String((raw_action as Dictionary).get("action_id", ""))
if not action_id.is_empty():
used[action_id] = true
@@ -0,0 +1 @@
uid://dum44yvr5v1k0
+157
View File
@@ -0,0 +1,157 @@
class_name StageActionPlan
extends RefCounted
## Pure helpers for ordered authored action steps.
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
static func validate_steps(steps: Array[Dictionary]) -> String:
var seen_ids: Dictionary = {}
for step_index: int in steps.size():
var actions_value: Variant = steps[step_index].get("actions", null)
if not actions_value is Array or (actions_value as Array).is_empty():
return "Step %d must contain an action." % (step_index + 1)
var actions: Array = actions_value
if actions.size() > 2:
return "Step %d has too many actions." % (step_index + 1)
var typed_actions: Array[Dictionary] = []
for raw_action: Variant in actions:
if not raw_action is Dictionary:
return "Step %d contains an invalid action." % (step_index + 1)
var action: Dictionary = raw_action
var action_id: Variant = action.get("action_id", null)
if not action_id is String or String(action_id).strip_edges().is_empty():
return "Actions must have non-empty string IDs."
if seen_ids.has(String(action_id)):
return "Action IDs must be unique."
seen_ids[String(action_id)] = true
var action_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(action_type):
return "Step %d contains an unknown action." % (step_index + 1)
typed_actions.append(action)
if typed_actions.size() == 2:
if not can_add_together({"actions": [typed_actions[0]]}, String(typed_actions[1].get("type", ""))):
return "Step %d contains actions that cannot run together." % (step_index + 1)
return ""
static func can_add_together(step: Dictionary, action_type: String) -> bool:
var actions_value: Variant = step.get("actions", [])
if not actions_value is Array:
return false
var existing_actions: Array[Dictionary] = []
for raw_action: Variant in actions_value:
if not raw_action is Dictionary:
return false
existing_actions.append(raw_action as Dictionary)
return ACTION_REGISTRY.can_add_authored_action_together(existing_actions, action_type)
static func append_step(steps: Array[Dictionary], action: Dictionary) -> Array[Dictionary]:
var result: Array[Dictionary] = steps.duplicate(true)
result.append({"actions": [action.duplicate(true)]})
return result
static func append_together(steps: Array[Dictionary], step_index: int, action: Dictionary) -> Array[Dictionary]:
var result: Array[Dictionary] = steps.duplicate(true)
if step_index < 0 or step_index >= result.size() or not can_add_together(result[step_index], String(action.get("type", ""))):
return []
var actions: Array = (result[step_index].get("actions", []) as Array).duplicate(true)
actions.append(action.duplicate(true))
result[step_index]["actions"] = actions
return result
static func replace_action(steps: Array[Dictionary], action_id: String, replacement: Dictionary) -> Array[Dictionary]:
var result: Array[Dictionary] = steps.duplicate(true)
var location: Dictionary = find_action(result, action_id)
if location.is_empty():
return []
var step_index: int = int(location["step_index"])
var action_index: int = int(location["action_index"])
var actions: Array = (result[step_index].get("actions", []) as Array).duplicate(true)
var updated: Dictionary = replacement.duplicate(true)
updated["action_id"] = action_id
actions[action_index] = updated
if actions.size() == 2 and not validate_steps([{"actions": actions}]).is_empty():
return []
result[step_index]["actions"] = actions
return result
static func flatten(steps: Array[Dictionary]) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for step_index: int in steps.size():
var raw_actions: Variant = steps[step_index].get("actions", [])
if not raw_actions is Array:
continue
for raw_action: Variant in raw_actions:
if not raw_action is Dictionary:
continue
var action: Dictionary = (raw_action as Dictionary).duplicate(true)
action["step_index"] = step_index
if ACTION_REGISTRY.is_move_action(action):
var destination_value: Variant = action.get("destination", null)
if destination_value is Dictionary:
var destination: Dictionary = destination_value
if String(destination.get("kind", "")) == "place":
action["target"] = _data_to_vector(destination.get("position", {}))
else:
action["target"] = _data_to_vector(destination.get("last_known_approach", {}))
result.append(action)
return result
static func find_action(steps: Array[Dictionary], action_id: String) -> Dictionary:
if action_id.is_empty():
return {}
for step_index: int in steps.size():
var raw_actions: Variant = steps[step_index].get("actions", [])
if not raw_actions is Array:
continue
for action_index: int in (raw_actions as Array).size():
var raw_action: Variant = (raw_actions as Array)[action_index]
if raw_action is Dictionary and String((raw_action as Dictionary).get("action_id", "")) == action_id:
return {"step_index": step_index, "action_index": action_index, "action": (raw_action as Dictionary).duplicate(true)}
return {}
static func move_step(steps: Array[Dictionary], from_index: int, to_index: int) -> Array[Dictionary]:
var result: Array[Dictionary] = steps.duplicate(true)
if from_index < 0 or from_index >= result.size() or to_index < 0 or to_index >= result.size():
return result
var step: Dictionary = result.pop_at(from_index)
result.insert(to_index, step)
return result
static func remove_action(steps: Array[Dictionary], action_id: String) -> Array[Dictionary]:
var result: Array[Dictionary] = steps.duplicate(true)
for step_index: int in range(result.size() - 1, -1, -1):
var actions_value: Variant = result[step_index].get("actions", [])
if not actions_value is Array:
continue
var actions: Array = actions_value
for action_index: int in range(actions.size() - 1, -1, -1):
var raw_action: Variant = actions[action_index]
if raw_action is Dictionary and String((raw_action as Dictionary).get("action_id", "")) == action_id:
actions.remove_at(action_index)
if actions.is_empty():
result.remove_at(step_index)
else:
result[step_index]["actions"] = actions
return result
return result
static func _data_to_vector(value: Variant) -> Vector2:
if not value is Dictionary:
return Vector2.ZERO
var data: Dictionary = value
var x_value: Variant = data.get("x", null)
var y_value: Variant = data.get("y", null)
if not (x_value is float or x_value is int) or not (y_value is float or y_value is int):
return Vector2.ZERO
var point := Vector2(float(x_value), float(y_value))
return point if point.is_finite() else Vector2.ZERO
+1
View File
@@ -0,0 +1 @@
uid://dq1q3dyink3av
File diff suppressed because it is too large Load Diff
+243
View File
@@ -48,6 +48,7 @@ const ROTATE_HANDLE_OFFSET_PX := 24.0
const ROTATE_HANDLE_HIT_HALF_SIZE_PX := 22.0
const ROTATE_ICON_RADIUS_PX := 8.0
const ROTATE_HANDLE_SAFE_TOP_PX := 112.0
const NAME_BADGE_OVERLAP_TOLERANCE_PX2 := 128.0
const SCALE_CROSS_SIZE_PX := 5.0
const SCALE_HIT_RADIUS_PX := 12.0
const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0
@@ -82,6 +83,12 @@ var _candidate_preview_name_corner_radius: float = 8.0
var _candidate_preview_name_border_width: float = 3.0
var _candidate_preview_name_padding: Vector2 = Vector2(12.0, 8.0)
var _candidate_preview_name_style: StyleBoxFlat = StyleBoxFlat.new()
var _selected_name_pill_font_size_px: float = 20.0
var _selected_name_pill_max_width_px: float = 260.0
var _selected_name_pill_style: StyleBoxFlat = StyleBoxFlat.new()
var _selected_name_badge_obstacles: Array[Rect2] = []
var _last_selected_name_badge_layouts: Array[Dictionary] = []
var _selected_name_badge_slots: Dictionary = {}
var _dragging: Handle = Handle.NONE
var _drag_last: Vector2 = Vector2.ZERO
var _drag_anchor: Node2D = null
@@ -102,6 +109,7 @@ var _scale_changed: bool = false
func set_targets(nodes: Array[Node2D]) -> void:
_targets = nodes.duplicate()
_sync_selected_name_badge_slots()
queue_redraw()
@@ -120,6 +128,13 @@ func set_candidate_preview_obstacles(obstacles: Array[Rect2]) -> void:
queue_redraw()
func set_selected_name_badge_obstacles(obstacles: Array[Rect2]) -> void:
if _selected_name_badge_obstacles == obstacles:
return
_selected_name_badge_obstacles = obstacles.duplicate()
queue_redraw()
func set_enabled(enabled: bool) -> void:
_enabled = enabled
visible = enabled
@@ -152,6 +167,8 @@ func apply_theme(stage_theme: SandboxTheme) -> void:
stage_theme.number("overlays.gizmos.candidate_name_pill_padding_x", 12.0, 4.0, 32.0),
stage_theme.number("overlays.gizmos.candidate_name_pill_padding_y", 8.0, 2.0, 24.0)
)
_selected_name_pill_font_size_px = stage_theme.number("overlays.gizmos.selected_name_pill_font_size_px", 20.0, 16.0, 30.0)
_selected_name_pill_max_width_px = stage_theme.number("overlays.gizmos.selected_name_pill_max_width_px", 260.0, 140.0, 420.0)
_outline_extra_width = stage_theme.number("overlays.gizmos.backing_extra_width", 3.0, 1.0, 8.0)
queue_redraw()
@@ -232,6 +249,7 @@ func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void:
_drag_anchor = node
if not _targets.has(node):
_targets = [node]
_sync_selected_name_badge_slots()
_dragging = Handle.TRANSLATE
_drag_last = world_pos
_translate_drag_origin = world_pos
@@ -313,6 +331,10 @@ func _draw() -> void:
for node: Node2D in _targets:
if node != null and is_instance_valid(node):
_draw_halo_rect(_target_bounds(node), _selection_core_color, SELECTION_WIDTH / zoom, _selection_backing_color)
if _candidate_preview == null:
_draw_selected_name_badges(zoom)
else:
_last_selected_name_badge_layouts.clear()
# A clear rectangle signals selection; the compact handle keeps rotation available.
if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]):
var rect: Rect2 = _target_bounds(_targets[0])
@@ -453,6 +475,227 @@ func _draw_candidate_preview_name(node: Node2D, zoom: float) -> void:
)
func _draw_selected_name_badges(zoom: float) -> void:
_last_selected_name_badge_layouts.clear()
_sync_selected_name_badge_slots()
if _targets.is_empty():
return
var font: Font = ThemeDB.fallback_font
if font == null:
return
var viewport: Viewport = get_viewport()
var viewport_size: Vector2 = viewport.get_visible_rect().size
var canvas_transform: Transform2D = viewport.get_canvas_transform()
var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse()
var safe_rect: Rect2 = Rect2(
Vector2(8.0, 72.0),
Vector2(maxf(0.0, viewport_size.x - 16.0), maxf(0.0, viewport_size.y - 112.0))
)
var entries: Array[Dictionary] = []
var selected_object_bounds: Array[Rect2] = []
for node: Node2D in _targets:
if node == null or not is_instance_valid(node) or not node.is_visible_in_tree():
continue
var full_name: String = _stage_name_for_node(node)
if full_name.is_empty():
continue
var font_size: int = maxi(1, roundi(_selected_name_pill_font_size_px / zoom))
var padding_world: Vector2 = _candidate_preview_name_padding / zoom
var max_text_width_world: float = maxf(32.0, (_selected_name_pill_max_width_px - _candidate_preview_name_padding.x * 2.0) / zoom)
var display_name: String = _elide_selected_name(full_name, font, font_size, max_text_width_world)
var text_size: Vector2 = font.get_string_size(display_name, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size)
var badge_world_size: Vector2 = Vector2(text_size.x, font.get_height(font_size)) + padding_world * 2.0
var bounds_screen: Rect2 = _world_rect_to_screen_rect(_target_bounds(node), canvas_transform)
selected_object_bounds.append(bounds_screen)
entries.append({
"node": node,
"stage_name": full_name,
"display_name": display_name,
"font_size": font_size,
"text_size": text_size,
"badge_world_size": badge_world_size,
"badge_screen_size": badge_world_size * zoom,
"bounds_screen": bounds_screen,
})
if entries.is_empty():
return
var occupied_badges: Array[Rect2] = []
for entry_index: int in entries.size():
var entry: Dictionary = entries[entry_index]
var node: Node2D = entry.get("node") as Node2D
var object_bounds: Rect2 = entry.get("bounds_screen", Rect2())
var badge_size: Vector2 = entry.get("badge_screen_size", Vector2.ZERO)
var object_center: Vector2 = object_bounds.get_center()
var gap: float = 8.0
var preferred: Rect2 = Rect2(Vector2(object_center.x - badge_size.x * 0.5, object_bounds.position.y - badge_size.y - gap), badge_size)
var candidates: Array[Rect2] = [
preferred,
Rect2(Vector2(object_bounds.position.x - badge_size.x - gap, object_center.y - badge_size.y * 0.5), badge_size),
Rect2(Vector2(object_bounds.end.x + gap, object_center.y - badge_size.y * 0.5), badge_size),
Rect2(Vector2(object_center.x - badge_size.x * 0.5, object_bounds.end.y + gap), badge_size),
Rect2(Vector2(object_bounds.position.x - badge_size.x - gap, object_bounds.position.y - badge_size.y - gap), badge_size),
Rect2(Vector2(object_bounds.end.x + gap, object_bounds.position.y - badge_size.y - gap), badge_size),
Rect2(Vector2(object_bounds.position.x - badge_size.x - gap, object_bounds.end.y - badge_size.y), badge_size),
Rect2(Vector2(object_bounds.end.x + gap, object_bounds.end.y - badge_size.y), badge_size),
]
var badge_id: int = node.get_instance_id()
var current_slot: int = int(_selected_name_badge_slots.get(badge_id, -1))
var best_slot: int = current_slot
var best_rect: Rect2 = _place_selected_name_badge(candidates[current_slot], badge_size, safe_rect) if current_slot >= 0 and current_slot < candidates.size() else Rect2()
var current_obstruction: float = _selected_name_badge_obstruction(
best_rect,
object_bounds,
entry_index,
selected_object_bounds,
occupied_badges,
node,
canvas_transform
) if current_slot >= 0 and current_slot < candidates.size() else INF
if current_obstruction > 0.01:
var best_score: float = INF
for candidate_index: int in candidates.size():
var placed: Rect2 = _place_selected_name_badge(candidates[candidate_index], badge_size, safe_rect)
var obstruction: float = _selected_name_badge_obstruction(
placed,
object_bounds,
entry_index,
selected_object_bounds,
occupied_badges,
node,
canvas_transform
)
var score: float = placed.position.distance_to(preferred.position) * 0.02 + obstruction
if score < best_score:
best_score = score
best_rect = placed
best_slot = candidate_index
_selected_name_badge_slots[badge_id] = best_slot
occupied_badges.append(best_rect)
entry["screen_rect"] = best_rect
entry["anchor_slot"] = best_slot
entry["tethered"] = best_rect.get_center().distance_to(preferred.get_center()) > 24.0
_last_selected_name_badge_layouts.append(entry)
for entry: Dictionary in _last_selected_name_badge_layouts:
var screen_rect: Rect2 = entry.get("screen_rect", Rect2())
var target_rect: Rect2 = entry.get("bounds_screen", Rect2())
if bool(entry.get("tethered", false)):
var target_point: Vector2 = _closest_point_on_rect(target_rect, screen_rect.get_center())
var badge_point: Vector2 = _closest_point_on_rect(screen_rect, target_point)
var tether_from: Vector2 = to_local(inverse_canvas_transform * target_point)
var tether_to: Vector2 = to_local(inverse_canvas_transform * badge_point)
draw_line(tether_from, tether_to, _candidate_preview_name_background, 4.0 / zoom, true)
draw_line(tether_from, tether_to, Color(_candidate_preview_color, 0.9), 1.5 / zoom, true)
entry["tether_from_screen"] = target_point
entry["tether_to_screen"] = badge_point
var local_position: Vector2 = to_local(inverse_canvas_transform * screen_rect.position)
var world_size: Vector2 = entry.get("badge_world_size", Vector2.ZERO)
var local_rect: Rect2 = Rect2(local_position, world_size)
_selected_name_pill_style.bg_color = _candidate_preview_name_background
_selected_name_pill_style.border_color = _candidate_preview_color
_selected_name_pill_style.set_corner_radius_all(maxi(1, roundi(_candidate_preview_name_corner_radius / zoom)))
_selected_name_pill_style.set_border_width_all(maxi(1, roundi(_candidate_preview_name_border_width / zoom)))
draw_style_box(_selected_name_pill_style, local_rect)
var font_size: int = int(entry.get("font_size", 20))
var text: String = String(entry.get("display_name", ""))
var baseline: float = local_rect.position.y + (local_rect.size.y - font.get_height(font_size)) * 0.5 + font.get_ascent(font_size)
draw_string(
font,
Vector2(local_rect.position.x + _candidate_preview_name_padding.x / zoom, baseline),
text,
HORIZONTAL_ALIGNMENT_LEFT,
-1.0,
font_size,
Color.WHITE
)
func _stage_name_for_node(node: Node2D) -> String:
if node == null or not is_instance_valid(node):
return ""
if node is PropBlockScript or node is StickmanRigScript or node.get_script() == MissingPropScript or node.get_script() == MissingStickmanScript:
return String(node.get("stage_name")).strip_edges()
return ""
func _sync_selected_name_badge_slots() -> void:
var selected_ids: Dictionary = {}
for node: Node2D in _targets:
if node != null and is_instance_valid(node):
selected_ids[node.get_instance_id()] = true
for id: Variant in _selected_name_badge_slots.keys():
if not selected_ids.has(id):
_selected_name_badge_slots.erase(id)
func _place_selected_name_badge(candidate: Rect2, badge_size: Vector2, safe_rect: Rect2) -> Rect2:
var safe_max: Vector2 = Vector2(
maxf(safe_rect.position.x, safe_rect.end.x - badge_size.x),
maxf(safe_rect.position.y, safe_rect.end.y - badge_size.y)
)
var clamped_position: Vector2 = Vector2(
clampf(candidate.position.x, safe_rect.position.x, safe_max.x),
clampf(candidate.position.y, safe_rect.position.y, safe_max.y)
)
return Rect2(clamped_position, badge_size)
func _selected_name_badge_obstruction(
badge_rect: Rect2,
object_bounds: Rect2,
entry_index: int,
selected_object_bounds: Array[Rect2],
occupied_badges: Array[Rect2],
node: Node2D,
canvas_transform: Transform2D
) -> float:
var obstruction: float = _selected_name_badge_overlap_penalty(badge_rect, object_bounds, 12000.0)
for other_index: int in selected_object_bounds.size():
if other_index != entry_index:
obstruction += _selected_name_badge_overlap_penalty(badge_rect, selected_object_bounds[other_index], 8000.0)
for obstacle: Rect2 in _selected_name_badge_obstacles:
obstruction += _selected_name_badge_overlap_penalty(badge_rect, obstacle, 12000.0)
for obstacle: Rect2 in _candidate_preview_obstacles:
obstruction += _selected_name_badge_overlap_penalty(badge_rect, obstacle, 12000.0)
for occupied: Rect2 in occupied_badges:
obstruction += _selected_name_badge_overlap_penalty(badge_rect, occupied, 24000.0)
if _transform_handles_visible and _targets.size() == 1:
var rotate_screen: Vector2 = canvas_transform * _rotate_handle_position(_target_bounds(node))
var rotate_rect: Rect2 = Rect2(rotate_screen - Vector2(24.0, 24.0), Vector2(48.0, 48.0))
obstruction += _selected_name_badge_overlap_penalty(badge_rect, rotate_rect, 16000.0)
if node is PropBlockScript:
for corner: Vector2 in _corners(_target_bounds(node)):
var corner_screen: Vector2 = canvas_transform * corner
var scale_rect: Rect2 = Rect2(corner_screen - Vector2(12.0, 12.0), Vector2(24.0, 24.0))
obstruction += _selected_name_badge_overlap_penalty(badge_rect, scale_rect, 16000.0)
return obstruction
func _selected_name_badge_overlap_penalty(badge_rect: Rect2, obstacle_rect: Rect2, weight: float) -> float:
var overlap_area: float = badge_rect.intersection(obstacle_rect).get_area()
return maxf(0.0, overlap_area - NAME_BADGE_OVERLAP_TOLERANCE_PX2) * weight
func _elide_selected_name(value: String, font: Font, font_size: int, max_width: float) -> String:
if font.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x <= max_width:
return value
var end_index: int = value.length()
while end_index > 1:
end_index -= 1
var candidate: String = value.substr(0, end_index) + "…"
if font.get_string_size(candidate, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x <= max_width:
return candidate
return "…"
func _closest_point_on_rect(rect: Rect2, point: Vector2) -> Vector2:
return Vector2(
clampf(point.x, rect.position.x, rect.end.x),
clampf(point.y, rect.position.y, rect.end.y)
)
func _world_rect_to_screen_rect(world_rect: Rect2, canvas_transform: Transform2D) -> Rect2:
var points: Array[Vector2] = [
world_rect.position,
+230 -37
View File
@@ -8,6 +8,7 @@ extends Node2D
## Sits above the World and ghost holder, below the CanvasLayer UI.
var camera: Camera2D = null
var surface_world: Node2D = null
## Terrain guide-line state.
var terrain_guide_visible: bool = false
@@ -21,6 +22,8 @@ var action_origin: Vector2 = Vector2.ZERO
var action_target: Vector2 = Vector2.ZERO
var action_curve_points: PackedVector2Array = PackedVector2Array()
var action_valid: bool = true
var action_show_trajectory: bool = true
var action_grounded_trajectory: bool = false
var action_requested_target: Vector2 = Vector2.ZERO
var action_show_requested_target: bool = false
var action_effective_target: Vector2 = Vector2.ZERO
@@ -40,6 +43,10 @@ var invalid_color: Color = Color(1.0, 0.3, 0.3)
var set_down_unknown_color: Color = Color(0.77, 0.81, 0.87, 0.9)
var set_down_reach_color: Color = Color(1.0, 0.7, 0.1, 0.9)
var set_down_ghost_alpha: float = 0.36
var set_down_surface_color: Color = Color(0.13, 0.78, 1.0, 0.5)
var set_down_surface_hover_color: Color = Color(0.13, 0.86, 1.0, 0.95)
var set_down_surface_line_width_px: float = 5.0
var set_down_surface_hover_line_width_px: float = 7.0
var line_width_px: float = 2.0
var dash_length_px: float = 8.0
var dash_gap_px: float = 5.0
@@ -48,6 +55,9 @@ const DASH_LENGTH_PX := 8.0
const DASH_GAP_PX := 5.0
const LINE_WIDTH_PX := 2.0
const MARKER_RADIUS_PX := 10.0
const SET_DOWN_SUPPORT_NORMAL_Y := -0.82
const SET_DOWN_SUPPORT_MIN_EDGE_PX := 18.0
const SET_DOWN_SURFACE_HOVER_RADIUS_PX := 22.0
func apply_style(style: Dictionary) -> void:
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
@@ -57,6 +67,12 @@ func apply_style(style: Dictionary) -> void:
set_down_unknown_color = Color.from_string(String(set_down_style.get("unknown_color", "")), set_down_unknown_color)
set_down_reach_color = Color.from_string(String(set_down_style.get("reach_color", "")), set_down_reach_color)
set_down_ghost_alpha = clampf(float(set_down_style.get("ghost_alpha", set_down_ghost_alpha)), 0.0, 1.0)
var set_down_surface_alpha: float = clampf(float(set_down_style.get("surface_alpha", 0.75)), 0.0, 1.0)
var set_down_surface_hover_alpha: float = clampf(float(set_down_style.get("surface_hover_alpha", 0.95)), 0.0, 1.0)
set_down_surface_color = Color(guide_line_color.r, guide_line_color.g, guide_line_color.b, set_down_surface_alpha)
set_down_surface_hover_color = Color(guide_line_color.r, guide_line_color.g, guide_line_color.b, set_down_surface_hover_alpha)
set_down_surface_line_width_px = maxf(1.0, float(set_down_style.get("surface_line_width_px", set_down_surface_line_width_px)))
set_down_surface_hover_line_width_px = maxf(set_down_surface_line_width_px, float(set_down_style.get("surface_hover_line_width_px", set_down_surface_hover_line_width_px)))
var defaults: Dictionary = style.get("style_defaults", {})
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
@@ -86,6 +102,8 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
action_target = target
action_curve_points.clear()
action_valid = valid
action_show_trajectory = true
action_grounded_trajectory = false
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
@@ -95,12 +113,41 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
set_down_prop = null
func set_grounded_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> void:
## Shows a flat walk/run route at the actor's ground level and keeps the
## cursor marker at its actual position.
set_action_trajectory(origin, target, valid)
action_grounded_trajectory = true
queue_redraw()
func set_action_marker(target: Vector2, valid: bool = true) -> void:
## Shows only the cursor marker when this capture already has a route preview.
action_visible = true
action_origin = target
action_target = target
action_curve_points.clear()
action_valid = valid
action_show_trajectory = false
action_grounded_trajectory = false
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
set_down_prop = null
queue_redraw()
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
action_visible = true
action_origin = origin
action_target = landing_target
action_curve_points = trajectory_points.duplicate()
action_valid = valid
action_show_trajectory = true
action_grounded_trajectory = false
action_requested_target = requested_target
action_show_requested_target = true
action_effective_target = effective_target if effective_target.is_finite() else landing_target
@@ -118,6 +165,8 @@ func set_throw_preview_unavailable(requested_target: Vector2) -> void:
action_origin = requested_target
action_target = requested_target
action_curve_points.clear()
action_show_trajectory = false
action_grounded_trajectory = false
action_valid = false
action_requested_target = requested_target
action_show_requested_target = false
@@ -133,6 +182,8 @@ func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid:
action_origin = origin
action_target = target
action_curve_points.clear()
action_show_trajectory = false
action_grounded_trajectory = false
action_valid = valid
action_preview_unavailable = not known
action_show_requested_target = false
@@ -148,6 +199,8 @@ func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid:
func clear_action() -> void:
action_visible = false
action_curve_points.clear()
action_show_trajectory = false
action_grounded_trajectory = false
action_show_requested_target = false
action_show_collision = false
action_preview_unavailable = false
@@ -176,10 +229,12 @@ func _draw() -> void:
color = Color(0.35, 0.8, 1.0, 0.95)
elif action_valid and action_preview_uncertain:
color = Color(0.8, 0.68, 1.0, 0.95)
if action_curve_points.size() >= 2:
draw_polyline(action_curve_points, color, line_width_px / _zoom(), true)
else:
_draw_dashed(action_origin, action_target, color)
if action_show_trajectory:
if action_curve_points.size() >= 2:
draw_polyline(action_curve_points, color, line_width_px / _zoom(), true)
else:
var route_target: Vector2 = Vector2(action_target.x, action_origin.y) if action_grounded_trajectory else action_target
_draw_dashed(action_origin, route_target, color)
if action_show_requested_target:
_draw_ghost_marker(action_effective_target, color)
var request_color := Color(1.0, 0.85, 0.25, 0.9)
@@ -192,40 +247,178 @@ func _draw() -> void:
func _draw_set_down_preview() -> void:
var zoom := _zoom()
var color := valid_color if action_valid else invalid_color
if not set_down_preview_known:
color = set_down_unknown_color
_draw_dashed(action_origin, set_down_approach if set_down_approach.is_finite() and set_down_approach != Vector2.ZERO else action_target, color)
if set_down_preview_known and set_down_prop != null and is_instance_valid(set_down_prop):
var polygon_node := set_down_prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if polygon_node != null and polygon_node.polygon.size() >= 3:
var local_transform: Transform2D = set_down_prop.global_transform.affine_inverse() * polygon_node.global_transform
var ghost_transform: Transform2D = set_down_transform * local_transform
var ghost_points := PackedVector2Array()
for point: Vector2 in polygon_node.polygon:
ghost_points.append(ghost_transform * point)
var fill := set_down_prop.fill_color
fill.a = set_down_ghost_alpha
draw_colored_polygon(ghost_points, fill)
var outline_points := ghost_points.duplicate()
outline_points.append(ghost_points[0])
draw_polyline(outline_points, color, line_width_px / zoom, true)
_draw_set_down_support_surfaces()
func _draw_set_down_support_surfaces() -> void:
if surface_world == null or not is_instance_valid(surface_world):
return
var support_surfaces: Array[Dictionary] = []
var occluders: Array[Dictionary] = []
for child: Node in surface_world.get_children():
if child is TerrainBlock:
var terrain: TerrainBlock = child as TerrainBlock
if not terrain.is_visible_in_tree():
continue
var terrain_points: PackedVector2Array = _terrain_collision_world_points(terrain)
if terrain_points.size() < 3:
continue
var terrain_entry: Dictionary = {"node": terrain, "points": terrain_points}
support_surfaces.append(terrain_entry)
occluders.append(terrain_entry)
elif child is PropBlock:
var prop: PropBlock = child as PropBlock
if not prop.is_visible_in_tree():
continue
var prop_points: PackedVector2Array = _prop_collision_world_points(prop)
if prop_points.size() < 3:
continue
var prop_entry: Dictionary = {"node": prop, "points": prop_points}
occluders.append(prop_entry)
if prop.runtime_role != PropBlock.RuntimeRole.HELD and bool(prop.stage_roles.get("holds_other_props", false)):
support_surfaces.append(prop_entry)
var hovered_piece: Dictionary = {}
var hovered_distance_px: float = SET_DOWN_SURFACE_HOVER_RADIUS_PX
var zoom: float = _zoom()
for surface: Dictionary in support_surfaces:
var surface_owner: Node = surface.get("node") as Node
var points: PackedVector2Array = surface.get("points", PackedVector2Array())
for top_edge: PackedVector2Array in _top_facing_edges(points):
var visible_pieces: Array[PackedVector2Array] = _clip_surface_edge(top_edge[0], top_edge[1], surface_owner, occluders)
for piece: PackedVector2Array in visible_pieces:
var start: Vector2 = piece[0]
var finish: Vector2 = piece[1]
draw_line(start, finish, set_down_surface_color, set_down_surface_line_width_px / zoom, true)
var closest: Vector2 = _closest_point_on_segment(action_target, start, finish)
var distance_px: float = action_target.distance_to(closest) * zoom
if distance_px <= hovered_distance_px:
hovered_distance_px = distance_px
hovered_piece = {"start": start, "finish": finish, "point": closest}
if hovered_piece.is_empty():
return
var hover_start: Vector2 = hovered_piece.get("start", Vector2.ZERO)
var hover_finish: Vector2 = hovered_piece.get("finish", Vector2.ZERO)
var pin_position: Vector2 = hovered_piece.get("point", action_target)
draw_line(hover_start, hover_finish, set_down_surface_hover_color, set_down_surface_hover_line_width_px / zoom, true)
var pin_radius: float = 5.0 / zoom
var pin_center: Vector2 = pin_position - Vector2(0.0, 8.0 / zoom)
draw_line(pin_center + Vector2(0.0, pin_radius), pin_position, set_down_surface_hover_color, line_width_px / zoom, true)
draw_circle(pin_center, pin_radius * 0.55, Color(set_down_surface_hover_color.r, set_down_surface_hover_color.g, set_down_surface_hover_color.b, 0.28))
draw_arc(pin_center, pin_radius, 0.0, TAU, 20, set_down_surface_hover_color, line_width_px / zoom, true)
func _terrain_collision_world_points(terrain: TerrainBlock) -> PackedVector2Array:
var collision: CollisionPolygon2D = terrain.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if collision == null or collision.disabled or collision.polygon.size() < 3:
return PackedVector2Array()
return _to_world_points(collision, collision.polygon)
func _prop_collision_world_points(prop: PropBlock) -> PackedVector2Array:
var polygon_collision: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D
if polygon_collision != null and not polygon_collision.disabled and polygon_collision.polygon.size() >= 3:
return _to_world_points(polygon_collision, polygon_collision.polygon)
var circle_collision: CollisionShape2D = prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if circle_collision == null or circle_collision.disabled or not circle_collision.shape is CircleShape2D:
return PackedVector2Array()
var circle: CircleShape2D = circle_collision.shape as CircleShape2D
var points := PackedVector2Array()
for index: int in PropBlock.CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(PropBlock.CIRCLE_SEGMENTS)
points.append(circle_collision.to_global(Vector2(cos(angle), sin(angle)) * circle.radius))
return points
func _to_world_points(transform_node: Node2D, local_points: PackedVector2Array) -> PackedVector2Array:
var points := PackedVector2Array()
for point: Vector2 in local_points:
points.append(transform_node.to_global(point))
return points
func _top_facing_edges(points: PackedVector2Array) -> Array[PackedVector2Array]:
var edges: Array[PackedVector2Array] = []
if points.size() < 3:
return edges
for index: int in points.size():
var start: Vector2 = points[index]
var finish: Vector2 = points[(index + 1) % points.size()]
var direction: Vector2 = finish - start
var edge_length: float = direction.length()
if edge_length * _zoom() < SET_DOWN_SUPPORT_MIN_EDGE_PX:
continue
var normal: Vector2 = Vector2(-direction.y, direction.x).normalized()
var midpoint: Vector2 = (start + finish) * 0.5
var sample_offset: Vector2 = normal * 2.0
var plus_inside: bool = Geometry2D.is_point_in_polygon(midpoint + sample_offset, points)
var minus_inside: bool = Geometry2D.is_point_in_polygon(midpoint - sample_offset, points)
var outward: Vector2 = Vector2.ZERO
if plus_inside and not minus_inside:
outward = -normal
elif minus_inside and not plus_inside:
outward = normal
else:
_draw_ghost_marker(action_target, color)
else:
_draw_ghost_marker(action_target, color)
var release_origin := set_down_transform.origin
var target_adjusted := set_down_preview_known and release_origin.distance_to(action_target) > 1.0
if target_adjusted:
_draw_dashed(action_target, release_origin, set_down_reach_color)
draw_arc(release_origin, 4.0 / zoom, 0.0, TAU, 16, set_down_reach_color, LINE_WIDTH_PX / zoom, true)
var cross_radius := 9.0 / zoom
var target_color := set_down_reach_color if target_adjusted else color
draw_line(action_target + Vector2(-cross_radius, 0.0), action_target + Vector2(cross_radius, 0.0), target_color, line_width_px / zoom, true)
draw_line(action_target + Vector2(0.0, -cross_radius), action_target + Vector2(0.0, cross_radius), target_color, line_width_px / zoom, true)
if set_down_grip.is_finite() and set_down_grip != Vector2.ZERO:
draw_line(set_down_approach, set_down_grip, set_down_reach_color, line_width_px / zoom, true)
continue
if outward.y > SET_DOWN_SUPPORT_NORMAL_Y:
continue
edges.append(PackedVector2Array([start, finish]))
return edges
func _clip_surface_edge(start: Vector2, finish: Vector2, surface_owner: Node, occluders: Array[Dictionary]) -> Array[PackedVector2Array]:
var pieces: Array[PackedVector2Array] = []
var cuts: Array[float] = [0.0, 1.0]
for occluder: Dictionary in occluders:
if occluder.get("node") == surface_owner:
continue
var polygon: PackedVector2Array = occluder.get("points", PackedVector2Array())
for index: int in polygon.size():
var intersection: float = _segment_intersection_factor(start, finish, polygon[index], polygon[(index + 1) % polygon.size()])
if intersection > 0.0001 and intersection < 0.9999:
cuts.append(intersection)
cuts.sort()
var unique_cuts: Array[float] = []
for cut: float in cuts:
if unique_cuts.is_empty() or absf(cut - unique_cuts.back()) > 0.0001:
unique_cuts.append(cut)
for index: int in range(unique_cuts.size() - 1):
var from_t: float = unique_cuts[index]
var to_t: float = unique_cuts[index + 1]
if to_t - from_t <= 0.0001:
continue
var midpoint: Vector2 = start.lerp(finish, (from_t + to_t) * 0.5)
var occluded: bool = false
for occluder: Dictionary in occluders:
if occluder.get("node") == surface_owner:
continue
var polygon: PackedVector2Array = occluder.get("points", PackedVector2Array())
if polygon.size() >= 3 and Geometry2D.is_point_in_polygon(midpoint, polygon):
occluded = true
break
if not occluded:
pieces.append(PackedVector2Array([start.lerp(finish, from_t), start.lerp(finish, to_t)]))
return pieces
func _segment_intersection_factor(start: Vector2, finish: Vector2, edge_start: Vector2, edge_finish: Vector2) -> float:
var direction: Vector2 = finish - start
var edge_direction: Vector2 = edge_finish - edge_start
var denominator: float = direction.cross(edge_direction)
if absf(denominator) <= 0.0001:
return -1.0
var offset: Vector2 = edge_start - start
var factor: float = offset.cross(edge_direction) / denominator
var edge_factor: float = offset.cross(direction) / denominator
return factor if factor >= 0.0 and factor <= 1.0 and edge_factor >= 0.0 and edge_factor <= 1.0 else -1.0
func _closest_point_on_segment(point: Vector2, start: Vector2, finish: Vector2) -> Vector2:
var segment: Vector2 = finish - start
var length_squared: float = segment.length_squared()
if length_squared <= 0.0001:
return start
var factor: float = clampf((point - start).dot(segment) / length_squared, 0.0, 1.0)
return start + segment * factor
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
+293 -28
View File
@@ -3,7 +3,7 @@ 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.8"
const FILE_VERSION: String = "1.9"
const DEFAULT_PLAIN_COLOR: String = "#F5EFE0"
const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
@@ -13,6 +13,7 @@ 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")
const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd")
const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd")
const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"]
const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"]
const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"]
@@ -69,6 +70,51 @@ static func decode_queue(entries: Array) -> Dictionary:
out.append(decoded)
return {"actions": out, "warnings": warnings}
static func encode_plan(steps: Array[Dictionary]) -> Dictionary:
var encoded_steps: Array[Dictionary] = []
var warnings: Array[String] = []
for step_index: int in steps.size():
var raw_actions: Variant = steps[step_index].get("actions", [])
if not raw_actions is Array or (raw_actions as Array).is_empty():
warnings.append("Skipped empty or malformed plan step %d." % (step_index + 1))
continue
var encoded_actions: Array[Dictionary] = []
for raw_action: Variant in raw_actions:
if not raw_action is Dictionary:
warnings.append("Skipped malformed action in step %d." % (step_index + 1))
continue
var encoded: Dictionary = _encode_plan_action(raw_action as Dictionary)
if encoded.is_empty():
warnings.append("Skipped invalid action in step %d." % (step_index + 1))
else:
encoded_actions.append(encoded)
if not encoded_actions.is_empty():
encoded_steps.append({"actions": encoded_actions})
return {"steps": encoded_steps, "warnings": warnings}
static func decode_plan(entries: Array) -> Dictionary:
var decoded_steps: Array[Dictionary] = []
var warnings: Array[String] = []
for step_index: int in entries.size():
var raw_step: Variant = entries[step_index]
if not raw_step is Dictionary:
return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d is malformed." % (step_index + 1)}
var raw_actions: Variant = (raw_step as Dictionary).get("actions", null)
if not raw_actions is Array or (raw_actions as Array).is_empty():
return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d must contain actions." % (step_index + 1)}
var decoded_actions: Array[Dictionary] = []
for raw_action: Variant in raw_actions:
if not raw_action is Dictionary:
return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d has a malformed action." % (step_index + 1)}
var decoded: Dictionary = _decode_plan_action(raw_action as Dictionary)
if decoded.is_empty():
return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d has an invalid action." % (step_index + 1)}
decoded_actions.append(decoded)
decoded_steps.append({"actions": decoded_actions})
return {"ok": true, "steps": decoded_steps, "warnings": warnings, "error": ""}
static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary:
var out: Array[Dictionary] = []
var warnings: Array[String] = []
@@ -153,7 +199,13 @@ static func read_file(path: String) -> Dictionary:
return {"ok": false, "data": {}, "error": "Scene is missing objects or event_rules arrays.", "warnings": []}
var validation: Dictionary = _validate_root(data)
if not bool(validation.get("ok", false)): return {"ok": false, "data": {}, "error": String(validation.get("error", "Invalid scene data.")), "warnings": []}
return {"ok": true, "data": data, "error": "", "warnings": []}
var warnings: Array[String] = []
var migration_warnings: Variant = data.get("_migration_warnings", [])
if migration_warnings is Array:
for warning: Variant in migration_warnings:
warnings.append(String(warning))
data.erase("_migration_warnings")
return {"ok": true, "data": data, "error": "", "warnings": warnings}
static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
path = CONTENT_PATHS.canonicalize(path)
@@ -175,26 +227,16 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
static func _migrate_to_current(data: Dictionary) -> Dictionary:
var version := String(data.get("version", ""))
if version == FILE_VERSION:
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":
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)
if not prop_entry.has("wobble_on_impact"):
prop_entry["wobble_on_impact"] = false
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]:
return data.duplicate(true)
if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8"]:
return {}
var migrated: Dictionary = data.duplicate(true)
if version != "1.7":
if version not in ["1.5", "1.6"]:
migrated["sky_id"] = "sky_neutral"
migrated["background_kind"] = "sky"
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
if version != "1.8":
if version != "1.7":
if version not in ["1.5", "1.6"]:
migrated["sky_id"] = "sky_neutral"
migrated["background_kind"] = "sky"
migrated["plain_color"] = DEFAULT_PLAIN_COLOR
for raw: Variant in migrated.get("objects", []):
if not raw is Dictionary:
continue
@@ -202,22 +244,96 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
if String(object.get("type", "")) == "prop":
if version == "1.0":
object["stage_object_id"] = String(object.get("file_id", ""))
if not object.has("roles"):
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
object["use_spots"] = object.get("use_spots", [])
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
if not object.has("wobble_on_impact"):
object["wobble_on_impact"] = false
_ensure_legacy_prop_fields(object)
if version not in ["1.5", "1.6", "1.7"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground":
object["finish_id"] = "finish_ground_legacy"
if version not in ["1.6", "1.7"] and String(object.get("type", "")) == "wall_panel":
_migrate_legacy_wall_tint(object)
migrated["version"] = FILE_VERSION
if version != "1.8":
_migrate_stage_object_identity(migrated.get("objects", []))
_assign_legacy_depths(migrated.get("objects", []))
_migrate_stage_object_identity(migrated.get("objects", []))
_migrate_legacy_action_plans(migrated)
migrated["version"] = FILE_VERSION
return migrated
static func _ensure_legacy_prop_fields(prop_entry: Dictionary) -> void:
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)
if not prop_entry.has("wobble_on_impact"):
prop_entry["wobble_on_impact"] = false
static func _migrate_legacy_action_plans(data: Dictionary) -> void:
var objects_value: Variant = data.get("objects", [])
if not objects_value is Array:
return
var warnings: Array[String] = []
for raw_object: Variant in objects_value:
if not raw_object is Dictionary:
continue
var object: Dictionary = raw_object
if String(object.get("type", "")) != "stickman":
continue
var raw_queue: Variant = object.get("action_queue", [])
var decoded: Dictionary = decode_queue(raw_queue as Array) if raw_queue is Array else {"actions": [], "warnings": ["Invalid action queue."]}
var queue: Array[Dictionary] = decoded.get("actions", [])
for warning: String in decoded.get("warnings", []):
warnings.append("%s: %s" % [String(object.get("stage_name", object.get("file_id", "Character"))), warning])
var plan: Array[Dictionary] = []
var queue_index: int = 0
while queue_index < queue.size():
var action: Dictionary = queue[queue_index].duplicate(true)
var action_id: String = _legacy_action_id(object, queue_index)
action["action_id"] = action_id
if ACTION_REGISTRY.is_move_action(action):
var target_value: Variant = action.get("target", null)
if target_value is Vector2:
action["destination"] = {"kind": "place", "position": target_value}
else:
warnings.append("%s: Invalid Move target was skipped." % String(object.get("stage_name", "Character")))
queue_index += 1
continue
action.erase("target")
var paired: bool = false
if String(action.get("type", "")) == "speak" and bool(action.get("keep_acting", false)) and queue_index + 1 < queue.size():
var next_action: Dictionary = queue[queue_index + 1]
if ACTION_REGISTRY.is_move_action(next_action) or String(next_action.get("type", "")) == "wait":
var primary: Dictionary = next_action.duplicate(true)
primary["action_id"] = _legacy_action_id(object, queue_index + 1)
if ACTION_REGISTRY.is_move_action(primary):
var primary_target: Variant = primary.get("target", null)
if primary_target is Vector2:
primary["destination"] = {"kind": "place", "position": primary_target}
primary.erase("target")
else:
primary.clear()
if not primary.is_empty():
action["legacy_nonblocking"] = true
plan.append({"actions": [action, primary]})
queue_index += 2
paired = true
if paired:
continue
if String(action.get("type", "")) == "speak" and bool(action.get("keep_acting", false)):
action["legacy_nonblocking"] = true
plan.append({"actions": [action]})
queue_index += 1
object["action_plan"] = (encode_plan(plan).get("steps", []) as Array).duplicate(true)
object.erase("action_queue")
if not warnings.is_empty():
data["_migration_warnings"] = warnings
static func _legacy_action_id(object: Dictionary, index: int) -> String:
var owner: String = String(object.get("stage_object_id", object.get("file_id", "actor"))).strip_edges()
if owner.is_empty():
owner = String(object.get("file_id", "actor"))
return "legacy_%s_action_%d" % [owner, index + 1]
static func _migrate_stage_object_identity(raw_objects: Variant) -> void:
if not raw_objects is Array:
return
@@ -324,6 +440,7 @@ static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
var seen_stage_names: Dictionary = {}
var seen_action_ids: Dictionary = {}
for raw: Variant in data["objects"]:
if not raw is Dictionary: continue
var entry: Dictionary = raw
@@ -331,6 +448,26 @@ static func _validate_root(data: Dictionary) -> Dictionary:
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
seen[file_id] = true
var object_type: String = String(entry.get("type", ""))
if object_type == "stickman":
if entry.has("action_queue"):
return {"ok": false, "error": "Scene v1.9 stickmen must not contain an action_queue."}
var plan_value: Variant = entry.get("action_plan", null)
if not plan_value is Array:
return {"ok": false, "error": "Scene stickman action_plan must be an array."}
var decoded_plan: Dictionary = decode_plan(plan_value as Array)
if not bool(decoded_plan.get("ok", false)):
return {"ok": false, "error": String(decoded_plan.get("error", "Scene action plan is invalid."))}
var plan_steps: Array[Dictionary] = decoded_plan.get("steps", [])
var plan_error: String = STAGE_ACTION_PLAN.validate_steps(plan_steps)
if not plan_error.is_empty():
return {"ok": false, "error": plan_error}
for step: Dictionary in plan_steps:
var actions: Array = step.get("actions", [])
for action: Dictionary in actions:
var action_id: String = String(action.get("action_id", ""))
if seen_action_ids.has(action_id):
return {"ok": false, "error": "Action IDs must be unique across the scene."}
seen_action_ids[action_id] = true
if object_type in ["stickman", "prop"]:
var raw_stage_id: Variant = entry.get("stage_object_id", null)
if not raw_stage_id is String:
@@ -519,6 +656,134 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id}
_: return {"type": type}
static func _encode_plan_action(action: Dictionary) -> Dictionary:
var action_id: Variant = action.get("action_id", null)
if not action_id is String or String(action_id).strip_edges().is_empty():
return {}
var type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(type):
return {}
var encoded: Dictionary = {}
if type == ACTION_REGISTRY.MOVE_TO:
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
var destination: Dictionary = _encode_plan_destination(action.get("destination", null))
if style.is_empty() or destination.is_empty():
return {}
encoded = {"type": type, "style": style, "destination": destination}
elif type == "speak":
encoded = _encode_speech(action)
elif type == "wait":
if not _finite_number(action.get("duration", null)) or float(action["duration"]) < 0.0:
return {}
encoded = {"type": type, "duration": float(action["duration"])}
else:
encoded = _encode_flat_action(action)
if encoded.is_empty():
return {}
encoded["action_id"] = String(action_id)
if bool(action.get("legacy_nonblocking", false)):
encoded["legacy_nonblocking"] = true
return encoded
static func _decode_plan_action(action: Dictionary) -> Dictionary:
var action_id: Variant = action.get("action_id", null)
if not action_id is String or String(action_id).strip_edges().is_empty():
return {}
var type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", "")))
if not ACTION_REGISTRY.has_type(type):
return {}
if action.has("legacy_nonblocking") and (not action["legacy_nonblocking"] is bool or type != "speak"):
return {}
var decoded: Dictionary = {}
if type == ACTION_REGISTRY.MOVE_TO:
var style: String = ACTION_REGISTRY.checked_style(action.get("style", null))
var destination: Dictionary = _decode_plan_destination(action.get("destination", null))
if style.is_empty() or destination.is_empty():
return {}
decoded = {"type": type, "style": style, "destination": destination}
decoded["target"] = destination.get("position", destination.get("last_known_approach", Vector2.ZERO))
elif type == "speak":
decoded = _decode_speech(action)
elif type == "wait":
if not _finite_number(action.get("duration", null)) or float(action["duration"]) < 0.0:
return {}
decoded = {"type": type, "duration": float(action["duration"])}
else:
decoded = _decode_flat_action(action)
if decoded.is_empty():
return {}
decoded["action_id"] = String(action_id)
if bool(action.get("legacy_nonblocking", false)):
decoded["legacy_nonblocking"] = true
return decoded
static func _encode_plan_destination(value: Variant) -> Dictionary:
if not value is Dictionary:
return {}
var destination: Dictionary = value
var kind: String = String(destination.get("kind", ""))
if kind == "place":
for key: String in destination.keys():
if key not in ["kind", "position"]:
return {}
var position: Variant = _vector_variant_to_data(destination.get("position", null))
return {"kind": kind, "position": position} if position is Dictionary else {}
if kind == "prop":
for key: String in destination.keys():
if key not in ["kind", "target_prop_id", "approach_offset", "last_known_approach"]:
return {}
var prop_id_value: Variant = destination.get("target_prop_id", null)
if not prop_id_value is String or String(prop_id_value).strip_edges().is_empty():
return {}
var offset: Variant = _vector_variant_to_data(destination.get("approach_offset", null))
var last_known: Variant = _vector_variant_to_data(destination.get("last_known_approach", null))
if not offset is Dictionary or not last_known is Dictionary:
return {}
return {"kind": kind, "target_prop_id": String(prop_id_value), "approach_offset": offset, "last_known_approach": last_known}
return {}
static func _decode_plan_destination(value: Variant) -> Dictionary:
if not value is Dictionary:
return {}
var destination: Dictionary = value
var kind: String = String(destination.get("kind", ""))
if kind == "place":
for key: String in destination.keys():
if key not in ["kind", "position"]:
return {}
if not is_vector_data(destination.get("position", null)):
return {}
return {"kind": kind, "position": data_to_vector(destination["position"], Vector2.ZERO)}
if kind == "prop":
for key: String in destination.keys():
if key not in ["kind", "target_prop_id", "approach_offset", "last_known_approach"]:
return {}
var prop_id_value: Variant = destination.get("target_prop_id", null)
if not prop_id_value is String or String(prop_id_value).strip_edges().is_empty():
return {}
if not is_vector_data(destination.get("approach_offset", null)) or not is_vector_data(destination.get("last_known_approach", null)):
return {}
return {
"kind": kind,
"target_prop_id": String(prop_id_value),
"approach_offset": data_to_vector(destination["approach_offset"], Vector2.ZERO),
"last_known_approach": data_to_vector(destination["last_known_approach"], Vector2.ZERO),
}
return {}
static func _vector_variant_to_data(value: Variant) -> Variant:
if value is Vector2:
return vector_to_data(value as Vector2) if (value as Vector2).is_finite() else null
if is_vector_data(value):
var point: Vector2 = data_to_vector(value, Vector2.ZERO)
return vector_to_data(point)
return null
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
if int(value) == -1: return null
return map.get(int(value), null)