Add Use Spot Overlay and Target Capture Bar functionality

- Introduced `StageUseTargetCaptureBar` class for managing user interactions with props and their use spots.
- Implemented UI elements including buttons for changing props and canceling actions.
- Added `PropUseSpotData` class for validation and serialization of prop use spots.
- Created tests for validating use spot data migration, serialization, and rule matching.
- Ensured proper handling of edge cases in use spot data, including unique IDs and finite positions.
This commit is contained in:
2026-10-13 11:23:44 -04:00
parent 235c6aa6a6
commit d8b1b1164b
42 changed files with 3847 additions and 50 deletions
+18
View File
@@ -83,6 +83,15 @@ const ACTION_TEMPLATES := {
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
"use_prop": {
"label": "Use…",
"icon": "🖐️",
"params": [
{ "key": "target_prop_id", "type": "prop", "required": true },
{ "key": "target_spot_id", "type": "spot", "required": true },
],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
},
}
@@ -167,6 +176,9 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
params["target_position"] = action.get("target_position", Vector2.ZERO)
"set_down_prop":
params["target_position"] = action.get("target_position", Vector2.ZERO)
"use_prop":
params["target_prop_id"] = String(action.get("target_prop_id", ""))
params["target_spot_id"] = String(action.get("target_spot_id", ""))
return {
"type": type,
"target": target_id,
@@ -199,6 +211,8 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
"set_down_prop":
return { "type": "set_down_prop", "target_position": params.get("target_position", Vector2.ZERO) }
"use_prop":
return { "type": "use_prop", "target_prop_id": String(params.get("target_prop_id", "")), "target_spot_id": String(params.get("target_spot_id", "")) }
_:
return { "type": type }
@@ -240,6 +254,10 @@ static func summarize(action: Dictionary) -> String:
var raw_release: Variant = action.get("target_position", params.get("target_position", Vector2.ZERO))
var release_position: Vector2 = raw_release as Vector2 if raw_release is Vector2 else Vector2.ZERO
return "Set Down Prop (%d, %d)" % [int(roundf(release_position.x)), int(roundf(release_position.y))]
"use_prop":
var prop_id: String = String(action.get("target_prop_id", params.get("target_prop_id", "")))
var spot_id: String = String(action.get("target_spot_id", params.get("target_spot_id", "")))
return "Use (%s · %s)" % [prop_id, spot_id] if not prop_id.is_empty() and not spot_id.is_empty() else "Use (Choose a new spot)"
_:
return type
+3 -2
View File
@@ -20,9 +20,10 @@ const DEFAULT_CONFIG: Dictionary = {
"choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"},
"toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"},
"action": {"min_target_size": 48, "close_width": 88, "surface_color": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A"},
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"}
"prop": {"stickman_cue_color": "#F5F7FA", "prop_cue_color": "#B7824D", "cue_outline_color": "#171B24"},
"spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6}
},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]},
"speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"}
}
+7 -4
View File
@@ -26,7 +26,8 @@ const BADGE_STACK_STEP := Vector2(0.0, -28.0)
## Phase 4 rule rendering constants.
const WAYPOINT_HIT_RADIUS_PX := 14.0
const RULE_TRIGGER_COLOR := Color(0.2, 0.85, 0.3)
const RULE_ACTION_COLOR := Color(1.0, 0.55, 0.15)
const RULE_ACTION_COLOR := Color("#7E57C2")
const RULE_CONNECTOR_COLOR := Color("#AAB2C0")
const RULE_LABEL_BG := Color(0.0, 0.0, 0.0, 0.7)
const RULE_LABEL_BORDER := Color(1.0, 1.0, 1.0, 0.25)
const RULE_LABEL_FONT_SIZE_PX := 14.0
@@ -47,6 +48,8 @@ var badge_radius: float = RULE_BADGE_RADIUS_PX
var completed_path_alpha: float = 0.20
var completed_path_color: Color = Color(0.58, 0.62, 0.68)
var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
var rule_action_color: Color = RULE_ACTION_COLOR
var rule_connector_color: Color = RULE_CONNECTOR_COLOR
var emoji_font: Font = null
## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is
@@ -341,13 +344,13 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
if not action_anchor.is_finite():
action_anchor = trigger_anchor
# Dashed white connector (trigger -> action).
# Quiet gray connector keeps the rule relationship distinct from reach guides.
if trigger_anchor.distance_to(action_anchor) > 0.5:
_draw_dashed(trigger_anchor, action_anchor, zoom)
_draw_dashed(trigger_anchor, action_anchor, zoom, rule_connector_color)
# Badges.
_draw_rule_badge(trigger_anchor, zoom, "⚡", RULE_TRIGGER_COLOR)
_draw_rule_badge(action_anchor, zoom, "→", RULE_ACTION_COLOR)
_draw_rule_badge(action_anchor, zoom, "→", rule_action_color)
# Label at the line midpoint on a dark rounded rect.
var summary := rule_summary(rule)
+15
View File
@@ -50,6 +50,21 @@ func matches_rule(rule: Dictionary, event: Dictionary) -> bool:
return ids_match(source_id, source)
"entered_area", "collided", "prop_picked_up", "prop_dropped", "prop_thrown":
return ids_match(source_id, source) and ids_match(target_id, target)
"prop_used":
if not ids_match(source_id, source) or not target is PropBlock:
return false
var params: Dictionary = trigger.get("params", {})
var target_prop_id: String = String(params.get("target_prop_id", ""))
var prop: PropBlock = target as PropBlock
if target_prop_id.is_empty() or prop.stage_object_id != target_prop_id:
return false
var scope: String = String(params.get("spot_scope", ""))
if scope == "any":
return not String(event.get("spot_id", "")).is_empty()
if scope != "specific":
return false
var spot_id: String = String(params.get("target_spot_id", ""))
return not spot_id.is_empty() and spot_id == String(event.get("spot_id", ""))
_:
return false
+54 -4
View File
@@ -3,12 +3,13 @@ extends RefCounted
## JSON boundary for Sandbox Stage documents. This class deliberately has no
## scene-tree dependencies, which makes rejected documents non-destructive.
const FILE_VERSION: String = "1.2"
const FILE_VERSION: String = "1.3"
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const TRIGGER_REGISTRY := preload("res://scripts/stage/trigger_registry.gd")
const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
static func vector_to_data(value: Vector2) -> Dictionary:
return {"x": value.x, "y": value.y}
@@ -160,11 +161,15 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
if version == FILE_VERSION:
var current: Dictionary = data.duplicate(true)
for raw: Variant in current.get("objects", []):
if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop" and not (raw as Dictionary).has("roles"):
(raw as Dictionary)["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop":
var prop_entry: Dictionary = raw
if not prop_entry.has("roles"):
prop_entry["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
prop_entry["use_spots"] = prop_entry.get("use_spots", [])
prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1)
_assign_legacy_depths(current.get("objects", []))
return current
if version not in ["1.0", "1.1"]:
if version not in ["1.0", "1.1", "1.2"]:
return {}
var migrated: Dictionary = data.duplicate(true)
for raw: Variant in migrated.get("objects", []):
@@ -176,6 +181,8 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary:
object["stage_object_id"] = String(object.get("file_id", ""))
if not object.has("roles"):
object["roles"] = PROP_STAGE_ROLES.legacy_placement(true)
object["use_spots"] = object.get("use_spots", [])
object["next_use_spot_number"] = object.get("next_use_spot_number", 1)
migrated["version"] = FILE_VERSION
_assign_legacy_depths(migrated.get("objects", []))
return migrated
@@ -222,6 +229,9 @@ static func _validate_root(data: Dictionary) -> Dictionary:
if stage_id.strip_edges().is_empty() or seen_stage_ids.has(stage_id):
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty."}
seen_stage_ids[stage_id] = true
var use_spot_error: String = PROP_USE_SPOT_DATA.validate_saved(entry.get("use_spots", []), entry.get("next_use_spot_number", 1))
if not use_spot_error.is_empty():
return {"ok": false, "error": use_spot_error}
if entry.has("roles"):
var roles_value: Variant = entry["roles"]
if not roles_value is Dictionary:
@@ -271,6 +281,11 @@ static func _encode_flat_action(action: Dictionary) -> Dictionary:
var release_position: Vector2 = action["target_position"]
if not release_position.is_finite(): return {}
return {"type": type, "target_position": vector_to_data(release_position)}
"use_prop":
var prop_id: String = String(action.get("target_prop_id", ""))
var spot_id: String = String(action.get("target_spot_id", ""))
if prop_id.strip_edges().is_empty() or spot_id.strip_edges().is_empty(): return {}
return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id}
_: return {"type": type}
static func _decode_flat_action(action: Dictionary) -> Dictionary:
@@ -297,6 +312,11 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary:
"set_down_prop":
if not is_vector_data(action.get("target_position")): return {}
return {"type": type, "target_position": data_to_vector(action["target_position"], Vector2.ZERO)}
"use_prop":
var prop_id: String = String(action.get("target_prop_id", ""))
var spot_id: String = String(action.get("target_spot_id", ""))
if prop_id.strip_edges().is_empty() or spot_id.strip_edges().is_empty(): return {}
return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id}
_: return {"type": type}
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
@@ -417,6 +437,11 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary
var source: Variant = _map_ref(trigger.get("source", -1), refs); var target: Variant = _map_ref(trigger.get("target", -1), refs)
if source == null and int(trigger.get("source", -1)) != -1: return {}
if target == null and int(trigger.get("target", -1)) != -1: return {}
if type == "prop_used":
if not trigger.has("source") or int(trigger.get("source", -1)) == -1 or source == null:
return {}
if not trigger.has("target") or int(trigger.get("target", -1)) != -1 or target != null:
return {}
var params: Dictionary = (trigger.get("params", {}) as Dictionary).duplicate(true)
if type == "arrived_at_waypoint":
if not params.get("waypoint_pos") is Vector2: return {}
@@ -425,10 +450,23 @@ static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary
var filter: String = String(params.get("action_type", ""))
if not filter.is_empty():
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
elif type == "prop_used":
var prop_id: String = String(params.get("target_prop_id", ""))
var scope: String = String(params.get("spot_scope", ""))
if prop_id.strip_edges().is_empty() or scope not in ["any", "specific"]: return {}
if scope == "specific":
var spot_id: String = String(params.get("target_spot_id", ""))
if spot_id.strip_edges().is_empty(): return {}
params = {"target_prop_id": prop_id, "spot_scope": scope, "target_spot_id": spot_id}
else:
if params.has("target_spot_id"): return {}
params = {"target_prop_id": prop_id, "spot_scope": scope}
return {"type": type, "source_id": source, "target_id": target, "params": params}
static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
var type := String(value.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
if type == "prop_used" and (not value.has("source_id") or value.get("source_id") == null or not value.has("target_id") or value.get("target_id") != null):
return {}
var source := _unmap_ref(value.get("source_id"), refs, unavailable); var target := _unmap_ref(value.get("target_id"), refs, unavailable)
if source == -2 or target == -2: return {}
var params: Dictionary = (value.get("params", {}) as Dictionary).duplicate(true)
@@ -439,6 +477,18 @@ static func _decode_trigger(value: Dictionary, refs: Dictionary, unavailable: Di
var filter: String = String(params.get("action_type", ""))
if not filter.is_empty():
params["action_type"] = ACTION_REGISTRY.canonical_type(filter)
elif type == "prop_used":
var prop_id: String = String(params.get("target_prop_id", ""))
var scope: String = String(params.get("spot_scope", ""))
if prop_id.strip_edges().is_empty() or scope not in ["any", "specific"]: return {}
if scope == "specific":
var spot_id: String = String(params.get("target_spot_id", ""))
if spot_id.strip_edges().is_empty(): return {}
params = {"target_prop_id": prop_id, "spot_scope": scope, "target_spot_id": spot_id}
else:
if params.has("target_spot_id"): return {}
params = {"target_prop_id": prop_id, "spot_scope": scope}
if source == -1 or target != -1: return {}
return {"type": type, "source": source, "target": target, "params": params}
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
+5
View File
@@ -47,6 +47,11 @@ const TRIGGER_TEMPLATES := {
"icon": "🏹",
"target_type": "prop",
},
"prop_used": {
"label": "Used prop",
"icon": "🖐️",
"target_type": "use_prop",
},
}