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