Add headless regression tests for Phase 4b features
- Implement test for popup anchor behavior in rule-builder menus to ensure consistent anchor positioning during menu transitions. - Create tests for stage logic, including mode transitions, toolbar visibility, and status bar updates. - Add terrain drag-painting tests to verify correct block placement behavior and conflict handling. - Introduce walk waypoint tests to check for arrival conditions and position stability after navigation.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
class_name AssetSelector
|
||||
extends PopupPanel
|
||||
|
||||
signal item_selected(entry: Dictionary)
|
||||
signal cancelled()
|
||||
signal browse_requested()
|
||||
signal refresh_requested()
|
||||
|
||||
const COLUMNS := 4
|
||||
const ROWS := 3
|
||||
const PAGE_SIZE := COLUMNS * ROWS
|
||||
|
||||
const CELL_MIN_SIZE := Vector2(140.0, 168.0)
|
||||
const THUMB_SIZE := Vector2(120.0, 120.0)
|
||||
|
||||
var kind: String = ""
|
||||
|
||||
var _entries: Array[Dictionary] = []
|
||||
var _page: int = 0
|
||||
var _thumbnails: Dictionary = {}
|
||||
var _cell_texrects: Dictionary = {}
|
||||
var _placeholder_tex: ImageTexture = null
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _grid: GridContainer = %GridContainer
|
||||
@onready var _empty_label: Label = %EmptyLabel
|
||||
@onready var _page_label: Label = %PageLabel
|
||||
@onready var _prev_button: Button = %PrevButton
|
||||
@onready var _next_button: Button = %NextButton
|
||||
@onready var _browse_button: Button = %BrowseButton
|
||||
@onready var _refresh_button: Button = %RefreshButton
|
||||
@onready var _close_button: Button = %CloseButton
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
exclusive = true
|
||||
_prev_button.pressed.connect(_on_prev)
|
||||
_next_button.pressed.connect(_on_next)
|
||||
_browse_button.pressed.connect(func() -> void: browse_requested.emit())
|
||||
_refresh_button.pressed.connect(func() -> void: refresh_requested.emit())
|
||||
_close_button.pressed.connect(func() -> void: cancelled.emit())
|
||||
get_tree().root.size_changed.connect(_on_root_size_changed)
|
||||
|
||||
|
||||
func _on_root_size_changed() -> void:
|
||||
if visible:
|
||||
popup_centered()
|
||||
|
||||
|
||||
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"
|
||||
set_entries(entries)
|
||||
popup_centered()
|
||||
|
||||
|
||||
func set_entries(entries: Array[Dictionary]) -> void:
|
||||
_entries = entries
|
||||
_thumbnails.clear()
|
||||
_cell_texrects.clear()
|
||||
_page = 0
|
||||
_rebuild()
|
||||
|
||||
|
||||
func set_thumbnail(entry: Dictionary, tex: Texture2D) -> void:
|
||||
var key := _entry_key(entry)
|
||||
_thumbnails[key] = tex
|
||||
if _cell_texrects.has(key):
|
||||
(_cell_texrects[key] as TextureRect).texture = tex
|
||||
|
||||
|
||||
func close() -> void:
|
||||
hide()
|
||||
|
||||
|
||||
func apply_font(ui_font: Font, emoji_font: Font) -> void:
|
||||
_ui_font = ui_font
|
||||
_emoji_font = emoji_font
|
||||
var controls: Array = [
|
||||
_title_label, _empty_label, _page_label,
|
||||
_prev_button, _next_button, _browse_button, _refresh_button, _close_button,
|
||||
]
|
||||
for c: Control in controls:
|
||||
_apply_font_to(c)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if visible and event is InputEventKey:
|
||||
var key := event as InputEventKey
|
||||
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
|
||||
get_viewport().set_input_as_handled()
|
||||
cancelled.emit()
|
||||
|
||||
|
||||
static func page_bounds(total: int, page: int, page_size: int = PAGE_SIZE) -> Dictionary:
|
||||
var page_count := maxi(1, int(ceil(float(total) / float(page_size))))
|
||||
var start := page * page_size
|
||||
if start >= total:
|
||||
start = 0
|
||||
var end := mini(start + page_size, total)
|
||||
return { "start": start, "end": end, "total": total, "page_count": page_count }
|
||||
|
||||
|
||||
func _entry_key(entry: Dictionary) -> String:
|
||||
if kind == "stickman":
|
||||
return String(entry.get("path", ""))
|
||||
return String(entry.get("id", ""))
|
||||
|
||||
|
||||
func _on_prev() -> void:
|
||||
if _page > 0:
|
||||
_page -= 1
|
||||
_rebuild()
|
||||
|
||||
|
||||
func _on_next() -> void:
|
||||
if (_page + 1) * PAGE_SIZE < _entries.size():
|
||||
_page += 1
|
||||
_rebuild()
|
||||
|
||||
|
||||
func _page_count() -> int:
|
||||
if _entries.is_empty():
|
||||
return 1
|
||||
return int(ceil(float(_entries.size()) / float(PAGE_SIZE)))
|
||||
|
||||
|
||||
func _rebuild() -> void:
|
||||
for c: Node in _grid.get_children():
|
||||
c.queue_free()
|
||||
_cell_texrects.clear()
|
||||
_empty_label.visible = _entries.is_empty()
|
||||
_grid.visible = not _entries.is_empty()
|
||||
var total := _page_count()
|
||||
var single := total <= 1
|
||||
_prev_button.visible = not single
|
||||
_next_button.visible = not single
|
||||
_page_label.visible = not single
|
||||
_prev_button.disabled = _page <= 0
|
||||
_next_button.disabled = (_page + 1) * PAGE_SIZE >= _entries.size()
|
||||
_page_label.text = "Page %d/%d" % [_page + 1, total]
|
||||
if _entries.is_empty():
|
||||
return
|
||||
var bounds := page_bounds(_entries.size(), _page)
|
||||
for i: int in range(int(bounds["start"]), int(bounds["end"])):
|
||||
_grid.add_child(_build_cell(_entries[i]))
|
||||
|
||||
|
||||
func _build_cell(entry: Dictionary) -> Control:
|
||||
var key := _entry_key(entry)
|
||||
var cell := Button.new()
|
||||
cell.custom_minimum_size = CELL_MIN_SIZE
|
||||
cell.toggle_mode = false
|
||||
cell.focus_mode = Control.FOCUS_NONE
|
||||
cell.pressed.connect(_on_cell_pressed.bind(entry))
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
vbox.add_theme_constant_override("separation", 2)
|
||||
cell.add_child(vbox)
|
||||
|
||||
var tex_rect := TextureRect.new()
|
||||
tex_rect.custom_minimum_size = THUMB_SIZE
|
||||
tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
tex_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
var tex: Texture2D = _thumbnails.get(key, null)
|
||||
tex_rect.texture = tex if tex != null else _get_placeholder()
|
||||
vbox.add_child(tex_rect)
|
||||
_cell_texrects[key] = tex_rect
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = String(entry.get("name", ""))
|
||||
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
name_label.add_theme_font_size_override("font_size", 14)
|
||||
_apply_font_to(name_label)
|
||||
vbox.add_child(name_label)
|
||||
|
||||
if kind == "prop":
|
||||
var badge := Label.new()
|
||||
badge.text = String(entry.get("material_label", ""))
|
||||
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
badge.add_theme_font_size_override("font_size", 12)
|
||||
badge.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
|
||||
_apply_font_to(badge)
|
||||
vbox.add_child(badge)
|
||||
|
||||
return cell
|
||||
|
||||
|
||||
func _on_cell_pressed(entry: Dictionary) -> void:
|
||||
item_selected.emit(entry)
|
||||
|
||||
|
||||
func _apply_font_to(c: Control) -> void:
|
||||
if c == null:
|
||||
return
|
||||
if _ui_font != null:
|
||||
c.add_theme_font_override("font", _ui_font)
|
||||
elif _emoji_font != null:
|
||||
c.add_theme_font_override("font", _emoji_font)
|
||||
|
||||
|
||||
func _get_placeholder() -> ImageTexture:
|
||||
if _placeholder_tex == null:
|
||||
var img := Image.create(THUMB_SIZE.x, THUMB_SIZE.y, false, Image.FORMAT_RGBA8)
|
||||
img.fill(Color(0.16, 0.16, 0.16, 1.0))
|
||||
_placeholder_tex = ImageTexture.create_from_image(img)
|
||||
return _placeholder_tex
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1ikg6vt3tiu3
|
||||
@@ -0,0 +1,50 @@
|
||||
class_name PropLibrary
|
||||
extends RefCounted
|
||||
|
||||
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
|
||||
static var _templates: Array[Dictionary] = []
|
||||
|
||||
|
||||
static func get_entries() -> Array[Dictionary]:
|
||||
if _templates.is_empty():
|
||||
_build_templates()
|
||||
return _templates
|
||||
|
||||
|
||||
static func get_ids() -> Array[String]:
|
||||
var ids: Array[String] = []
|
||||
for t: Dictionary in get_entries():
|
||||
ids.append(String(t["id"]))
|
||||
return ids
|
||||
|
||||
|
||||
static func get_entry(id: String) -> Dictionary:
|
||||
for t: Dictionary in get_entries():
|
||||
if String(t["id"]) == id:
|
||||
return t
|
||||
return {}
|
||||
|
||||
|
||||
static func get_default_id() -> String:
|
||||
return "crate"
|
||||
|
||||
|
||||
static func _build_templates() -> void:
|
||||
_templates = [
|
||||
_make("crate", "Crate", PROP_BLOCK.MaterialPreset.WOOD, "Wood", PROP_UTILS.create_box()),
|
||||
_make("ball", "Ball", PROP_BLOCK.MaterialPreset.RUBBER, "Rubber", PROP_UTILS.create_ball()),
|
||||
_make("plank", "Plank", PROP_BLOCK.MaterialPreset.METAL, "Metal", PROP_UTILS.create_plank()),
|
||||
_make("triangle", "Triangle", PROP_BLOCK.MaterialPreset.CARDBOARD, "Cardboard", PROP_UTILS.create_triangle()),
|
||||
]
|
||||
|
||||
|
||||
static func _make(id: String, name: String, preset: int, label: String, payload: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"material_preset": preset,
|
||||
"material_label": label,
|
||||
"payload": payload,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0s2ncqoqflus
|
||||
+1032
-85
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,14 @@ 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 rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX
|
||||
var emoji_font: Font = null
|
||||
|
||||
## 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] = []
|
||||
@@ -50,6 +58,19 @@ func set_enabled(value: bool) -> void:
|
||||
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))
|
||||
mark_dirty()
|
||||
|
||||
|
||||
func set_rules(r: Array[Dictionary]) -> void:
|
||||
rules = r
|
||||
mark_dirty()
|
||||
@@ -144,10 +165,10 @@ func _draw_waypoint(pos: Vector2, zoom: float, number: String) -> void:
|
||||
_draw_number(pos + Vector2(radius + 6.0 / zoom, 0.0), number, zoom)
|
||||
|
||||
func _draw_number(pos: Vector2, number: String, zoom: float) -> void:
|
||||
draw_string(ThemeDB.fallback_font, pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(NUMBER_FONT_SIZE_PX / zoom), NUMBER_COLOR)
|
||||
draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), NUMBER_COLOR)
|
||||
|
||||
func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String) -> void:
|
||||
var s := ICON_SIZE_PX / zoom
|
||||
var s := badge_icon_size / zoom
|
||||
match type:
|
||||
"speak":
|
||||
var bw := s * 1.6
|
||||
@@ -221,7 +242,7 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
|
||||
var summary := rule_summary(rule)
|
||||
var mid := (trigger_anchor + action_anchor) * 0.5
|
||||
var font := ThemeDB.fallback_font
|
||||
var font_size := int(RULE_LABEL_FONT_SIZE_PX / zoom)
|
||||
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)
|
||||
@@ -242,15 +263,20 @@ func _draw_rule(rule: Dictionary, zoom: float) -> void:
|
||||
|
||||
|
||||
func _draw_rule_badge(anchor: Vector2, zoom: float, glyph: String, color: Color) -> void:
|
||||
var radius := RULE_BADGE_RADIUS_PX / zoom
|
||||
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 := ThemeDB.fallback_font
|
||||
var font_size := int(ICON_SIZE_PX / zoom)
|
||||
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:
|
||||
return emoji_font if emoji_font != null else 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.
|
||||
|
||||
@@ -166,7 +166,7 @@ func _draw() -> void:
|
||||
if not _enabled:
|
||||
return
|
||||
var zoom := _zoom()
|
||||
if _hovered != null and is_instance_valid(_hovered) and not _targets.has(_hovered):
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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_valid: bool = true
|
||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||
var invalid_color: Color = Color(1.0, 0.3, 0.3)
|
||||
|
||||
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 _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_valid = valid
|
||||
|
||||
|
||||
func clear_action() -> void:
|
||||
action_visible = 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:
|
||||
var color := valid_color if action_valid else invalid_color
|
||||
_draw_dashed(action_origin, action_target, color)
|
||||
_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 _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
|
||||
@@ -59,6 +59,15 @@ func clear_selection() -> void:
|
||||
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
|
||||
|
||||
+61
-16
@@ -14,6 +14,7 @@ extends RefCounted
|
||||
const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd")
|
||||
const PROP_UTILS := preload("res://scripts/prop_utils.gd")
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
|
||||
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
|
||||
const TRIGGER_AREA := preload("res://scripts/trigger_area.gd")
|
||||
|
||||
@@ -36,7 +37,9 @@ const TERRAIN_GRID_SIZE: float = 16.0
|
||||
|
||||
var _world: Node2D
|
||||
var _registry: Array[Dictionary] = []
|
||||
var _stickman_data: Dictionary = {}
|
||||
var selected_stickman_path: String = DEFAULT_STICKMAN_PATH
|
||||
var selected_prop_id: String = "crate"
|
||||
var _stickman_cache: Dictionary = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
@@ -44,8 +47,8 @@ var _stickman_data: Dictionary = {}
|
||||
|
||||
func _init(world: Node2D) -> void:
|
||||
_world = world
|
||||
_stickman_data = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH)
|
||||
if _stickman_data.is_empty():
|
||||
_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()
|
||||
|
||||
@@ -72,6 +75,47 @@ func get_spawn_offset(id: String) -> Vector2:
|
||||
return entry.get("spawn_offset", Vector2.ZERO)
|
||||
|
||||
|
||||
func get_selected_stickman_path() -> String:
|
||||
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:
|
||||
@@ -167,13 +211,7 @@ func _build_registry() -> void:
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
"id": "crate", "label": "Crate", "kind": "prop",
|
||||
"payload": PROP_UTILS.create_box(), "preset": PROP_BLOCK.MaterialPreset.WOOD,
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
"id": "ball", "label": "Ball", "kind": "prop",
|
||||
"payload": PROP_UTILS.create_ball(), "preset": PROP_BLOCK.MaterialPreset.RUBBER,
|
||||
"id": "prop", "label": "Prop", "kind": "prop",
|
||||
"spawn_offset": Vector2.ZERO,
|
||||
},
|
||||
{
|
||||
@@ -214,13 +252,16 @@ func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock:
|
||||
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:
|
||||
var payload: Dictionary = entry["payload"]
|
||||
var preset: int = int(entry.get("preset", PROP_BLOCK.MaterialPreset.WOOD))
|
||||
return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO)
|
||||
var t: Dictionary = PROP_LIBRARY.get_entry(selected_prop_id)
|
||||
if t.is_empty():
|
||||
push_warning("StageSpawner: unknown selected prop '%s'." % selected_prop_id)
|
||||
return null
|
||||
return PROP_UTILS.spawn_prop(_world, world_position, t["payload"], int(t["material_preset"]), Vector2.ZERO)
|
||||
|
||||
|
||||
func _spawn_area(world_position: Vector2) -> Node2D:
|
||||
@@ -232,10 +273,14 @@ func _spawn_area(world_position: Vector2) -> Node2D:
|
||||
|
||||
|
||||
func _spawn_stickman(world_position: Vector2) -> StickmanRig:
|
||||
if _stickman_data.is_empty():
|
||||
push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH)
|
||||
var data: Dictionary = _stickman_cache.get(selected_stickman_path, {})
|
||||
if data.is_empty():
|
||||
data = STICKMAN_FACTORY.load_stk(selected_stickman_path)
|
||||
_stickman_cache[selected_stickman_path] = data
|
||||
if data.is_empty():
|
||||
push_warning("StageSpawner: no stickman data for '%s'." % selected_stickman_path)
|
||||
return null
|
||||
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(_stickman_data)
|
||||
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(data)
|
||||
if rig == null:
|
||||
push_warning("StageSpawner: failed to spawn stickman.")
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
class_name StickmanLibrary
|
||||
extends RefCounted
|
||||
|
||||
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
|
||||
|
||||
const STICKMEN_DIR := "res://stickmen"
|
||||
|
||||
var entries: Array[Dictionary] = []
|
||||
|
||||
|
||||
func scan(dir_path: String = STICKMEN_DIR) -> Array[Dictionary]:
|
||||
entries = _scan_dir(dir_path)
|
||||
return entries
|
||||
|
||||
|
||||
func get_entries() -> Array[Dictionary]:
|
||||
return entries
|
||||
|
||||
|
||||
func find_by_path(path: String) -> Dictionary:
|
||||
for e: Dictionary in entries:
|
||||
if String(e.get("path", "")) == path:
|
||||
return e
|
||||
return {}
|
||||
|
||||
|
||||
func make_entry(path: String) -> Dictionary:
|
||||
var data: Dictionary = STICKMAN_FACTORY.load_stk(path)
|
||||
if data.is_empty() or not data.has("body_parts"):
|
||||
if not data.is_empty():
|
||||
push_warning("StickmanLibrary: skipped '%s' (missing body_parts)." % path)
|
||||
return {}
|
||||
var name := String(data.get("stickman_name", "")).strip_edges()
|
||||
if name.is_empty():
|
||||
name = path.get_file().get_basename()
|
||||
return { "path": path, "name": name, "data": data }
|
||||
|
||||
|
||||
func _scan_dir(dir_path: String) -> Array[Dictionary]:
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
push_warning("StickmanLibrary: stickmen dir '%s' not found." % dir_path)
|
||||
return []
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
push_warning("StickmanLibrary: failed to open '%s'." % dir_path)
|
||||
return []
|
||||
var files: Array[String] = []
|
||||
dir.list_dir_begin()
|
||||
var fname := dir.get_next()
|
||||
while fname != "":
|
||||
if not dir.current_is_dir() and fname.get_extension().to_lower() == "stk":
|
||||
files.append(dir_path + "/" + fname)
|
||||
fname = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
var built: Array[Dictionary] = []
|
||||
for path: String in files:
|
||||
var entry := make_entry(path)
|
||||
if not entry.is_empty():
|
||||
built.append(entry)
|
||||
built.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var na := String(a["name"])
|
||||
var nb := String(b["name"])
|
||||
if na != nb:
|
||||
return na < nb
|
||||
return String(a["path"]) < String(b["path"])
|
||||
)
|
||||
return built
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1s0xetl4uics
|
||||
+88
-13
@@ -160,6 +160,9 @@ const NAV_AGENT_LOCAL_POS := Vector2(0.0, 385.0)
|
||||
const ARRIVE_DISTANCE := 8.0
|
||||
const NAV_PATH_DESIRED_DISTANCE := 8.0
|
||||
const NAV_TARGET_DESIRED_DISTANCE := 12.0
|
||||
## How many post-sync physics frames to wait for the nav agent's reachability
|
||||
## flag before deciding the walk is genuinely off-mesh (latch "direct").
|
||||
const LATCH_PROBE_MAX_FRAMES := 6
|
||||
## Rig-local anchor for the speech bubble, above the head.
|
||||
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
|
||||
|
||||
@@ -335,6 +338,9 @@ var _walking: bool = false
|
||||
var _walk_target_feet: Vector2 = Vector2.ZERO
|
||||
var _walk_speed_current: float = 300.0
|
||||
var _walk_mode: String = "nav" # "nav" (follow mesh) | "direct" (off-mesh straight line)
|
||||
var _walk_mode_latched: bool = false
|
||||
var _walk_latch_probe_frames: int = 0
|
||||
var _walk_settle_frames: int = 0
|
||||
var _walk_done: bool = false
|
||||
|
||||
var _ragdoll_at_rest: bool = false
|
||||
@@ -412,6 +418,7 @@ func _physics_process(delta: float) -> void:
|
||||
_track_momentum(delta)
|
||||
_update_rest_detection(delta)
|
||||
_update_walking(delta)
|
||||
_settle_walk_markers()
|
||||
_update_speech(delta)
|
||||
_update_runner(delta)
|
||||
|
||||
@@ -1058,6 +1065,12 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
|
||||
_anim_player.play(anim_name)
|
||||
_walking = true
|
||||
_walk_done = false
|
||||
# The nav/direct steering mode is latched once per walk (on the first
|
||||
# post-sync frame) so it cannot flip between frames and oscillate the rig.
|
||||
_walk_mode = "nav"
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
|
||||
|
||||
func is_walking() -> bool:
|
||||
@@ -1076,29 +1089,61 @@ func _update_walking(delta: float) -> void:
|
||||
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
|
||||
_walk_dbg("sync pending")
|
||||
return
|
||||
# Latch the steering mode once the map is synced, so a waypoint that sits
|
||||
# near the mesh boundary can't flip nav<->direct between frames (that flip
|
||||
# swaps between two vertically-offset targets and reads as up/down jitter).
|
||||
# Reachability only becomes meaningful a frame or two AFTER the map syncs and
|
||||
# a forced path query round-trips, so probe for up to LATCH_PROBE_MAX_FRAMES:
|
||||
# latch "nav" as soon as the agent reports the target reachable; if it never
|
||||
# does within the bound (genuinely off-mesh), latch "direct".
|
||||
if not _walk_mode_latched:
|
||||
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
|
||||
_walk_latch_probe_frames += 1
|
||||
_nav_agent.get_next_path_position()
|
||||
if _nav_agent.is_target_reachable():
|
||||
_walk_mode_latched = true
|
||||
_walk_mode = "nav"
|
||||
_walk_dbg("latch mode=nav (probe %d)" % _walk_latch_probe_frames)
|
||||
else:
|
||||
_walk_dbg("latch probe %d (not reachable yet)" % _walk_latch_probe_frames)
|
||||
return
|
||||
else:
|
||||
_walk_mode_latched = true
|
||||
_walk_mode = "direct"
|
||||
_walk_dbg("latch mode=direct (probe bound reached)")
|
||||
# Ask the agent for its next waypoint FIRST. This forces the agent's internal
|
||||
# path update (_update_navigation), which re-queries the map whenever the
|
||||
# stored path is empty (set_target_position resets it via _request_repath).
|
||||
# The read-only get_current_navigation_path() accessor alone never triggers a
|
||||
# repath, so checking it directly would leave the path empty forever.
|
||||
var next_feet := _nav_agent.get_next_path_position()
|
||||
var final_root := _walk_target_feet + FOOT_OFFSET
|
||||
var dist_to_final := global_position.distance_to(final_root)
|
||||
var root_target: Vector2
|
||||
if _nav_agent.is_target_reachable():
|
||||
# NAV branch: the target lies on the mesh — follow the path to it.
|
||||
_walk_mode = "nav"
|
||||
if _nav_agent.is_navigation_finished():
|
||||
_finish_walk("finished")
|
||||
if _walk_mode == "nav":
|
||||
# Terminate once the nav agent reports its path complete AND the rig is
|
||||
# close enough: the agent finishes at target_desired_distance (12 px,
|
||||
# feet-space) while the rig's hard arrival radius is 8 px, so chasing the
|
||||
# last stale next-waypoint would oscillate the rig around the 16-px band.
|
||||
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||
global_position = final_root
|
||||
_finish_walk("arrive")
|
||||
return
|
||||
root_target = next_feet + FOOT_OFFSET
|
||||
# Near the destination, ignore the (possibly behind-path) next waypoint
|
||||
# and steer straight at the final target so the rig cannot reverse.
|
||||
if dist_to_final <= 2.0 * ARRIVE_DISTANCE:
|
||||
root_target = final_root
|
||||
else:
|
||||
root_target = next_feet + FOOT_OFFSET
|
||||
else:
|
||||
# DIRECT branch: an off-mesh waypoint is now a normal, supported case —
|
||||
# steer straight at the clicked point, ignoring the nav mesh.
|
||||
if _walk_mode != "direct":
|
||||
_walk_dbg("off-mesh waypoint: switching to direct steering")
|
||||
_walk_mode = "direct"
|
||||
root_target = _walk_target_feet + FOOT_OFFSET
|
||||
# DIRECT branch: an off-mesh waypoint is a supported case — steer
|
||||
# straight at the clicked point, ignoring the nav mesh.
|
||||
root_target = final_root
|
||||
global_position = global_position.move_toward(root_target, _walk_speed_current * delta)
|
||||
if global_position.distance_to(_walk_target_feet + FOOT_OFFSET) <= ARRIVE_DISTANCE:
|
||||
# Unified arrival radius against the FINAL target, in both branches. Snap
|
||||
# the residual offset away so the rig lands exactly on the waypoint.
|
||||
if dist_to_final <= ARRIVE_DISTANCE:
|
||||
global_position = final_root
|
||||
_finish_walk("arrive")
|
||||
return
|
||||
_walk_dbg("frame=%d idx=%d mode=%s root=(%.1f, %.1f) feet=(%.1f, %.1f) target=(%.1f, %.1f) dist=%.1f finished=%s reachable=%s final=(%.1f, %.1f) pts=%d next=(%.1f, %.1f) map_iter=%d" % [
|
||||
@@ -1118,6 +1163,15 @@ func _update_walking(delta: float) -> void:
|
||||
])
|
||||
|
||||
|
||||
## Re-asserts the standing pose one extra physics frame after a walk ends, so
|
||||
## any residual walk-animation body bob (a ±12.5 px torso keyframe) is not left
|
||||
## on the markers when the animation stop and marker restore race.
|
||||
func _settle_walk_markers() -> void:
|
||||
if _walk_settle_frames > 0:
|
||||
_walk_settle_frames -= 1
|
||||
_restore_standing_markers()
|
||||
|
||||
|
||||
func _finish_walk(reason: String = "") -> void:
|
||||
if DEBUG_WALK:
|
||||
var map_iter := -1
|
||||
@@ -1132,6 +1186,7 @@ func _finish_walk(reason: String = "") -> void:
|
||||
if _anim_player != null and is_instance_valid(_anim_player):
|
||||
_anim_player.stop()
|
||||
_restore_standing_markers()
|
||||
_walk_settle_frames = 1
|
||||
_walk_done = true
|
||||
_walking = false
|
||||
arrived.emit(_walk_target_feet)
|
||||
@@ -1145,6 +1200,9 @@ func _cancel_walking() -> void:
|
||||
_nav_agent.target_position = _nav_anchor.global_position
|
||||
_walking = false
|
||||
_walk_done = false
|
||||
_walk_mode_latched = false
|
||||
_walk_latch_probe_frames = 0
|
||||
_walk_settle_frames = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1221,6 +1279,8 @@ func queue_size() -> int:
|
||||
func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
if actions.is_empty():
|
||||
return
|
||||
for action: Dictionary in actions:
|
||||
action["reactive"] = true
|
||||
var start := action_queue.size()
|
||||
action_queue.append_array(actions)
|
||||
queue_changed.emit()
|
||||
@@ -1233,6 +1293,21 @@ func enqueue_reactive(actions: Array[Dictionary]) -> void:
|
||||
_stop_requested = false
|
||||
|
||||
|
||||
## Drops rule-injected ("reactive") actions from the queue, restoring the
|
||||
## authored sequential queue after a Play session. No-op while the runner is
|
||||
## executing (callers invoke it on mode exit, after stop_queue()).
|
||||
func clear_reactive_actions() -> void:
|
||||
if _runner_state == RunnerState.EXECUTING:
|
||||
return
|
||||
var kept: Array[Dictionary] = []
|
||||
for action: Dictionary in action_queue:
|
||||
if not bool(action.get("reactive", false)):
|
||||
kept.append(action)
|
||||
if kept.size() != action_queue.size():
|
||||
action_queue = kept
|
||||
queue_changed.emit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner state machine (Phase 3a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,6 +50,11 @@ const COLLISION_NODE_NAME := "CollisionPolygon2D"
|
||||
if _outline != null:
|
||||
_outline.width = value
|
||||
|
||||
## Registry id of the spawn template that produced this block (Phase 4b). Used
|
||||
## by the terrain drag-painting three-state overlap query to detect same-type
|
||||
## overlaps. Plain var (not inspector-editable).
|
||||
var spawn_id: String = ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal node references (built in _ready, @tool-safe)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
class_name PropThumbnail
|
||||
extends Node
|
||||
|
||||
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
|
||||
|
||||
const SIZE := Vector2i(200, 200)
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _world: Node2D
|
||||
var _camera: Camera2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.size = SIZE
|
||||
_viewport.transparent_bg = true
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
add_child(_viewport)
|
||||
_world = Node2D.new()
|
||||
_viewport.add_child(_world)
|
||||
_camera = Camera2D.new()
|
||||
_camera.enabled = true
|
||||
_viewport.add_child(_camera)
|
||||
_camera.make_current()
|
||||
|
||||
|
||||
func render(payload: Dictionary, material_preset: int) -> Texture2D:
|
||||
_clear_world()
|
||||
var points := _compute_points(payload)
|
||||
var fill := Color(payload.get("fill_color", Color.WHITE))
|
||||
var outline := Color(payload.get("outline_color", Color.BLACK))
|
||||
if material_preset != PROP_BLOCK.MaterialPreset.NONE:
|
||||
var tint := PROP_BLOCK.tint_for(material_preset)
|
||||
fill = tint
|
||||
outline = tint.darkened(0.55)
|
||||
var outline_width := float(payload.get("outline_width", 2.0))
|
||||
var visual := Node2D.new()
|
||||
var poly := Polygon2D.new()
|
||||
poly.polygon = points
|
||||
poly.color = fill
|
||||
var line := Line2D.new()
|
||||
var loop := points.duplicate()
|
||||
if not loop.is_empty():
|
||||
loop.append(points[0])
|
||||
line.points = loop
|
||||
line.default_color = outline
|
||||
line.width = outline_width
|
||||
line.joint_mode = Line2D.LINE_JOINT_ROUND
|
||||
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
line.end_cap_mode = Line2D.LINE_CAP_ROUND
|
||||
visual.add_child(poly)
|
||||
visual.add_child(line)
|
||||
_world.add_child(visual)
|
||||
_frame_camera(_points_bbox(points))
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var tex := _viewport.get_texture()
|
||||
visual.queue_free()
|
||||
if tex == null:
|
||||
return null
|
||||
var img := tex.get_image()
|
||||
if _is_blank(img):
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
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:
|
||||
var radius := float(payload.get("radius", 24.0))
|
||||
var loop := PackedVector2Array()
|
||||
for i: int in PROP_BLOCK.CIRCLE_SEGMENTS:
|
||||
var angle: float = TAU * float(i) / float(PROP_BLOCK.CIRCLE_SEGMENTS)
|
||||
loop.append(Vector2(cos(angle), sin(angle)) * radius)
|
||||
return loop
|
||||
return payload.get("points", PackedVector2Array())
|
||||
|
||||
|
||||
func _points_bbox(points: PackedVector2Array) -> Rect2:
|
||||
if points.is_empty():
|
||||
return Rect2(Vector2(-24, -24), Vector2(48, 48))
|
||||
var rect := Rect2(points[0], Vector2.ZERO)
|
||||
for p: Vector2 in points:
|
||||
rect = rect.expand(p)
|
||||
if rect.size.x < 1.0 or rect.size.y < 1.0:
|
||||
return Rect2(Vector2(-24, -24), Vector2(48, 48))
|
||||
return rect
|
||||
|
||||
|
||||
func _clear_world() -> void:
|
||||
for child: Node in _world.get_children():
|
||||
child.queue_free()
|
||||
|
||||
|
||||
func _frame_camera(bbox: Rect2) -> void:
|
||||
var margin := 12.0
|
||||
var fit := minf((SIZE.x - margin * 2.0) / bbox.size.x, (SIZE.y - margin * 2.0) / bbox.size.y)
|
||||
_camera.position = bbox.get_center()
|
||||
_camera.zoom = Vector2(maxf(fit, 0.05), maxf(fit, 0.05))
|
||||
|
||||
|
||||
func _is_blank(img: Image) -> bool:
|
||||
if img == null or img.is_empty():
|
||||
return true
|
||||
var used := img.get_used_rect()
|
||||
if used.size.x <= 0 or used.size.y <= 0:
|
||||
return true
|
||||
for y: int in range(used.position.y, used.end.y):
|
||||
for x: int in range(used.position.x, used.end.x):
|
||||
if img.get_pixel(x, y).a > 0.0:
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://baau4hydvu46y
|
||||
@@ -0,0 +1,73 @@
|
||||
class_name StickmanThumbnail
|
||||
extends Node
|
||||
|
||||
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
|
||||
const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd")
|
||||
|
||||
const SIZE := Vector2i(200, 200)
|
||||
|
||||
var _viewport: SubViewport
|
||||
var _world: Node2D
|
||||
var _camera: Camera2D
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_viewport = SubViewport.new()
|
||||
_viewport.size = SIZE
|
||||
_viewport.transparent_bg = true
|
||||
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
|
||||
add_child(_viewport)
|
||||
_world = Node2D.new()
|
||||
_viewport.add_child(_world)
|
||||
_camera = Camera2D.new()
|
||||
_camera.enabled = true
|
||||
_viewport.add_child(_camera)
|
||||
_camera.make_current()
|
||||
|
||||
|
||||
func render(stk_data: Dictionary) -> Texture2D:
|
||||
_clear_world()
|
||||
var rig: Node2D = STICKMAN_FACTORY.spawn_from_data(stk_data)
|
||||
if rig == null:
|
||||
return null
|
||||
_world.add_child(rig)
|
||||
rig.position = Vector2.ZERO
|
||||
var bbox := STAGE_SPAWNER.get_world_aabb(rig)
|
||||
if not bbox.has_area() or bbox.size.x < 1.0 or bbox.size.y < 1.0:
|
||||
bbox = Rect2(Vector2(-60, -500), Vector2(120, 500))
|
||||
_frame_camera(bbox)
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
var tex := _viewport.get_texture()
|
||||
rig.queue_free()
|
||||
if tex == null:
|
||||
return null
|
||||
var img := tex.get_image()
|
||||
if _is_blank(img):
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
func _clear_world() -> void:
|
||||
for child: Node in _world.get_children():
|
||||
child.queue_free()
|
||||
|
||||
|
||||
func _frame_camera(bbox: Rect2) -> void:
|
||||
var margin := 12.0
|
||||
var fit := minf((SIZE.x - margin * 2.0) / bbox.size.x, (SIZE.y - margin * 2.0) / bbox.size.y)
|
||||
_camera.position = bbox.get_center()
|
||||
_camera.zoom = Vector2(maxf(fit, 0.05), maxf(fit, 0.05))
|
||||
|
||||
|
||||
func _is_blank(img: Image) -> bool:
|
||||
if img == null or img.is_empty():
|
||||
return true
|
||||
var used := img.get_used_rect()
|
||||
if used.size.x <= 0 or used.size.y <= 0:
|
||||
return true
|
||||
for y: int in range(used.position.y, used.end.y):
|
||||
for x: int in range(used.position.x, used.end.x):
|
||||
if img.get_pixel(x, y).a > 0.0:
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://5b0eubudwgua
|
||||
@@ -0,0 +1,58 @@
|
||||
class_name ThumbnailCache
|
||||
extends RefCounted
|
||||
|
||||
const STICKMEN_DIR := "user://thumbnails/stickmen"
|
||||
const PROP_DIR := "user://thumbnails/props"
|
||||
const PROP_VERSION := 1
|
||||
|
||||
|
||||
func stickman_key(path: String) -> String:
|
||||
return "%s_%d" % [path.get_file().get_basename(), FileAccess.get_modified_time(path)]
|
||||
|
||||
|
||||
func stickman_png(key: String) -> String:
|
||||
return STICKMEN_DIR + "/" + key + ".png"
|
||||
|
||||
|
||||
func prop_png(id: String) -> String:
|
||||
return PROP_DIR + "/" + id + "_v" + str(PROP_VERSION) + ".png"
|
||||
|
||||
|
||||
func load_png(png_path: String) -> Texture2D:
|
||||
if not FileAccess.file_exists(png_path):
|
||||
return null
|
||||
var img := Image.load_from_file(png_path)
|
||||
if img == null:
|
||||
return null
|
||||
return ImageTexture.create_from_image(img)
|
||||
|
||||
|
||||
func save_png(tex: Texture2D, png_path: String) -> Error:
|
||||
if tex == null:
|
||||
return ERR_INVALID_PARAMETER
|
||||
var img := tex.get_image()
|
||||
if img == null:
|
||||
return ERR_CANT_CREATE
|
||||
ensure_dir(png_path.get_base_dir())
|
||||
return img.save_png(png_path)
|
||||
|
||||
|
||||
func ensure_dir(dir: String) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
|
||||
|
||||
func clean_stale_stickmen(valid_keys: Dictionary) -> void:
|
||||
if not DirAccess.dir_exists_absolute(STICKMEN_DIR):
|
||||
return
|
||||
var dir := DirAccess.open(STICKMEN_DIR)
|
||||
if dir == null:
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var fname := dir.get_next()
|
||||
while fname != "":
|
||||
if not dir.current_is_dir() and fname.ends_with(".png"):
|
||||
var key := fname.trim_suffix(".png")
|
||||
if not valid_keys.has(key):
|
||||
DirAccess.remove_absolute(STICKMEN_DIR + "/" + fname)
|
||||
fname = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ceffhet5bsvy3
|
||||
Reference in New Issue
Block a user