feat: Implement Phase 4 Trigger Events System

- Added a new event-driven system for reactive storytelling, allowing rules like "When X happens, do Y."
- Introduced TriggerArea class for placeable sensors in the stage.
- Enhanced StickmanRig to emit signals for actions and arrivals.
- Updated StageDirectorVisuals to render rules visually with labels and badges.
- Modified StageSpawner to support spawning TriggerAreas.
- Improved text baseline calculations in speech bubbles and rule labels.
- Added tests for text baseline fixes to ensure proper rendering.
- Documented the implementation plan for Phase 4 in PHASE_4_TRIGGER_EVENTS.md.
- Created a polish plan for Phase 4 in PHASE_4b_POLISH.md.
This commit is contained in:
2026-09-01 08:58:07 -04:00
parent bf11a5fab5
commit f208127917
17 changed files with 1538 additions and 8 deletions
+158
View File
@@ -0,0 +1,158 @@
# test_text_baseline_fix.gd
# Headless regression test for two text-baseline drawing fixes:
# 1. scripts/stickman_speech_bubble.gd _draw() text baseline now includes
# font.get_ascent(FONT_SIZE) so glyphs sit inside the bubble rect.
# 2. scripts/stage_director_visuals.gd _draw_rule() rule-label baseline now
# includes font.get_ascent(font_size) so white text sits on the dark rect.
#
# The math is replicated against the real ThemeDB.fallback_font using each
# file's actual constants (read off the script resources, not copied here), so
# the test tracks any future constant/geometry changes and proves the OLD
# (buggy) formula would fail the same containment checks (discriminator).
#
# Run with:
# & "C:\Godot4\Godot_v4.4-stable_win64_console.exe" --headless --script res://tests/test_text_baseline_fix.gd --path .
#
# Prints PASS/FAIL per assertion and exits 0 on all PASS, 1 on any FAIL.
extends SceneTree
const SpeechBubbleScript := preload("res://scripts/stickman_speech_bubble.gd")
const StageDirectorVisualsScript := preload("res://scripts/stage_director_visuals.gd")
# Inline constants from stage_director_visuals.gd's _draw_rule() (they are not
# named constants there). RULE_LABEL_FONT_SIZE_PX is read from the script.
const RULE_LABEL_PADDING := Vector2(6.0, 4.0)
const RULE_ZOOM := 1.0
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
print("")
print("========================================================")
print(" TEXT BASELINE REGRESSION TEST (headless)")
print("========================================================")
_test_speech_bubble()
_test_rule_label()
print("--------------------------------------------------------")
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
# ---------------------------------------------------------------------------
# Bug 1: SpeechBubble._draw()
# ---------------------------------------------------------------------------
func _test_speech_bubble() -> void:
print("")
print("--- SpeechBubble._draw() baseline (bug 1) ---")
var bubble: Node2D = SpeechBubbleScript.new()
root.add_child(bubble)
# Long enough that, wrapped at MAX_WIDTH=320 @ FONT_SIZE=28, the string
# spans multiple lines (proves the vertical-layout path is exercised).
var text := "Hello there! This is a nice long speech bubble message that wraps over multiple lines, just like a real line of dialogue."
bubble.show_text(text)
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, SpeechBubbleScript.MAX_WIDTH, SpeechBubbleScript.FONT_SIZE)
var box_size := text_size + SpeechBubbleScript.PADDING * 2.0
var box := Rect2(Vector2(-box_size.x * 0.5, -SpeechBubbleScript.TAIL_HEIGHT - box_size.y), box_size)
var ascent := font.get_ascent(SpeechBubbleScript.FONT_SIZE)
var descent := font.get_descent(SpeechBubbleScript.FONT_SIZE)
# Fixed formula (current code in _draw()).
var fixed_baseline := Vector2(
box.position.x + SpeechBubbleScript.PADDING.x,
box.position.y + SpeechBubbleScript.PADDING.y + ascent
)
# Old (buggy) formula: draw_string(font, box.position + PADDING, ...).
var old_baseline := box.position + SpeechBubbleScript.PADDING
# Prove the probe string genuinely exceeds the wrap width (and therefore the
# production box is clamped to MAX_WIDTH). Note: in Godot 4.4
# get_string_size() caps the width at the given width but reports a
# single-line height, so "wrap exercised" is proven via the natural width
# exceeding MAX_WIDTH and the box width equaling MAX_WIDTH.
var natural_width := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, SpeechBubbleScript.FONT_SIZE).x
_check(natural_width > SpeechBubbleScript.MAX_WIDTH,
"SpeechBubble: probe string natural width (%.1f) exceeds MAX_WIDTH (%.1f) -> wrap path exercised" % [natural_width, SpeechBubbleScript.MAX_WIDTH])
_check(text_size.x <= SpeechBubbleScript.MAX_WIDTH + 0.001,
"SpeechBubble: box width is clamped to MAX_WIDTH (%.1f)" % text_size.x)
_check(fixed_baseline.y - ascent >= box.position.y - 0.001,
"SpeechBubble: FIXED glyph top (%.2f) is not above box top (%.2f)" % [fixed_baseline.y - ascent, box.position.y])
_check(fixed_baseline.y + descent <= box.position.y + box.size.y + 0.001,
"SpeechBubble: FIXED glyph bottom (%.2f) is not below box bottom (%.2f)" % [fixed_baseline.y + descent, box.position.y + box.size.y])
_check(old_baseline.y - ascent < box.position.y - 0.001,
"SpeechBubble: DISCRIMINATOR - OLD formula glyph top (%.2f) IS above box top (%.2f)" % [old_baseline.y - ascent, box.position.y])
print(" box=%s text_size=%s ascent=%.2f descent=%.2f" % [box, text_size, ascent, descent])
print(" fixed_baseline=%s old_baseline=%s" % [fixed_baseline, old_baseline])
bubble.free()
# ---------------------------------------------------------------------------
# Bug 2: StageDirectorVisuals._draw_rule() label baseline
# ---------------------------------------------------------------------------
func _test_rule_label() -> void:
print("")
print("--- StageDirectorVisuals._draw_rule() label baseline (bug 2) ---")
var visuals: Node2D = StageDirectorVisualsScript.new()
root.add_child(visuals)
# A realistic When->Then rule whose summary exercises two actions.
var rule := {
"id": 1,
"trigger": {"type": "arrived_at_waypoint", "params": {"waypoint_pos": Vector2(100.0, 0.0)}, "source": 0},
"actions": [
{"type": "speak", "params": {"text": "Hello there!"}, "target": 0},
{"type": "wait", "params": {"duration": 5.0}, "target": 0},
],
}
var summary := StageDirectorVisualsScript.rule_summary(rule)
var zoom := RULE_ZOOM
var font := ThemeDB.fallback_font
var font_size := int(StageDirectorVisualsScript.RULE_LABEL_FONT_SIZE_PX / zoom)
var text_size := font.get_string_size(summary, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
var padding := RULE_LABEL_PADDING / zoom
var mid := Vector2(50.0, 50.0)
var box := Rect2(mid - text_size * 0.5 - padding, text_size + padding * 2.0)
var ascent := font.get_ascent(font_size)
var descent := font.get_descent(font_size)
# Fixed formula (current code in _draw_rule()).
var fixed_baseline := box.position + padding + Vector2(0.0, ascent)
# Old (buggy) formula: draw_string(font, box.position + padding, ...).
var old_baseline := box.position + padding
_check(fixed_baseline.y - ascent >= box.position.y - 0.001,
"RuleLabel: FIXED glyph top (%.2f) is not above box top (%.2f)" % [fixed_baseline.y - ascent, box.position.y])
_check(fixed_baseline.y + descent <= box.position.y + box.size.y + 0.001,
"RuleLabel: FIXED glyph bottom (%.2f) is not below box bottom (%.2f)" % [fixed_baseline.y + descent, box.position.y + box.size.y])
_check(old_baseline.y - ascent < box.position.y - 0.001,
"RuleLabel: DISCRIMINATOR - OLD formula glyph top (%.2f) IS above box top (%.2f)" % [old_baseline.y - ascent, box.position.y])
print(" summary='%s'" % summary)
print(" box=%s text_size=%s font_size=%d ascent=%.2f descent=%.2f" % [box, text_size, font_size, ascent, descent])
print(" fixed_baseline=%s old_baseline=%s" % [fixed_baseline, old_baseline])
visuals.free()
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)