feat: Integrate custom prop library and sandbox functionality

- Added support for custom props in the sandbox, including loading, saving, and rendering.
- Implemented `PropLibrary` for managing custom prop entries and scanning for `.prop` files.
- Introduced `PropVisuals` for shared visual and collision representation of custom props.
- Enhanced `StageSpawner` to handle spawning of custom props with layered visuals and collision hulls.
- Created `MissingPropPlaceholder` to manage missing custom props during scene reloads.
- Updated thumbnail generation to support custom props with unique keys based on file paths.
- Added tests for custom prop lifecycle, authored visual values, and missing prop handling.
- Refactored existing code to accommodate new prop functionalities and ensure backward compatibility.
This commit is contained in:
2026-09-21 23:13:35 -04:00
parent 00abacc7b2
commit 97f3730413
19 changed files with 581 additions and 23 deletions
+3 -2
View File
@@ -54,8 +54,9 @@ func _on_root_size_changed() -> void:
func open(p_kind: String, entries: Array[Dictionary]) -> void:
kind = p_kind
_title_label.text = "Choose Your Stickman" if kind == "stickman" else "Choose a Prop"
_browse_button.visible = kind == "stickman"
_refresh_button.visible = kind == "stickman"
_browse_button.visible = kind == "stickman" or kind == "prop"
_refresh_button.visible = kind == "stickman" or kind == "prop"
_empty_label.text = "No props found. Browse for a .prop file." if kind == "prop" else "No stickmen found."
set_entries(entries)
popup_centered()
+21
View File
@@ -0,0 +1,21 @@
class_name MissingPropPlaceholder
extends Node2D
var original_entry: Dictionary = {}
var missing_prop_path: String = ""
const MARKER_SIZE := Vector2(180.0, 54.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_prop_path = String(entry.get("asset_id", ""))
queue_redraw()
func get_area_rect() -> Rect2:
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
func _draw() -> void:
var rect := get_area_rect()
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
var asset_name := missing_prop_path.get_file()
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 33.0), "Missing prop - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
+1
View File
@@ -0,0 +1 @@
uid://iwogmwpek8cc
+29 -1
View File
@@ -10,6 +10,8 @@ extends RigidBody2D
## properties update the children live. A material preset selector sets mass,
## friction/bounce (PhysicsMaterial), and themed visuals.
const PropVisuals := preload("res://scripts/prop_visuals.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
@@ -91,6 +93,8 @@ var _outline: Line2D
var _collision_polygon: CollisionPolygon2D
var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D
var _custom_shapes: bool = false
var _visual_local_bounds := Rect2()
# ---------------------------------------------------------------------------
# Signals
@@ -241,10 +245,34 @@ func _apply_style() -> void:
func _apply_material_preset() -> void:
mass = mass_for(material_preset)
physics_material_override = physics_material(material_preset)
if material_preset != MaterialPreset.NONE:
if not _custom_shapes and material_preset != MaterialPreset.NONE:
fill_color = tint_for(material_preset)
outline_color = tint_for(material_preset).darkened(0.55)
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void:
_ensure_children()
_custom_shapes = true
_polygon.visible = false
_outline.visible = false
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
_collision_polygon.polygon = collision_hull
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
if visuals == null:
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
for child: Node in visuals.get_children(): child.queue_free()
_visual_local_bounds = PropVisuals.build(visuals, shapes)
_apply_material_preset()
func has_custom_shapes() -> bool:
return _custom_shapes
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
# ---------------------------------------------------------------------------
# Static preset factories
# ---------------------------------------------------------------------------
+64 -1
View File
@@ -3,6 +3,8 @@ extends RefCounted
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
static var _templates: Array[Dictionary] = []
@@ -24,7 +26,64 @@ static func get_entry(id: String) -> Dictionary:
for t: Dictionary in get_entries():
if String(t["id"]) == id:
return t
return {}
return make_custom_entry(id) if is_custom_path(id) else {}
static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
var entries: Array[Dictionary] = get_entries().duplicate(true)
if not DirAccess.dir_exists_absolute(dir_path):
push_warning("PropLibrary: prop directory '%s' does not exist." % dir_path)
return entries
var directory: DirAccess = DirAccess.open(dir_path)
if directory == null:
return entries
var paths: Array[String] = []
directory.list_dir_begin()
var file_name: String = directory.get_next()
while not file_name.is_empty():
if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT):
paths.append(dir_path.path_join(file_name))
file_name = directory.get_next()
directory.list_dir_end()
paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0)
for path: String in paths:
var entry := make_custom_entry(path)
if not entry.is_empty(): entries.append(entry)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if bool(a.get("is_custom", false)) != bool(b.get("is_custom", false)): return not bool(a.get("is_custom", false))
var an := String(a.get("name", "")).to_lower(); var bn := String(b.get("name", "")).to_lower()
return an < bn if an != bn else String(a.get("path", "")).nocasecmp_to(String(b.get("path", ""))) < 0)
return entries
static func make_custom_entry(path: String) -> Dictionary:
var normalized_path: String = canonical_path(path)
if not is_custom_path(normalized_path): return {}
var raw: Dictionary = PROP_FILE.load(normalized_path)
if raw.is_empty():
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
return {}
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
if parsed.is_empty():
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
return {}
var shapes: Array[Dictionary] = parsed.get("shapes", [])
var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes)
if shapes.is_empty() or hull.size() < 3:
push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path)
return {}
var name := String(parsed.get("prop_name", "")).strip_edges()
if name.is_empty(): name = normalized_path.get_file().get_basename()
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull}
static func is_custom_path(path: String) -> bool:
return path.to_lower().ends_with(PROP_FILE.FILE_EXT) and (path.begins_with("res://") or path.begins_with("user://") or path.is_absolute_path())
static func canonical_path(path: String) -> String:
return path.replace("\\", "/").simplify_path()
static func get_default_id() -> String:
@@ -47,4 +106,8 @@ static func _make(id: String, name: String, preset: int, label: String, payload:
"material_preset": preset,
"material_label": label,
"payload": payload,
"is_custom": false,
"path": "",
"shapes": [],
"collision_hull": PackedVector2Array(),
}
+89
View File
@@ -0,0 +1,89 @@
class_name PropVisuals
extends RefCounted
## Shared custom-prop drawing and collision approximation.
const CIRCLE_SEGMENTS: int = 24
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2:
var bounds := Rect2()
var has_bounds := false
for index: int in shapes.size():
var shape: Dictionary = shapes[index]
var points := _shape_points(shape)
if points.size() < 2:
push_warning("PropVisuals: skipped shape with insufficient points.")
continue
var group := Node2D.new()
group.name = "Shape_%d" % index
parent.add_child(group)
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
var fill := Color.from_string(String(shape.get("fill_color", "")), Color.WHITE)
var outline := Color.from_string(String(shape.get("outline_color", "")), Color.BLACK)
var width: float = maxf(0.0, float(shape.get("outline_width", 2.0)))
if closed and points.size() >= 3:
var polygon := Polygon2D.new()
polygon.name = "Polygon2D"
polygon.polygon = points
polygon.color = fill
group.add_child(polygon)
var line := Line2D.new()
line.name = "Line2D"
var loop: PackedVector2Array = points.duplicate()
if closed:
loop.append(points[0])
line.points = loop
line.default_color = outline
line.width = width
line.joint_mode = Line2D.LINE_JOINT_ROUND
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
line.end_cap_mode = Line2D.LINE_CAP_ROUND
group.add_child(line)
for point: Vector2 in points:
if not has_bounds:
bounds = Rect2(point, Vector2.ZERO)
has_bounds = true
else:
bounds = bounds.expand(point)
return bounds
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array:
var points := PackedVector2Array()
for shape: Dictionary in shapes:
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
if closed:
points.append_array(_shape_points(shape))
if points.size() < 3:
return PackedVector2Array()
var hull: PackedVector2Array = Geometry2D.convex_hull(points)
if hull.size() < 3 or absf(_polygon_area(hull)) <= 0.001:
return PackedVector2Array()
return hull
static func _shape_points(shape: Dictionary) -> PackedVector2Array:
var raw: Variant = shape.get("points", PackedVector2Array())
var points := PackedVector2Array()
if raw is PackedVector2Array:
points = raw as PackedVector2Array
elif raw is Array:
for item: Variant in raw as Array:
if item is Vector2:
points.append(item as Vector2)
elif item is Dictionary:
var data: Dictionary = item as Dictionary
points.append(Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))))
if points.is_empty() and String(shape.get("shape_type", "")) == "circle":
var radius: float = maxf(0.0, float(shape.get("radius", 24.0)))
for index: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
points.append(Vector2(cos(angle), sin(angle)) * radius)
return points
static func _polygon_area(points: PackedVector2Array) -> float:
var area: float = 0.0
for index: int in points.size():
var next: Vector2 = points[(index + 1) % points.size()]
area += points[index].cross(next)
return area * 0.5
+1
View File
@@ -0,0 +1 @@
uid://7cehuu3v8tig
+33 -10
View File
@@ -39,6 +39,7 @@ const RULE_EDITOR := preload("res://scenes/rule_editor.tscn")
const WAYPOINT_CONTEXT := preload("res://scripts/waypoint_context.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage_scene_io.gd")
const MISSING_STICKMAN_PLACEHOLDER := preload("res://scripts/missing_stickman_placeholder.gd")
const MISSING_PROP_PLACEHOLDER := preload("res://scripts/missing_prop_placeholder.gd")
# ---------------------------------------------------------------------------
# Enums
@@ -1272,7 +1273,7 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
var n := node as Node2D
var type := ""
if n is TerrainBlock: type = "terrain"
elif n is PropBlock: type = "prop"
elif n is PropBlock or n.get_script() == MISSING_PROP_PLACEHOLDER: type = "prop"
elif n is STICKMAN_RIG or n.get_script() == MISSING_STICKMAN_PLACEHOLDER: type = "stickman"
elif n is TriggerArea: type = "area"
else: continue
@@ -1286,6 +1287,9 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
if type == "terrain":
entry["asset_id"] = (n as TerrainBlock).spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop" and n.get_script() == MISSING_PROP_PLACEHOLDER:
entry = (n.get("original_entry") as Dictionary).duplicate(true)
entry["file_id"] = file_id; entry["type"] = "prop"; entry["position"] = STAGE_SCENE_IO.vector_to_data(pos); entry["rotation"] = rot; entry["scale"] = STAGE_SCENE_IO.vector_to_data(n.scale)
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
@@ -1355,7 +1359,11 @@ func _apply_scene_data(data: Dictionary, source_path: String, is_recovery: bool)
var node: Node2D = null
match type:
"terrain": node = _spawner.spawn_terrain_asset(String(entry.get("asset_id", "")), pos)
"prop": node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
"prop":
node = _spawner.spawn_prop_asset(String(entry.get("asset_id", "")), pos)
if node == null and PROP_LIBRARY.is_custom_path(String(entry.get("asset_id", ""))):
var prop_placeholder: Node2D = MISSING_PROP_PLACEHOLDER.new() as Node2D
prop_placeholder.configure(entry); prop_placeholder.position = pos; _world.add_child(prop_placeholder); node = prop_placeholder
"area": node = _spawner.spawn_area(pos)
"stickman":
node = _spawner.spawn_stickman_asset(String(entry.get("stk_path", "")), pos)
@@ -2408,7 +2416,7 @@ func _open_selector(kind: String) -> void:
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
else:
entries = PROP_LIBRARY.get_entries()
entries = PROP_LIBRARY.scan()
if kind == "stickman" and entries.size() == 1:
_on_asset_selected(entries[0])
return
@@ -2449,18 +2457,29 @@ func _on_selector_cancelled() -> void:
func _on_browse_requested() -> void:
if _browse_dialog != null:
if _selector_kind == "prop":
_browse_dialog.title = "Open .prop"
_browse_dialog.filters = PackedStringArray(["*.prop ; Prop Files"])
else:
_browse_dialog.title = "Open .stk"
_browse_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
_browse_dialog.popup_centered()
func _on_browse_file_selected(path: String) -> void:
var entry := _stickman_library.make_entry(path)
var entry: Dictionary = PROP_LIBRARY.make_custom_entry(path) if _selector_kind == "prop" else _stickman_library.make_entry(path)
if entry.is_empty():
_show_toast("Could not load stickman: " + path)
_show_toast("Could not load %s: %s" % [_selector_kind, path])
return
_on_asset_selected(entry)
func _on_refresh_requested() -> void:
if _selector_kind == "prop":
var prop_entries: Array[Dictionary] = PROP_LIBRARY.scan()
_selector.set_entries(prop_entries)
_load_or_enqueue_thumbnails("prop", prop_entries)
return
var entries: Array[Dictionary] = _stickman_library.scan()
if not entries.is_empty() and _stickman_library.find_by_path(_spawner.get_selected_stickman_path()).is_empty():
_spawner.selected_stickman_path = String(entries[0]["path"])
@@ -2485,7 +2504,7 @@ func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> vo
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
else:
var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_png(id)
var png := _thumbnail_cache.prop_file_png(id) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(id)
var tex := _thumbnail_cache.get_texture(png)
if tex != null:
if tex != null and _selector != null:
@@ -2509,11 +2528,15 @@ func _drain_thumbnail_queue() -> void:
if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if bool(entry.get("is_custom", false)):
tex = await _prop_thumb.render_shapes(entry.get("shapes", []) as Array[Dictionary])
else:
var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset)
if tex != null:
_thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex)
var fallback := _thumbnail_cache.prop_file_png(String(entry["id"])) if bool(entry.get("is_custom", false)) else _thumbnail_cache.prop_png(String(entry["id"]))
_thumbnail_cache.store_texture(String(job.get("cache_path", fallback)), tex)
_thumbnail_busy = false
if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
_selector.set_thumbnail(entry, tex)
+12 -3
View File
@@ -364,7 +364,10 @@ func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
var params: Dictionary = trigger.get("params", {})
return params.get("waypoint_pos", Vector2.INF)
"entered_area":
var area := instance_from_id(int(trigger.get("target", -1)))
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)
@@ -373,7 +376,10 @@ func _rule_trigger_anchor(trigger: Dictionary) -> Vector2:
func _rule_source_position(trigger: Dictionary) -> Vector2:
var src := instance_from_id(int(trigger.get("source", -1)))
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
@@ -385,7 +391,10 @@ func _rule_action_anchor(rule: Dictionary, fallback: Vector2) -> Vector2:
var actions: Array = rule.get("actions", [])
if actions.is_empty():
return fallback
var target := instance_from_id(int(actions[0].get("target", -1)))
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
+6
View File
@@ -141,6 +141,12 @@ static func get_world_aabb(node: Node2D) -> 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)
+17 -2
View File
@@ -165,8 +165,17 @@ func spawn_prop_asset(asset_id: String, object_position: Vector2) -> PropBlock:
if template.is_empty():
push_warning("StageSpawner: unknown prop '%s'." % asset_id)
return null
var prop: PropBlock = PROP_UTILS.spawn_prop(_world, object_position, template["payload"], int(template["material_preset"]), Vector2.ZERO)
prop.spawn_id = asset_id
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))
return prop
@@ -202,6 +211,12 @@ static func get_world_aabb(node: Node2D) -> 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)
+15
View File
@@ -2,6 +2,7 @@ class_name PropThumbnail
extends Node
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const PROP_VISUALS := preload("res://scripts/prop_visuals.gd")
const SIZE := Vector2i(200, 200)
@@ -64,6 +65,20 @@ func render(payload: Dictionary, material_preset: int) -> Texture2D:
return ImageTexture.create_from_image(img)
func render_shapes(shapes: Array[Dictionary]) -> Texture2D:
_clear_world()
var visual := Node2D.new()
_world.add_child(visual)
var bounds: Rect2 = PROP_VISUALS.build(visual, shapes)
_frame_camera(bounds if bounds.size.length_squared() > 0.0 else Rect2(Vector2(-24, -24), Vector2(48, 48)))
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var tex := _viewport.get_texture()
visual.queue_free()
if tex == null or _is_blank(tex.get_image()): return null
return ImageTexture.create_from_image(tex.get_image())
func _compute_points(payload: Dictionary) -> PackedVector2Array:
var shape_type: int = int(payload.get("type", PROP_BLOCK.ShapeType.POLYGON))
if shape_type == PROP_BLOCK.ShapeType.CIRCLE:
+3 -1
View File
@@ -40,7 +40,9 @@ func prop_png(id: String) -> String:
func prop_file_key(path: String) -> String:
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
var canonical: String = path.simplify_path()
var digest: String = canonical.sha256_text().substr(0, 12)
return "file_%s_%d" % [digest, FileAccess.get_modified_time(path)]
func prop_file_png(path: String) -> String: