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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://iwogmwpek8cc
|
||||
+29
-1
@@ -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
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://7cehuu3v8tig
|
||||
+33
-10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user