Add accessibility pass: escalation meter and working HUD font scale
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Failing after 3m31s

- Escalation cue now includes a shape meter and numeric level
  ("Escalation: Blocking [###--] L3/5") in addition to the colour and name, so it
  reads in grayscale. Hud.escalation_meter(level) is pure and tested.
- HUD font scale from settings actually works: set_font_scale() resizes fonts via
  theme overrides instead of Control.scale, which pushed the right-anchored clock
  label off-screen. It now covers the top, log, target, hint, time, mode,
  escalation, cooldown, and debug labels.
- Settings load before the HUD exists, so _ready() re-applies player settings after
  _build_ui(); previously a saved font scale was ignored until the next apply.

Tests/docs
- Headless suite 134 -> 139 (test_accessibility.gd).
- Verified visually at 1.4x scale: no overflow, meter readable.
- README, TODO section moved to DONE, MEMORY. Version 0.2.9.
This commit is contained in:
zwitschi committed 2026-10-31 21:17:42 +01:00
1 parent 294ac17333
commit 3f4ac7e9aa
15 files changed
+115 -21

No files matched your search

+3
View File
@@ -141,6 +141,9 @@ func _ready() -> void:
_connect_crowd()
_build_ui()
# The HUD does not exist yet when settings are loaded, so push them again now
# that it does (otherwise the saved font scale is ignored until the next apply).
_apply_settings(player_settings)
_last_phase = -1
_apply_phase_lighting()
# Boot into the venue menu; the shift starts when the player picks a venue.
+24 -9
View File
@@ -49,6 +49,8 @@ var _debug_enabled := false
var _log_lines: Array[String] = []
var _cam_overlay: Control = null
var _body_camera_on := false
var _font_scale := 1.0
var _base_font_sizes: Dictionary = {}
func _init() -> void:
name = "HUD"
@@ -300,16 +302,20 @@ func is_body_camera_on() -> bool:
return _body_camera_on
## Scale the whole HUD text block (accessibility settings).
## Resize HUD text by changing font sizes (not Control.scale, which overflows
## anchored labels and breaks the right-aligned layout).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
if top_label:
top_label.scale = Vector2(clamped, clamped)
if log_label:
log_label.scale = Vector2(clamped, clamped)
if target_label:
target_label.scale = Vector2(clamped, clamped)
if hint_label:
hint_label.scale = Vector2(clamped, clamped)
_font_scale = clamped
var scaled: Array[Label] = [top_label, log_label, target_label, hint_label,
time_label, mode_label, escalation_label, radio_cooldown_label, debug_label]
for label in scaled:
if label == null:
continue
if not _base_font_sizes.has(label):
_base_font_sizes[label] = label.get_theme_font_size("font_size")
var base: int = int(_base_font_sizes[label])
label.add_theme_font_size_override("font_size", maxi(8, int(round(float(base) * clamped))))
func _make_icon(texture: Texture2D) -> TextureRect:
var tr := TextureRect.new()
@@ -353,9 +359,18 @@ func set_action_hint(text: String) -> void:
## Color-coded escalation feedback (PLAN.md: Polish and Accessibility).
## Green → yellow → orange → red as the escalation level rises.
## Grayscale-safe escalation meter: filled/empty blocks plus the level number, so
## the cue does not rely on hue alone (accessibility).
static func escalation_meter(level: int) -> String:
var clamped := clampi(level, 0, 5)
var filled := ""
for i in 5:
filled += "#" if i < clamped else "-"
return "[%s] L%d/5" % [filled, clamped]
func set_escalation(level: int) -> void:
var name: String = Rules.LEVEL_NAMES.get(level, "Observation")
escalation_label.text = Strings.fmt("hud.escalation_prefix", [name])
escalation_label.text = "%s %s" % [Strings.fmt("hud.escalation_prefix", [name]), escalation_meter(level)]
var tint := Color(0.6, 0.9, 0.6)
match level:
Rules.Level.OBSERVATION, Rules.Level.SIMPLE_DETERRENCE:
+64
View File
@@ -0,0 +1,64 @@
extends RefCounted
## Accessibility: escalation cues that survive grayscale (shape + level, not hue
## alone) and the HUD font scale from settings.
const Hud := preload("res://src/game/ui/hud.gd")
const Rules := preload("res://src/core/rules.gd")
func test_meter_shape_and_level() -> bool:
var a := TestUtils.check_eq(Hud.escalation_meter(0), "[-----] L0/5", "calm shows an empty meter")
var b := TestUtils.check_eq(Hud.escalation_meter(3), "[###--] L3/5", "three filled blocks at blocking")
var c := TestUtils.check_eq(Hud.escalation_meter(5), "[#####] L5/5", "emergency fills the meter")
var d := TestUtils.check_eq(Hud.escalation_meter(99), "[#####] L5/5", "levels clamp at 5")
var e := TestUtils.check_eq(Hud.escalation_meter(-2), "[-----] L0/5", "levels clamp at 0")
return a and b and c and d and e
func test_meter_is_not_hue_only() -> bool:
## Every level must differ in shape/text, independent of colour.
var seen: Array[String] = []
for level in 6:
var meter: String = Hud.escalation_meter(level)
if seen.has(meter):
return TestUtils.fail("levels %d and %d share a meter" % [level, seen.find(meter)])
seen.append(meter)
return TestUtils.check(seen.size() == 6, "all six levels have distinct meters")
func test_escalation_label_includes_meter_and_name() -> bool:
var hud: Node = Hud.new()
hud.set_escalation(Rules.Level.FORMAL_WARNING)
var text: String = hud.escalation_label.text
var a := TestUtils.check(text.contains("Formal Warning"), "label names the level")
var b := TestUtils.check(text.contains("[##---]"), "label shows the shape meter")
var c := TestUtils.check(text.contains("L2/5"), "label shows the numeric level")
hud.free()
return a and b and c
func test_font_scale_applies_to_core_labels() -> bool:
var hud: Node = Hud.new()
var base_top: int = hud.top_label.get_theme_font_size("font_size")
var base_log: int = hud.log_label.get_theme_font_size("font_size")
var base_esc: int = hud.escalation_label.get_theme_font_size("font_size")
var base_mode: int = hud.mode_label.get_theme_font_size("font_size")
hud.set_font_scale(1.4)
var a := TestUtils.check(hud.top_label.get_theme_font_size("font_size") > base_top, "top label grows")
var b := TestUtils.check(hud.log_label.get_theme_font_size("font_size") > base_log, "log grows")
var c := TestUtils.check(hud.escalation_label.get_theme_font_size("font_size") > base_esc, "escalation label grows")
var d := TestUtils.check(hud.mode_label.get_theme_font_size("font_size") > base_mode, "mode label grows")
# Scaling is by font size, not Control.scale, so anchored labels keep layout.
var e := TestUtils.check(is_equal_approx(hud.top_label.scale.x, 1.0), "labels are not Control-scaled")
hud.set_font_scale(9.0)
var capped: int = hud.top_label.get_theme_font_size("font_size")
var f := TestUtils.check(capped <= int(round(float(base_top) * 1.75)) + 1, "scale clamps at 1.75")
hud.free()
return a and b and c and d and e and f
func test_settings_font_scale_is_applied_after_ui_exists() -> bool:
## The HUD is created after settings load, so the manager must re-apply.
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("_apply_settings(player_settings)"), "settings are applied on boot")
var build_idx := src.find("_build_ui()")
var apply_idx := src.find("_apply_settings(player_settings)", build_idx)
var b := TestUtils.check(apply_idx > build_idx, "the second apply happens after the HUD is built")
var c := TestUtils.check(src.contains("hud.set_font_scale(float(settings.get("),
"settings drive the HUD font scale")
return a and b and c