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:
@@ -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)
|
||||
Reference in New Issue
Block a user