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
+70
View File
@@ -31,12 +31,18 @@ var _surface_color: Color = Color("#252B38")
var _border_color: Color = Color("#FFFFFF33")
var _prop_target_label: Label = null
var _set_prop_target_btn: Button = null
var _use_target_label: Label = null
var _set_use_target_btn: Button = null
var _hand_option: OptionButton = null
var _stop_current_check: CheckBox = null
var _show_reactive_options: bool = false
var _has_target: bool = false
var _target_pos: Vector2 = Vector2.ZERO
var _prop_target_id: String = ""
var _use_prop_target_id: String = ""
var _use_spot_target_id: String = ""
var _use_target_summary: String = ""
var _use_target_summary_resolver: Callable = Callable()
var _has_throw_target: bool = false
var _throw_target_pos: Vector2 = Vector2.ZERO
@@ -68,6 +74,9 @@ func open_new(show_reactive_options: bool = false) -> void:
_target_pos = Vector2.ZERO
_move_style = ACTION_REGISTRY.WALK
_prop_target_id = ""
_use_prop_target_id = ""
_use_spot_target_id = ""
_use_target_summary = ""
_has_throw_target = false
_throw_target_pos = Vector2.ZERO
_select_type(ACTION_REGISTRY.MOVE_TO)
@@ -88,6 +97,9 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
_has_target = action.has("target") and action["target"] is Vector2
_target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO
_prop_target_id = String(action.get("target_prop_id", ""))
_use_prop_target_id = String(action.get("target_prop_id", "")) if type == "use_prop" else ""
_use_spot_target_id = String(action.get("target_spot_id", "")) if type == "use_prop" else ""
_use_target_summary = "Use spot chosen" if not _use_spot_target_id.is_empty() else ""
_has_throw_target = action.has("target_position") and action["target_position"] is Vector2
_throw_target_pos = action.get("target_position", Vector2.ZERO) if _has_throw_target else Vector2.ZERO
_move_style = ACTION_REGISTRY.checked_style(action.get("style", null))
@@ -113,6 +125,25 @@ func set_prop_target(prop_id: String) -> void:
popup_centered()
func set_use_target(prop_id: String, spot_id: String, summary: String) -> void:
_use_prop_target_id = prop_id
_use_spot_target_id = spot_id
_use_target_summary = summary
_update_use_target_label()
popup_centered()
func set_use_target_summary_resolver(resolver: Callable) -> void:
_use_target_summary_resolver = resolver
refresh_use_target_summary()
func refresh_use_target_summary() -> void:
if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty():
_use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id))
_update_use_target_label()
func set_throw_target(pos: Vector2) -> void:
_has_throw_target = true
_throw_target_pos = pos
@@ -250,6 +281,8 @@ func _rebuild_params(type: String) -> void:
_set_target_btn = null
_prop_target_label = null
_set_prop_target_btn = null
_use_target_label = null
_set_use_target_btn = null
_hand_option = null
_stop_current_check = null
_walk_style_btn = null
@@ -301,6 +334,19 @@ func _rebuild_params(type: String) -> void:
var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto")))
_hand_option.select(maxi(hand_idx, 0))
hand_row.add_child(_hand_option)
"use_prop":
var use_row := VBoxContainer.new()
use_row.add_theme_constant_override("separation", 6)
_params_box.add_child(use_row)
_use_target_label = Label.new()
_use_target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
use_row.add_child(_use_target_label)
_set_use_target_btn = Button.new()
_set_use_target_btn.text = "Choose prop and spot"
_set_use_target_btn.custom_minimum_size.y = 48.0
_set_use_target_btn.pressed.connect(func() -> void: target_requested.emit())
use_row.add_child(_set_use_target_btn)
_update_use_target_label()
"throw_prop", "set_down_prop":
var throw_row := HBoxContainer.new()
_params_box.add_child(throw_row)
@@ -414,6 +460,21 @@ func _update_prop_target_label() -> void:
_prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set")
func _update_use_target_label() -> void:
if _use_target_label == null:
return
if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty():
_use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id))
if not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty():
_use_target_label.text = "Using: %s" % (_use_target_summary if not _use_target_summary.is_empty() else "Use spot chosen")
if _set_use_target_btn != null:
_set_use_target_btn.text = "Change spot"
else:
_use_target_label.text = "Choose a prop and one exact Use spot."
if _set_use_target_btn != null:
_set_use_target_btn.text = "Choose prop and spot"
func _update_throw_target_label() -> void:
if _target_label == null:
return
@@ -442,6 +503,15 @@ func _on_ok_pressed() -> void:
var hands: Array[String] = ["auto", "left", "right"]
action["target_prop_id"] = _prop_target_id
action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto"
"use_prop":
if _use_prop_target_id.is_empty() or _use_spot_target_id.is_empty():
target_requested.emit()
return
if _use_target_summary == "Choose a new spot":
target_requested.emit()
return
action["target_prop_id"] = _use_prop_target_id
action["target_spot_id"] = _use_spot_target_id
"throw_prop", "set_down_prop":
if not _has_throw_target:
target_requested.emit()
+14 -1
View File
@@ -36,6 +36,7 @@ var _base_font_size: int = 0
var _row_font_size: int = 0
var _title_font_size: int = 0
var _title_font: Font = null
var _use_target_summary_resolver: Callable = Callable()
func _ready() -> void:
@@ -56,6 +57,11 @@ func setup(r: StickmanRig) -> void:
refresh()
func set_use_target_summary_resolver(resolver: Callable) -> void:
_use_target_summary_resolver = resolver
refresh()
func refresh() -> void:
if _list == null:
return
@@ -145,7 +151,14 @@ func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer:
var summary := Label.new()
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), ACTION_REGISTRY.summarize(action)]
var action_summary: String = ACTION_REGISTRY.summarize(action)
if String(action.get("type", "")) == "use_prop" and _use_target_summary_resolver.is_valid():
action_summary = String(_use_target_summary_resolver.call(
String(action.get("target_prop_id", "")),
"specific",
String(action.get("target_spot_id", ""))
))
summary.text = "%s %s" % [ACTION_REGISTRY.icon(String(action.get("type", ""))), action_summary]
row.add_child(summary)
var status_label := Label.new()
status_label.text = _status_label(status)
+63 -2
View File
@@ -34,6 +34,7 @@ var _action_type_option: OptionButton = null
var _trigger_readonly_label: Label = null
var _actions_box: VBoxContainer = null
var _empty_actions_label: Label = null
var _use_target_summary_resolver: Callable = Callable()
## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no
## override (engine default); Font null = no override.
@@ -79,6 +80,28 @@ func set_trigger_target(target_id: int, params: Dictionary) -> void:
popup_centered()
func set_trigger_use_target(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void:
var params: Dictionary = {"target_prop_id": target_prop_id, "spot_scope": spot_scope}
if spot_scope == "specific":
params["target_spot_id"] = target_spot_id
_trigger["type"] = "prop_used"
_trigger["target"] = -1
_trigger["params"] = params
_refresh_trigger()
popup_centered()
func set_use_target_summary_resolver(resolver: Callable) -> void:
_use_target_summary_resolver = resolver
_refresh_trigger()
_refresh_actions()
func refresh_use_target_summaries() -> void:
_refresh_trigger()
_refresh_actions()
## Called by the stage after an action is collected. `index` < 0 appends.
func set_action(index: int, action: Dictionary) -> void:
if index < 0:
@@ -272,7 +295,15 @@ func _refresh_trigger() -> void:
_target_row.visible = full
if not full:
_trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), TRIGGER_REGISTRY.summarize(_trigger)]
var read_only_summary: String = TRIGGER_REGISTRY.summarize(_trigger)
if type == "prop_used":
var params: Dictionary = _trigger.get("params", {})
read_only_summary = "uses %s" % _resolve_use_target_summary(
String(params.get("target_prop_id", "")),
String(params.get("spot_scope", "any")),
String(params.get("target_spot_id", ""))
)
_trigger_readonly_label.text = "When: %s %s" % [_actor_name(int(_trigger.get("source", -1))), read_only_summary]
return
# Show only the target control relevant to this trigger type.
@@ -296,6 +327,13 @@ func _refresh_trigger() -> void:
"prop":
_target_label.text = "Target: %s" % _node_name(int(_trigger.get("target", -1)))
_set_target_btn.visible = true
"use_prop":
var use_params: Dictionary = _trigger.get("params", {})
var prop_id: String = String(use_params.get("target_prop_id", ""))
var scope: String = String(use_params.get("spot_scope", "any"))
_target_label.text = _resolve_use_target_summary(prop_id, scope, String(use_params.get("target_spot_id", "")))
_set_target_btn.text = "Choose prop"
_set_target_btn.visible = true
_:
pass
@@ -346,10 +384,14 @@ func _make_action_row(index: int, action: Dictionary) -> Control:
var summary := Label.new()
summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var action_summary: String = ACTION_REGISTRY.summarize(action)
if String(action.get("type", "")) == "use_prop":
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action)
action_summary = _resolve_use_target_summary(String(flat.get("target_prop_id", "")), "specific", String(flat.get("target_spot_id", "")))
summary.text = "%s %s %s" % [
ACTION_REGISTRY.icon(String(action.get("type", ""))),
_actor_name(int(action.get("target", -1))),
ACTION_REGISTRY.summarize(action),
action_summary,
]
row.add_child(summary)
@@ -378,6 +420,17 @@ func _on_ok_pressed() -> void:
if _current_type() == "action_finished":
_sync_action_type()
_trigger["type"] = _current_type()
if String(_trigger.get("type", "")) == "prop_used":
var use_params: Dictionary = _trigger.get("params", {})
var target_prop_id: String = String(use_params.get("target_prop_id", ""))
var spot_scope: String = String(use_params.get("spot_scope", ""))
if target_prop_id.is_empty() or spot_scope not in ["any", "specific"] or (spot_scope == "specific" and String(use_params.get("target_spot_id", "")).is_empty()) or _resolve_use_target_summary(target_prop_id, spot_scope, String(use_params.get("target_spot_id", ""))) == "Choose a new spot":
return
for action: Dictionary in _actions:
if String(action.get("type", "")) == "use_prop":
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(action)
if _resolve_use_target_summary(String(flat.get("target_prop_id", "")), "specific", String(flat.get("target_spot_id", ""))) == "Choose a new spot":
return
var rule := {
"id": _rule_id,
"trigger": _trigger.duplicate(true),
@@ -386,6 +439,14 @@ func _on_ok_pressed() -> void:
committed.emit(rule)
func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String:
if prop_id.is_empty():
return "Choose a new spot"
if not _use_target_summary_resolver.is_valid():
return "Choose a new spot"
return String(_use_target_summary_resolver.call(prop_id, scope, spot_id))
func _actor_name(id: int) -> String:
if id <= 0:
# -1 is the "no source / no actor" sentinel; instance_from_id(-1) would
+29 -1
View File
@@ -34,6 +34,7 @@ var _base_font_size: int = 0
var _row_font_size: int = 0
var _title_font_size: int = 0
var _title_font: Font = null
var _use_target_summary_resolver: Callable = Callable()
func _ready() -> void:
@@ -55,6 +56,11 @@ func set_rules(rules: Array[Dictionary], title_hint: String) -> void:
refresh()
func set_use_target_summary_resolver(resolver: Callable) -> void:
_use_target_summary_resolver = resolver
refresh()
## Attaches a filtered rule list + a source hint, rebuilds the rows and pops up.
func show_rules(rules: Array[Dictionary], title_hint: String) -> void:
set_rules(rules, title_hint)
@@ -231,6 +237,14 @@ func _make_label(text: String, dim: bool = false) -> Label:
func _trigger_summary(rule: Dictionary) -> String:
var trigger: Dictionary = rule.get("trigger", {})
var type := String(trigger.get("type", ""))
if type == "prop_used":
var params: Dictionary = trigger.get("params", {})
var target_summary: String = _resolve_use_target_summary(
String(params.get("target_prop_id", "")),
String(params.get("spot_scope", "any")),
String(params.get("target_spot_id", ""))
)
return "%s When %s uses %s" % [TRIGGER_REGISTRY.icon(type), _actor_name(int(trigger.get("source", -1))), target_summary]
return "%s %s %s" % [
TRIGGER_REGISTRY.icon(type),
_actor_name(int(trigger.get("source", -1))),
@@ -241,14 +255,28 @@ func _trigger_summary(rule: Dictionary) -> String:
func _action_summaries(rule: Dictionary) -> Array[String]:
var out: Array[String] = []
for a: Dictionary in rule.get("actions", []):
var action_summary: String = ACTION_REGISTRY.summarize(a)
if String(a.get("type", "")) == "use_prop":
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(a)
action_summary = _resolve_use_target_summary(
String(flat.get("target_prop_id", "")),
"specific",
String(flat.get("target_spot_id", ""))
)
out.append("→ %s %s %s" % [
ACTION_REGISTRY.icon(String(a.get("type", ""))),
_actor_name(int(a.get("target", -1))),
ACTION_REGISTRY.summarize(a),
action_summary,
])
return out
func _resolve_use_target_summary(prop_id: String, scope: String, spot_id: String) -> String:
if not _use_target_summary_resolver.is_valid():
return "Choose a new spot"
return String(_use_target_summary_resolver.call(prop_id, scope, spot_id))
func _row_style(bg: Color) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
File diff suppressed because it is too large Load Diff
@@ -266,6 +266,11 @@ func set_prop_target(stage_id: String) -> void:
_action_editor.set_prop_target(stage_id)
func set_use_target(prop_id: String, spot_id: String, summary: String) -> void:
if _action_editor != null:
_action_editor.set_use_target(prop_id, spot_id, summary)
func set_throw_target(position: Vector2) -> void:
if _action_editor != null:
_action_editor.set_throw_target(position)
@@ -286,7 +291,7 @@ func request_action_target_capture() -> void:
return
_action_editor.hide()
var action_type: String = _action_editor.current_action_type()
if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop"]:
if editor_kind == "rule_add" and editor_actor_id < 0 and action_type in [ACTION_REGISTRY.MOVE_TO, "pick_up_prop", "throw_prop", "set_down_prop", "use_prop"]:
_emit_capture_request(
&"actor",
"🎯 Click the stickman who will act (Esc or RMB to cancel)",
@@ -300,6 +305,8 @@ func request_action_target_capture() -> void:
_emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture"))
"pick_up_prop":
_emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture"))
"use_prop":
_emit_capture_request(&"use_prop", "Click a prop, then choose its Use spot", Callable(self, "set_use_target_from_capture"), Callable(self, "cancel_target_capture"))
"throw_prop":
_emit_capture_request(&"throw_position", "🏹 Click where to throw (Esc or RMB to cancel)", Callable(self, "set_throw_target"), Callable(self, "cancel_target_capture"))
"set_down_prop":
@@ -330,5 +337,14 @@ func accept_prop_target(value: Node2D) -> void:
show_action_editor()
func set_use_target_from_capture(value: Variant) -> void:
if not value is Dictionary:
show_action_editor()
return
var target: Dictionary = value
set_use_target(String(target.get("prop_id", "")), String(target.get("spot_id", "")), String(target.get("summary", "")))
show_action_editor()
func cancel_target_capture() -> void:
show_action_editor()
@@ -68,6 +68,7 @@ func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_fo
_action_popup.add_item("💥 Ragdoll", int(action_ids.get("ragdoll", 3)))
_action_popup.add_item("🔄 Recover", int(action_ids.get("recover", 4)))
_action_popup.add_item("🫴 Pick Up Prop…", int(action_ids.get("pick_up_prop", 9)))
_action_popup.add_item("🖐️ Use…", int(action_ids.get("use_prop", 13)))
_action_popup.add_item("⬇️ Drop Prop", int(action_ids.get("drop_prop", 10)))
_action_popup.add_item("🏹 Throw Prop…", int(action_ids.get("throw_prop", 11)))
_action_popup.add_item("🫳 Set Down…", int(action_ids.get("set_down_prop", 12)))
@@ -10,6 +10,11 @@ const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
signal role_choice_requested(key: String, value: String)
signal role_toggle_requested(enabled: bool, key: String)
signal spot_add_requested
signal spot_move_requested(spot_id: String)
signal spot_rename_requested(spot_id: String, name: String)
signal spot_erase_requested(spot_id: String)
signal spot_selected(spot_id: String)
signal close_requested
var _stage_theme: SandboxTheme
@@ -22,6 +27,8 @@ var _notice: Label
var _depth_group: StagePanelChoiceGroup
var _motion_group: StagePanelChoiceGroup
var _controls: Dictionary = {}
var _spot_rows: VBoxContainer
var _spot_empty_label: Label
var _active_selection_key: String = ""
var _dismissed_selection_key: String = ""
@@ -46,7 +53,8 @@ func show_prop(
title: String,
roles: Dictionary,
safe_rect: Rect2,
selection: Array[Node2D]
selection: Array[Node2D],
selected_spot_id: String = ""
) -> void:
if prop == null or not is_instance_valid(prop):
hide_card()
@@ -62,6 +70,7 @@ func show_prop(
_fields.visible = true
_card.set_header(title, "📦")
_sync_roles(prop, roles)
_sync_spots(prop.get_use_spots(), selected_spot_id)
func show_multiple_props(selection: Array[Node2D], safe_rect: Rect2) -> void:
@@ -184,6 +193,7 @@ func _build() -> void:
_controls["blocks_stickmen"] = _make_toggle_row(_fields, "blocks_stickmen", "Blocks stickmen")
_controls["can_pick_up"] = _make_toggle_row(_fields, "can_pick_up", "Stickmen can pick it up")
_add_release_options()
_add_use_spots_section()
_reopen_button = Button.new()
_reopen_button.name = "ReopenPropSettings"
@@ -196,6 +206,105 @@ func _build() -> void:
add_child(_reopen_button)
func _add_use_spots_section() -> void:
var section := VBoxContainer.new()
section.name = "UseSpotsSection"
section.add_theme_constant_override("separation", _stage_panel_integer("spots.row_gap", 8, 4, 24))
_fields.add_child(section)
_add_heading(section, "Use spots")
var helper := Label.new()
helper.text = "Stickmen can touch these places without picking up the prop."
helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_apply_ui_font(helper)
section.add_child(helper)
var add_button := Button.new()
add_button.name = "AddUseSpot"
add_button.text = "+ Add a spot"
add_button.custom_minimum_size.y = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
_card.style_action_button(add_button, true)
add_button.pressed.connect(func() -> void: spot_add_requested.emit())
section.add_child(add_button)
_spot_empty_label = Label.new()
_spot_empty_label.text = "No Use spots yet. Add one where a stickman should reach."
_spot_empty_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_spot_empty_label.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0")))
_apply_ui_font(_spot_empty_label)
section.add_child(_spot_empty_label)
_spot_rows = VBoxContainer.new()
_spot_rows.name = "UseSpotRows"
_spot_rows.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_spot_rows.add_theme_constant_override("separation", _stage_panel_integer("spots.row_gap", 8, 4, 24))
section.add_child(_spot_rows)
func _sync_spots(spots: Array[Dictionary], selected_spot_id: String) -> void:
if _spot_rows == null:
return
for child: Node in _spot_rows.get_children():
child.queue_free()
_spot_empty_label.visible = spots.is_empty()
var button_height: float = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96)))
for spot: Dictionary in spots:
var spot_id: String = String(spot.get("id", ""))
var number: int = int(spot.get("number", 0))
var row := VBoxContainer.new()
row.name = "UseSpot_%d" % number
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_theme_constant_override("separation", 4)
_spot_rows.add_child(row)
var header := HBoxContainer.new()
row.add_child(header)
var select_button := Button.new()
select_button.text = "%d" % number
select_button.tooltip_text = "Show spot %d on the prop" % number
select_button.custom_minimum_size = Vector2(button_height, button_height)
select_button.pressed.connect(func() -> void: spot_selected.emit(spot_id))
_card.style_action_button(select_button, spot_id == selected_spot_id)
header.add_child(select_button)
var name_column := VBoxContainer.new()
name_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(name_column)
var name_label := Label.new()
name_label.text = "Spot name"
name_label.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32))
_apply_ui_font(name_label)
name_column.add_child(name_label)
var name_edit := LineEdit.new()
name_edit.text = String(spot.get("name", "Spot %d" % number))
name_edit.placeholder_text = "Spot %d" % number
name_edit.custom_minimum_size.y = button_height
name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_apply_ui_font(name_edit)
name_edit.text_submitted.connect(func(_text: String) -> void: _commit_spot_name(name_edit, spot_id))
name_edit.focus_exited.connect(func() -> void: _commit_spot_name(name_edit, spot_id))
name_column.add_child(name_edit)
var actions := FlowContainer.new()
actions.add_theme_constant_override("h_separation", 8)
actions.add_theme_constant_override("v_separation", 4)
row.add_child(actions)
var move_button := Button.new()
move_button.text = "Move"
move_button.custom_minimum_size = Vector2(88.0, button_height)
move_button.pressed.connect(func() -> void: spot_move_requested.emit(spot_id))
_card.style_action_button(move_button, false)
actions.add_child(move_button)
var erase_button := Button.new()
erase_button.text = "Erase"
erase_button.custom_minimum_size = Vector2(88.0, button_height)
erase_button.pressed.connect(func() -> void: spot_erase_requested.emit(spot_id))
_card.style_action_button(erase_button, false)
actions.add_child(erase_button)
_card.request_fit()
func _commit_spot_name(edit: LineEdit, spot_id: String) -> void:
if edit == null or not is_instance_valid(edit):
return
spot_rename_requested.emit(spot_id, edit.text)
func _add_legacy_order_cue() -> void:
var legacy_cue := Label.new()
legacy_cue.name = "LegacyOrderCue"
@@ -18,6 +18,7 @@ const TRIG_BACK: int = 5
const TRIG_PROP_PICKED_UP: int = 6
const TRIG_PROP_DROPPED: int = 7
const TRIG_PROP_THROWN: int = 8
const TRIG_PROP_USED: int = 9
const RULE_MORE_ADD: int = 0
const RULE_MORE_DONE: int = 1
const RULE_ACTION_DONE: int = 6
@@ -90,6 +91,7 @@ func build_ui(
_trigger_popup.add_item("🫴 Picks up a prop", int(popup_ids.get("trigger_prop_picked_up", TRIG_PROP_PICKED_UP)))
_trigger_popup.add_item("⬇️ Drops a prop", int(popup_ids.get("trigger_prop_dropped", TRIG_PROP_DROPPED)))
_trigger_popup.add_item("🏹 Throws a prop", int(popup_ids.get("trigger_prop_thrown", TRIG_PROP_THROWN)))
_trigger_popup.add_item("🖐️ Used prop", int(popup_ids.get("trigger_prop_used", TRIG_PROP_USED)))
_trigger_popup.add_separator()
_trigger_popup.add_item("⬅ Back to actions", int(popup_ids.get("trigger_back", TRIG_BACK)))
_trigger_popup.id_pressed.connect(_on_trigger_popup_id_pressed)
@@ -105,6 +107,7 @@ func build_ui(
_action_popup.add_item("⬇️ Drop Prop", int(popup_ids.get("action_drop_prop", 10)))
_action_popup.add_item("🏹 Throw Prop…", int(popup_ids.get("action_throw_prop", 11)))
_action_popup.add_item("🫳 Set Down…", int(popup_ids.get("action_set_down_prop", 12)))
_action_popup.add_item("🖐️ Use…", int(popup_ids.get("action_use_prop", 13)))
_action_popup.id_pressed.connect(_on_rule_action_popup_id_pressed)
parent.add_child(_action_popup)
@@ -155,6 +158,19 @@ func get_rule_editor() -> RuleEditor:
return _rule_editor
func set_use_target_summary_resolver(resolver: Callable) -> void:
if _rule_panel != null:
_rule_panel.set_use_target_summary_resolver(resolver)
if _rule_editor != null:
_rule_editor.set_use_target_summary_resolver(resolver)
func refresh_use_target_summaries(rules: Array[Dictionary]) -> void:
refresh_rule_panel(rules)
if _rule_editor != null:
_rule_editor.refresh_use_target_summaries()
func edit_rule_by_id(rules: Array[Dictionary], rule_id: int) -> bool:
for rule: Dictionary in rules:
if int(rule.get("id", -1)) == rule_id:
@@ -186,6 +202,8 @@ func trigger_target_capture_hint(target_type: String) -> String:
return "🎯 Click the trigger area (Esc or RMB to cancel)"
"prop":
return "💥 Click the prop (Esc or RMB to cancel)"
"use_prop":
return "Click a prop, then click a named spot (Esc or RMB to cancel)"
return ""
@@ -302,7 +320,7 @@ func on_trigger_selected(id: int) -> void:
var trigger: Dictionary = builder.get("trigger", {})
trigger["type"] = trigger_type_string(id)
builder["trigger"] = trigger
if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN]:
if id in [TRIG_ARRIVED, TRIG_ENTERED_AREA, TRIG_COLLIDED, TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN, TRIG_PROP_USED]:
step = Step.TRIGGER_TARGET
match id:
TRIG_ARRIVED:
@@ -313,6 +331,8 @@ func on_trigger_selected(id: int) -> void:
hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
TRIG_PROP_PICKED_UP, TRIG_PROP_DROPPED, TRIG_PROP_THROWN:
hint = "Click the prop to watch (Esc or RMB to cancel)"
TRIG_PROP_USED:
hint = "Click a prop, then click a named spot"
else:
step = Step.SELECT_ACTION
open_rule_action_popup()
@@ -359,6 +379,8 @@ func trigger_type_string(id: int) -> String:
return "prop_dropped"
TRIG_PROP_THROWN:
return "prop_thrown"
TRIG_PROP_USED:
return "prop_used"
_:
return ""
@@ -383,6 +405,8 @@ func action_type_string(id: int) -> String:
return "throw_prop"
12:
return "set_down_prop"
13:
return "use_prop"
_:
return ""
@@ -407,6 +431,8 @@ func action_verb(id: int) -> String:
return "throw a prop"
12:
return "set down a prop"
13:
return "use a prop"
_:
return "act"
@@ -438,6 +464,23 @@ func accept_trigger_prop(prop: PropBlock) -> void:
open_rule_action_popup()
func accept_trigger_use_prop(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void:
var trigger: Dictionary = builder.get("trigger", {})
trigger["target"] = -1
var params: Dictionary = {"target_prop_id": target_prop_id, "spot_scope": spot_scope}
if spot_scope == "specific":
params["target_spot_id"] = target_spot_id
trigger["params"] = params
builder["trigger"] = trigger
step = Step.SELECT_ACTION
open_rule_action_popup()
func set_rule_editor_trigger_use_target(target_prop_id: String, spot_scope: String, target_spot_id: String = "") -> void:
if _rule_editor != null:
_rule_editor.set_trigger_use_target(target_prop_id, spot_scope, target_spot_id)
func set_trigger_target_miss_hint(trigger_type: String) -> void:
match trigger_type:
"arrived_at_waypoint":
@@ -9,6 +9,7 @@ const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/characters/stickman
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd")
const PROP_LIBRARY := preload("res://scripts/props/prop_library.gd")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd")
func collect(
@@ -57,6 +58,8 @@ func collect(
entry["asset_id"] = prop.spawn_id
entry["stage_object_id"] = prop.stage_object_id
entry["roles"] = prop.stage_roles.duplicate(true)
entry["use_spots"] = PROP_USE_SPOT_DATA.encode(prop.get_use_spots())
entry["next_use_spot_number"] = prop.next_use_spot_number
var prop_scale: Vector2 = authored_state.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
@@ -172,6 +175,8 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary
if node is PropBlock:
var prop: PropBlock = node as PropBlock
prop.stage_object_id = String(entry.get("stage_object_id", ""))
var decoded_spots: Array[Dictionary] = PROP_USE_SPOT_DATA.decode(entry.get("use_spots", []) as Array)
prop.set_use_spots(decoded_spots, int(entry.get("next_use_spot_number", 1)))
var default_roles: Dictionary = PROP_STAGE_ROLES.new_placement(prop.has_usable_grip())
if entry.get("roles", null) is Dictionary:
prop.set_stage_roles(PROP_STAGE_ROLES.normalize(entry["roles"], default_roles))
@@ -4,7 +4,7 @@ extends RefCounted
const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION }
enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT }
var kind: int = Kind.NONE
var hint: String = ""
@@ -16,9 +16,62 @@ var throw_preview_cache: Dictionary = {}
var set_down_capture_rig: StickmanRig
var set_down_preview_prefix: Array[Dictionary] = []
var set_down_preview_cache: Dictionary = {}
var spot_edit_prop: PropBlock
var spot_edit_id: String = ""
var spot_edit_active: bool = false
var spot_edit_ghost_world: Vector2 = Vector2.INF
var use_target_active: bool = false
var use_target_prop: PropBlock
var use_target_actor: StickmanRig
var use_target_context: String = ""
var use_target_step: int = 0
var use_target_choices: Array[Dictionary] = []
var use_target_preview_cache: Dictionary = {}
var use_target_preview_prefix: Array[Dictionary] = []
var use_target_prefix_known: bool = false
func is_stage_custom_capture() -> bool:
return kind in [Kind.SPOT_ADD, Kind.SPOT_MOVE, Kind.USE_PROP, Kind.USE_SPOT]
func begin_spot_edit(prop: PropBlock, spot_id: String, ghost_world: Vector2) -> void:
finish()
spot_edit_prop = prop if prop != null and is_instance_valid(prop) else null
spot_edit_id = spot_id
spot_edit_active = spot_edit_prop != null
spot_edit_ghost_world = ghost_world
kind = Kind.SPOT_MOVE if not spot_id.is_empty() else Kind.SPOT_ADD
func begin_use_target(context: String, actor: StickmanRig, queue_prefix: Array[Dictionary], prefix_known: bool) -> void:
finish()
use_target_active = true
use_target_context = context
use_target_actor = actor if actor != null and is_instance_valid(actor) else null
use_target_preview_prefix = queue_prefix.duplicate(true)
use_target_prefix_known = prefix_known
kind = Kind.USE_PROP
func select_use_target_prop(prop: PropBlock) -> void:
use_target_prop = prop if prop != null and is_instance_valid(prop) else null
use_target_step = 1 if use_target_prop != null else 0
use_target_choices.clear()
use_target_preview_cache.clear()
kind = Kind.USE_SPOT if use_target_step == 1 else Kind.USE_PROP
func reset_use_target_prop() -> void:
use_target_prop = null
use_target_step = 0
use_target_choices.clear()
use_target_preview_cache.clear()
kind = Kind.USE_PROP if use_target_active else Kind.NONE
func begin(kind_value: int, hint_value: String, on_resolve: Callable, on_cancel: Callable) -> void:
finish()
kind = kind_value
hint = hint_value
resolve_callback = on_resolve
@@ -54,6 +107,19 @@ func finish() -> void:
set_down_capture_rig = null
set_down_preview_prefix.clear()
set_down_preview_cache.clear()
spot_edit_prop = null
spot_edit_id = ""
spot_edit_active = false
spot_edit_ghost_world = Vector2.INF
use_target_active = false
use_target_prop = null
use_target_actor = null
use_target_context = ""
use_target_step = 0
use_target_choices.clear()
use_target_preview_cache.clear()
use_target_preview_prefix.clear()
use_target_prefix_known = false
func update_preview(camera: Camera2D, snap_enabled: bool, grid_size: float) -> void:
@@ -0,0 +1,344 @@
class_name StageUseSpotOverlay
extends Control
## Screen-space presentation for authored Use spots and short runtime cues.
const DEFAULT_PIN_HIT_SIZE: float = 44.0
const DEFAULT_PIN_RADIUS: float = 15.0
const DEFAULT_PULSE_DURATION: float = 0.6
var _theme: SandboxTheme
var _pin_hit_size: float = DEFAULT_PIN_HIT_SIZE
var _pin_radius: float = DEFAULT_PIN_RADIUS
var _pin_outline_width: float = 2.0
var _pill_padding: float = 10.0
var _pulse_duration: float = DEFAULT_PULSE_DURATION
var _prop: PropBlock
var _visible_spots: Array[Dictionary] = []
var _selected_spot_id: String = ""
var _moving_spot_id: String = ""
var _ghost_world: Vector2 = Vector2.INF
var _instruction: String = ""
var _show_ghost: bool = false
var _guide: Dictionary = {}
var _success_prop: PropBlock
var _success_spot_id: String = ""
var _success_remaining: float = 0.0
var _failure_text: String = ""
var _failure_prop: PropBlock
var _failure_spot_id: String = ""
var _failure_remaining: float = 0.0
func configure(theme: SandboxTheme) -> void:
_theme = theme
_pin_hit_size = float(_theme.integer("stage_panels.spots.pin_hit_size", 44, 44, 128)) if _theme != null else DEFAULT_PIN_HIT_SIZE
_pin_radius = float(_theme.integer("stage_panels.spots.pin_visual_size", 30, 12, 64)) * 0.5 if _theme != null else DEFAULT_PIN_RADIUS
_pin_outline_width = float(_theme.integer("stage_panels.spots.pin_outline_width", 2, 1, 8)) if _theme != null else 2.0
_pill_padding = float(_theme.integer("stage_panels.spots.name_pill_padding", 10, 4, 32)) if _theme != null else 10.0
_pulse_duration = _theme.number("overlays.use_preview.completion_duration", 0.6, 0.1, 2.0) if _theme != null else DEFAULT_PULSE_DURATION
queue_redraw()
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
set_process(true)
func show_edit(prop: PropBlock, ghost_world: Vector2 = Vector2.INF, instruction: String = "", selected_spot_id: String = "", moving_spot_id: String = "") -> void:
_set_visible_prop(prop)
_selected_spot_id = selected_spot_id
_moving_spot_id = moving_spot_id
_ghost_world = ghost_world
_instruction = instruction
_show_ghost = instruction != ""
_guide.clear()
queue_redraw()
func show_targeting(prop: PropBlock, guide: Dictionary = {}, instruction: String = "") -> void:
_set_visible_prop(prop)
_selected_spot_id = ""
_moving_spot_id = ""
_ghost_world = Vector2.INF
_instruction = instruction
_show_ghost = false
_guide = guide.duplicate(true)
queue_redraw()
func _set_visible_prop(prop: PropBlock) -> void:
_prop = prop if prop != null and is_instance_valid(prop) else null
_visible_spots.clear()
if _prop != null:
_visible_spots.assign(_prop.get_use_spots())
func show_success(prop: PropBlock, spot_id: String) -> void:
_success_prop = prop if prop != null and is_instance_valid(prop) else null
_success_spot_id = spot_id
_success_remaining = _pulse_duration
queue_redraw()
func show_failure(prop: PropBlock, spot_id: String, reason: String) -> void:
_failure_prop = prop if prop != null and is_instance_valid(prop) else null
_failure_spot_id = spot_id
_failure_text = reason
_failure_remaining = 1.4
queue_redraw()
func hit_spots(screen_position: Vector2) -> Array[String]:
var label_hits: Array[String] = []
if _prop == null or not is_instance_valid(_prop):
return label_hits
for spot: Dictionary in _visible_spots:
var spot_id: String = String(spot.get("id", ""))
var world_position: Vector2 = _prop.use_spot_world_position(spot_id)
if world_position.is_finite() and _spot_label_rect(_prop, spot).has_point(screen_position):
label_hits.append(spot_id)
if not label_hits.is_empty():
return label_hits
var result: Array[String] = []
for spot: Dictionary in _visible_spots:
var spot_id: String = String(spot.get("id", ""))
var world_position: Vector2 = _prop.use_spot_world_position(spot_id)
if world_position.is_finite() and _screen_position(world_position).distance_to(screen_position) <= _pin_hit_size * 0.5:
result.append(spot_id)
return result
func closest_hit_spot(screen_position: Vector2) -> String:
if _prop == null or not is_instance_valid(_prop):
return ""
var closest_label_id: String = ""
var closest_label_distance: float = INF
var closest_pin_id: String = ""
var closest_pin_distance: float = _pin_hit_size * 0.5
for spot: Dictionary in _visible_spots:
var spot_id: String = String(spot.get("id", ""))
var world_position: Vector2 = _prop.use_spot_world_position(spot_id)
if not world_position.is_finite():
continue
var distance: float = _screen_position(world_position).distance_to(screen_position)
if _spot_label_rect(_prop, spot).has_point(screen_position):
if distance < closest_label_distance:
closest_label_id = spot_id
closest_label_distance = distance
elif distance <= closest_pin_distance:
closest_pin_id = spot_id
closest_pin_distance = distance
return closest_label_id if not closest_label_id.is_empty() else closest_pin_id
func clear_session() -> void:
_prop = null
_visible_spots.clear()
_selected_spot_id = ""
_moving_spot_id = ""
_ghost_world = Vector2.INF
_instruction = ""
_show_ghost = false
_guide.clear()
_success_prop = null
_success_spot_id = ""
_success_remaining = 0.0
_failure_prop = null
_failure_spot_id = ""
_failure_text = ""
_failure_remaining = 0.0
queue_redraw()
func set_paused(paused: bool) -> void:
set_process(not paused)
func _process(delta: float) -> void:
if _success_remaining > 0.0:
_success_remaining = maxf(0.0, _success_remaining - delta)
if _failure_remaining > 0.0:
_failure_remaining = maxf(0.0, _failure_remaining - delta)
queue_redraw()
func _draw() -> void:
if _prop != null and is_instance_valid(_prop):
for spot: Dictionary in _visible_spots:
var spot_id: String = String(spot.get("id", ""))
if spot_id == _moving_spot_id:
_draw_moving_source(_prop, spot)
else:
_draw_pin(_prop, spot, spot_id == _selected_spot_id)
if _visible_spots.is_empty() and not _instruction.is_empty():
var local_bounds: Rect2 = _prop.get_visual_local_bounds()
var prompt_world: Vector2 = _prop.global_transform * local_bounds.get_center()
_draw_pill(_screen_position(prompt_world) + Vector2(0.0, -42.0), _instruction, _theme_color("mode_colors.direct", Color("#FFB300")))
if _show_ghost and _ghost_world.is_finite():
var ghost_position: Vector2 = _screen_position(_ghost_world)
var moving_spot: Dictionary = _find_visible_spot(_moving_spot_id)
if not moving_spot.is_empty() and _prop != null and is_instance_valid(_prop):
var source_position: Vector2 = _screen_position(_prop.use_spot_world_position(_moving_spot_id))
var guide_color := Color("#FFB300")
guide_color.a = 0.72
draw_line(source_position, ghost_position, guide_color, 2.0, true)
_draw_pin_at(ghost_position, int(moving_spot.get("number", 0)), Color("#FFB300"), true)
else:
_draw_pin_at(ghost_position, 0, Color("#FFB300"), false)
if not _instruction.is_empty():
_draw_pill(ghost_position + Vector2(26.0, -24.0), _instruction, Color("#FFB300"))
if bool(_guide.get("known", false)) or _guide.has("reason_code"):
var start: Vector2 = _guide.get("approach_root", Vector2.INF)
var end: Vector2 = _guide.get("spot_world", Vector2.INF)
if start.is_finite() and end.is_finite():
var known: bool = bool(_guide.get("known", true))
var valid: bool = bool(_guide.get("valid", false))
var end_screen: Vector2 = _screen_position(end)
var guide_color: Color
if not known:
guide_color = _theme_color("overlays.use_preview.uncertain_color", Color("#FFB300"))
elif valid:
guide_color = _theme_color("overlays.use_preview.valid_color", Color("#42D887"))
else:
guide_color = _theme_color("overlays.use_preview.invalid_color", Color("#F06464"))
if not known:
var uncertain_reason: String = String(_guide.get("reason", "We'll check when you play."))
_draw_reach_marker(end_screen + Vector2(24.0, 0.0), "?", guide_color)
_draw_pill(end_screen + Vector2(0.0, 40.0), uncertain_reason, guide_color)
else:
var reason: String = String(_guide.get("reason", _reason_copy(String(_guide.get("reason_code", "")), valid)))
_draw_reach_marker(end_screen + Vector2(24.0, 0.0), "✓" if valid else "!", guide_color)
_draw_pill(end_screen + Vector2(0.0, -58.0), reason, guide_color)
if _success_remaining > 0.0 and _success_prop != null and is_instance_valid(_success_prop):
var success_spot: Vector2 = _success_prop.use_spot_world_position(_success_spot_id)
if success_spot.is_finite():
var progress: float = 1.0 - _success_remaining / _pulse_duration
var alpha: float = clampf(_success_remaining / _pulse_duration, 0.0, 1.0)
var pulse_color: Color = _theme_color("overlays.use_preview.valid_color", Color("#42D887"))
pulse_color.a = alpha
draw_arc(_screen_position(success_spot), 15.0 + progress * 24.0, 0.0, TAU, 32, pulse_color, 4.0, true)
if _failure_remaining > 0.0 and _failure_prop != null and is_instance_valid(_failure_prop):
var failure_spot: Vector2 = _failure_prop.use_spot_world_position(_failure_spot_id)
if failure_spot.is_finite():
_draw_pill(_screen_position(failure_spot) + Vector2(0.0, -42.0), _failure_text, Color("#F06464"))
func _draw_pin(prop: PropBlock, spot: Dictionary, selected: bool) -> void:
var spot_id: String = String(spot.get("id", ""))
var point: Vector2 = prop.use_spot_world_position(spot_id)
if not point.is_finite():
return
var color: Color = _theme_color("mode_colors.direct", Color("#FFB300")) if selected else _theme_color("stage_panels.spots.pin_surface_color", Color("#F4F7FB"))
_draw_pin_at(_screen_position(point), int(spot.get("number", 0)), color, selected)
_draw_pill(_spot_label_center(_screen_position(point)), _spot_label_text(spot), _theme_color("mode_colors.direct", Color("#FFB300")))
func _spot_label_text(spot: Dictionary) -> String:
var number: int = int(spot.get("number", 0))
var spot_name: String = String(spot.get("name", "Spot %d" % number)).strip_edges()
if spot_name.is_empty():
spot_name = "Spot %d" % number
return "%d %s" % [number, spot_name]
func _spot_label_center(screen_point: Vector2) -> Vector2:
return screen_point + Vector2(0.0, -28.0)
func _spot_label_rect(prop: PropBlock, spot: Dictionary) -> Rect2:
var world_position: Vector2 = prop.use_spot_world_position(String(spot.get("id", "")))
if not world_position.is_finite():
return Rect2()
var font: Font = ThemeDB.fallback_font
var text_size: Vector2 = font.get_string_size(_spot_label_text(spot), HORIZONTAL_ALIGNMENT_LEFT, -1, 16)
var center: Vector2 = _spot_label_center(_screen_position(world_position))
return Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0))
func _draw_moving_source(prop: PropBlock, spot: Dictionary) -> void:
var spot_id: String = String(spot.get("id", ""))
var point: Vector2 = prop.use_spot_world_position(spot_id)
if not point.is_finite():
return
var screen_point: Vector2 = _screen_position(point)
var amber := Color("#FFB300")
var ghost_fill := amber
ghost_fill.a = 0.30
draw_circle(screen_point, _pin_radius + 4.0, _theme_color("stage_panels.spots.pin_outline_color", Color("#171B24")))
draw_circle(screen_point, _pin_radius, ghost_fill)
draw_arc(screen_point, _pin_radius, 0.0, TAU, 32, amber, maxf(_pin_outline_width, 3.0), true)
draw_arc(screen_point, _pin_radius + 5.0, 0.0, TAU, 32, amber, 2.0, true)
draw_circle(screen_point, 3.0, amber)
var font: Font = ThemeDB.fallback_font
var number_text: String = str(int(spot.get("number", 0)))
var text_size: Vector2 = font.get_string_size(number_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 15)
draw_string(font, screen_point + Vector2(-text_size.x * 0.5, 5.0), number_text, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#171B24"))
_draw_pill(screen_point + Vector2(0.0, -28.0), "Moving " + String(spot.get("name", "Spot")), amber)
func _find_visible_spot(spot_id: String) -> Dictionary:
if spot_id.is_empty():
return {}
for spot: Dictionary in _visible_spots:
if String(spot.get("id", "")) == spot_id:
return spot
return {}
func _draw_pin_at(point: Vector2, number: int, accent: Color, selected: bool) -> void:
draw_circle(point, _pin_radius + 3.0, _theme_color("stage_panels.spots.pin_outline_color", Color("#171B24")))
draw_circle(point, _pin_radius, accent)
draw_arc(point, _pin_radius, 0.0, TAU, 32, Color.WHITE, _pin_outline_width, true)
draw_circle(point, 3.0, Color("#171B24"))
if number > 0:
var font: Font = ThemeDB.fallback_font
var label: String = str(number)
var label_size: Vector2 = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15)
draw_string(font, point + Vector2(-label_size.x * 0.5, 5.0), label, HORIZONTAL_ALIGNMENT_LEFT, -1, 15, Color("#171B24"))
func _draw_pill(center: Vector2, text_value: String, accent: Color) -> void:
if text_value.is_empty():
return
var font: Font = ThemeDB.fallback_font
var text_size: Vector2 = font.get_string_size(text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16)
var rect := Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0))
draw_rect(rect, Color("#171B24"), true)
draw_rect(rect, accent, false, 2.0)
draw_string(font, rect.position + Vector2(10.0, 18.0), text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE)
func _draw_reach_marker(center: Vector2, glyph: String, accent: Color) -> void:
draw_circle(center, 13.0, Color("#171B24"))
draw_circle(center, 10.0, accent)
var font: Font = ThemeDB.fallback_font
var font_size := 16
var glyph_size: Vector2 = font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
draw_string(font, center + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.35), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color("#171B24"))
func _screen_position(world_position: Vector2) -> Vector2:
return get_viewport().get_canvas_transform() * world_position
func _reason_copy(reason_code: String, valid: bool) -> String:
if valid:
return "Ready to use"
match reason_code:
"route_blocked":
return "Can't walk there"
"no_standing_room":
return "No room to stand"
"hands_full":
return "Hands must be empty"
"prop_held":
return "Prop is busy"
"earlier_actions":
return "We'll check when you play."
_:
return "Too far to reach"
func _theme_color(path: String, fallback: Color) -> Color:
return fallback if _theme == null else _theme.color(path, fallback)
@@ -0,0 +1 @@
uid://d1ocurcl8fpiu
@@ -0,0 +1,227 @@
class_name StageUseTargetCaptureBar
extends PanelContainer
## Clear, screen-space instructions and actions for choosing a Use target.
signal cancel_requested
signal change_prop_requested
const MAX_BAR_WIDTH: float = 800.0
const VIEWPORT_WIDTH_RATIO: float = 0.68
const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0
const ACTION_ROW_MIN_WIDTH: float = 480.0
const ACTION_BUTTON_WIDTH: float = 200.0
const DEFAULT_BUTTON_HEIGHT: float = 56.0
var _stage_theme: SandboxTheme
var _top_bar_height: float = 64.0
var _horizontal_layout: HBoxContainer
var _vertical_layout: VBoxContainer
var _active_layout: Container
var _actions_row: HBoxContainer
var _actions_column: VBoxContainer
var _actions_parent: BoxContainer
var _prompt: Label
var _change_prop_button: Button
var _cancel_button: Button
func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void:
_stage_theme = stage_theme
_top_bar_height = maxf(0.0, top_bar_height)
func _ready() -> void:
_build_controls()
apply_theme()
z_index = 100
visible = false
set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
get_viewport().size_changed.connect(_layout_capture_bar)
_layout_capture_bar()
func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void:
if _prompt == null:
return
if not prop_selected:
_prompt.text = "Choose a prop to use"
elif prop_has_use_spots:
_prompt.text = "Choose a spot on the prop"
else:
_prompt.text = "This prop has no Use spots. Add one in EDIT."
_change_prop_button.visible = prop_selected
visible = true
_layout_capture_bar()
func hide_capture() -> void:
visible = false
func apply_theme() -> void:
if not is_node_ready() or _prompt == null:
return
theme = _stage_theme.control_theme() if _stage_theme != null else null
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
var shell: StyleBoxFlat = StyleBoxFlat.new()
shell.bg_color = _color("stage_panels.shell.surface_color", Color("#202633FA"))
shell.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 10, 24))
shell.border_color = accent
shell.border_width_left = 8
shell.border_width_right = 0
shell.border_width_top = 0
shell.border_width_bottom = 0
shell.content_margin_left = 20.0
shell.content_margin_right = 18.0
shell.content_margin_top = 14.0
shell.content_margin_bottom = 14.0
shell.shadow_color = Color(0.0, 0.0, 0.0, 0.38)
shell.shadow_size = 8
shell.shadow_offset = Vector2(0.0, 4.0)
add_theme_stylebox_override("panel", shell)
var prompt_size: int = clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34)
_prompt.add_theme_font_size_override("font_size", prompt_size)
_prompt.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA")))
var body_font: Font = _stage_theme.font() if _stage_theme != null else ThemeDB.fallback_font
if body_font != null:
_prompt.add_theme_font_override("font", body_font)
_change_prop_button.add_theme_font_override("font", body_font)
_cancel_button.add_theme_font_override("font", body_font)
var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30)
_change_prop_button.add_theme_font_size_override("font_size", body_size)
_cancel_button.add_theme_font_size_override("font_size", body_size)
_style_button(_change_prop_button, false)
_style_button(_cancel_button, true)
var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96)))
_change_prop_button.custom_minimum_size.y = button_height
_cancel_button.custom_minimum_size.y = button_height
_actions_row.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 12, 8, 24))
_actions_column.add_theme_constant_override("separation", _integer("stage_panels.shell.header_gap", 10, 8, 24))
_layout_capture_bar()
func _build_controls() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
_horizontal_layout = HBoxContainer.new()
_horizontal_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_horizontal_layout.add_theme_constant_override("separation", 20)
_vertical_layout = VBoxContainer.new()
_vertical_layout.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_vertical_layout.add_theme_constant_override("separation", 12)
_active_layout = _horizontal_layout
add_child(_active_layout)
_prompt = Label.new()
_prompt.name = "Prompt"
_prompt.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_prompt.max_lines_visible = 2
_prompt.mouse_filter = Control.MOUSE_FILTER_IGNORE
_prompt.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_prompt.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_active_layout.add_child(_prompt)
_actions_row = HBoxContainer.new()
_actions_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_actions_row.alignment = BoxContainer.ALIGNMENT_END
_actions_column = VBoxContainer.new()
_actions_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_actions_parent = _actions_row
_active_layout.add_child(_actions_parent)
_change_prop_button = Button.new()
_change_prop_button.name = "ChangeProp"
_change_prop_button.text = "Change prop"
_change_prop_button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
_change_prop_button.focus_mode = Control.FOCUS_ALL
_change_prop_button.pressed.connect(func() -> void: change_prop_requested.emit())
_actions_row.add_child(_change_prop_button)
_cancel_button = Button.new()
_cancel_button.name = "Cancel"
_cancel_button.text = "✕ Cancel"
_cancel_button.custom_minimum_size.x = ACTION_BUTTON_WIDTH
_cancel_button.focus_mode = Control.FOCUS_ALL
_cancel_button.pressed.connect(func() -> void: cancel_requested.emit())
_actions_row.add_child(_cancel_button)
func _style_button(button: Button, prominent: bool) -> void:
var accent: Color = _color("mode_colors.direct", Color("#FFB300"))
var text_color: Color = Color("#171B24") if prominent else _color("stage_panels.type.text_color", Color("#F5F7FA"))
var normal_bg: Color = accent if prominent else _color("stage_panels.action.surface_color", Color("#171B24"))
var hover_bg: Color = accent.lightened(0.08) if prominent else _color("stage_panels.action.hover_surface", Color("#303A4B"))
var pressed_bg: Color = accent.darkened(0.12) if prominent else _color("stage_panels.action.selected_surface", Color("#12394A"))
var border: Color = accent if prominent else _color("stage_panels.choice.border_color", Color("#FFFFFF55"))
button.add_theme_stylebox_override("normal", _button_style(normal_bg, border, 1))
button.add_theme_stylebox_override("hover", _button_style(hover_bg, accent, 2))
button.add_theme_stylebox_override("pressed", _button_style(pressed_bg, accent, 2))
button.add_theme_stylebox_override("hover_pressed", _button_style(hover_bg, accent, 2))
button.add_theme_stylebox_override("focus", _button_style(Color(0.0, 0.0, 0.0, 0.0), accent, 2))
button.add_theme_color_override("font_color", text_color)
button.add_theme_color_override("font_hover_color", text_color)
button.add_theme_color_override("font_pressed_color", text_color)
button.add_theme_color_override("font_focus_color", text_color)
func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat:
var style: StyleBoxFlat = StyleBoxFlat.new()
style.bg_color = background
style.border_color = border
style.set_border_width_all(border_width)
style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32))
style.content_margin_left = 10.0
style.content_margin_right = 10.0
style.content_margin_top = 6.0
style.content_margin_bottom = 6.0
return style
func _layout_capture_bar() -> void:
if not is_node_ready() or _prompt == null:
return
var viewport_size: Vector2 = get_viewport_rect().size
var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48))
var available_width: float = maxf(1.0, viewport_size.x - inset * 2.0)
var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width))
custom_minimum_size.x = width
size.x = width
position = Vector2((viewport_size.x - width) * 0.5, _top_bar_height + 56.0)
_set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH, width < ACTION_ROW_MIN_WIDTH)
call_deferred("_fit_height_to_content")
func _set_layout(compact: bool, stack_actions: bool) -> void:
var target_layout: Container = _vertical_layout if compact else _horizontal_layout
var target_actions: BoxContainer = _actions_column if stack_actions else _actions_row
if _actions_parent != target_actions:
var previous_actions: BoxContainer = _actions_parent
previous_actions.remove_child(_change_prop_button)
previous_actions.remove_child(_cancel_button)
_active_layout.remove_child(previous_actions)
_actions_parent = target_actions
_active_layout.add_child(_actions_parent)
_actions_parent.add_child(_change_prop_button)
_actions_parent.add_child(_cancel_button)
if _active_layout != target_layout:
_active_layout.remove_child(_prompt)
_active_layout.remove_child(_actions_parent)
remove_child(_active_layout)
_active_layout = target_layout
add_child(_active_layout)
_active_layout.add_child(_prompt)
_active_layout.add_child(_actions_parent)
_change_prop_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH
_cancel_button.custom_minimum_size.x = 0.0 if stack_actions else ACTION_BUTTON_WIDTH
_change_prop_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL
_cancel_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stack_actions else Control.SIZE_FILL
func _fit_height_to_content() -> void:
if not is_inside_tree() or not visible:
return
custom_minimum_size.y = get_combined_minimum_size().y
size.y = custom_minimum_size.y
func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int:
return fallback if _stage_theme == null else _stage_theme.integer(path, fallback, minimum, maximum)
func _color(path: String, fallback: Color) -> Color:
return fallback if _stage_theme == null else _stage_theme.color(path, fallback)
@@ -0,0 +1 @@
uid://gnnheadxlx6r