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
+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)