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:
2026-09-04 15:08:08 -04:00
parent d77b5e5441
commit 1f91f3d2e5
54 changed files with 7960 additions and 162 deletions
+217
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://b1ikg6vt3tiu3
+50
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
uid://b0s2ncqoqflus
+1032 -85
View File
File diff suppressed because it is too large Load Diff
+32 -6
View File
@@ -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.
+1 -1
View File
@@ -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:
+106
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://ehmg2htvh4xt
+9
View File
@@ -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
View File
@@ -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
+67
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://d1s0xetl4uics
+88 -13
View File
@@ -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)
# ---------------------------------------------------------------------------
+5
View File
@@ -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)
# ---------------------------------------------------------------------------
+112
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://baau4hydvu46y
+73
View File
@@ -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
+58
View File
@@ -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