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

- Added UI/UX specification for prop roles and set down mechanics in phase 17a.
- Created `PropStageRoles` script to manage normalized behavior for props, including depth, motion, and collision settings.
- Introduced `StagePhysicsPolicy` for shared physics configurations related to props and actors.
- Developed comprehensive tests for prop roles, including default settings, legacy migration, serialization, and runtime behavior.
- Ensured proper handling of set down actions, including clearance and orientation preservation.
This commit is contained in:
2026-10-09 23:11:34 -04:00
parent 3f66cc3195
commit 1d4a71525a
52 changed files with 20051 additions and 109 deletions
+16
View File
@@ -75,6 +75,14 @@ const ACTION_TEMPLATES := {
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"set_down_prop": {
"label": "Set Down Prop",
"icon": "🫳",
"params": [
{ "key": "target_position", "type": "position", "required": true },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
}
@@ -157,6 +165,8 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
params["hand"] = String(action.get("hand", "auto"))
"throw_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
"set_down_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
return {
"type": type,
"target": target_id,
@@ -187,6 +197,8 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
return { "type": "drop_prop" }
"throw_prop":
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
"set_down_prop":
return { "type": "set_down_prop", "target_position": params.get("target_position", Vector2.ZERO) }
_:
return { "type": type }
@@ -224,6 +236,10 @@ static func summarize(action: Dictionary) -> String:
var raw_position: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var target_position: Vector2 = raw_position as Vector2 if raw_position is Vector2 else Vector2.ZERO
return "Throw Prop (%d, %d)" % [int(roundf(target_position.x)), int(roundf(target_position.y))]
"set_down_prop":
var raw_release: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var release_position: Vector2 = raw_release as Vector2 if raw_release is Vector2 else Vector2.ZERO
return "Set Down Prop (%d, %d)" % [int(roundf(release_position.x)), int(roundf(release_position.y))]
_:
return type
+45 -1
View File
@@ -14,7 +14,15 @@ const DEFAULT_CONFIG: Dictionary = {
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"stage_panels": {
"shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"},
"type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"},
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"},
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"}
},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
}
@@ -222,12 +230,48 @@ func _apply_legacy_aliases(source: Dictionary) -> void:
var modes: Dictionary = source.get("mode_colors", {})
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
var old_panel_colors: Dictionary = source.get("stage_prop_settings", {})
if old_panel_colors.has("edit_accent") and not modes.has("edit"):
modes["edit"] = old_panel_colors["edit_accent"]
var fonts: Dictionary = source.get("fonts", {})
var sizes: Dictionary = fonts.get("sizes", {})
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
var legacy: String = name + "_font_size"
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
var old_panel: Dictionary = source.get("stage_prop_settings", {})
if not old_panel.is_empty():
var stage_panels: Dictionary = source.get("stage_panels", {})
for mapping: Array in [
["card_width", "shell", "preferred_width"], ["padding", "shell", "padding"],
["corner_radius", "shell", "corner_radius"], ["title_size", "type", "title_size"],
["section_size", "type", "section_size"], ["body_size", "type", "body_size"],
["helper_font_size", "type", "helper_size"], ["choice_text_color", "type", "text_color"],
["helper_color", "type", "muted_text_color"], ["choice_min_height", "choice", "minimum_height"],
["button_min_height", "action", "min_target_size"],
["choice_neutral_surface", "choice", "neutral_surface"], ["choice_hover_surface", "choice", "hover_surface"],
["choice_selected_surface", "choice", "selected_surface"], ["neutral_border", "choice", "border_color"],
["disabled_surface", "choice", "disabled_surface"],
["toggle_row_min_height", "toggle", "minimum_height"], ["toggle_help_min_height", "toggle", "help_minimum_height"],
["row_padding", "toggle", "row_padding"], ["switch_width", "toggle", "switch_width"],
["switch_height", "toggle", "switch_height"], ["switch_knob_size", "toggle", "knob_size"],
["toggle_surface", "toggle", "neutral_surface"], ["toggle_hover_surface", "toggle", "hover_surface"],
["toggle_selected_surface", "toggle", "selected_surface"], ["switch_on_color", "toggle", "switch_on_color"],
["disabled_surface", "toggle", "disabled_surface"], ["neutral_border", "toggle", "border_color"],
["switch_off_color", "toggle", "switch_off_color"], ["switch_disabled_color", "toggle", "switch_disabled_color"],
["switch_knob_color", "toggle", "knob_color"], ["stickman_cue_color", "prop", "stickman_cue_color"],
["prop_cue_color", "prop", "prop_cue_color"], ["cue_outline_color", "prop", "cue_outline_color"]
]:
var old_key: String = mapping[0]
var group_key: String = mapping[1]
var new_key: String = mapping[2]
if not old_panel.has(old_key):
continue
var group: Dictionary = stage_panels.get(group_key, {})
if not group.has(new_key):
group[new_key] = old_panel[old_key]
stage_panels[group_key] = group
source["stage_panels"] = stage_panels
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
+22
View File
@@ -0,0 +1,22 @@
class_name StagePhysicsPolicy
extends RefCounted
## Named physics bits shared by Stage objects and stickman collision probes.
const TERRAIN_LAYER: int = 1 << 0
const ACTOR_LAYER: int = 1 << 1
const PROP_LAYER: int = 1 << 2
const ACTOR_BLOCKING_PROP_LAYER: int = 1 << 3
const ACTOR_QUERY_MASK: int = TERRAIN_LAYER | ACTOR_LAYER | ACTOR_BLOCKING_PROP_LAYER
const PROP_QUERY_MASK: int = TERRAIN_LAYER | ACTOR_LAYER | PROP_LAYER
static func prop_layer(blocks_stickmen: bool) -> int:
return PROP_LAYER | (ACTOR_BLOCKING_PROP_LAYER if blocks_stickmen else 0)
static func prop_mask(blocks_stickmen: bool) -> int:
return TERRAIN_LAYER | PROP_LAYER | (ACTOR_LAYER if blocks_stickmen else 0)
static func actor_mask() -> int:
return TERRAIN_LAYER | ACTOR_LAYER | ACTOR_BLOCKING_PROP_LAYER
@@ -0,0 +1 @@
uid://gpw1hqukuefb
+77 -1
View File
@@ -29,8 +29,17 @@ var action_show_collision: bool = false
var action_preview_unavailable: bool = false
var action_preview_planned: bool = false
var action_preview_uncertain: bool = false
var set_down_preview_visible: bool = false
var set_down_preview_known: bool = false
var set_down_prop: PropBlock = null
var set_down_transform: Transform2D = Transform2D.IDENTITY
var set_down_approach: Vector2 = Vector2.ZERO
var set_down_grip: Vector2 = Vector2.ZERO
var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3)
var set_down_unknown_color: Color = Color(0.77, 0.81, 0.87, 0.9)
var set_down_reach_color: Color = Color(1.0, 0.7, 0.1, 0.9)
var set_down_ghost_alpha: float = 0.36
var line_width_px: float = 2.0
var dash_length_px: float = 8.0
var dash_gap_px: float = 5.0
@@ -44,6 +53,10 @@ func apply_style(style: Dictionary) -> void:
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
var set_down_style: Dictionary = style.get("set_down", {})
set_down_unknown_color = Color.from_string(String(set_down_style.get("unknown_color", "")), set_down_unknown_color)
set_down_reach_color = Color.from_string(String(set_down_style.get("reach_color", "")), set_down_reach_color)
set_down_ghost_alpha = clampf(float(set_down_style.get("ghost_alpha", set_down_ghost_alpha)), 0.0, 1.0)
var defaults: Dictionary = style.get("style_defaults", {})
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
@@ -78,6 +91,8 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
set_down_prop = null
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
@@ -94,6 +109,8 @@ func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_ta
action_preview_unavailable = false
action_preview_planned = planned
action_preview_uncertain = uncertain
set_down_preview_visible = false
set_down_prop = null
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
@@ -108,6 +125,24 @@ func set_throw_preview_unavailable(requested_target: Vector2) -> void:
action_preview_unavailable = true
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid: bool, prop: PropBlock = null, release_transform: Transform2D = Transform2D.IDENTITY, approach: Vector2 = Vector2.ZERO, grip: Vector2 = Vector2.ZERO) -> void:
action_visible = true
action_origin = origin
action_target = target
action_curve_points.clear()
action_valid = valid
action_preview_unavailable = not known
action_show_requested_target = false
action_show_collision = false
set_down_preview_visible = true
set_down_preview_known = known
set_down_prop = prop
set_down_transform = release_transform
set_down_approach = approach
set_down_grip = grip
func clear_action() -> void:
@@ -118,6 +153,8 @@ func clear_action() -> void:
action_preview_unavailable = false
action_preview_planned = false
action_preview_uncertain = false
set_down_preview_visible = false
set_down_prop = null
queue_redraw()
@@ -127,7 +164,9 @@ func _draw() -> void:
_draw_cell_marker(terrain_anchor, guide_line_color)
_draw_cell_marker(terrain_target, guide_line_color)
if action_visible:
if action_preview_unavailable:
if set_down_preview_visible:
_draw_set_down_preview()
elif action_preview_unavailable:
var zoom := _zoom()
var unavailable_color := Color(0.74, 0.79, 0.85, 0.9)
draw_arc(action_target, MARKER_RADIUS_PX / zoom, 0.0, TAU, 24, unavailable_color, LINE_WIDTH_PX / zoom, true)
@@ -152,6 +191,43 @@ func _draw() -> void:
_draw_ghost_marker(action_target, color)
func _draw_set_down_preview() -> void:
var zoom := _zoom()
var color := valid_color if action_valid else invalid_color
if not set_down_preview_known:
color = set_down_unknown_color
_draw_dashed(action_origin, set_down_approach if set_down_approach.is_finite() and set_down_approach != Vector2.ZERO else action_target, color)
if set_down_preview_known and set_down_prop != null and is_instance_valid(set_down_prop):
var polygon_node := set_down_prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
if polygon_node != null and polygon_node.polygon.size() >= 3:
var local_transform: Transform2D = set_down_prop.global_transform.affine_inverse() * polygon_node.global_transform
var ghost_transform: Transform2D = set_down_transform * local_transform
var ghost_points := PackedVector2Array()
for point: Vector2 in polygon_node.polygon:
ghost_points.append(ghost_transform * point)
var fill := set_down_prop.fill_color
fill.a = set_down_ghost_alpha
draw_colored_polygon(ghost_points, fill)
var outline_points := ghost_points.duplicate()
outline_points.append(ghost_points[0])
draw_polyline(outline_points, color, line_width_px / zoom, true)
else:
_draw_ghost_marker(action_target, color)
else:
_draw_ghost_marker(action_target, color)
var release_origin := set_down_transform.origin
var target_adjusted := set_down_preview_known and release_origin.distance_to(action_target) > 1.0
if target_adjusted:
_draw_dashed(action_target, release_origin, set_down_reach_color)
draw_arc(release_origin, 4.0 / zoom, 0.0, TAU, 16, set_down_reach_color, LINE_WIDTH_PX / zoom, true)
var cross_radius := 9.0 / zoom
var target_color := set_down_reach_color if target_adjusted else color
draw_line(action_target + Vector2(-cross_radius, 0.0), action_target + Vector2(cross_radius, 0.0), target_color, line_width_px / zoom, true)
draw_line(action_target + Vector2(0.0, -cross_radius), action_target + Vector2(0.0, cross_radius), target_color, line_width_px / zoom, true)
if set_down_grip.is_finite() and set_down_grip != Vector2.ZERO:
draw_line(set_down_approach, set_down_grip, set_down_reach_color, line_width_px / zoom, true)
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
var zoom := _zoom()
var dash := dash_length_px / zoom
+58 -4
View File
@@ -3,11 +3,12 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.1"
const FILE_VERSION: String = "1.2"
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
@@ -157,8 +158,13 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
static func _migrate_to_current(data: Dictionary) -> Dictionary:
var version := String(data.get("version", ""))
if version == FILE_VERSION:
return data
if version != "1.0":
var current: Dictionary = data.duplicate(true)
for raw: Variant in current.get("objects", []):
if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop" and not (raw as Dictionary).has("roles"):
(raw as Dictionary)["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1"]:
return {}
var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []):
@@ -166,10 +172,39 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
continue
var object: Dictionary = raw
if String(object.get("type", "")) == "prop":
object["stage_object_id"] = String(object.get("file_id", ""))
if version == "1.0":
object["stage_object_id"] = String(object.get("file_id", ""))
if not object.has("roles"):
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
static func _assign_legacy_depths(raw_objects: Variant) -> void:
if not raw_objects is Array:
return
var objects: Array = raw_objects
for index: int in objects.size():
if not objects[index] is Dictionary:
continue
var entry: Dictionary = objects[index]
if String(entry.get("type", "")) != "prop" or not entry.get("roles", {}) is Dictionary:
continue
var roles: Dictionary = entry["roles"]
if not bool(roles.get("legacy_draw_order", false)):
continue
var characters_before: int = 0
var characters_after: int = 0
for other_index: int in objects.size():
if not objects[other_index] is Dictionary or String((objects[other_index] as Dictionary).get("type", "")) != "stickman":
continue
if other_index < index:
characters_before += 1
else:
characters_after += 1
roles["depth"] = PROP_STAGE_ROLES.DEPTH_FRONT if characters_before > characters_after else PROP_STAGE_ROLES.DEPTH_BEHIND
static func _validate_root(data: Dictionary) -> Dictionary:
var seen: Dictionary = {}
var seen_stage_ids: Dictionary = {}
@@ -187,6 +222,17 @@ static func _validate_root(data: Dictionary) -> Dictionary:
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
seen_stage_ids[stage_id] = true
if entry.has("roles"):
var roles_value: Variant = entry["roles"]
if not roles_value is Dictionary:
return {"ok": false, "error": "Prop roles must be an object."}
var roles: Dictionary = roles_value
for key: String in ["depth", "start_motion"]:
if roles.has(key) and (not roles[key] is String or (key == "depth" and not [PROP_STAGE_ROLES.DEPTH_BEHIND, PROP_STAGE_ROLES.DEPTH_FRONT].has(roles[key])) or (key == "start_motion" and not [PROP_STAGE_ROLES.START_STILL, PROP_STAGE_ROLES.START_PHYSICS].has(roles[key]))):
return {"ok": false, "error": "Prop role '%s' is invalid." % key}
for key: String in ["holds_other_props", "blocks_stickmen", "can_pick_up", "released_blocks_stickmen", "released_bumps_other_props", "legacy_draw_order"]:
if roles.has(key) and not roles[key] is bool:
return {"ok": false, "error": "Prop role '%s' must be true or false." % key}
return {"ok": true, "error": ""}
static func _recover_backup(path: String) -> void:
@@ -220,6 +266,11 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
var target_position: Vector2 = action["target_position"]
if not target_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(target_position)}
"set_down_prop":
if not action.get("target_position") is Vector2: return {}
var release_position: Vector2 = action["target_position"]
if not release_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(release_position)}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
@@ -243,6 +294,9 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
"throw_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
"set_down_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
+26 -18
View File
@@ -97,7 +97,30 @@ func is_selected(node: Node2D) -> bool:
## Returns the frontmost selectable node under `world_pos`, or null.
func hit_test(world_pos: Vector2) -> Node2D:
return _frontmost_at(world_pos)
var hits: Array[Node2D] = hit_test_all(world_pos)
return hits[0] if not hits.is_empty() else null
## Returns every geometrically selectable object under the point, frontmost first.
func hit_test_all(world_pos: Vector2) -> Array[Node2D]:
var hits: Array[Node2D] = []
for child: Node in _world.get_children():
var node := child as Node2D
if node == null or not _is_selectable(node):
continue
if get_world_aabb(node).has_point(world_pos):
hits.append(node)
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
if ragdoll_hit != null and not hits.has(ragdoll_hit):
hits.append(ragdoll_hit)
hits.sort_custom(func(a: Node2D, b: Node2D) -> bool:
if a.z_index != b.z_index:
return a.z_index > b.z_index
if a.get_index() != b.get_index():
return a.get_index() > b.get_index()
return get_world_aabb(a).get_area() < get_world_aabb(b).get_area()
)
return hits
## Refreshes the hovered node under `world_pos`, emitting hover_changed when it
@@ -223,20 +246,5 @@ func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
## Highest World child index wins; on equal area the frontmost (first hit in
## reverse iteration) stays.
func _frontmost_at(world_pos: Vector2) -> Node2D:
var ragdoll_hit: Node2D = _ragdoll_rig_at(world_pos)
if ragdoll_hit != null:
return ragdoll_hit
var children := _world.get_children()
var best: Node2D = null
var best_area := INF
for i: int in range(children.size() - 1, -1, -1):
var node := children[i] as Node2D
if node == null or not _is_selectable(node):
continue
var aabb := get_world_aabb(node)
if aabb.has_point(world_pos):
var area := aabb.get_area()
if best == null or area < best_area:
best = node
best_area = area
return best
var hits: Array[Node2D] = hit_test_all(world_pos)
return hits[0] if not hits.is_empty() else null
+2
View File
@@ -14,6 +14,7 @@ extends RefCounted
const TERRAIN_UTILS := preload("res://scripts/terrain/terrain_utils.gd")
const PROP_UTILS := preload("res://scripts/props/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const STICKMAN_FACTORY := preload("res://scripts/characters/stickman/stickman_factory.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
@@ -180,6 +181,7 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
prop.spawn_id = String(template.get("id", asset_id))
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
prop.ensure_default_grip()
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(prop.has_usable_grip()))
return prop