Add Phase 4 Final Bugs Specification and Tests
- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow. - Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs. - Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more. - Added a unique identifier for the test file to ensure proper tracking and management.
This commit is contained in:
+122
-10
@@ -188,6 +188,9 @@ var _ghost_holder: Node2D = null
|
||||
|
||||
## Phase 4b terrain drag-painting state.
|
||||
var _terrain_dragging: bool = false
|
||||
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
|
||||
var _terrain_press_world: Vector2 = Vector2.ZERO
|
||||
var _terrain_painting: bool = false
|
||||
var _terrain_anchor_cell: Vector2i = Vector2i.ZERO
|
||||
var _terrain_target_cell: Vector2i = Vector2i.ZERO
|
||||
var _drag_cells: Array[Vector2i] = []
|
||||
@@ -210,6 +213,8 @@ var _authored: Dictionary = {}
|
||||
## Physics frames remaining before re-asserting the authored restore (a safety
|
||||
## net for engines where a same-frame freeze + teleport does not stick).
|
||||
var _restore_frames_left: int = 0
|
||||
var _edit_physics_targets: Dictionary = {} # { RigidBody2D: Transform2D }
|
||||
var _edit_physics_settle_frames: int = 0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Director state (Phase 3a)
|
||||
@@ -220,6 +225,7 @@ var _context_rig: StickmanRig = null
|
||||
var _pending_walk_target: bool = false
|
||||
var _speak_dialog: AcceptDialog = null
|
||||
var _speak_edit: LineEdit = null
|
||||
var _speak_stop_current_check: CheckBox = null
|
||||
var _wait_dialog: AcceptDialog = null
|
||||
var _wait_spin: SpinBox = null
|
||||
var _director_visuals = null # StageDirectorVisuals (preloaded)
|
||||
@@ -414,6 +420,7 @@ func _process(delta: float) -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_sync_pending_editable_physics_transforms()
|
||||
if _restore_frames_left > 0:
|
||||
_restore_frames_left -= 1
|
||||
_restore_authored_state()
|
||||
@@ -495,7 +502,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||||
return
|
||||
if mb.button_index != MOUSE_BUTTON_LEFT:
|
||||
return
|
||||
var world_pos := _camera.get_global_mouse_position()
|
||||
# Use the position carried by this exact event. Polling the global cursor
|
||||
# here can lag behind an unhandled press/release, leaving the gizmo with an
|
||||
# unchanged drag origin even though the pointer has moved.
|
||||
var world_pos := _viewport_to_world(mb.position)
|
||||
# Rule-builder click routing is highest priority (Phase 4).
|
||||
if _rule_step != RuleStep.IDLE:
|
||||
if mb.pressed:
|
||||
@@ -548,16 +558,22 @@ func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
||||
return
|
||||
if current_mode == StageMode.PLAY:
|
||||
return
|
||||
var world_pos := _camera.get_global_mouse_position()
|
||||
var world_pos := _viewport_to_world(mm.position)
|
||||
if _gizmos.is_dragging():
|
||||
_gizmos.drag_to(world_pos)
|
||||
_director_visuals.mark_dirty()
|
||||
elif _terrain_dragging:
|
||||
_update_terrain_drag(world_pos)
|
||||
elif _box_selecting:
|
||||
_box_rect = Rect2(_box_start, world_pos - _box_start)
|
||||
_gizmos.set_box_rect(_box_rect)
|
||||
elif not _is_mouse_over_ui() and current_mode == StageMode.EDIT:
|
||||
_selection.update_hover(world_pos)
|
||||
|
||||
|
||||
func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
|
||||
return get_viewport().get_canvas_transform().affine_inverse() * viewport_pos
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mode management
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -657,6 +673,8 @@ func _enter_direct_mode() -> void:
|
||||
|
||||
|
||||
func _enter_play_mode() -> void:
|
||||
_edit_physics_targets.clear()
|
||||
_edit_physics_settle_frames = 0
|
||||
_gizmos.set_enabled(false)
|
||||
_selection.clear_selection()
|
||||
set_placement_mode("")
|
||||
@@ -817,7 +835,7 @@ func _update_ghost_position() -> void:
|
||||
return
|
||||
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
|
||||
if _is_terrain_placement():
|
||||
pos = _terrain_cell_center(_world_to_terrain_cell(pos))
|
||||
pos = _snap_to_grid(pos) if _snap_enabled else pos
|
||||
elif _snap_enabled:
|
||||
pos = _snap_to_grid(pos)
|
||||
_ghost.position = pos
|
||||
@@ -868,14 +886,20 @@ func _begin_terrain_drag(world_pos: Vector2) -> void:
|
||||
_free_ghost()
|
||||
_drag_painted.clear()
|
||||
_terrain_dragging = true
|
||||
_terrain_painting = false
|
||||
_terrain_press_world = world_pos
|
||||
_terrain_anchor_cell = _world_to_terrain_cell(world_pos)
|
||||
_terrain_target_cell = _terrain_anchor_cell
|
||||
_update_terrain_drag(world_pos)
|
||||
|
||||
|
||||
func _update_terrain_drag(world_pos: Vector2) -> void:
|
||||
if not _terrain_dragging:
|
||||
return
|
||||
if not _terrain_painting:
|
||||
var zoom: float = _camera.zoom.x if _camera != null else 1.0
|
||||
if (world_pos - _terrain_press_world).length() * zoom < TERRAIN_DRAG_THRESHOLD_PX:
|
||||
return
|
||||
_terrain_painting = true
|
||||
var target := _world_to_terrain_cell(world_pos)
|
||||
if Input.is_key_pressed(KEY_SHIFT):
|
||||
var d := target - _terrain_anchor_cell
|
||||
@@ -989,6 +1013,13 @@ func _free_terrain_ghosts() -> void:
|
||||
|
||||
func _commit_terrain_drag() -> void:
|
||||
var placed: Array[Node2D] = []
|
||||
if not _terrain_painting:
|
||||
var click_pos: Vector2 = _snap_to_grid(_terrain_press_world) if _snap_enabled else _terrain_press_world
|
||||
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
|
||||
if click_node != null:
|
||||
_finalize_placed_node(click_node)
|
||||
object_placed.emit(click_node)
|
||||
placed.append(click_node)
|
||||
for cell: Vector2i in _drag_cells:
|
||||
if _classify_cell(cell) != 1:
|
||||
continue
|
||||
@@ -1012,6 +1043,8 @@ func _cancel_terrain_drag() -> void:
|
||||
|
||||
func _end_terrain_drag() -> void:
|
||||
_terrain_dragging = false
|
||||
_terrain_painting = false
|
||||
_terrain_press_world = Vector2.ZERO
|
||||
_drag_cells.clear()
|
||||
_last_drag_cells.clear()
|
||||
_drag_painted.clear()
|
||||
@@ -1461,10 +1494,16 @@ func _build_ui() -> void:
|
||||
_speak_dialog = AcceptDialog.new()
|
||||
_speak_dialog.title = "Speak"
|
||||
_speak_dialog.confirmed.connect(_on_speak_confirmed)
|
||||
var speak_box := VBoxContainer.new()
|
||||
_speak_dialog.add_child(speak_box)
|
||||
_speak_edit = LineEdit.new()
|
||||
_speak_edit.placeholder_text = "Say something…"
|
||||
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
|
||||
_speak_dialog.add_child(_speak_edit)
|
||||
speak_box.add_child(_speak_edit)
|
||||
_speak_stop_current_check = CheckBox.new()
|
||||
_speak_stop_current_check.text = "Stop what he's doing?"
|
||||
_speak_stop_current_check.visible = false
|
||||
speak_box.add_child(_speak_stop_current_check)
|
||||
_speak_dialog.register_text_enter(_speak_edit)
|
||||
ui.add_child(_speak_dialog)
|
||||
|
||||
@@ -1568,6 +1607,8 @@ func _build_gizmo_layer() -> void:
|
||||
_gizmos.name = "GizmoLayer"
|
||||
_gizmos.camera = _camera
|
||||
_gizmos.transform_committed.connect(_on_transform_committed)
|
||||
_gizmos.transform_started.connect(_on_gizmo_transform_started)
|
||||
_gizmos.transform_updated.connect(_on_gizmo_transform_updated)
|
||||
add_child(_gizmos)
|
||||
|
||||
|
||||
@@ -1817,11 +1858,59 @@ func _on_hover_changed(node: Node2D) -> void:
|
||||
|
||||
func _on_transform_committed(nodes: Array[Node2D]) -> void:
|
||||
for node: Node2D in nodes:
|
||||
if node is RigidBody2D:
|
||||
_edit_physics_targets[node] = node.global_transform
|
||||
_sync_editable_physics_transform(node)
|
||||
_save_object_state(node)
|
||||
if node is TerrainBlock:
|
||||
_nav_dirty = true
|
||||
_rebuild_grid_cells()
|
||||
_director_visuals.mark_dirty()
|
||||
_edit_physics_settle_frames = 3
|
||||
|
||||
|
||||
func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
|
||||
_restore_frames_left = 0
|
||||
_edit_physics_targets.clear()
|
||||
_edit_physics_settle_frames = 0
|
||||
|
||||
|
||||
func _on_gizmo_transform_updated(nodes: Array[Node2D]) -> void:
|
||||
for node: Node2D in nodes:
|
||||
if node is RigidBody2D:
|
||||
_edit_physics_targets[node] = node.global_transform
|
||||
_sync_editable_physics_transform(node)
|
||||
|
||||
|
||||
func _sync_pending_editable_physics_transforms() -> void:
|
||||
if _edit_physics_targets.is_empty() or current_mode != StageMode.EDIT:
|
||||
return
|
||||
for node: RigidBody2D in _edit_physics_targets.keys().duplicate():
|
||||
if node == null or not is_instance_valid(node):
|
||||
_edit_physics_targets.erase(node)
|
||||
continue
|
||||
node.global_transform = _edit_physics_targets[node]
|
||||
_sync_editable_physics_transform(node)
|
||||
if not _gizmos.is_dragging() and _edit_physics_settle_frames > 0:
|
||||
_edit_physics_settle_frames -= 1
|
||||
if _edit_physics_settle_frames == 0:
|
||||
_edit_physics_targets.clear()
|
||||
|
||||
|
||||
func _sync_editable_physics_transform(node: Node2D) -> void:
|
||||
if not (node is RigidBody2D):
|
||||
return
|
||||
var body := node as RigidBody2D
|
||||
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
body.freeze = true
|
||||
body.linear_velocity = Vector2.ZERO
|
||||
body.angular_velocity = 0.0
|
||||
var target_transform: Transform2D = body.global_transform
|
||||
var rid := body.get_rid()
|
||||
if rid.is_valid():
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, target_transform)
|
||||
body.global_transform = target_transform
|
||||
body.reset_physics_interpolation()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Director tool (Phase 3a)
|
||||
@@ -1897,6 +1986,8 @@ func _on_action_popup_id_pressed(id: int) -> void:
|
||||
_refresh_status()
|
||||
ACT_SPEAK:
|
||||
_speak_edit.text = ""
|
||||
_speak_stop_current_check.visible = false
|
||||
_speak_stop_current_check.button_pressed = false
|
||||
_speak_dialog.popup_centered()
|
||||
_speak_edit.grab_focus()
|
||||
ACT_WAIT:
|
||||
@@ -2020,7 +2111,7 @@ func _update_tooltip_position() -> void:
|
||||
|
||||
func _on_speak_confirmed() -> void:
|
||||
if _rule_step == RuleStep.PARAMS:
|
||||
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0 })
|
||||
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed })
|
||||
_open_rule_more_popup()
|
||||
return
|
||||
if _context_rig == null or not is_instance_valid(_context_rig):
|
||||
@@ -2264,6 +2355,8 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
|
||||
"speak":
|
||||
_rule_step = RuleStep.PARAMS
|
||||
_speak_edit.text = ""
|
||||
_speak_stop_current_check.visible = true
|
||||
_speak_stop_current_check.button_pressed = false
|
||||
_speak_dialog.popup_centered()
|
||||
_speak_edit.grab_focus()
|
||||
"wait":
|
||||
@@ -2345,6 +2438,9 @@ func _cancel_rule_build() -> void:
|
||||
|
||||
|
||||
func _reset_rule_builder() -> void:
|
||||
if _speak_stop_current_check != null:
|
||||
_speak_stop_current_check.visible = false
|
||||
_speak_stop_current_check.button_pressed = false
|
||||
_rule_step = RuleStep.IDLE
|
||||
_rule_builder = {}
|
||||
_rule_context_rig = null
|
||||
@@ -2409,6 +2505,11 @@ func _on_rig_speech_finished(rig: StickmanRig) -> void:
|
||||
|
||||
|
||||
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
|
||||
# Physics contacts can be delivered one frame after PLAY is stopped. Never
|
||||
# recreate a flinch or dispatch a reactive rule in EDIT/DIRECT after the
|
||||
# authoritative snap_to_standing() cleanup has run.
|
||||
if current_mode != StageMode.PLAY:
|
||||
return
|
||||
if other == null or not is_instance_valid(other):
|
||||
return
|
||||
if bool(other.get_meta("stickman_ragdoll_body", false)):
|
||||
@@ -2521,11 +2622,21 @@ func _ids_match(a: int, b: Node2D) -> bool:
|
||||
|
||||
|
||||
func _execute_rule_actions(rule: Dictionary) -> void:
|
||||
var batches: Dictionary = {}
|
||||
for a: Dictionary in rule.get("actions", []):
|
||||
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
|
||||
if rig == null or not is_instance_valid(rig):
|
||||
continue
|
||||
rig.enqueue_reactive([_action_for_rig(a)])
|
||||
var id: int = rig.get_instance_id()
|
||||
if not batches.has(id):
|
||||
batches[id] = { "rig": rig, "actions": [] }
|
||||
(batches[id]["actions"] as Array).append(_action_for_rig(a))
|
||||
for batch: Dictionary in batches.values():
|
||||
var target_rig := batch.get("rig") as StickmanRig
|
||||
if target_rig != null and is_instance_valid(target_rig):
|
||||
var batch_actions: Array[Dictionary] = []
|
||||
batch_actions.assign(batch.get("actions", []))
|
||||
target_rig.enqueue_reactive(batch_actions)
|
||||
|
||||
|
||||
## Converts a rule action into the queue-action shape the runner understands.
|
||||
@@ -2538,6 +2649,7 @@ func _action_for_rig(a: Dictionary) -> Dictionary:
|
||||
"speak":
|
||||
out["text"] = String(params.get("text", ""))
|
||||
out["duration"] = float(params.get("duration", 2.0))
|
||||
out["stop_current"] = bool(params.get("stop_current", false))
|
||||
"wait":
|
||||
out["duration"] = float(params.get("duration", 0.0))
|
||||
return out
|
||||
@@ -3082,9 +3194,9 @@ func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: Stickm
|
||||
elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null:
|
||||
_rule_editor.hide()
|
||||
if flat.is_empty():
|
||||
_action_editor.open_new()
|
||||
_action_editor.open_new(kind == "rule_add" or kind == "rule_edit")
|
||||
else:
|
||||
_action_editor.open_edit(flat)
|
||||
_action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit")
|
||||
|
||||
|
||||
func _on_action_editor_committed(flat: Dictionary) -> void:
|
||||
@@ -3204,7 +3316,7 @@ func _apply_grid_settings() -> void:
|
||||
_grid.enabled = _show_grid
|
||||
_grid.visible = _show_grid and current_mode == StageMode.EDIT
|
||||
if _gizmos != null:
|
||||
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0
|
||||
_gizmos.set_snap_size(_grid_size if _snap_enabled else 0.0)
|
||||
|
||||
|
||||
func _set_edit_controls_visible(visible: bool) -> void:
|
||||
|
||||
Reference in New Issue
Block a user