- 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.
159 lines
7.3 KiB
GDScript
159 lines
7.3 KiB
GDScript
# 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)
|