Phase 14 - Organized directories.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
class_name ActionRegistry
|
||||
extends RefCounted
|
||||
## ActionRegistry - Registry of director action templates (Phase 3c).
|
||||
##
|
||||
## Single source of truth for the action types the Director Tool and the event
|
||||
## system understand. Adding a new action type is just appending an entry here;
|
||||
## the ActionEditor and the panels generate their UI from this registry, so no
|
||||
## other code changes are required.
|
||||
|
||||
enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
|
||||
|
||||
const ACTION_TEMPLATES := {
|
||||
"walk_to": {
|
||||
"label": "Walk To",
|
||||
"icon": "🚶",
|
||||
"params": [
|
||||
{ "key": "target", "type": "position", "required": true },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"speak": {
|
||||
"label": "Speak",
|
||||
"icon": "💬",
|
||||
"params": [
|
||||
{ "key": "text", "type": "text", "required": true },
|
||||
{ "key": "duration", "type": "float", "default": 2.0 },
|
||||
],
|
||||
"reactive_role": ReactiveRole.OVERLAY, "overlay_eligible": true,
|
||||
},
|
||||
"wait": {
|
||||
"label": "Wait",
|
||||
"icon": "⏳",
|
||||
"params": [
|
||||
{ "key": "duration", "type": "float", "required": true },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"ragdoll": {
|
||||
"label": "Ragdoll",
|
||||
"icon": "💥",
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||
},
|
||||
"recover": {
|
||||
"label": "Recover",
|
||||
"icon": "🔄",
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
|
||||
},
|
||||
"pick_up_prop": {
|
||||
"label": "Pick Up Prop",
|
||||
"icon": "🫴",
|
||||
"params": [
|
||||
{ "key": "target_prop_id", "type": "prop", "required": true },
|
||||
{ "key": "hand", "type": "choice", "default": "auto", "choices": ["auto", "left", "right"] },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"drop_prop": {
|
||||
"label": "Drop Prop",
|
||||
"icon": "⬇️",
|
||||
"params": [],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
"throw_prop": {
|
||||
"label": "Throw Prop",
|
||||
"icon": "🏹",
|
||||
"params": [
|
||||
{ "key": "target_position", "type": "position", "required": true },
|
||||
],
|
||||
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func types() -> Array[String]:
|
||||
# Dictionary.keys() returns an untyped Array at runtime; build a genuinely
|
||||
# typed Array[String] so callers can store it in typed locals (see
|
||||
# ActionEditor._current_type / RuleEditor._select_action_type).
|
||||
var out: Array[String] = []
|
||||
out.assign(ACTION_TEMPLATES.keys())
|
||||
return out
|
||||
|
||||
|
||||
static func has_type(type: String) -> bool:
|
||||
return ACTION_TEMPLATES.has(type)
|
||||
|
||||
|
||||
static func label(type: String) -> String:
|
||||
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||
return String(tpl.get("label", type))
|
||||
|
||||
|
||||
static func icon(type: String) -> String:
|
||||
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||
return String(tpl.get("icon", ""))
|
||||
|
||||
|
||||
static func is_overlay_eligible(type: String) -> bool:
|
||||
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||
return bool(tpl.get("overlay_eligible", false))
|
||||
|
||||
|
||||
static func reactive_role(action: Dictionary) -> ReactiveRole:
|
||||
var type: String = String(action.get("type", ""))
|
||||
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
|
||||
var role: ReactiveRole = int(tpl.get("reactive_role", ReactiveRole.SEQUENTIAL))
|
||||
var params: Dictionary = action.get("params", {})
|
||||
if type == "speak":
|
||||
if params.has("keep_acting") or action.has("keep_acting"):
|
||||
return ReactiveRole.OVERLAY if bool(params.get("keep_acting", action.get("keep_acting", false))) else ReactiveRole.SEQUENTIAL
|
||||
if bool(params.get("stop_current", action.get("stop_current", false))):
|
||||
return ReactiveRole.SEQUENTIAL
|
||||
return role
|
||||
|
||||
|
||||
## Converts a flat queue action into a rule-action shape (adds `target` + nests
|
||||
## params). `target_id` is the acting stickman's instance id.
|
||||
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
|
||||
var params: Dictionary = {}
|
||||
match String(action.get("type", "")):
|
||||
"walk_to":
|
||||
params["target"] = action.get("target", Vector2.ZERO)
|
||||
"speak":
|
||||
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
|
||||
if action.has(key):
|
||||
params[key] = action[key].duplicate(true) if action[key] is Dictionary or action[key] is Array else action[key]
|
||||
"wait":
|
||||
params["duration"] = float(action.get("duration", 0.0))
|
||||
"pick_up_prop":
|
||||
params["target_prop_id"] = String(action.get("target_prop_id", ""))
|
||||
params["hand"] = String(action.get("hand", "auto"))
|
||||
"throw_prop":
|
||||
params["target_position"] = action.get("target_position", Vector2.ZERO)
|
||||
return {
|
||||
"type": String(action.get("type", "")),
|
||||
"target": target_id,
|
||||
"params": params,
|
||||
}
|
||||
|
||||
|
||||
## Converts a rule-action shape back into a flat queue action (drops `target`).
|
||||
static func from_rule_action(rule_action: Dictionary) -> Dictionary:
|
||||
var params: Dictionary = rule_action.get("params", {})
|
||||
match String(rule_action.get("type", "")):
|
||||
"walk_to":
|
||||
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
|
||||
"speak":
|
||||
var speak: Dictionary = {"type": "speak"}
|
||||
for key: String in ["text", "duration", "stop_current", "keep_acting", "visual", "arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "placement", "bubbles"]:
|
||||
if params.has(key):
|
||||
speak[key] = params[key].duplicate(true) if params[key] is Dictionary or params[key] is Array else params[key]
|
||||
if not speak.has("keep_acting"):
|
||||
speak["keep_acting"] = not bool(params.get("stop_current", false))
|
||||
return speak
|
||||
"wait":
|
||||
return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
|
||||
"pick_up_prop":
|
||||
return { "type": "pick_up_prop", "target_prop_id": String(params.get("target_prop_id", "")), "hand": String(params.get("hand", "auto")) }
|
||||
"drop_prop":
|
||||
return { "type": "drop_prop" }
|
||||
"throw_prop":
|
||||
return { "type": "throw_prop", "target_position": params.get("target_position", Vector2.ZERO) }
|
||||
_:
|
||||
return { "type": String(rule_action.get("type", "")) }
|
||||
|
||||
|
||||
## Human-readable one-line summary for both flat queue actions and rule-action
|
||||
## shapes (the get() fallbacks tolerate either key layout).
|
||||
static func summarize(action: Dictionary) -> String:
|
||||
var type := String(action.get("type", ""))
|
||||
var params: Dictionary = action.get("params", {})
|
||||
match type:
|
||||
"walk_to":
|
||||
# Rule actions use top-level `target` for the actor instance id and
|
||||
# nested `params.target` for the waypoint. Prefer the nested value and
|
||||
# type-guard legacy/malformed payloads so summaries never crash.
|
||||
var raw_target: Variant = params.get("target", action.get("target", Vector2.ZERO))
|
||||
var t: Vector2 = raw_target as Vector2 if raw_target is Vector2 else Vector2.ZERO
|
||||
return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))]
|
||||
"speak":
|
||||
var text := String(action.get("text", params.get("text", "")))
|
||||
var dur := float(action.get("duration", params.get("duration", 2.0)))
|
||||
var has_keep_acting: bool = params.has("keep_acting") or action.has("keep_acting")
|
||||
var keep_acting: bool = bool(params.get("keep_acting", action.get("keep_acting", false)))
|
||||
var legacy_stop_current: bool = bool(params.get("stop_current", action.get("stop_current", false)))
|
||||
var suffix: String = " — keeps acting" if has_keep_acting and keep_acting else (" — stops current" if not has_keep_acting and legacy_stop_current else "")
|
||||
return "Speak \"%s\" (%ss)%s" % [text, _fmt_duration(dur), suffix]
|
||||
"wait":
|
||||
var wd := float(action.get("duration", params.get("duration", 0.0)))
|
||||
return "Wait %ss" % _fmt_duration(wd)
|
||||
"ragdoll":
|
||||
return "Ragdoll"
|
||||
"recover":
|
||||
return "Recover"
|
||||
"pick_up_prop":
|
||||
return "Pick Up Prop (%s)" % String(action.get("target_prop_id", params.get("target_prop_id", "")))
|
||||
"drop_prop":
|
||||
return "Drop Prop"
|
||||
"throw_prop":
|
||||
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))]
|
||||
_:
|
||||
return type
|
||||
|
||||
|
||||
static func _fmt_duration(v: float) -> String:
|
||||
if v == roundf(v):
|
||||
return str(int(v))
|
||||
return str(v)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cncpyuy6cymwp
|
||||
@@ -0,0 +1,243 @@
|
||||
class_name SandboxTheme
|
||||
extends RefCounted
|
||||
## Validated, cached presentation data for the Sandbox Stage.
|
||||
|
||||
const SCHEMA_VERSION: String = "2.0"
|
||||
const DEFAULT_PATH: String = "res://sandbox_theme.json"
|
||||
|
||||
const DEFAULT_CONFIG: Dictionary = {
|
||||
"version": SCHEMA_VERSION,
|
||||
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
|
||||
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}, "comic": {"label": "Comic", "path": "res://resources/fonts/comic.ttf", "speech_base_size": 28}, "pixel": {"label": "Pixel", "path": "res://resources/fonts/pixel.ttf", "speech_base_size": 40}, "scary": {"label": "Scary", "path": "res://resources/fonts/scary.ttf", "speech_base_size": 28}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "timestamp_badge": 22, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
|
||||
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
|
||||
"grid": {"snap_size": 15.0},
|
||||
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[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"}},
|
||||
"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"}
|
||||
}
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _font_cache: Dictionary = {}
|
||||
var _style_cache: Dictionary = {}
|
||||
var _control_theme: Theme = null
|
||||
var _warned: Dictionary = {}
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool:
|
||||
invalidate_caches()
|
||||
_config = DEFAULT_CONFIG.duplicate(true)
|
||||
if not FileAccess.file_exists(path):
|
||||
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
|
||||
return false
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
|
||||
return false
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary:
|
||||
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
|
||||
return false
|
||||
_apply_legacy_aliases(parsed as Dictionary)
|
||||
_config = _deep_merge(_config, parsed as Dictionary)
|
||||
return true
|
||||
|
||||
func raw_config() -> Dictionary:
|
||||
return _config.duplicate(true)
|
||||
|
||||
func value(path: String, fallback: Variant = null) -> Variant:
|
||||
var cursor: Variant = _config
|
||||
for piece: String in path.split("."):
|
||||
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
|
||||
return fallback
|
||||
cursor = (cursor as Dictionary)[piece]
|
||||
return cursor
|
||||
|
||||
func color(path: String, fallback: Color) -> Color:
|
||||
var raw: Variant = value(path, null)
|
||||
if not raw is String:
|
||||
return fallback
|
||||
var text: String = String(raw)
|
||||
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
|
||||
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
|
||||
return fallback
|
||||
return Color.from_string(text, fallback)
|
||||
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
|
||||
var raw: Variant = value(path, fallback)
|
||||
if not raw is int and not raw is float:
|
||||
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
|
||||
return fallback
|
||||
var n: float = float(raw)
|
||||
if not is_finite(n): return fallback
|
||||
return clampf(n, minimum, maximum)
|
||||
|
||||
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
|
||||
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
|
||||
|
||||
func font(component_path: String = "", preset_id: String = "ui") -> Font:
|
||||
var path: String = ""
|
||||
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
|
||||
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
|
||||
if path.is_empty(): path = String(value("fonts.ui_font", ""))
|
||||
if path.is_empty():
|
||||
if preset_id == "ui": return ThemeDB.fallback_font
|
||||
var system_key: String = "system:" + preset_id
|
||||
if _font_cache.has(system_key): return _font_cache[system_key]
|
||||
var system_font := SystemFont.new()
|
||||
match preset_id:
|
||||
"comic": system_font.font_names = PackedStringArray(["Comic Sans MS", "Comic Neue", "Chalkboard", "Comic Sans", "sans-serif"])
|
||||
"pixel": system_font.font_names = PackedStringArray(["Consolas", "Courier New", "Liberation Mono", "monospace"])
|
||||
"scary": system_font.font_names = PackedStringArray(["Impact", "Chiller", "Papyrus", "fantasy", "sans-serif"])
|
||||
_: system_font.font_names = PackedStringArray(["Arial", "Liberation Sans", "sans-serif"])
|
||||
_font_cache[system_key] = system_font
|
||||
return system_font
|
||||
if _font_cache.has(path): return _font_cache[path]
|
||||
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
|
||||
if resource is Font:
|
||||
_font_cache[path] = resource
|
||||
return resource as Font
|
||||
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
|
||||
return ThemeDB.fallback_font
|
||||
|
||||
func speech_font(preset_id: String = "ui") -> Font:
|
||||
return font("", preset_id)
|
||||
|
||||
|
||||
func speech_font_size(preset_id: String, size_preset: String) -> int:
|
||||
var base_size: int = integer("fonts.presets.%s.speech_base_size" % preset_id, 28, 8, 96)
|
||||
var scale: float = 1.0
|
||||
match size_preset:
|
||||
"small": scale = 0.72
|
||||
"huge": scale = 1.43
|
||||
return clampi(roundi(float(base_size) * scale), 8, 128)
|
||||
|
||||
func emoji_font() -> Font:
|
||||
var path: String = String(value("fonts.emoji_font", ""))
|
||||
if path.is_empty(): return font()
|
||||
return font("", "emoji") if ResourceLoader.exists(path) else font()
|
||||
|
||||
func style(component_path: String) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var section: String = component_path.get_slice(".", 0)
|
||||
result = _deep_merge(result, value("global", {}))
|
||||
result = _deep_merge(result, value(section + ".style_defaults", {}))
|
||||
result = _deep_merge(result, value(component_path, {}))
|
||||
return result
|
||||
|
||||
func stylebox(component_path: String) -> StyleBoxFlat:
|
||||
var cfg: Dictionary = style(component_path)
|
||||
var key: String = JSON.stringify(cfg)
|
||||
if not _style_cache.has(key):
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
|
||||
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
|
||||
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
|
||||
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
|
||||
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
|
||||
box.content_margin_left = padding; box.content_margin_right = padding
|
||||
box.content_margin_top = padding; box.content_margin_bottom = padding
|
||||
_style_cache[key] = box
|
||||
return (_style_cache[key] as StyleBoxFlat).duplicate()
|
||||
|
||||
func control_theme() -> Theme:
|
||||
if _control_theme == null:
|
||||
_control_theme = Theme.new()
|
||||
_control_theme.default_font = font()
|
||||
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
|
||||
return _control_theme
|
||||
|
||||
func swatches() -> Array[Color]:
|
||||
var result: Array[Color] = []
|
||||
for item: Variant in value("widgets.swatches", []):
|
||||
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
|
||||
return result
|
||||
|
||||
func size_presets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for item: Variant in value("widgets.size_presets", []):
|
||||
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
|
||||
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
|
||||
return result
|
||||
|
||||
func font_presets() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var presets: Dictionary = value("fonts.presets", {})
|
||||
for id: String in presets:
|
||||
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
|
||||
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
|
||||
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
|
||||
return result
|
||||
|
||||
func emoji_items() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
|
||||
return result
|
||||
|
||||
func speech_color(frame: String, channel: String, fallback: Color) -> Color:
|
||||
if not ["talk", "shout", "think", "whisper", "sfx"].has(frame) or not ["fill", "outline"].has(channel):
|
||||
return fallback
|
||||
return color("speech.styles.%s.%s" % [frame, channel], fallback)
|
||||
|
||||
func speech_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
|
||||
if not ["ui.panel_width", "ui.button_min_height", "ui.heading_size", "layout.max_width", "layout.stack_gap", "layout.screen_margin", "layout.bubble_padding", "animation.letter_interval", "animation.word_interval", "animation.pop_duration"].has(token):
|
||||
return fallback
|
||||
return number("speech." + token, fallback, minimum, maximum)
|
||||
|
||||
|
||||
func speech_toolbar_number(token: String, fallback: float, minimum: float, maximum: float) -> float:
|
||||
if not ["gap", "max_width", "min_hit_height"].has(token):
|
||||
return fallback
|
||||
return number("speech.toolbar." + token, fallback, minimum, maximum)
|
||||
|
||||
func speech_text_colors() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var raw_colors: Variant = value("speech.text_colors", [])
|
||||
if not raw_colors is Array:
|
||||
return result
|
||||
for raw: Variant in raw_colors as Array:
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
var item: Dictionary = raw as Dictionary
|
||||
var name: String = String(item.get("name", "")).strip_edges()
|
||||
var color_text: String = String(item.get("color", ""))
|
||||
if not name.is_empty() and _is_valid_hex_color(color_text):
|
||||
result.append({"name": name, "color": Color.from_string(color_text, Color.BLACK)})
|
||||
return result
|
||||
|
||||
func _is_valid_hex_color(text: String) -> bool:
|
||||
if text.length() != 7 and text.length() != 9 or not text.begins_with("#"):
|
||||
return false
|
||||
for index: int in range(1, text.length()):
|
||||
if not "0123456789abcdefABCDEF".contains(text.substr(index, 1)):
|
||||
return false
|
||||
return true
|
||||
|
||||
func invalidate_caches() -> void:
|
||||
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
|
||||
|
||||
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 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
|
||||
|
||||
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
|
||||
var merged: Dictionary = base.duplicate(true)
|
||||
for key: Variant in overrides:
|
||||
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
|
||||
else: merged[key] = overrides[key]
|
||||
return merged
|
||||
|
||||
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
|
||||
return Color.from_string(String(cfg.get(key, "")), fallback)
|
||||
|
||||
func _warn_once(key: String, message: String) -> void:
|
||||
if not _warned.has(key): _warned[key] = true; push_warning(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://02tfgtwtgfcw
|
||||
@@ -0,0 +1,501 @@
|
||||
class_name StageDirectorVisuals
|
||||
extends Node2D
|
||||
## StageDirectorVisuals - Edit-mode director overlay (Phase 3a).
|
||||
##
|
||||
## Draws each stickman's action queue as walk-to waypoint dots with dashed
|
||||
## connectors plus small badges for speak/wait/ragdoll/recover, with order
|
||||
## numbers. Pure drawing; no hit-testing. Visible in EDIT, hidden in PLAY.
|
||||
|
||||
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
|
||||
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||
|
||||
const WAYPOINT_RADIUS_PX := 6.0
|
||||
const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0)
|
||||
const WAYPOINT_OUTLINE := Color.WHITE
|
||||
const DASH_COLOR := Color(1.0, 1.0, 1.0, 0.6)
|
||||
const DASH_WIDTH_PX := 2.0
|
||||
const DASH_LENGTH_PX := 6.0
|
||||
const DASH_GAP_PX := 4.0
|
||||
const NUMBER_COLOR := Color.WHITE
|
||||
const NUMBER_FONT_SIZE_PX := 16.0
|
||||
const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9)
|
||||
const ICON_SIZE_PX := 12.0
|
||||
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_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
|
||||
const RULE_BADGE_RADIUS_PX := 7.0
|
||||
const RULE_DELETE_HIT_PX := 14.0
|
||||
const RULE_DELETE_COLOR := Color(1.0, 0.3, 0.3, 0.9)
|
||||
|
||||
var camera: Camera2D = null
|
||||
var world: Node2D = null
|
||||
var enabled: bool = true
|
||||
var _dirty: bool = true
|
||||
|
||||
## Phase 4b style overrides (set via set_style from sandbox_theme.json). Defaults
|
||||
## equal the constants above so behavior is unchanged when no theme is present.
|
||||
var badge_icon_size: float = ICON_SIZE_PX
|
||||
var badge_number_size: float = NUMBER_FONT_SIZE_PX
|
||||
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 emoji_font: Font = null
|
||||
|
||||
## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is
|
||||
## realised via ui_font_bold / FontVariation; null falls back to ui_font.
|
||||
var ui_font: Font = null
|
||||
var bold_font: Font = null
|
||||
var italic_font: Font = null
|
||||
var rule_label_bold: bool = false
|
||||
var badge_bold: bool = true
|
||||
|
||||
## Phase 4 rule rendering: stored rules plus per-frame hit regions for the label
|
||||
## and delete icon ({"rect": Rect2, "id": int, "part": String}).
|
||||
var rules: Array[Dictionary] = []
|
||||
var _rule_hit_regions: Array[Dictionary] = []
|
||||
|
||||
## Phase 3c: the walk_to waypoint currently being visually edited (blinking
|
||||
## highlight). Vector2.INF when none.
|
||||
var _edit_waypoint: Vector2 = Vector2.INF
|
||||
var _live_session: bool = false
|
||||
|
||||
## Highlights a waypoint while its walk target is being re-placed on the stage.
|
||||
func set_edit_waypoint(pos: Vector2) -> void:
|
||||
_edit_waypoint = pos
|
||||
mark_dirty()
|
||||
|
||||
|
||||
func clear_edit_waypoint() -> void:
|
||||
_edit_waypoint = Vector2.INF
|
||||
mark_dirty()
|
||||
|
||||
func set_enabled(value: bool) -> void:
|
||||
enabled = value
|
||||
visible = value
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_live_session(paused: bool) -> void:
|
||||
_live_session = paused
|
||||
mark_dirty()
|
||||
|
||||
func mark_dirty() -> void:
|
||||
_dirty = true
|
||||
|
||||
|
||||
## Applies a theme dictionary (sandbox_theme.json §fonts) onto the badge/label
|
||||
## sizes. Missing keys fall back to the current constants so behavior is
|
||||
## unchanged when no theme is present.
|
||||
func set_style(cfg: Dictionary) -> void:
|
||||
var fonts: Dictionary = cfg.get("fonts", {})
|
||||
badge_icon_size = float(fonts.get("assignment_badge_font_size", ICON_SIZE_PX))
|
||||
badge_number_size = float(fonts.get("assignment_badge_font_size", NUMBER_FONT_SIZE_PX))
|
||||
badge_radius = float(fonts.get("assignment_badge_radius", RULE_BADGE_RADIUS_PX))
|
||||
rule_label_font_size = float(fonts.get("rule_label_font_size", RULE_LABEL_FONT_SIZE_PX))
|
||||
rule_label_bold = bool(fonts.get("rule_label_bold", false))
|
||||
badge_bold = bool(fonts.get("badge_bold", true))
|
||||
mark_dirty()
|
||||
|
||||
|
||||
## Resolved-theme entry point. `set_style` remains for legacy callers/tests.
|
||||
func apply_style(style: Dictionary, fonts: Dictionary = {}) -> void:
|
||||
var legacy: Dictionary = {"fonts": fonts.duplicate(true)}
|
||||
if not fonts.is_empty():
|
||||
var sizes: Dictionary = fonts.get("sizes", fonts)
|
||||
legacy["fonts"] = {
|
||||
"assignment_badge_font_size": sizes.get("assignment_badge", badge_icon_size),
|
||||
"assignment_badge_radius": style.get("badge_radius", badge_radius),
|
||||
"rule_label_font_size": sizes.get("rule_label", rule_label_font_size),
|
||||
"rule_label_bold": fonts.get("flags", {}).get("rule_label_bold", rule_label_bold),
|
||||
"badge_bold": fonts.get("flags", {}).get("badge_bold", badge_bold),
|
||||
}
|
||||
set_style(legacy)
|
||||
|
||||
|
||||
func set_rules(r: Array[Dictionary]) -> void:
|
||||
rules = r
|
||||
mark_dirty()
|
||||
|
||||
|
||||
## Returns the hit region under `world_pos` (label/delete), or an empty dict.
|
||||
func hit_test_rule(world_pos: Vector2) -> Dictionary:
|
||||
for region: Dictionary in _rule_hit_regions:
|
||||
var rect: Rect2 = region.get("rect", Rect2())
|
||||
if rect.has_point(world_pos):
|
||||
return { "id": int(region.get("id", -1)), "part": String(region.get("part", "")) }
|
||||
return {}
|
||||
|
||||
|
||||
## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing
|
||||
## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss.
|
||||
func hit_test_waypoint(world_pos: Vector2) -> Vector2:
|
||||
var hit := hit_test_waypoint_action(world_pos)
|
||||
if hit.is_empty():
|
||||
return Vector2.INF
|
||||
return hit["pos"]
|
||||
|
||||
|
||||
## Like hit_test_waypoint but also returns the owning rig and queue index:
|
||||
## {"rig": StickmanRig, "index": int, "pos": Vector2}, or {} on miss. Used by
|
||||
## the Phase 3c waypoint context menu to locate the exact walk action.
|
||||
func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
|
||||
var radius := WAYPOINT_HIT_RADIUS_PX / _zoom()
|
||||
var best: Dictionary = {}
|
||||
var best_dist := radius
|
||||
for rig: StickmanRig in _collect_rigs():
|
||||
var queue_view: Array[Dictionary] = rig.get_queue_view()
|
||||
if queue_view.is_empty():
|
||||
continue
|
||||
var anchor: Dictionary = _queue_anchor(rig)
|
||||
var current: Vector2 = anchor.get("ground", anchor.get("feet", rig.global_position - StickmanLocomotion.FOOT_OFFSET))
|
||||
for i: int in queue_view.size():
|
||||
var entry: Dictionary = queue_view[i]
|
||||
var action: Dictionary = entry.get("action", {})
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
var target: Vector2 = action.get("target", current)
|
||||
var d := world_pos.distance_to(target)
|
||||
if d <= best_dist:
|
||||
best_dist = d
|
||||
best = { "rig": rig, "index": int(entry.get("authored_index", i)), "action_id": int(entry.get("action_id", -1)), "status": int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED)), "pos": target }
|
||||
current = target
|
||||
return best
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Redraw every frame while a waypoint is being edited (the blink is
|
||||
# time-animated); otherwise only on the dirty flag.
|
||||
if _dirty or (_edit_waypoint.is_finite() and enabled):
|
||||
_dirty = false
|
||||
queue_redraw()
|
||||
|
||||
func _zoom() -> float:
|
||||
if camera != null and is_instance_valid(camera):
|
||||
return maxf(camera.zoom.x, 0.0001)
|
||||
return 1.0
|
||||
|
||||
func _collect_rigs() -> Array[StickmanRig]:
|
||||
var result: Array[StickmanRig] = []
|
||||
if world == null or not is_instance_valid(world):
|
||||
return result
|
||||
for child: Node in world.get_children():
|
||||
if child is STICKMAN_RIG:
|
||||
result.append(child as StickmanRig)
|
||||
return result
|
||||
|
||||
func _draw() -> void:
|
||||
if not enabled:
|
||||
return
|
||||
_rule_hit_regions.clear()
|
||||
var zoom := _zoom()
|
||||
for rig: StickmanRig in _collect_rigs():
|
||||
_draw_rig_queue(rig, zoom)
|
||||
_draw_rules(zoom)
|
||||
|
||||
func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
|
||||
var queue_view: Array[Dictionary] = rig.get_queue_view()
|
||||
if queue_view.is_empty():
|
||||
return
|
||||
var anchor: Dictionary = _queue_anchor(rig)
|
||||
var authored_current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
var has_authored_cursor: bool = not _live_session
|
||||
var live_current: Vector2 = anchor.get("feet", authored_current)
|
||||
if _live_session:
|
||||
live_current = anchor.get("ground", live_current) if bool(anchor.get("has_ground", false)) else anchor.get("feet", live_current)
|
||||
if bool(anchor.get("airborne", false)) and bool(anchor.get("has_ground", false)):
|
||||
draw_line(anchor.get("feet", live_current), anchor.get("ground", live_current), Color(0.8, 0.8, 0.8, 0.35), 1.5 / zoom, true)
|
||||
var stack := 0
|
||||
for i: int in queue_view.size():
|
||||
var entry: Dictionary = queue_view[i]
|
||||
var action: Dictionary = entry.get("action", {})
|
||||
var status: int = int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED))
|
||||
var tint: Color = _status_color(status)
|
||||
var consumed: bool = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
var current: Vector2 = authored_current if _live_session and consumed else live_current if _live_session else authored_current
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
var target: Vector2 = action.get("target", current)
|
||||
if not (_live_session and consumed and not has_authored_cursor):
|
||||
_draw_dashed(current, target, zoom, tint)
|
||||
_draw_waypoint(target, zoom, str(i + 1), tint)
|
||||
if _live_session and consumed:
|
||||
authored_current = target
|
||||
has_authored_cursor = true
|
||||
else:
|
||||
live_current = target
|
||||
authored_current = target
|
||||
stack = 0
|
||||
else:
|
||||
var badge_anchor: Vector2 = current + BADGE_STACK_STEP * float(stack) / zoom
|
||||
_draw_badge(badge_anchor, String(action.get("type", "")), zoom, str(i + 1), tint)
|
||||
stack += 1
|
||||
|
||||
|
||||
func _queue_anchor(rig: StickmanRig) -> Dictionary:
|
||||
if _live_session:
|
||||
return rig.get_director_anchor()
|
||||
var feet: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
return {"feet": feet, "ground": feet, "has_ground": false, "airborne": false}
|
||||
|
||||
|
||||
func _status_color(status: int) -> Color:
|
||||
if _live_session and (status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED):
|
||||
return Color(completed_path_color.r, completed_path_color.g, completed_path_color.b, completed_path_alpha)
|
||||
return Color(1.0, 1.0, 1.0, 1.0)
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, zoom: float, tint: Color = DASH_COLOR) -> void:
|
||||
var dash := DASH_LENGTH_PX / zoom
|
||||
var gap := DASH_GAP_PX / zoom
|
||||
var dir := from.direction_to(to)
|
||||
var total := from.distance_to(to)
|
||||
var dist := 0.0
|
||||
while dist < total:
|
||||
var start := from + dir * dist
|
||||
var len := minf(dash, total - dist)
|
||||
var line_color: Color = DASH_COLOR if tint == Color.WHITE else tint
|
||||
line_color.a *= DASH_COLOR.a
|
||||
draw_line(start, start + dir * len, line_color, DASH_WIDTH_PX / zoom, true)
|
||||
dist += dash + gap
|
||||
|
||||
func _draw_waypoint(pos: Vector2, zoom: float, number: String, tint: Color = WAYPOINT_COLOR) -> void:
|
||||
var radius := WAYPOINT_RADIUS_PX / zoom
|
||||
draw_circle(pos, radius, tint)
|
||||
var outline: Color = Color.WHITE
|
||||
outline.a = tint.a
|
||||
draw_arc(pos, radius, 0.0, TAU, 32, outline, 2.0 / zoom, true)
|
||||
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom, outline)
|
||||
# Phase 3c: pulsing highlight ring while this waypoint is being edited.
|
||||
if _edit_waypoint.is_finite() and pos.distance_to(_edit_waypoint) < 0.5:
|
||||
var pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0)
|
||||
draw_arc(pos, radius + 6.0 / zoom, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 / zoom, true)
|
||||
|
||||
func _draw_number(pos: Vector2, number: String, zoom: float, tint: Color = NUMBER_COLOR) -> void:
|
||||
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), tint)
|
||||
|
||||
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String, tint: Color = ICON_COLOR) -> void:
|
||||
var s := badge_icon_size / zoom
|
||||
match type:
|
||||
"speak":
|
||||
var bw := s * 1.6
|
||||
var bh := s * 1.1
|
||||
draw_rect(Rect2(anchor - Vector2(bw, bh) * 0.5, Vector2(bw, bh)), tint, true)
|
||||
draw_colored_polygon(PackedVector2Array([
|
||||
anchor + Vector2(-s * 0.25, bh * 0.5),
|
||||
anchor + Vector2(s * 0.25, bh * 0.5),
|
||||
anchor + Vector2(0.0, bh * 0.5 + s * 0.5),
|
||||
]), tint)
|
||||
"wait":
|
||||
draw_arc(anchor, s, 0.0, TAU, 32, tint, 2.0 / zoom, true)
|
||||
draw_line(anchor, anchor + Vector2(0.0, -s * 0.7), tint, 2.0 / zoom, true)
|
||||
draw_line(anchor, anchor + Vector2(s * 0.5, 0.0), tint, 2.0 / zoom, true)
|
||||
"ragdoll":
|
||||
draw_line(anchor + Vector2(-s, -s) * 0.5, anchor + Vector2(s, s) * 0.5, tint, 2.0 / zoom, true)
|
||||
draw_line(anchor + Vector2(s, -s) * 0.5, anchor + Vector2(-s, s) * 0.5, tint, 2.0 / zoom, true)
|
||||
"recover":
|
||||
var base := anchor + Vector2(0.0, s * 0.6)
|
||||
var tip := anchor + Vector2(0.0, -s * 0.6)
|
||||
draw_line(base, tip, tint, 2.0 / zoom, true)
|
||||
draw_line(tip, tip + Vector2(-s * 0.5, s * 0.4), tint, 2.0 / zoom, true)
|
||||
draw_line(tip, tip + Vector2(s * 0.5, s * 0.4), tint, 2.0 / zoom, true)
|
||||
_draw_number(anchor + Vector2(s, -s), number, zoom, tint)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule rendering (Phase 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Collects every walk_to waypoint position, mirroring _draw_rig_queue's anchor
|
||||
## math exactly so waypoint hit-testing matches rendering.
|
||||
func _collect_waypoints() -> Array[Vector2]:
|
||||
var result: Array[Vector2] = []
|
||||
for rig: StickmanRig in _collect_rigs():
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
continue
|
||||
var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
var target: Vector2 = action.get("target", current)
|
||||
result.append(target)
|
||||
current = target
|
||||
return result
|
||||
|
||||
|
||||
func _draw_rules(zoom: float) -> void:
|
||||
for rule: Dictionary in rules:
|
||||
_draw_rule(rule, zoom)
|
||||
|
||||
|
||||
func _draw_rule(rule: Dictionary, zoom: float) -> void:
|
||||
var trigger: Dictionary = rule.get("trigger", {})
|
||||
var trigger_anchor := _rule_trigger_anchor(trigger)
|
||||
if not trigger_anchor.is_finite():
|
||||
return
|
||||
var action_anchor := _rule_action_anchor(rule, trigger_anchor)
|
||||
if not action_anchor.is_finite():
|
||||
action_anchor = trigger_anchor
|
||||
|
||||
# Dashed white connector (trigger -> action).
|
||||
if trigger_anchor.distance_to(action_anchor) > 0.5:
|
||||
_draw_dashed(trigger_anchor, action_anchor, zoom)
|
||||
|
||||
# Badges.
|
||||
_draw_rule_badge(trigger_anchor, zoom, "⚡", RULE_TRIGGER_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)
|
||||
var mid := (trigger_anchor + action_anchor) * 0.5
|
||||
var font := _rule_label_font()
|
||||
var font_size := int(rule_label_font_size / zoom)
|
||||
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
var padding := Vector2(6.0, 4.0) / zoom
|
||||
var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0)
|
||||
draw_rect(box, RULE_LABEL_BG, true)
|
||||
draw_rect(box, RULE_LABEL_BORDER, false, 1.0 / zoom)
|
||||
var baseline := box.position + padding + Vector2(0.0, font.get_ascent(font_size))
|
||||
draw_string(font, baseline, summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
|
||||
_rule_hit_regions.append({ "rect": box, "id": int(rule.get("id", -1)), "part": "label" })
|
||||
|
||||
# Delete icon (✕) right of the label, drawn as two crossing lines.
|
||||
var del_size := RULE_DELETE_HIT_PX / zoom
|
||||
var del_center := Vector2(box.end.x + del_size * 0.5 + 4.0 / zoom, box.position.y + box.size.y * 0.5)
|
||||
var del_rect := Rect2(del_center - Vector2(del_size, del_size) * 0.5, Vector2(del_size, del_size))
|
||||
var half := del_size * 0.35
|
||||
draw_line(del_center + Vector2(-half, -half), del_center + Vector2(half, half), RULE_DELETE_COLOR, 2.0 / zoom, true)
|
||||
draw_line(del_center + Vector2(half, -half), del_center + Vector2(-half, half), RULE_DELETE_COLOR, 2.0 / zoom, true)
|
||||
_rule_hit_regions.append({ "rect": del_rect, "id": int(rule.get("id", -1)), "part": "delete" })
|
||||
|
||||
|
||||
func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color) -> void:
|
||||
var radius := badge_radius / zoom
|
||||
draw_circle(anchor, radius, color)
|
||||
draw_arc(anchor, radius, 0.0, TAU, 32, Color.WHITE, 2.0 / zoom, true)
|
||||
var font := _badge_font()
|
||||
var font_size := int(badge_icon_size / zoom)
|
||||
var glyph_size := font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
|
||||
draw_string(font, anchor + Vector2(-glyph_size.x * 0.5, glyph_size.y * 0.5), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
|
||||
|
||||
|
||||
## Badge glyph font: the configured emoji font when set, else the fallback font.
|
||||
func _badge_font() -> Font:
|
||||
if badge_bold and bold_font != null:
|
||||
return bold_font
|
||||
if emoji_font != null:
|
||||
return emoji_font
|
||||
return ThemeDB.fallback_font
|
||||
|
||||
|
||||
## Rule-label font: bold variant when enabled, else ui_font, else fallback.
|
||||
func _rule_label_font() -> Font:
|
||||
if rule_label_bold and bold_font != null:
|
||||
return bold_font
|
||||
if ui_font != null:
|
||||
return ui_font
|
||||
return ThemeDB.fallback_font
|
||||
|
||||
|
||||
## Trigger badge anchor: waypoint pos for arrived_at_waypoint, area center for
|
||||
## entered_area (or source pos fallback), source position otherwise. Vector2.INF
|
||||
## on unresolved source.
|
||||
func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
|
||||
match String(trigger.get("type", "")):
|
||||
"arrived_at_waypoint":
|
||||
var params: Dictionary = trigger.get("params", {})
|
||||
return params.get("waypoint_pos", Vector2.INF)
|
||||
"entered_area":
|
||||
var target_id: int = int(trigger.get("target", -1))
|
||||
if target_id < 0:
|
||||
return _rule_source_position(trigger)
|
||||
var area := instance_from_id(target_id)
|
||||
if area is Node2D and is_instance_valid(area):
|
||||
return (area as Node2D).global_position
|
||||
return _rule_source_position(trigger)
|
||||
_:
|
||||
return _rule_source_position(trigger)
|
||||
|
||||
|
||||
func _rule_source_position(trigger: Dictionary) -> Vector2:
|
||||
var source_id: int = int(trigger.get("source", -1))
|
||||
if source_id < 0:
|
||||
return Vector2.INF
|
||||
var src := instance_from_id(source_id)
|
||||
if src is Node2D and is_instance_valid(src):
|
||||
return (src as Node2D).global_position
|
||||
return Vector2.INF
|
||||
|
||||
|
||||
## Action badge anchor: position of the first action's target stickman, else the
|
||||
## trigger anchor.
|
||||
func _rule_action_anchor(rule: Dictionary, fallback: Vector2) -> Vector2:
|
||||
var actions: Array = rule.get("actions", [])
|
||||
if actions.is_empty():
|
||||
return fallback
|
||||
var target_id: int = int(actions[0].get("target", -1))
|
||||
if target_id < 0:
|
||||
return fallback
|
||||
var target := instance_from_id(target_id)
|
||||
if target is Node2D and is_instance_valid(target):
|
||||
return (target as Node2D).global_position
|
||||
return fallback
|
||||
|
||||
|
||||
## "When arrives → Speak 'Hello there!'" summary of a rule.
|
||||
static func rule_summary(rule: Dictionary) -> String:
|
||||
var trigger: Dictionary = rule.get("trigger", {})
|
||||
var verb := _trigger_verb(String(trigger.get("type", "")))
|
||||
var actions: Array = rule.get("actions", [])
|
||||
var descs: Array[String] = []
|
||||
for a: Dictionary in actions:
|
||||
descs.append(_action_desc(a))
|
||||
if descs.is_empty():
|
||||
return "When %s" % verb
|
||||
if descs.size() == 1:
|
||||
return "When %s → %s" % [verb, descs[0]]
|
||||
if descs.size() == 2:
|
||||
return "When %s → %s then %s" % [verb, descs[0], descs[1]]
|
||||
return "When %s → %s (+%d more)" % [verb, descs[0], descs.size() - 1]
|
||||
|
||||
|
||||
static func _trigger_verb(type: String) -> String:
|
||||
match type:
|
||||
"arrived_at_waypoint":
|
||||
return "arrives"
|
||||
"action_finished":
|
||||
return "completes an action"
|
||||
"speech_finished":
|
||||
return "finishes speaking"
|
||||
"entered_area":
|
||||
return "enters area"
|
||||
"collided":
|
||||
return "collides"
|
||||
_:
|
||||
return "triggers"
|
||||
|
||||
|
||||
static func _action_desc(action: Dictionary) -> String:
|
||||
var params: Dictionary = action.get("params", {})
|
||||
match String(action.get("type", "")):
|
||||
"walk_to":
|
||||
return "Walks"
|
||||
"speak":
|
||||
return "Speak '%s'" % String(params.get("text", ""))
|
||||
"wait":
|
||||
return "Wait %ss" % _fmt_duration(float(params.get("duration", 0.0)))
|
||||
"ragdoll":
|
||||
return "Ragdolls"
|
||||
"recover":
|
||||
return "Recovers"
|
||||
_:
|
||||
return "Acts"
|
||||
|
||||
|
||||
static func _fmt_duration(v: float) -> String:
|
||||
if v == roundf(v):
|
||||
return str(int(v))
|
||||
return str(v)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsmvfj2hn3h3p
|
||||
@@ -0,0 +1,272 @@
|
||||
class_name StageGizmos
|
||||
extends Node2D
|
||||
## StageGizmos - Hover highlight, selection outlines and a rotate ring for the
|
||||
## Sandbox Stage Builder (Phase 2).
|
||||
##
|
||||
## Translation is done by dragging an object directly (no move handle); a drag
|
||||
## moves the whole multi-selection. The rotate ring only appears when exactly one
|
||||
## object is selected (rotating a multi-selection isn't useful). Pure drawing +
|
||||
## distance-based hit-testing (no Area2D). Dragging drives global_position /
|
||||
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
|
||||
|
||||
const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd")
|
||||
const PropBlockScript := preload("res://scripts/props/prop_block.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
enum Handle { NONE, TRANSLATE, ROTATE, SCALE }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
signal transform_committed(nodes: Array[Node2D])
|
||||
signal transform_started(nodes: Array[Node2D])
|
||||
signal transform_updated(nodes: Array[Node2D])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style constants (screen-space px; divided by zoom for world-space drawing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const SELECTION_COLOR := Color(1.0, 1.0, 1.0, 0.9)
|
||||
const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5)
|
||||
const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9)
|
||||
|
||||
const SELECTION_WIDTH := 2.0
|
||||
const HOVER_WIDTH := 1.5
|
||||
const ROTATE_WIDTH := 2.0
|
||||
|
||||
const ROTATE_HANDLE_MARGIN_PX := 48.0
|
||||
const ROTATE_RING_TOLERANCE_PX := 12.0
|
||||
const SCALE_CROSS_SIZE_PX := 5.0
|
||||
const SCALE_HIT_RADIUS_PX := 12.0
|
||||
const MIN_SCALE: float = 0.05
|
||||
const MAX_SCALE: float = 20.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var camera: Camera2D = null
|
||||
|
||||
## When > 0, translation drags snap to this grid size (0 = off).
|
||||
var snap_size: float = 0.0
|
||||
|
||||
var _enabled: bool = true
|
||||
var _targets: Array[Node2D] = []
|
||||
var _hovered: Node2D = null
|
||||
var _dragging: Handle = Handle.NONE
|
||||
var _drag_last: Vector2 = Vector2.ZERO
|
||||
var _drag_anchor: Node2D = null
|
||||
var _translate_drag_origin: Vector2 = Vector2.ZERO
|
||||
var _translate_start_positions: Dictionary = {}
|
||||
var _rotate_start_rotation: float = 0.0
|
||||
var _rotate_start_angle: float = 0.0
|
||||
var _rotate_center: Vector2 = Vector2.ZERO
|
||||
var _box_rect: Rect2 = Rect2()
|
||||
var _scale_start: Vector2 = Vector2.ONE
|
||||
var _scale_pivot: Vector2 = Vector2.ZERO
|
||||
var _scale_corner: Vector2 = Vector2.ZERO
|
||||
var _scale_changed: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func set_targets(nodes: Array[Node2D]) -> void:
|
||||
_targets = nodes.duplicate()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_hover(node: Node2D) -> void:
|
||||
_hovered = node
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_enabled(enabled: bool) -> void:
|
||||
_enabled = enabled
|
||||
visible = enabled
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_snap_size(size: float) -> void:
|
||||
snap_size = maxf(size, 0.0)
|
||||
|
||||
|
||||
func set_box_rect(rect: Rect2) -> void:
|
||||
_box_rect = rect
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Returns ROTATE if the cursor is on the rotate ring (single selection only).
|
||||
func hit_test(world_pos: Vector2) -> Handle:
|
||||
if not _enabled or _targets.size() != 1:
|
||||
return Handle.NONE
|
||||
var target := _targets[0]
|
||||
if target == null or not is_instance_valid(target):
|
||||
return Handle.NONE
|
||||
var rect := STAGE_SELECTION.get_world_aabb(target)
|
||||
if target is PropBlockScript:
|
||||
for corner: Vector2 in _corners(rect):
|
||||
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
|
||||
return Handle.SCALE
|
||||
var ring := _rotate_ring_radius(rect)
|
||||
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
|
||||
return Handle.ROTATE
|
||||
return Handle.NONE
|
||||
|
||||
|
||||
## Begins a rotate drag (used when the ring is clicked).
|
||||
func begin_drag(world_pos: Vector2) -> void:
|
||||
_dragging = hit_test(world_pos)
|
||||
_drag_last = world_pos
|
||||
if _dragging == Handle.ROTATE and _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
_rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center()
|
||||
_rotate_start_rotation = _targets[0].global_rotation
|
||||
_rotate_start_angle = (world_pos - _rotate_center).angle()
|
||||
transform_started.emit(_targets.duplicate())
|
||||
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
||||
var target: Node2D = _targets[0]
|
||||
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(target)
|
||||
_scale_pivot = target.global_position
|
||||
_scale_corner = _nearest_corner(rect, world_pos)
|
||||
_scale_start = target.scale
|
||||
_scale_changed = false
|
||||
transform_started.emit(_targets.duplicate())
|
||||
|
||||
|
||||
## Begins a translate drag anchored on `node` (used when an object body is
|
||||
## clicked). The drag moves the whole current selection (`_targets`); if `node`
|
||||
## is not currently selected, the drag targets just `node`.
|
||||
func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void:
|
||||
if node == null or not is_instance_valid(node):
|
||||
return
|
||||
_drag_anchor = node
|
||||
if not _targets.has(node):
|
||||
_targets = [node]
|
||||
_dragging = Handle.TRANSLATE
|
||||
_drag_last = world_pos
|
||||
_translate_drag_origin = world_pos
|
||||
_translate_start_positions.clear()
|
||||
for target: Node2D in _targets:
|
||||
if target != null and is_instance_valid(target):
|
||||
_translate_start_positions[target] = target.global_position
|
||||
transform_started.emit(_targets.duplicate())
|
||||
|
||||
|
||||
func drag_to(world_pos: Vector2) -> void:
|
||||
if _dragging == Handle.NONE or _targets.is_empty():
|
||||
return
|
||||
if _dragging == Handle.TRANSLATE:
|
||||
var actual := world_pos - _translate_drag_origin
|
||||
if snap_size > 0.0 and _drag_anchor != null and is_instance_valid(_drag_anchor):
|
||||
var anchor_start: Vector2 = _translate_start_positions.get(_drag_anchor, _drag_anchor.global_position)
|
||||
actual = _snap(anchor_start + actual) - anchor_start
|
||||
for node: Node2D in _targets:
|
||||
if is_instance_valid(node) and _translate_start_positions.has(node):
|
||||
node.global_position = (_translate_start_positions[node] as Vector2) + actual
|
||||
elif _dragging == Handle.ROTATE:
|
||||
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var target := _targets[0]
|
||||
var current_angle := (world_pos - _rotate_center).angle()
|
||||
var angle_delta := current_angle - _rotate_start_angle
|
||||
var new_rotation := _rotate_start_rotation + angle_delta
|
||||
if Input.is_key_pressed(KEY_CTRL):
|
||||
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
|
||||
target.global_rotation = new_rotation
|
||||
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
|
||||
var prop: Node2D = _targets[0]
|
||||
var start: Vector2 = _scale_corner - _scale_pivot
|
||||
var denominator: float = start.length_squared()
|
||||
if denominator > 0.0001:
|
||||
var requested: float = (world_pos - _scale_pivot).dot(start) / denominator
|
||||
if is_finite(requested):
|
||||
var lower: float = maxf(MIN_SCALE / maxf(absf(_scale_start.x), 0.0001), MIN_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
||||
var upper: float = minf(MAX_SCALE / maxf(absf(_scale_start.x), 0.0001), MAX_SCALE / maxf(absf(_scale_start.y), 0.0001))
|
||||
var next_scale: Vector2 = _scale_start * clampf(requested, lower, upper)
|
||||
if not next_scale.is_equal_approx(prop.scale):
|
||||
prop.scale = next_scale
|
||||
_scale_changed = true
|
||||
_drag_last = world_pos
|
||||
transform_updated.emit(_targets.duplicate())
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func end_drag() -> void:
|
||||
if _dragging != Handle.NONE and not _targets.is_empty() and (_dragging != Handle.SCALE or _scale_changed):
|
||||
transform_committed.emit(_targets.duplicate())
|
||||
_dragging = Handle.NONE
|
||||
_drag_anchor = null
|
||||
_scale_changed = false
|
||||
_translate_start_positions.clear()
|
||||
|
||||
|
||||
func is_dragging() -> bool:
|
||||
return _dragging != Handle.NONE
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame / drawing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _enabled and (_targets.size() > 0 or _hovered != null or _box_rect.size != Vector2.ZERO):
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
var zoom := _zoom()
|
||||
if _hovered != null and is_instance_valid(_hovered) and not _hovered.is_queued_for_deletion() and not _targets.has(_hovered):
|
||||
draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
||||
# A selection outline for every selected object.
|
||||
for node: Node2D in _targets:
|
||||
if node != null and is_instance_valid(node):
|
||||
draw_rect(STAGE_SELECTION.get_world_aabb(node), SELECTION_COLOR, false, SELECTION_WIDTH / zoom)
|
||||
# Rotate ring only for a single selection.
|
||||
if _targets.size() == 1 and is_instance_valid(_targets[0]):
|
||||
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
|
||||
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
|
||||
if _targets[0] is PropBlockScript:
|
||||
var half: float = SCALE_CROSS_SIZE_PX / zoom
|
||||
for corner: Vector2 in _corners(rect):
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
|
||||
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
|
||||
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
|
||||
if _box_rect.size != Vector2.ZERO:
|
||||
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _zoom() -> float:
|
||||
if camera != null and is_instance_valid(camera):
|
||||
return maxf(camera.zoom.x, 0.0001)
|
||||
return 1.0
|
||||
|
||||
|
||||
func _rotate_ring_radius(rect: Rect2) -> float:
|
||||
return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom()
|
||||
|
||||
|
||||
func _snap(v: Vector2) -> Vector2:
|
||||
if snap_size <= 0.0:
|
||||
return v
|
||||
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
|
||||
|
||||
func _corners(rect: Rect2) -> Array[Vector2]:
|
||||
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
|
||||
|
||||
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
|
||||
var nearest: Vector2 = rect.position
|
||||
var distance: float = INF
|
||||
for corner: Vector2 in _corners(rect):
|
||||
var candidate: float = corner.distance_squared_to(world_pos)
|
||||
if candidate < distance:
|
||||
distance = candidate
|
||||
nearest = corner
|
||||
return nearest
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcwylgjtosys7
|
||||
@@ -0,0 +1,46 @@
|
||||
class_name StageGrid
|
||||
extends Node2D
|
||||
## StageGrid - Optional world-space grid overlay for the Sandbox Stage Builder.
|
||||
##
|
||||
## Draws grid lines that pan/zoom with the camera, with a heavier major line
|
||||
## every few cells. Pure drawing; no hit-testing. Rendered behind the World by
|
||||
## keeping this node as the first child of the stage root.
|
||||
|
||||
var camera: Camera2D = null
|
||||
var grid_size: float = 15.0
|
||||
var enabled: bool = true
|
||||
|
||||
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.08)
|
||||
const MAJOR_COLOR := Color(1.0, 1.0, 1.0, 0.16)
|
||||
const MAJOR_EVERY := 5
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if enabled:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if not enabled or grid_size <= 0.0:
|
||||
return
|
||||
if camera == null or not is_instance_valid(camera):
|
||||
return
|
||||
var viewport_size := get_viewport_rect().size
|
||||
var zoom := maxf(camera.zoom.x, 0.0001)
|
||||
var half := viewport_size * 0.5 / zoom
|
||||
var center := camera.get_screen_center_position()
|
||||
var rect := Rect2(center - half, half * 2.0)
|
||||
var line_width := 1.0 / zoom
|
||||
|
||||
var x := floorf(rect.position.x / grid_size) * grid_size
|
||||
while x <= rect.end.x:
|
||||
var grid_index := int(round(x / grid_size))
|
||||
var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR
|
||||
draw_line(Vector2(x, rect.position.y), Vector2(x, rect.end.y), color, line_width)
|
||||
x += grid_size
|
||||
|
||||
var y := floorf(rect.position.y / grid_size) * grid_size
|
||||
while y <= rect.end.y:
|
||||
var grid_index := int(round(y / grid_size))
|
||||
var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR
|
||||
draw_line(Vector2(rect.position.x, y), Vector2(rect.end.x, y), color, line_width)
|
||||
y += grid_size
|
||||
@@ -0,0 +1 @@
|
||||
uid://dpyu1lyn6evw7
|
||||
@@ -0,0 +1,199 @@
|
||||
class_name StagePlacementOverlay
|
||||
extends Node2D
|
||||
## StagePlacementOverlay - Phase 4b world-space overlay for the Sandbox Stage.
|
||||
##
|
||||
## Draws (1) the terrain drag-painting guide line between the anchor and target
|
||||
## cells and (2) the director action rubber-band trajectory + ghost target
|
||||
## marker while a click-awaiting step is active. Pure drawing; no hit-testing.
|
||||
## Sits above the World and ghost holder, below the CanvasLayer UI.
|
||||
|
||||
var camera: Camera2D = null
|
||||
|
||||
## Terrain guide-line state.
|
||||
var terrain_guide_visible: bool = false
|
||||
var terrain_anchor: Vector2 = Vector2.ZERO
|
||||
var terrain_target: Vector2 = Vector2.ZERO
|
||||
var guide_line_color: Color = Color("#22c6ff")
|
||||
|
||||
## Director action-trajectory state.
|
||||
var action_visible: bool = false
|
||||
var action_origin: Vector2 = Vector2.ZERO
|
||||
var action_target: Vector2 = Vector2.ZERO
|
||||
var action_curve_points: PackedVector2Array = PackedVector2Array()
|
||||
var action_valid: bool = true
|
||||
var action_requested_target: Vector2 = Vector2.ZERO
|
||||
var action_show_requested_target: bool = false
|
||||
var action_effective_target: Vector2 = Vector2.ZERO
|
||||
var action_collision_position: Vector2 = Vector2.INF
|
||||
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 valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||
var line_width_px: float = 2.0
|
||||
var dash_length_px: float = 8.0
|
||||
var dash_gap_px: float = 5.0
|
||||
|
||||
const DASH_LENGTH_PX := 8.0
|
||||
const DASH_GAP_PX := 5.0
|
||||
const LINE_WIDTH_PX := 2.0
|
||||
const MARKER_RADIUS_PX := 10.0
|
||||
|
||||
func apply_style(style: Dictionary) -> void:
|
||||
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
|
||||
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 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)))
|
||||
dash_gap_px = maxf(0.0, float(defaults.get("dash_gap", dash_gap_px)))
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if terrain_guide_visible or action_visible:
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_terrain_guide(anchor: Vector2, target: Vector2) -> void:
|
||||
terrain_guide_visible = true
|
||||
terrain_anchor = anchor
|
||||
terrain_target = target
|
||||
|
||||
|
||||
func clear_terrain_guide() -> void:
|
||||
terrain_guide_visible = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> void:
|
||||
action_visible = true
|
||||
action_origin = origin
|
||||
action_target = target
|
||||
action_curve_points.clear()
|
||||
action_valid = valid
|
||||
action_show_requested_target = false
|
||||
action_show_collision = false
|
||||
action_preview_unavailable = false
|
||||
action_preview_planned = false
|
||||
action_preview_uncertain = false
|
||||
|
||||
|
||||
func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void:
|
||||
action_visible = true
|
||||
action_origin = origin
|
||||
action_target = landing_target
|
||||
action_curve_points = trajectory_points.duplicate()
|
||||
action_valid = valid
|
||||
action_requested_target = requested_target
|
||||
action_show_requested_target = true
|
||||
action_effective_target = effective_target if effective_target.is_finite() else landing_target
|
||||
action_collision_position = collision_position
|
||||
action_show_collision = collision_position.is_finite()
|
||||
action_preview_unavailable = false
|
||||
action_preview_planned = planned
|
||||
action_preview_uncertain = uncertain
|
||||
|
||||
|
||||
func set_throw_preview_unavailable(requested_target: Vector2) -> void:
|
||||
action_visible = true
|
||||
action_origin = requested_target
|
||||
action_target = requested_target
|
||||
action_curve_points.clear()
|
||||
action_valid = false
|
||||
action_requested_target = requested_target
|
||||
action_show_requested_target = false
|
||||
action_show_collision = false
|
||||
action_preview_unavailable = true
|
||||
action_preview_planned = false
|
||||
action_preview_uncertain = false
|
||||
|
||||
|
||||
func clear_action() -> void:
|
||||
action_visible = false
|
||||
action_curve_points.clear()
|
||||
action_show_requested_target = false
|
||||
action_show_collision = false
|
||||
action_preview_unavailable = false
|
||||
action_preview_planned = false
|
||||
action_preview_uncertain = false
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if terrain_guide_visible:
|
||||
_draw_dashed(terrain_anchor, terrain_target, guide_line_color)
|
||||
_draw_cell_marker(terrain_anchor, guide_line_color)
|
||||
_draw_cell_marker(terrain_target, guide_line_color)
|
||||
if action_visible:
|
||||
if 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)
|
||||
else:
|
||||
var color := valid_color if action_valid else invalid_color
|
||||
if action_valid and action_preview_planned:
|
||||
color = Color(0.35, 0.8, 1.0, 0.95)
|
||||
elif action_valid and action_preview_uncertain:
|
||||
color = Color(0.8, 0.68, 1.0, 0.95)
|
||||
if action_curve_points.size() >= 2:
|
||||
draw_polyline(action_curve_points, color, line_width_px / _zoom(), true)
|
||||
else:
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
if action_show_requested_target:
|
||||
_draw_ghost_marker(action_effective_target, color)
|
||||
var request_color := Color(1.0, 0.85, 0.25, 0.9)
|
||||
var r := 7.0 / _zoom()
|
||||
draw_arc(action_requested_target, r, 0.0, TAU, 20, request_color, LINE_WIDTH_PX / _zoom(), true)
|
||||
if action_show_collision:
|
||||
_draw_collision_marker(action_collision_position)
|
||||
else:
|
||||
_draw_ghost_marker(action_target, color)
|
||||
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||
var zoom := _zoom()
|
||||
var dash := dash_length_px / zoom
|
||||
var gap := dash_gap_px / zoom
|
||||
var dir := from.direction_to(to)
|
||||
var total := from.distance_to(to)
|
||||
var dist := 0.0
|
||||
while dist < total:
|
||||
var start := from + dir * dist
|
||||
var len := minf(dash, total - dist)
|
||||
draw_line(start, start + dir * len, color, line_width_px / zoom, true)
|
||||
dist += dash + gap
|
||||
|
||||
|
||||
func _draw_cell_marker(pos: Vector2, color: Color) -> void:
|
||||
var zoom := _zoom()
|
||||
var r := MARKER_RADIUS_PX / zoom
|
||||
draw_arc(pos, r, 0.0, TAU, 24, color, LINE_WIDTH_PX / zoom, true)
|
||||
|
||||
|
||||
## Semi-transparent flag/ring at the target point: a filled soft circle plus a
|
||||
## crosshair so the drop location reads clearly under the cursor.
|
||||
func _draw_ghost_marker(pos: Vector2, color: Color) -> void:
|
||||
var zoom := _zoom()
|
||||
var r := MARKER_RADIUS_PX / zoom
|
||||
var fill := Color(color.r, color.g, color.b, 0.25)
|
||||
draw_circle(pos, r, fill)
|
||||
draw_arc(pos, r, 0.0, TAU, 32, color, LINE_WIDTH_PX / zoom, true)
|
||||
draw_line(pos + Vector2(-r, 0.0), pos + Vector2(r, 0.0), color, LINE_WIDTH_PX / zoom, true)
|
||||
draw_line(pos + Vector2(0.0, -r), pos + Vector2(0.0, r), color, LINE_WIDTH_PX / zoom, true)
|
||||
|
||||
|
||||
func _draw_collision_marker(pos: Vector2) -> void:
|
||||
var zoom := _zoom()
|
||||
var r := 8.0 / zoom
|
||||
var color := Color(1.0, 0.25, 0.15, 0.95)
|
||||
draw_arc(pos, r, 0.0, TAU, 24, color, LINE_WIDTH_PX / zoom, true)
|
||||
draw_line(pos + Vector2(-r * 0.55, -r * 0.55), pos + Vector2(r * 0.55, r * 0.55), color, LINE_WIDTH_PX / zoom, true)
|
||||
draw_line(pos + Vector2(-r * 0.55, r * 0.55), pos + Vector2(r * 0.55, -r * 0.55), color, LINE_WIDTH_PX / zoom, true)
|
||||
|
||||
|
||||
func _zoom() -> float:
|
||||
if camera != null and is_instance_valid(camera):
|
||||
return maxf(camera.zoom.x, 0.0001)
|
||||
return 1.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://ehmg2htvh4xt
|
||||
@@ -0,0 +1,388 @@
|
||||
class_name StageSceneIO
|
||||
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 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")
|
||||
|
||||
static func vector_to_data(value: Vector2) -> Dictionary:
|
||||
return {"x": value.x, "y": value.y}
|
||||
|
||||
static func is_vector_data(value: Variant) -> bool:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
var data: Dictionary = value
|
||||
return _finite_number(data.get("x")) and _finite_number(data.get("y"))
|
||||
|
||||
static func data_to_vector(value: Variant, fallback: Vector2) -> Vector2:
|
||||
if not is_vector_data(value):
|
||||
return fallback
|
||||
var data: Dictionary = value
|
||||
return Vector2(float(data["x"]), float(data["y"]))
|
||||
|
||||
static func encode_queue(actions: Array[Dictionary]) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for action_index: int in actions.size():
|
||||
var action: Dictionary = actions[action_index]
|
||||
var encoded := _encode_flat_action(action)
|
||||
if encoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action %d (%s)." % [action_index + 1, String(action.get("type", "unknown"))])
|
||||
else:
|
||||
out.append(encoded)
|
||||
return {"actions": out, "warnings": warnings}
|
||||
|
||||
static func decode_queue(entries: Array) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for entry_index: int in entries.size():
|
||||
var entry: Variant = entries[entry_index]
|
||||
if not entry is Dictionary:
|
||||
warnings.append("Skipped malformed queue action %d (unknown type)." % (entry_index + 1))
|
||||
continue
|
||||
var decoded := _decode_flat_action(entry as Dictionary)
|
||||
if decoded.is_empty():
|
||||
warnings.append("Skipped invalid queue action %d (%s)." % [entry_index + 1, String((entry as Dictionary).get("type", "unknown"))])
|
||||
else:
|
||||
out.append(decoded)
|
||||
return {"actions": out, "warnings": warnings}
|
||||
|
||||
static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
for rule: Dictionary in rules:
|
||||
var trigger: Variant = rule.get("trigger", {})
|
||||
if not trigger is Dictionary:
|
||||
warnings.append("Skipped malformed rule.")
|
||||
continue
|
||||
var encoded_trigger := _encode_trigger(trigger as Dictionary, runtime_to_file)
|
||||
if encoded_trigger.is_empty():
|
||||
warnings.append("Skipped invalid rule trigger.")
|
||||
continue
|
||||
var encoded_actions: Array[Dictionary] = []
|
||||
var valid := true
|
||||
var actions: Variant = rule.get("actions", [])
|
||||
if not actions is Array:
|
||||
valid = false
|
||||
else:
|
||||
for action: Variant in actions:
|
||||
if not action is Dictionary:
|
||||
valid = false
|
||||
break
|
||||
var encoded := _encode_rule_action(action as Dictionary, runtime_to_file)
|
||||
if encoded.is_empty():
|
||||
valid = false
|
||||
break
|
||||
encoded_actions.append(encoded)
|
||||
if not valid:
|
||||
warnings.append("Skipped invalid rule action.")
|
||||
continue
|
||||
out.append({"id": int(rule.get("id", -1)), "trigger": encoded_trigger, "actions": encoded_actions})
|
||||
return {"rules": out, "warnings": warnings}
|
||||
|
||||
static func decode_rules(entries: Array, file_to_runtime: Dictionary, unavailable_ids: Dictionary) -> Dictionary:
|
||||
var out: Array[Dictionary] = []
|
||||
var warnings: Array[String] = []
|
||||
var skipped := 0
|
||||
var used_ids: Dictionary = {}
|
||||
var next_id := 0
|
||||
for entry: Variant in entries:
|
||||
if not entry is Dictionary:
|
||||
warnings.append("Skipped malformed rule."); skipped += 1; continue
|
||||
var d: Dictionary = entry
|
||||
var trigger_value: Variant = d.get("trigger", {})
|
||||
if not trigger_value is Dictionary:
|
||||
warnings.append("Skipped malformed rule."); skipped += 1; continue
|
||||
var trigger := _decode_trigger(trigger_value as Dictionary, file_to_runtime, unavailable_ids)
|
||||
if trigger.is_empty():
|
||||
warnings.append("Skipped unresolved rule."); skipped += 1; continue
|
||||
var decoded_actions: Array[Dictionary] = []
|
||||
var valid := d.get("actions") is Array
|
||||
if valid:
|
||||
for action: Variant in d["actions"]:
|
||||
if not action is Dictionary:
|
||||
valid = false; break
|
||||
var decoded := _decode_rule_action(action as Dictionary, file_to_runtime, unavailable_ids)
|
||||
if decoded.is_empty():
|
||||
valid = false; break
|
||||
decoded_actions.append(decoded)
|
||||
if not valid:
|
||||
warnings.append("Skipped unresolved rule."); skipped += 1; continue
|
||||
var id := int(d.get("id", -1))
|
||||
if id < 0 or used_ids.has(id):
|
||||
while used_ids.has(next_id): next_id += 1
|
||||
id = next_id
|
||||
used_ids[id] = true
|
||||
out.append({"id": id, "trigger": trigger, "actions": decoded_actions})
|
||||
return {"rules": out, "warnings": warnings, "skipped": skipped}
|
||||
|
||||
static func read_file(path: String) -> Dictionary:
|
||||
path = CONTENT_PATHS.canonicalize(path)
|
||||
_recover_backup(path)
|
||||
if not FileAccess.file_exists(path): return {"ok": false, "data": {}, "error": "File not found: %s" % path, "warnings": []}
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null: return {"ok": false, "data": {}, "error": "Could not open scene file.", "warnings": []}
|
||||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not parsed is Dictionary: return {"ok": false, "data": {}, "error": "Invalid scene JSON.", "warnings": []}
|
||||
var data := _migrate_to_current(parsed as Dictionary)
|
||||
if data.is_empty(): return {"ok": false, "data": {}, "error": "Unsupported or invalid scene version.", "warnings": []}
|
||||
if not data.get("objects") is Array or not data.get("event_rules") is Array:
|
||||
return {"ok": false, "data": {}, "error": "Scene is missing objects or event_rules arrays.", "warnings": []}
|
||||
var validation: Dictionary = _validate_root(data)
|
||||
if not bool(validation.get("ok", false)): return {"ok": false, "data": {}, "error": String(validation.get("error", "Invalid scene data.")), "warnings": []}
|
||||
return {"ok": true, "data": data, "error": "", "warnings": []}
|
||||
|
||||
static func write_file_safe(path: String, data: Dictionary) -> Dictionary:
|
||||
path = CONTENT_PATHS.canonicalize(path)
|
||||
var tmp := path + ".tmp"
|
||||
var bak := path + ".bak"
|
||||
var file := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
if file == null: return {"ok": false, "error": "Could not write temporary scene file."}
|
||||
file.store_string(JSON.stringify(data, "\t", false))
|
||||
file.flush(); file.close()
|
||||
if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
|
||||
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
|
||||
DirAccess.remove_absolute(tmp); return {"ok": false, "error": "Could not back up existing scene file."}
|
||||
if DirAccess.rename_absolute(tmp, path) != OK:
|
||||
if FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
|
||||
return {"ok": false, "error": "Could not replace scene file."}
|
||||
if FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
|
||||
return {"ok": true, "error": ""}
|
||||
|
||||
static func _migrate_to_current(data: Dictionary) -> Dictionary:
|
||||
var version := String(data.get("version", ""))
|
||||
if version == FILE_VERSION:
|
||||
return data
|
||||
if version != "1.0":
|
||||
return {}
|
||||
var migrated: Dictionary = data.duplicate(true)
|
||||
for raw: Variant in migrated.get("objects", []):
|
||||
if not raw is Dictionary:
|
||||
continue
|
||||
var object: Dictionary = raw
|
||||
if String(object.get("type", "")) == "prop":
|
||||
object["stage_object_id"] = String(object.get("file_id", ""))
|
||||
migrated["version"] = FILE_VERSION
|
||||
return migrated
|
||||
|
||||
static func _validate_root(data: Dictionary) -> Dictionary:
|
||||
var seen: Dictionary = {}
|
||||
var seen_stage_ids: Dictionary = {}
|
||||
for raw: Variant in data["objects"]:
|
||||
if not raw is Dictionary: continue
|
||||
var entry: Dictionary = raw
|
||||
var file_id := String(entry.get("file_id", ""))
|
||||
if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."}
|
||||
seen[file_id] = true
|
||||
if String(entry.get("type", "")) == "prop":
|
||||
var raw_stage_id: Variant = entry.get("stage_object_id", "")
|
||||
if not raw_stage_id is String:
|
||||
return {"ok": false, "error": "Prop stage IDs must be unique and non-empty strings."}
|
||||
var stage_id := String(raw_stage_id)
|
||||
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
|
||||
return {"ok": true, "error": ""}
|
||||
|
||||
static func _recover_backup(path: String) -> void:
|
||||
var bak := path + ".bak"
|
||||
if not FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.rename_absolute(bak, path)
|
||||
elif FileAccess.file_exists(path) and FileAccess.file_exists(bak): DirAccess.remove_absolute(bak)
|
||||
if FileAccess.file_exists(path + ".tmp"): DirAccess.remove_absolute(path + ".tmp")
|
||||
|
||||
static func _finite_number(value: Variant) -> bool:
|
||||
return (value is float or value is int) and is_finite(float(value))
|
||||
|
||||
static func _encode_flat_action(action: Dictionary) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
"walk_to":
|
||||
if not action.get("target") is Vector2: return {}
|
||||
return {"type": type, "target": vector_to_data(action["target"])}
|
||||
"speak": return _encode_speech(action)
|
||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
|
||||
"pick_up_prop":
|
||||
var prop_id := String(action.get("target_prop_id", ""))
|
||||
var hand := String(action.get("hand", "auto"))
|
||||
if prop_id.is_empty() or not ["auto", "left", "right"].has(hand): return {}
|
||||
return {"type": type, "target_prop_id": prop_id, "hand": hand}
|
||||
"drop_prop": return {"type": type}
|
||||
"throw_prop":
|
||||
if not action.get("target_position") is Vector2: return {}
|
||||
var target_position: Vector2 = action["target_position"]
|
||||
if not target_position.is_finite(): return {}
|
||||
return {"type": type, "target_position": vector_to_data(target_position)}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _decode_flat_action(action: Dictionary) -> Dictionary:
|
||||
var type := String(action.get("type", ""))
|
||||
if not ACTION_REGISTRY.has_type(type): return {}
|
||||
match type:
|
||||
"walk_to":
|
||||
if not is_vector_data(action.get("target")): return {}
|
||||
return {"type": type, "target": data_to_vector(action["target"], Vector2.ZERO)}
|
||||
"speak":
|
||||
return _decode_speech(action)
|
||||
"wait": return {"type": type, "duration": float(action.get("duration", 0.0))} if _finite_number(action.get("duration", 0.0)) else {}
|
||||
"pick_up_prop":
|
||||
var prop_id := String(action.get("target_prop_id", ""))
|
||||
var hand := String(action.get("hand", "auto"))
|
||||
if prop_id.is_empty() or not ["auto", "left", "right"].has(hand): return {}
|
||||
return {"type": type, "target_prop_id": prop_id, "hand": hand}
|
||||
"drop_prop": return {"type": type}
|
||||
"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)}
|
||||
_: return {"type": type}
|
||||
|
||||
static func _map_ref(value: Variant, map: Dictionary) -> Variant:
|
||||
if int(value) == -1: return null
|
||||
return map.get(int(value), null)
|
||||
|
||||
static func _unmap_ref(value: Variant, map: Dictionary, unavailable: Dictionary) -> int:
|
||||
if value == null: return -1
|
||||
if not value is String or unavailable.has(value) or not map.has(value): return -2
|
||||
return int(map[value])
|
||||
|
||||
|
||||
static func _encode_speech(action: Dictionary) -> Dictionary:
|
||||
if not _finite_number(action.get("duration", 2.0)):
|
||||
return {}
|
||||
var out: Dictionary = {"type": "speak", "text": String(action.get("text", "")), "duration": float(action.get("duration", 2.0)), "stop_current": bool(action.get("stop_current", false)), "keep_acting": bool(action.get("keep_acting", false))}
|
||||
if not action.has("bubbles"):
|
||||
if action.get("visual", null) is Dictionary:
|
||||
out["visual"] = (action["visual"] as Dictionary).duplicate(true)
|
||||
for key: String in ["reveal", "pop_in"]:
|
||||
if action.has(key):
|
||||
out[key] = action[key]
|
||||
var legacy_speech: Dictionary = SPEECH_PAYLOAD.normalize(action)
|
||||
for key: String in ["arrangement", "bubble_layout", "connect_bubbles"]:
|
||||
out[key] = legacy_speech.get(key)
|
||||
if action.get("placement", null) is Dictionary:
|
||||
var legacy_placement: Dictionary = (action["placement"] as Dictionary).duplicate(true)
|
||||
if String(legacy_placement.get("mode", "auto")) == "manual" and legacy_placement.get("center", null) is Vector2:
|
||||
legacy_placement["center"] = vector_to_data(legacy_placement["center"])
|
||||
out["placement"] = legacy_placement
|
||||
return out
|
||||
var checked: Dictionary = action.duplicate(true)
|
||||
checked["type"] = "speak"
|
||||
var error: String = SPEECH_PAYLOAD.validate(checked)
|
||||
if not error.is_empty():
|
||||
return {}
|
||||
var normalized: Dictionary = SPEECH_PAYLOAD.normalize(checked)
|
||||
for key: String in ["arrangement", "bubble_layout", "connect_bubbles", "reveal", "pop_in", "duration_mode", "keep_acting"]:
|
||||
if normalized.has(key):
|
||||
out[key] = normalized[key]
|
||||
var encoded_bubbles: Array[Dictionary] = []
|
||||
for raw_bubble: Variant in normalized.get("bubbles", []):
|
||||
if not raw_bubble is Dictionary:
|
||||
return {}
|
||||
var bubble: Dictionary = (raw_bubble as Dictionary).duplicate(true)
|
||||
var bubble_placement_value: Variant = bubble.get("placement", null)
|
||||
if bubble_placement_value is Dictionary:
|
||||
var bubble_placement: Dictionary = bubble_placement_value
|
||||
if String(bubble_placement.get("mode", "auto")) == "manual":
|
||||
var bubble_center: Variant = bubble_placement.get("center", null)
|
||||
if bubble_center is Vector2:
|
||||
bubble_placement["center"] = vector_to_data(bubble_center)
|
||||
bubble["placement"] = bubble_placement
|
||||
encoded_bubbles.append(bubble)
|
||||
out["bubbles"] = encoded_bubbles
|
||||
var placement: Dictionary = normalized.get("placement", {"mode": "auto"}).duplicate(true)
|
||||
if String(placement.get("mode", "auto")) == "manual":
|
||||
var center: Variant = placement.get("center", Vector2.ZERO)
|
||||
if center is Vector2:
|
||||
placement["center"] = vector_to_data(center)
|
||||
out["placement"] = placement
|
||||
return out
|
||||
|
||||
|
||||
static func _decode_speech(action: Dictionary) -> Dictionary:
|
||||
if not _finite_number(action.get("duration", 2.0)):
|
||||
return {}
|
||||
var raw: Dictionary = action.duplicate(true)
|
||||
if raw.has("placement") and raw["placement"] is Dictionary:
|
||||
var placement: Dictionary = raw["placement"]
|
||||
if String(placement.get("mode", "auto")) == "manual":
|
||||
if not is_vector_data(placement.get("center")):
|
||||
return {}
|
||||
placement["center"] = data_to_vector(placement["center"], Vector2.ZERO)
|
||||
if raw.get("bubbles", null) is Array:
|
||||
var decoded_bubbles: Array = []
|
||||
for raw_bubble: Variant in raw["bubbles"] as Array:
|
||||
if not raw_bubble is Dictionary:
|
||||
return {}
|
||||
var bubble: Dictionary = (raw_bubble as Dictionary).duplicate(true)
|
||||
if bubble.has("placement"):
|
||||
if not bubble["placement"] is Dictionary:
|
||||
return {}
|
||||
var bubble_placement: Dictionary = bubble["placement"]
|
||||
if String(bubble_placement.get("mode", "auto")) == "manual":
|
||||
if not is_vector_data(bubble_placement.get("center")):
|
||||
return {}
|
||||
bubble_placement["center"] = data_to_vector(bubble_placement["center"], Vector2.ZERO)
|
||||
decoded_bubbles.append(bubble)
|
||||
raw["bubbles"] = decoded_bubbles
|
||||
if not raw.has("bubbles"):
|
||||
var legacy: Dictionary = {"type": "speak", "text": String(raw.get("text", "")), "duration": float(raw.get("duration", 2.0))}
|
||||
if raw.get("visual", null) is Dictionary:
|
||||
legacy["visual"] = (raw["visual"] as Dictionary).duplicate(true)
|
||||
if raw.get("placement", null) is Dictionary:
|
||||
legacy["placement"] = (raw["placement"] as Dictionary).duplicate(true)
|
||||
if bool(raw.get("stop_current", false)):
|
||||
legacy["stop_current"] = true
|
||||
if raw.has("keep_acting"):
|
||||
legacy["keep_acting"] = bool(raw.get("keep_acting", false))
|
||||
for key: String in ["reveal", "pop_in"]:
|
||||
if raw.has(key):
|
||||
legacy[key] = raw[key]
|
||||
var legacy_speech: Dictionary = SPEECH_PAYLOAD.normalize(raw)
|
||||
for key: String in ["arrangement", "bubble_layout", "connect_bubbles"]:
|
||||
legacy[key] = legacy_speech.get(key)
|
||||
return legacy
|
||||
if raw.has("bubbles"):
|
||||
var error: String = SPEECH_PAYLOAD.validate(raw)
|
||||
if not error.is_empty():
|
||||
return {}
|
||||
var decoded: Dictionary = SPEECH_PAYLOAD.normalize(raw)
|
||||
decoded.erase("legacy_fixed")
|
||||
return decoded
|
||||
|
||||
static func _encode_trigger(trigger: Dictionary, refs: Dictionary) -> Dictionary:
|
||||
var type := String(trigger.get("type", "")); if not TRIGGER_REGISTRY.has_type(type): return {}
|
||||
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 {}
|
||||
var params: Dictionary = trigger.get("params", {})
|
||||
if type == "arrived_at_waypoint":
|
||||
if not params.get("waypoint_pos") is Vector2: return {}
|
||||
params = {"waypoint_pos": vector_to_data(params["waypoint_pos"])}
|
||||
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 {}
|
||||
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", {})
|
||||
if type == "arrived_at_waypoint":
|
||||
if not is_vector_data(params.get("waypoint_pos")): return {}
|
||||
params = {"waypoint_pos": data_to_vector(params["waypoint_pos"], Vector2.ZERO)}
|
||||
return {"type": type, "source": source, "target": target, "params": params}
|
||||
|
||||
static func _encode_rule_action(action: Dictionary, refs: Dictionary) -> Dictionary:
|
||||
var id: Variant = _map_ref(action.get("target", -1), refs); if id == null and int(action.get("target", -1)) != -1: return {}
|
||||
var flat := _encode_flat_action(ACTION_REGISTRY.from_rule_action(action)); if flat.is_empty(): return {}
|
||||
var params := flat.duplicate(); params.erase("type")
|
||||
return {"type": String(flat["type"]), "target_id": id, "params": params}
|
||||
|
||||
static func _decode_rule_action(value: Dictionary, refs: Dictionary, unavailable: Dictionary) -> Dictionary:
|
||||
var target := _unmap_ref(value.get("target_id"), refs, unavailable); if target == -2: return {}
|
||||
var flat: Dictionary = value.get("params", {}).duplicate() if value.get("params") is Dictionary else {}
|
||||
flat["type"] = String(value.get("type", "")); var decoded := _decode_flat_action(flat); if decoded.is_empty(): return {}
|
||||
return ACTION_REGISTRY.to_rule_action(decoded, target)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwmd6m4bdwm0m
|
||||
@@ -0,0 +1,242 @@
|
||||
class_name StageSelection
|
||||
extends RefCounted
|
||||
## StageSelection - Hover, click and box selection for the Sandbox Stage Builder.
|
||||
##
|
||||
## Hit-testing is geometric (world-space AABBs), so it works uniformly for
|
||||
## TerrainBlock, PropBlock and StickmanRig (which has no physics collision).
|
||||
## Frontmost World child wins; smallest area breaks ties. The ragdoll body
|
||||
## container and its subtree are never selectable.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
signal hover_changed(node: Node2D)
|
||||
signal selection_changed(nodes: Array[Node2D])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
|
||||
## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds).
|
||||
const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _world: Node2D
|
||||
var _camera: Camera2D
|
||||
var _selected: Array[Node2D] = []
|
||||
var _hovered: Node2D = null
|
||||
var _primary: Node2D = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _init(world: Node2D, camera: Camera2D) -> void:
|
||||
_world = world
|
||||
_camera = camera
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func get_selected() -> Array[Node2D]:
|
||||
return _selected
|
||||
|
||||
|
||||
func get_primary() -> Node2D:
|
||||
return _primary
|
||||
|
||||
|
||||
func clear_selection() -> void:
|
||||
_selected.clear()
|
||||
_primary = null
|
||||
selection_changed.emit(_selected.duplicate())
|
||||
|
||||
|
||||
## Clears the hovered node (e.g. when it is about to be deleted), emitting
|
||||
## hover_changed(null) so the gizmo layer drops its stale highlight.
|
||||
func clear_hover() -> void:
|
||||
if _hovered == null:
|
||||
return
|
||||
_hovered = null
|
||||
hover_changed.emit(null)
|
||||
|
||||
|
||||
func select_only(node: Node2D) -> void:
|
||||
_selected = [node]
|
||||
_primary = node
|
||||
selection_changed.emit(_selected.duplicate())
|
||||
|
||||
|
||||
func add_to_selection(node: Node2D) -> void:
|
||||
if not _selected.has(node):
|
||||
_selected.append(node)
|
||||
_primary = node
|
||||
selection_changed.emit(_selected.duplicate())
|
||||
|
||||
|
||||
func toggle_selection(node: Node2D) -> void:
|
||||
if _selected.has(node):
|
||||
_selected.erase(node)
|
||||
_primary = _selected[_selected.size() - 1] if not _selected.is_empty() else null
|
||||
else:
|
||||
_selected.append(node)
|
||||
_primary = node
|
||||
selection_changed.emit(_selected.duplicate())
|
||||
|
||||
|
||||
func is_selected(node: Node2D) -> bool:
|
||||
return _selected.has(node)
|
||||
|
||||
|
||||
## Returns the frontmost selectable node under `world_pos`, or null.
|
||||
func hit_test(world_pos: Vector2) -> Node2D:
|
||||
return _frontmost_at(world_pos)
|
||||
|
||||
|
||||
## Refreshes the hovered node under `world_pos`, emitting hover_changed when it
|
||||
## changes. Returns the new hover target (or null).
|
||||
func update_hover(world_pos: Vector2) -> Node2D:
|
||||
var hit := _frontmost_at(world_pos)
|
||||
if hit != _hovered:
|
||||
_hovered = hit
|
||||
hover_changed.emit(hit)
|
||||
return hit
|
||||
|
||||
|
||||
## Selects all selectable nodes whose AABB intersects `rect`. When `additive`,
|
||||
## appends to the current selection instead of replacing it.
|
||||
func box_select(rect: Rect2, additive: bool) -> void:
|
||||
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).intersects(rect):
|
||||
hits.append(node)
|
||||
if additive:
|
||||
for node: Node2D in hits:
|
||||
if not _selected.has(node):
|
||||
_selected.append(node)
|
||||
if not hits.is_empty():
|
||||
_primary = hits[hits.size() - 1]
|
||||
else:
|
||||
_selected = hits
|
||||
_primary = hits[hits.size() - 1] if not hits.is_empty() else null
|
||||
selection_changed.emit(_selected.duplicate())
|
||||
|
||||
|
||||
## World-space AABB for a node. Terrain/props use their Polygon2D child; a
|
||||
## stickman rig unions its mounted `Body/*` shape geometry (so the box is
|
||||
## centered on the actual figure, head to feet).
|
||||
static func get_world_aabb(node: Node2D) -> Rect2:
|
||||
if node == null or not is_instance_valid(node):
|
||||
return Rect2()
|
||||
# Duck-typed TriggerArea: expose its centered local rect through the transform.
|
||||
if node.has_method("get_area_rect"):
|
||||
return node.global_transform * (node.call("get_area_rect") as Rect2)
|
||||
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(visuals, visual_acc)
|
||||
if visual_acc["min_x"] <= visual_acc["max_x"]:
|
||||
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
|
||||
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||
if poly != null and not poly.polygon.is_empty():
|
||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
||||
for p: Vector2 in poly.polygon:
|
||||
rect = rect.expand(node.to_global(p))
|
||||
return rect
|
||||
var body := node.get_node_or_null(NodePath("Body")) as Node2D
|
||||
if body != null:
|
||||
var acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(body, acc)
|
||||
if acc["min_x"] <= acc["max_x"]:
|
||||
return Rect2(Vector2(acc["min_x"], acc["min_y"]), Vector2(acc["max_x"] - acc["min_x"], acc["max_y"] - acc["min_y"]))
|
||||
if node.get_node_or_null(NodePath("Skeleton2D")) != null:
|
||||
return node.global_transform * RIG_LOCAL_RECT
|
||||
return Rect2(node.global_position, Vector2.ZERO)
|
||||
|
||||
|
||||
## Recursively unions the world-space points of every Line2D / Polygon2D under
|
||||
## `node` into `acc` (keys min_x/min_y/max_x/max_y).
|
||||
static func _collect_visual_points(node: Node, acc: Dictionary) -> void:
|
||||
if node is Line2D:
|
||||
for p: Vector2 in (node as Line2D).points:
|
||||
_accumulate((node as Line2D).to_global(p), acc)
|
||||
elif node is Polygon2D:
|
||||
for p: Vector2 in (node as Polygon2D).polygon:
|
||||
_accumulate((node as Polygon2D).to_global(p), acc)
|
||||
for child: Node in node.get_children():
|
||||
_collect_visual_points(child, acc)
|
||||
|
||||
|
||||
static func _accumulate(p: Vector2, acc: Dictionary) -> void:
|
||||
acc["min_x"] = minf(acc["min_x"], p.x)
|
||||
acc["min_y"] = minf(acc["min_y"], p.y)
|
||||
acc["max_x"] = maxf(acc["max_x"], p.x)
|
||||
acc["max_y"] = maxf(acc["max_y"], p.y)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## A selectable node is a direct Node2D child of World that is not the ragdoll
|
||||
## body container.
|
||||
func _is_selectable(node: Node) -> bool:
|
||||
if node == null or not (node is Node2D):
|
||||
return false
|
||||
if node.get_parent() != _world:
|
||||
return false
|
||||
if node.is_in_group("stickman_ragdoll_container") or bool(node.get_meta("stickman_ragdoll_container", false)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _ragdoll_rig_at(world_pos: Vector2) -> Node2D:
|
||||
if _world == null or not is_instance_valid(_world):
|
||||
return null
|
||||
var state: PhysicsDirectSpaceState2D = _world.get_world_2d().direct_space_state
|
||||
var query := PhysicsPointQueryParameters2D.new()
|
||||
query.position = world_pos
|
||||
query.collision_mask = 0x7FFFFFFF
|
||||
query.collide_with_areas = false
|
||||
query.collide_with_bodies = true
|
||||
var hits: Array[Dictionary] = state.intersect_point(query, 32)
|
||||
for hit: Dictionary in hits:
|
||||
var body: Object = hit.get("collider") as Object
|
||||
if body == null or not body is Node or not (body as Node).has_meta("stickman_ragdoll_body"):
|
||||
continue
|
||||
var rig: Node2D = (body as Node).get_meta("stickman_rig") as Node2D
|
||||
if rig == null or not is_instance_valid(rig) or rig.is_queued_for_deletion() or rig.get_parent() != _world:
|
||||
continue
|
||||
return rig
|
||||
return null
|
||||
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c52c38mrywjpi
|
||||
@@ -0,0 +1,354 @@
|
||||
class_name StageSpawner
|
||||
extends RefCounted
|
||||
## StageSpawner - Registry-driven factory for the Sandbox Stage Builder (Phase 2).
|
||||
##
|
||||
## A spawn registry maps an id to a terrain/prop/stickman template. Adding a new
|
||||
## spawnable type only requires appending a registry entry - no hard-coded match
|
||||
## statements on ids. Reuses TerrainUtils, PropUtils and StickmanFactory.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preloaded dependencies (resolved directly, independent of the global class
|
||||
# registry, so this script compiles even when the editor's class cache is stale)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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_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")
|
||||
const TRIGGER_AREA := preload("res://scripts/stage/trigger_area.gd")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Default stickman asset (the only complete, upright figure).
|
||||
const DEFAULT_STICKMAN_PATH := "res://content/stickmen/test.stk"
|
||||
|
||||
## The rig's feet rest ~385 px below its root (hips), so placing the root 385 px
|
||||
## above the cursor puts the feet on it.
|
||||
const STICKMAN_FOOT_OFFSET := Vector2(0.0, -385.0)
|
||||
|
||||
const TERRAIN_GRID_SIZE: float = 16.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _world: Node2D
|
||||
var _registry: Array[Dictionary] = []
|
||||
var selected_stickman_path: String = DEFAULT_STICKMAN_PATH
|
||||
var selected_prop_id: String = "crate"
|
||||
var _stickman_cache: Dictionary = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _init(world: Node2D) -> void:
|
||||
_world = world
|
||||
_stickman_cache[DEFAULT_STICKMAN_PATH] = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
|
||||
if (_stickman_cache[DEFAULT_STICKMAN_PATH] as Dictionary).is_empty():
|
||||
push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH)
|
||||
_build_registry()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func get_spawnable_ids() -> Array[String]:
|
||||
var ids: Array[String] = []
|
||||
for entry: Dictionary in _registry:
|
||||
ids.append(String(entry["id"]))
|
||||
return ids
|
||||
|
||||
|
||||
## UI-safe registry view. Callers receive no templates or mutable registry state.
|
||||
func get_palette_entries() -> Array[Dictionary]:
|
||||
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
|
||||
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
|
||||
var result: Array[Dictionary] = []
|
||||
for entry: Dictionary in _registry:
|
||||
var id: String = String(entry.get("id", ""))
|
||||
var kind: String = String(entry.get("kind", ""))
|
||||
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
|
||||
return result
|
||||
|
||||
|
||||
func get_label(id: String) -> String:
|
||||
var entry := _find_entry(id)
|
||||
return String(entry.get("label", id)) if not entry.is_empty() else id
|
||||
|
||||
|
||||
func get_spawn_offset(id: String) -> Vector2:
|
||||
var entry := _find_entry(id)
|
||||
if entry.is_empty():
|
||||
return Vector2.ZERO
|
||||
return entry.get("spawn_offset", Vector2.ZERO)
|
||||
|
||||
|
||||
func get_selected_stickman_path() -> String:
|
||||
selected_stickman_path = CONTENT_PATHS.canonicalize(selected_stickman_path)
|
||||
return selected_stickman_path
|
||||
|
||||
|
||||
func get_selected_prop_id() -> String:
|
||||
return selected_prop_id
|
||||
|
||||
|
||||
## True when `id` names a terrain template (ground/ramp/step).
|
||||
func is_terrain_id(id: String) -> bool:
|
||||
var entry := _find_entry(id)
|
||||
return String(entry.get("kind", "")) == "terrain"
|
||||
|
||||
|
||||
## Local-space AABB of a terrain template's centered polygon (before placement),
|
||||
## used to size ghosts and rasterize the terrain-painting occupancy cells.
|
||||
## The AABB is computed over the SAME sanitized geometry that `_spawn_terrain`
|
||||
## actually places (TerrainUtils.sanitize_points at TERRAIN_GRID_SIZE), so the
|
||||
## D1 paint stride matches the real block footprint (e.g. the 200px-wide Ground
|
||||
## template sanitizes to a 192px footprint and therefore a 192px stride, which
|
||||
## makes horizontal runs tile edge-to-edge with no gaps).
|
||||
func get_template_aabb(id: String) -> Rect2:
|
||||
var entry := _find_entry(id)
|
||||
if entry.is_empty() or String(entry.get("kind", "")) != "terrain":
|
||||
return Rect2()
|
||||
var template: PackedVector2Array = entry["points"]
|
||||
if template.is_empty():
|
||||
return Rect2()
|
||||
var center := _points_center(template)
|
||||
var centered := PackedVector2Array()
|
||||
for p: Vector2 in template:
|
||||
centered.append(p - center)
|
||||
var cleaned := TERRAIN_UTILS.sanitize_points(centered, TERRAIN_GRID_SIZE)
|
||||
if cleaned.is_empty():
|
||||
return Rect2()
|
||||
var rect := Rect2(cleaned[0], Vector2.ZERO)
|
||||
for p: Vector2 in cleaned:
|
||||
rect = rect.expand(p)
|
||||
return rect
|
||||
|
||||
|
||||
## Spawn the registry type at `world_position`; returns null + push_warning on
|
||||
## an unknown id.
|
||||
func spawn(id: String, world_position: Vector2) -> Node2D:
|
||||
var entry := _find_entry(id)
|
||||
if entry.is_empty():
|
||||
push_warning("StageSpawner: unknown spawn id '%s'." % id)
|
||||
return null
|
||||
var offset: Vector2 = entry.get("spawn_offset", Vector2.ZERO)
|
||||
var pos := world_position + offset
|
||||
match String(entry.get("kind", "")):
|
||||
"terrain":
|
||||
return spawn_terrain_asset(id, pos)
|
||||
"prop":
|
||||
return spawn_prop_asset(selected_prop_id, pos)
|
||||
"stickman":
|
||||
return spawn_stickman_asset(selected_stickman_path, pos)
|
||||
"area":
|
||||
return spawn_area(pos)
|
||||
_:
|
||||
push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", ""))
|
||||
return null
|
||||
|
||||
|
||||
## Load-safe factories take real object origins and never apply palette offsets.
|
||||
func spawn_terrain_asset(asset_id: String, object_position: Vector2) -> TerrainBlock:
|
||||
var entry := _find_entry(asset_id)
|
||||
if entry.is_empty() or String(entry.get("kind", "")) != "terrain":
|
||||
push_warning("StageSpawner: unknown terrain '%s'." % asset_id)
|
||||
return null
|
||||
return _spawn_terrain(entry, object_position)
|
||||
|
||||
|
||||
func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
|
||||
var template: Dictionary = PROP_LIBRARY.get_entry(asset_id)
|
||||
if template.is_empty():
|
||||
push_warning("StageSpawner: unknown prop '%s'." % asset_id)
|
||||
return null
|
||||
var prop: PropBlock
|
||||
if bool(template.get("is_custom", false)):
|
||||
prop = PROP_BLOCK.new()
|
||||
prop.name = "PropBlock"
|
||||
prop.position = object_position
|
||||
prop.material_preset = int(template["material_preset"])
|
||||
_world.add_child(prop)
|
||||
prop.set_custom_shapes(template.get("shapes", []) as Array[Dictionary], template.get("collision_hull", PackedVector2Array()) as PackedVector2Array)
|
||||
else:
|
||||
prop = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
|
||||
prop.spawn_id = String(template.get("id", asset_id))
|
||||
prop.set_interaction_anchors(template.get("anchors", {}) as Dictionary)
|
||||
prop.ensure_default_grip()
|
||||
return prop
|
||||
|
||||
|
||||
func spawn_stickman_asset(stk_path: String, root_position: Vector2) -> StickmanRig:
|
||||
var normalized: String = CONTENT_PATHS.canonicalize(stk_path)
|
||||
var data: Dictionary = _stickman_cache.get(normalized, {})
|
||||
if data.is_empty():
|
||||
data = STICKMAN_FACTORY.load_stk(normalized)
|
||||
_stickman_cache[normalized] = data
|
||||
if data.is_empty():
|
||||
push_warning("StageSpawner: no stickman data for '%s'." % normalized)
|
||||
return null
|
||||
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
|
||||
if rig == null:
|
||||
push_warning("StageSpawner: failed to spawn stickman.")
|
||||
return null
|
||||
rig.position = root_position
|
||||
rig.source_stk_path = normalized
|
||||
_world.add_child(rig)
|
||||
return rig
|
||||
|
||||
|
||||
func spawn_area(object_position: Vector2) -> TriggerArea:
|
||||
return _spawn_area(object_position) as TriggerArea
|
||||
|
||||
|
||||
## World-space AABB for a spawnable node: union of the Polygon2D child's world
|
||||
## points; for a stickman rig, union of its mounted `Body/*` shape geometry; a
|
||||
## point rect otherwise.
|
||||
static func get_world_aabb(node: Node2D) -> Rect2:
|
||||
if node == null or not is_instance_valid(node):
|
||||
return Rect2()
|
||||
# Duck-typed TriggerArea: expose its centered local rect through the transform.
|
||||
if node.has_method("get_area_rect"):
|
||||
return node.global_transform * (node.call("get_area_rect") as Rect2)
|
||||
var visuals := node.get_node_or_null(NodePath("Visuals")) as Node2D
|
||||
if visuals != null:
|
||||
var visual_acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(visuals, visual_acc)
|
||||
if visual_acc["min_x"] <= visual_acc["max_x"]:
|
||||
return Rect2(Vector2(visual_acc["min_x"], visual_acc["min_y"]), Vector2(visual_acc["max_x"] - visual_acc["min_x"], visual_acc["max_y"] - visual_acc["min_y"]))
|
||||
var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D
|
||||
if poly != null and not poly.polygon.is_empty():
|
||||
var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO)
|
||||
for p: Vector2 in poly.polygon:
|
||||
rect = rect.expand(node.to_global(p))
|
||||
return rect
|
||||
var body := node.get_node_or_null(NodePath("Body")) as Node2D
|
||||
if body != null:
|
||||
var acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF }
|
||||
_collect_visual_points(body, acc)
|
||||
if acc["min_x"] <= acc["max_x"]:
|
||||
return Rect2(Vector2(acc["min_x"], acc["min_y"]), Vector2(acc["max_x"] - acc["min_x"], acc["max_y"] - acc["min_y"]))
|
||||
if node.get_node_or_null(NodePath("Skeleton2D")) != null:
|
||||
return node.global_transform * Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0))
|
||||
return Rect2(node.global_position, Vector2.ZERO)
|
||||
|
||||
|
||||
static func _collect_visual_points(node: Node, acc: Dictionary) -> void:
|
||||
if node is Line2D:
|
||||
for p: Vector2 in (node as Line2D).points:
|
||||
_accumulate((node as Line2D).to_global(p), acc)
|
||||
elif node is Polygon2D:
|
||||
for p: Vector2 in (node as Polygon2D).polygon:
|
||||
_accumulate((node as Polygon2D).to_global(p), acc)
|
||||
for child: Node in node.get_children():
|
||||
_collect_visual_points(child, acc)
|
||||
|
||||
|
||||
static func _accumulate(p: Vector2, acc: Dictionary) -> void:
|
||||
acc["min_x"] = minf(acc["min_x"], p.x)
|
||||
acc["min_y"] = minf(acc["min_y"], p.y)
|
||||
acc["max_x"] = maxf(acc["max_x"], p.x)
|
||||
acc["max_y"] = maxf(acc["max_y"], p.y)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_registry() -> void:
|
||||
_registry = [
|
||||
{
|
||||
"id": "ground", "label": "Ground", "kind": "terrain",
|
||||
"points": PackedVector2Array([Vector2(-100, -16), Vector2(100, -16), Vector2(100, 16), Vector2(-100, 16)]),
|
||||
"fill": Color(0.25, 0.55, 0.25), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
"id": "ramp", "label": "Ramp", "kind": "terrain",
|
||||
"points": PackedVector2Array([Vector2(-96, 32), Vector2(96, -96), Vector2(96, -32), Vector2(-96, 96)]),
|
||||
"fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
"id": "step", "label": "Step", "kind": "terrain",
|
||||
"points": PackedVector2Array([
|
||||
Vector2(-128, 128), Vector2(128, 128), Vector2(128, -128), Vector2(64, -128),
|
||||
Vector2(64, -64), Vector2(0, -64), Vector2(0, 0), Vector2(-64, 0),
|
||||
Vector2(-64, 64), Vector2(-128, 64),
|
||||
]),
|
||||
"fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0,
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
"id": "prop", "label": "Prop", "kind": "prop",
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
"id": "stickman", "label": "Stickman", "kind": "stickman",
|
||||
"spawn_offset": STICKMAN_FOOT_OFFSET,
|
||||
},
|
||||
{
|
||||
"id": "area", "label": "Area", "kind": "area",
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
func _find_entry(id: String) -> Dictionary:
|
||||
for entry: Dictionary in _registry:
|
||||
if String(entry.get("id", "")) == id:
|
||||
return entry
|
||||
return {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spawn helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Center the terrain template on its local origin so the block rotates about
|
||||
## its own center, then place the block at the cursor.
|
||||
func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
|
||||
var template: PackedVector2Array = entry["points"]
|
||||
var center := _points_center(template)
|
||||
var centered := PackedVector2Array()
|
||||
for p: Vector2 in template:
|
||||
centered.append(p - center)
|
||||
var block: TerrainBlock = TERRAIN_UTILS.spawn_block(
|
||||
_world,
|
||||
centered,
|
||||
TERRAIN_GRID_SIZE,
|
||||
entry.get("fill", TERRAIN_UTILS.DEFAULT_FILL_COLOR),
|
||||
entry.get("outline", TERRAIN_UTILS.DEFAULT_OUTLINE_COLOR),
|
||||
float(entry.get("width", 2.0))
|
||||
)
|
||||
block.position = world_position
|
||||
block.spawn_id = String(entry.get("id", ""))
|
||||
return block
|
||||
|
||||
|
||||
func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock:
|
||||
return spawn_prop_asset(String(entry.get("id", selected_prop_id)), world_position)
|
||||
|
||||
|
||||
func _spawn_area(world_position: Vector2) -> Node2D:
|
||||
var area: Node2D = TRIGGER_AREA.new()
|
||||
area.name = "TriggerArea"
|
||||
area.position = world_position
|
||||
_world.add_child(area)
|
||||
return area
|
||||
|
||||
|
||||
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
|
||||
return spawn_stickman_asset(selected_stickman_path, world_position)
|
||||
|
||||
|
||||
static func _points_center(pts: PackedVector2Array) -> Vector2:
|
||||
if pts.is_empty():
|
||||
return Vector2.ZERO
|
||||
var sum := Vector2.ZERO
|
||||
for p: Vector2 in pts:
|
||||
sum += p
|
||||
return sum / float(pts.size())
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4adnnx5m3qss
|
||||
@@ -0,0 +1,50 @@
|
||||
class_name TriggerArea
|
||||
extends Node2D
|
||||
## TriggerArea - Placeable rectangular sensor for the Sandbox Stage (Phase 4).
|
||||
##
|
||||
## A drawn Node2D (NOT a physics Area2D) that the stage's geometric event engine
|
||||
## polls for movable overlap. Purely visual plus a size query; no signals, no
|
||||
## physics. Draws a translucent green fill with a dashed green border so it reads
|
||||
## as a trigger zone in EDIT mode. World-space child, so no zoom division is
|
||||
## needed for the dashed border.
|
||||
|
||||
## Half-extents of the rectangular sensor in local space.
|
||||
@export var size: Vector2 = Vector2(96.0, 96.0):
|
||||
set(value):
|
||||
size = value
|
||||
queue_redraw()
|
||||
|
||||
|
||||
## Local-space rectangle (centered on the node origin) used for overlap tests.
|
||||
func get_area_rect() -> Rect2:
|
||||
return Rect2(-size * 0.5, size)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var rect := get_area_rect()
|
||||
draw_rect(rect, Color(0.2, 0.8, 0.3, 0.12), true)
|
||||
_draw_dashed_rect(rect, Color(0.2, 0.8, 0.3, 0.6), 2.0, 6.0, 4.0)
|
||||
|
||||
|
||||
## Dashed border along each edge of `rect` (top/right/bottom/left), drawn with a
|
||||
## small manual dash loop using draw_line.
|
||||
func _draw_dashed_rect(rect: Rect2, color: Color, width: float, dash: float, gap: float) -> void:
|
||||
var tl := rect.position
|
||||
var tr := rect.position + Vector2(rect.size.x, 0.0)
|
||||
var br := rect.position + rect.size
|
||||
var bl := rect.position + Vector2(0.0, rect.size.y)
|
||||
_draw_dashed_edge(tl, tr, color, width, dash, gap)
|
||||
_draw_dashed_edge(tr, br, color, width, dash, gap)
|
||||
_draw_dashed_edge(br, bl, color, width, dash, gap)
|
||||
_draw_dashed_edge(bl, tl, color, width, dash, gap)
|
||||
|
||||
|
||||
func _draw_dashed_edge(from: Vector2, to: Vector2, color: Color, width: float, dash: float, gap: float) -> void:
|
||||
var dir := from.direction_to(to)
|
||||
var total := from.distance_to(to)
|
||||
var dist := 0.0
|
||||
while dist < total:
|
||||
var start := from + dir * dist
|
||||
var len := minf(dash, total - dist)
|
||||
draw_line(start, start + dir * len, color, width, true)
|
||||
dist += dash + gap
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfx0hi5ey1auw
|
||||
@@ -0,0 +1,86 @@
|
||||
class_name TriggerRegistry
|
||||
extends RefCounted
|
||||
## TriggerRegistry - Registry of rule trigger templates (Phase 3c).
|
||||
##
|
||||
## Single source of truth for the trigger types the event system understands.
|
||||
## Adding a new trigger type is just appending an entry here; the RuleEditor and
|
||||
## the panels generate their UI from this registry.
|
||||
|
||||
const TRIGGER_TEMPLATES := {
|
||||
"arrived_at_waypoint": {
|
||||
"label": "Arrives at waypoint",
|
||||
"icon": "📍",
|
||||
"target_type": "waypoint",
|
||||
},
|
||||
"action_finished": {
|
||||
"label": "Completes any action",
|
||||
"icon": "✅",
|
||||
"target_type": "action_type",
|
||||
},
|
||||
"speech_finished": {
|
||||
"label": "Finishes speaking",
|
||||
"icon": "💬",
|
||||
"target_type": "none",
|
||||
},
|
||||
"entered_area": {
|
||||
"label": "Enters trigger area",
|
||||
"icon": "🎯",
|
||||
"target_type": "area",
|
||||
},
|
||||
"collided": {
|
||||
"label": "Collides with something",
|
||||
"icon": "💥",
|
||||
"target_type": "prop",
|
||||
},
|
||||
"prop_picked_up": {
|
||||
"label": "Picks up a prop",
|
||||
"icon": "🫴",
|
||||
"target_type": "prop",
|
||||
},
|
||||
"prop_dropped": {
|
||||
"label": "Drops a prop",
|
||||
"icon": "⬇️",
|
||||
"target_type": "prop",
|
||||
},
|
||||
"prop_thrown": {
|
||||
"label": "Throws a prop",
|
||||
"icon": "🏹",
|
||||
"target_type": "prop",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static func types() -> Array[String]:
|
||||
# Dictionary.keys() returns an untyped Array at runtime; build a genuinely
|
||||
# typed Array[String] so callers can store it in typed locals (see
|
||||
# RuleEditor._current_type).
|
||||
var out: Array[String] = []
|
||||
out.assign(TRIGGER_TEMPLATES.keys())
|
||||
return out
|
||||
|
||||
|
||||
static func has_type(type: String) -> bool:
|
||||
return TRIGGER_TEMPLATES.has(type)
|
||||
|
||||
|
||||
static func label(type: String) -> String:
|
||||
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
|
||||
return String(tpl.get("label", type))
|
||||
|
||||
|
||||
static func icon(type: String) -> String:
|
||||
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
|
||||
return String(tpl.get("icon", ""))
|
||||
|
||||
|
||||
## Which kind of stage target a trigger needs: "waypoint", "action_type",
|
||||
## "area", "prop", or "none".
|
||||
static func target_type(type: String) -> String:
|
||||
var tpl: Dictionary = TRIGGER_TEMPLATES.get(type, {})
|
||||
return String(tpl.get("target_type", "none"))
|
||||
|
||||
|
||||
## Human-readable summary of a trigger, e.g. "Arrives at waypoint".
|
||||
static func summarize(trigger: Dictionary) -> String:
|
||||
var type := String(trigger.get("type", ""))
|
||||
return "%s %s" % [icon(type), label(type)]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cv1ng0wekc4gc
|
||||
@@ -0,0 +1,40 @@
|
||||
class_name WaypointContext
|
||||
extends PopupMenu
|
||||
## WaypointContext - Right-click context menu for a walk_to waypoint (Phase 3c.3).
|
||||
##
|
||||
## Provides Edit/Delete/Insert actions for the waypoint's walk action plus a
|
||||
## "Edit Trigger Rules" entry (shown only when rules target this waypoint).
|
||||
|
||||
const EDIT_WALK := 0
|
||||
const DELETE_WALK := 1
|
||||
const INSERT_BEFORE := 2
|
||||
const INSERT_AFTER := 3
|
||||
const EDIT_TRIGGER_RULES := 4
|
||||
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
|
||||
|
||||
const _EDIT_TRIGGER_IDX := 5 # item position of the trigger-rules entry (after a separator at 4)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
# _init runs before the node is in the tree; item order is fixed here.
|
||||
add_item("✎ Edit this Walk", EDIT_WALK)
|
||||
add_item("✕ Delete this Walk", DELETE_WALK)
|
||||
add_item("⬆ Insert action before", INSERT_BEFORE)
|
||||
add_item("⬇ Insert action after", INSERT_AFTER)
|
||||
add_separator()
|
||||
add_item("⚡ Edit Trigger Rules", EDIT_TRIGGER_RULES)
|
||||
|
||||
|
||||
## Updates the trigger-rules entry (count / enabled) and pops up at `rect`.
|
||||
func popup_for(rect: Rect2i, trigger_rule_count: int, queue_status: int = ACTION_RUNNER.QueueStatus.NOT_STARTED) -> void:
|
||||
var consumed: bool = queue_status == ACTION_RUNNER.QueueStatus.COMPLETED or queue_status == ACTION_RUNNER.QueueStatus.INTERRUPTED
|
||||
set_item_disabled(get_item_index(EDIT_WALK), consumed)
|
||||
set_item_disabled(get_item_index(INSERT_BEFORE), consumed or queue_status == ACTION_RUNNER.QueueStatus.ACTIVE)
|
||||
set_item_disabled(get_item_index(INSERT_AFTER), consumed)
|
||||
if trigger_rule_count > 0:
|
||||
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules (%d rule%s)" % [trigger_rule_count, "" if trigger_rule_count == 1 else "s"])
|
||||
set_item_disabled(_EDIT_TRIGGER_IDX, false)
|
||||
else:
|
||||
set_item_text(_EDIT_TRIGGER_IDX, "⚡ Edit Trigger Rules")
|
||||
set_item_disabled(_EDIT_TRIGGER_IDX, true)
|
||||
popup(rect)
|
||||
@@ -0,0 +1 @@
|
||||
uid://chrtaky4j2w25
|
||||
Reference in New Issue
Block a user