feat(ui): add YAML-based UI string loading system (#409)

Adds UIStrings autoload that loads display text from a YAML file,
replacing hardcoded strings in HUD and interaction prompt. Copy team
can now author UI microcopy in client/data/ui-strings.yaml without
touching GDScript. Includes 38 strings across 5 categories and 16
gdUnit4 tests for the parser and lookup API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 17:18:01 +01:00
co-authored by Claude Opus 4.6
parent 3b757f5adc
commit 5a04656781
6 changed files with 210 additions and 4 deletions
+51
View File
@@ -0,0 +1,51 @@
# UI strings for The Settled Reach client
# Authored by copy team. Loaded by UIStrings autoload.
# Format: one-level-nested key-value pairs (section.key -> value)
interaction:
prompt_prefix: "E"
talk: "Talk"
examine: "Examine"
observe: "Observe"
pick_up: "Pick up"
use: "Use"
inspect: "Inspect"
open: "Open"
close: "Close"
interact: "Interact"
hud:
mode_label: "Mode"
time_label: "Time"
health_label: "Health"
pause_indicator: "PAUSED"
location_prefix: "Location"
knowledge:
header_known_contacts: "Known Contacts"
header_persons_of_interest: "Persons of Interest"
header_case_notes: "Case Notes"
header_evidence: "Evidence"
header_known_locations: "Known Locations"
header_recent_observations: "Recent Observations"
empty_panel: "Nothing recorded yet."
relationship:
unknown: "Unknown"
acquaintance: "Acquaintance"
colleague: "Colleague"
trusted_colleague: "Trusted colleague"
informant: "Informant"
suspect: "Suspect"
hostile: "Hostile"
friendly: "Friendly"
flagged_by_case: "Flagged by case file"
person_of_interest: "Person of interest"
tutorial:
welcome: "Welcome to Sova Transit District."
movement: "Use WASD to move."
interact_hint: "Press E near someone to interact."
perception_hint: "Press TAB to toggle perception mode."
pause_hint: "Press SPACE to pause."
look_around: "Look around. Get your bearings."
+1
View File
@@ -20,6 +20,7 @@ config/icon="res://icon.svg"
SimBridge="*res://scripts/autoloads/sim_bridge.gd"
GameState="*res://scripts/autoloads/game_state.gd"
InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
[display]
+72
View File
@@ -0,0 +1,72 @@
extends Node
# UIStrings — loads UI display text from data/ui-strings.yaml.
# Access: UIStrings.get_text("hud.mode_label") -> "Mode"
# Falls back to the key itself if not found, with a push_warning.
const STRINGS_PATH: String = "res://data/ui-strings.yaml"
var _strings: Dictionary = {}
var _loaded: bool = false
func _ready() -> void:
_load_strings()
func _load_strings() -> void:
if not FileAccess.file_exists(STRINGS_PATH):
push_warning("UIStrings: file not found: %s" % STRINGS_PATH)
return
var file := FileAccess.open(STRINGS_PATH, FileAccess.READ)
if file == null:
push_warning("UIStrings: failed to open: %s" % STRINGS_PATH)
return
var text := file.get_as_text()
file.close()
_strings = _parse_yaml(text)
_loaded = true
print("UIStrings: loaded %d strings from %s" % [_strings.size(), STRINGS_PATH])
## Get a UI string by dotted key. Returns the key itself if not found.
func get_text(key: String) -> String:
if _strings.has(key):
return _strings[key]
push_warning("UIStrings: missing key '%s'" % key)
return key
## Check if a key exists.
func has_key(key: String) -> bool:
return _strings.has(key)
## Get all keys.
func get_all_keys() -> PackedStringArray:
return PackedStringArray(_strings.keys())
## Reload from disk (useful for hot-reload during development).
func reload() -> void:
_strings.clear()
_loaded = false
_load_strings()
## Parse subset of YAML: one-level-nested key-value pairs.
## Returns flat Dictionary with dotted keys: { "section.key": "value" }.
static func _parse_yaml(text: String) -> Dictionary:
var strings := {}
var current_section := ""
for line in text.split("\n"):
var stripped := line.strip_edges(false, true)
if stripped.is_empty() or stripped.begins_with("#"):
continue
var indent := line.length() - line.lstrip(" ").length()
var content := stripped.strip_edges()
var colon_pos := content.find(":")
if colon_pos < 0:
continue
var key := content.substr(0, colon_pos).strip_edges()
var val := content.substr(colon_pos + 1).strip_edges()
if val.length() >= 2 and val.begins_with("\"") and val.ends_with("\""):
val = val.substr(1, val.length() - 2)
if indent == 0:
current_section = key
elif indent >= 2 and not current_section.is_empty():
strings[current_section + "." + key] = val
return strings
+82
View File
@@ -0,0 +1,82 @@
class_name TestUIStrings
extends GdUnitTestSuite
# Tests for ticket #409: UI string loading system.
# Covers YAML parsing, autoload lookup, and fallback behavior.
# -- YAML parser: basic behavior --
func test_parse_simple_key_value() -> void:
var yaml := "section:\n key: value"
var result := UIStrings._parse_yaml(yaml)
assert_that(result.has("section.key")).is_true()
assert_that(result["section.key"]).is_equal("value")
func test_parse_quoted_value() -> void:
var yaml := "section:\n key: \"hello world\""
var result := UIStrings._parse_yaml(yaml)
assert_that(result["section.key"]).is_equal("hello world")
func test_parse_multiple_sections() -> void:
var yaml := "hud:\n mode: Mode\ninteraction:\n talk: Talk"
var result := UIStrings._parse_yaml(yaml)
assert_that(result.has("hud.mode")).is_true()
assert_that(result.has("interaction.talk")).is_true()
assert_that(result["hud.mode"]).is_equal("Mode")
assert_that(result["interaction.talk"]).is_equal("Talk")
func test_parse_skips_comments() -> void:
var yaml := "# comment\nsection:\n key: value\n # another comment"
var result := UIStrings._parse_yaml(yaml)
assert_that(result.size()).is_equal(1)
func test_parse_skips_empty_lines() -> void:
var yaml := "section:\n\n key: value\n\n"
var result := UIStrings._parse_yaml(yaml)
assert_that(result["section.key"]).is_equal("value")
func test_parse_empty_string() -> void:
var result := UIStrings._parse_yaml("")
assert_that(result.size()).is_equal(0)
func test_parse_value_with_colon() -> void:
var yaml := "hud:\n time: \"12:30\""
var result := UIStrings._parse_yaml(yaml)
assert_that(result["hud.time"]).is_equal("12:30")
func test_parse_multiple_keys_per_section() -> void:
var yaml := "hud:\n mode: Mode\n time: Time\n health: Health"
var result := UIStrings._parse_yaml(yaml)
assert_that(result.size()).is_equal(3)
assert_that(result["hud.mode"]).is_equal("Mode")
assert_that(result["hud.time"]).is_equal("Time")
assert_that(result["hud.health"]).is_equal("Health")
# -- Autoload: registration and file loading --
func test_autoload_registered() -> void:
assert_that(UIStrings).is_not_null()
func test_autoload_loaded_strings() -> void:
assert_that(UIStrings.has_key("hud.mode_label")).is_true()
func test_get_text_returns_value() -> void:
assert_that(UIStrings.get_text("hud.mode_label")).is_equal("Mode")
func test_get_text_interaction_prefix() -> void:
assert_that(UIStrings.get_text("interaction.prompt_prefix")).is_equal("E")
func test_get_text_missing_key_returns_key() -> void:
var result := UIStrings.get_text("nonexistent.key")
assert_that(result).is_equal("nonexistent.key")
func test_get_all_keys_not_empty() -> void:
assert_that(UIStrings.get_all_keys().size()).is_greater(0)
func test_has_key_true_for_existing() -> void:
assert_that(UIStrings.has_key("interaction.talk")).is_true()
func test_has_key_false_for_missing() -> void:
assert_that(UIStrings.has_key("nonexistent.key")).is_false()
+3 -3
View File
@@ -13,12 +13,12 @@ func _ready() -> void:
func update_from_hud_data(data: Dictionary) -> void:
# Update perception mode display
if data.has("perception_mode"):
perception_label.text = "Mode: " + data.perception_mode.capitalize()
perception_label.text = UIStrings.get_text("hud.mode_label") + ": " + data.perception_mode.capitalize()
# Update time display (per D-031)
if data.has("time"):
time_label.text = "Time: " + data.time
time_label.text = UIStrings.get_text("hud.time_label") + ": " + data.time
# Update player health (from player data, not hud_data)
func update_health(health: int) -> void:
health_label.text = "Health: " + str(health)
health_label.text = UIStrings.get_text("hud.health_label") + ": " + str(health)
+1 -1
View File
@@ -40,7 +40,7 @@ func _show_prompt(interaction: Dictionary) -> void:
# v0.1: pick first verb (sorted by priority from server)
var verb_label: String = verbs[0].get("label", "")
var display_text: String = "E - %s" % verb_label
var display_text: String = "%s - %s" % [UIStrings.get_text("interaction.prompt_prefix"), verb_label]
if _current_target_id != target_id or prompt_label.text != display_text:
prompt_label.text = display_text