diff --git a/client/data/ui-strings.yaml b/client/data/ui-strings.yaml new file mode 100644 index 000000000..07a3f52b3 --- /dev/null +++ b/client/data/ui-strings.yaml @@ -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." diff --git a/client/project.godot b/client/project.godot index c60e68590..f72f90543 100644 --- a/client/project.godot +++ b/client/project.godot @@ -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] diff --git a/client/scripts/autoloads/ui_strings.gd b/client/scripts/autoloads/ui_strings.gd new file mode 100644 index 000000000..7c790909f --- /dev/null +++ b/client/scripts/autoloads/ui_strings.gd @@ -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 diff --git a/client/tests/test_ui_strings.gd b/client/tests/test_ui_strings.gd new file mode 100644 index 000000000..9b7bea73c --- /dev/null +++ b/client/tests/test_ui_strings.gd @@ -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() diff --git a/client/ui/hud.gd b/client/ui/hud.gd index eb59d387f..ea4d1590b 100644 --- a/client/ui/hud.gd +++ b/client/ui/hud.gd @@ -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) diff --git a/client/ui/interaction_prompt.gd b/client/ui/interaction_prompt.gd index dfa569d0c..9975c4109 100644 --- a/client/ui/interaction_prompt.gd +++ b/client/ui/interaction_prompt.gd @@ -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