diff --git a/client/scenes/main.tscn b/client/scenes/main.tscn index 6073bd572..ef47cef8b 100644 --- a/client/scenes/main.tscn +++ b/client/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=19 format=3 uid="uid://bswrmh7w8dbgm"] +[gd_scene load_steps=20 format=3 uid="uid://bswrmh7w8dbgm"] [ext_resource type="Script" path="res://scripts/main.gd" id="1_main"] [ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"] @@ -17,7 +17,8 @@ [ext_resource type="PackedScene" path="res://ui/dialogue_box.tscn" id="15_dialogue"] [ext_resource type="Script" path="res://scripts/rendering/fog_entities.gd" id="16_fogent"] [ext_resource type="PackedScene" path="res://ui/gauntlet_hud.tscn" id="17_gauntlet"] -[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="18_bugreport"] +[ext_resource type="PackedScene" path="res://ui/checklist_overlay.tscn" id="18_checklist"] +[ext_resource type="PackedScene" path="res://ui/bug_report_dialog.tscn" id="19_bugreport"] [node name="Game" type="Node2D"] script = ExtResource("1_main") @@ -136,6 +137,9 @@ layer = 20 ; #496: Gauntlet HUD — room timer + personal bests, hidden in non-gauntlet mode [node name="GauntletHUD" parent="UILayer" instance=ExtResource("17_gauntlet")] +; #503: Auto-checklist overlay — condition progress in gauntlet mode +[node name="ChecklistOverlay" parent="UILayer" instance=ExtResource("18_checklist")] + ; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys [node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")] @@ -149,4 +153,4 @@ script = ExtResource("10_cursor") layer = 30 ; #495: WRONG button (F12) — bug report capture dialog -[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("18_bugreport")] +[node name="BugReportDialog" parent="ModalLayer" instance=ExtResource("19_bugreport")] diff --git a/client/scripts/checklist/checklist_evaluator.gd b/client/scripts/checklist/checklist_evaluator.gd new file mode 100644 index 000000000..0d517ace9 --- /dev/null +++ b/client/scripts/checklist/checklist_evaluator.gd @@ -0,0 +1,326 @@ +extends RefCounted + +## #503: Auto-checklist progress tracking — evaluates ObserverSnapshot against +## checklist YAML conditions and latches satisfied conditions. +## +## Usage: +## var evaluator := ChecklistEvaluator.new() +## evaluator.load_room("inventory_warehouse") +## evaluator.evaluate() # call each tick +## var results := evaluator.get_results() +## +## Condition types (per checklist.schema.json): +## player_near, player_facing, entity_present, entity_absent, +## expected_monologue, expected_dialogue, expected_interaction_verb +## +## Spec ref: D-030 (testability), checklist.schema.json (#497). + +var _room_conditions: Array = [] # Conditions from per-room checklist +var _cross_conditions: Array = [] # Conditions from cross_room_checks.yaml +var _latched: Dictionary = {} # condition_id -> true (once met, stays met) +var _current_room_id: String = "" +var _content_base: String = "" # Absolute path to content/ directory +var _loaded: bool = false + + +func _init() -> void: + var project_path := ProjectSettings.globalize_path("res://") + _content_base = project_path.path_join("../content") + + +## Load checklist for a room. Clears per-room latches; cross-room latches persist. +func load_room(room_id: String) -> void: + if room_id == _current_room_id and _loaded: + return + + _current_room_id = room_id + _room_conditions.clear() + + # Clear per-room latches (keep cross-room latches) + var cross_ids := {} + for cond in _cross_conditions: + cross_ids[cond.get("id", "")] = true + var kept := {} + for cid in _latched: + if cross_ids.has(cid): + kept[cid] = true + _latched = kept + + # Load per-room checklist + var room_path := _content_base.path_join( + "gauntlet/rooms/%s/checklist.yaml" % room_id) + var room_data := _load_checklist_file(room_path) + if room_data.has("conditions"): + _room_conditions = room_data["conditions"] + + # Load cross-room checks (only on first load) + if _cross_conditions.is_empty(): + var cross_path := _content_base.path_join("gauntlet/cross_room_checks.yaml") + var cross_data := _load_checklist_file(cross_path) + if cross_data.has("conditions"): + _cross_conditions = cross_data["conditions"] + + _loaded = true + + +## Evaluate all conditions against current GameState. Latches newly met conditions. +func evaluate() -> void: + for cond in _room_conditions + _cross_conditions: + var cid: String = cond.get("id", "") + if cid.is_empty() or _latched.has(cid): + continue + if _evaluate_condition(cond): + _latched[cid] = true + + +## Returns array of {id, description, met} for all loaded conditions. +func get_results() -> Array: + var results: Array = [] + for cond in _room_conditions + _cross_conditions: + var cid: String = cond.get("id", "") + results.append({ + "id": cid, + "description": cond.get("description", ""), + "condition_type": cond.get("condition_type", ""), + "met": _latched.has(cid), + }) + return results + + +## Total number of loaded conditions. +func get_total_count() -> int: + return _room_conditions.size() + _cross_conditions.size() + + +## Number of latched (met) conditions. +func get_met_count() -> int: + return _latched.size() + + +## Whether all conditions are met. +func is_complete() -> bool: + return get_met_count() >= get_total_count() and get_total_count() > 0 + + +## Whether any checklist is loaded. +func is_loaded() -> bool: + return _loaded + + +## Reset all state (room change to null, or disconnect). +func reset() -> void: + _room_conditions.clear() + _cross_conditions.clear() + _latched.clear() + _current_room_id = "" + _loaded = false + + +# -- Condition evaluation ------------------------------------------------------ + +func _evaluate_condition(cond: Dictionary) -> bool: + match cond.get("condition_type", ""): + "player_near": + return _eval_player_near(cond) + "player_facing": + return _eval_player_facing(cond) + "entity_present": + return _eval_entity_present(cond) + "entity_absent": + return _eval_entity_absent(cond) + "expected_monologue": + return _eval_expected_monologue(cond) + "expected_dialogue": + return _eval_expected_dialogue(cond) + "expected_interaction_verb": + return _eval_expected_interaction_verb(cond) + push_warning("ChecklistEvaluator: unknown condition_type '%s'" % cond.get("condition_type", "")) + return false + + +func _eval_player_near(cond: Dictionary) -> bool: + var tx: float = float(cond.get("x", 0)) + var ty: float = float(cond.get("y", 0)) + var radius: float = float(cond.get("radius", 0.0)) + var target := Vector2(tx, ty) + return GameState.player_position.distance_to(target) <= radius + + +func _eval_player_facing(cond: Dictionary) -> bool: + var direction: String = str(cond.get("direction", "")) + # Schema uses 4-cardinal (North/South/East/West). + # GameState uses 8-directional. Exact match only. + return GameState.player_facing == direction + + +func _eval_entity_present(cond: Dictionary) -> bool: + var entity_id: int = int(cond.get("entity_id", -1)) + return _find_entity(entity_id) + + +func _eval_entity_absent(cond: Dictionary) -> bool: + var entity_id: int = int(cond.get("entity_id", -1)) + return not _find_entity(entity_id) + + +func _eval_expected_monologue(cond: Dictionary) -> bool: + var contains: String = str(cond.get("contains", "")) + if GameState.current_monologue == null: + return false + var text: String = str(GameState.current_monologue.get("text", "")) + return text.find(contains) >= 0 + + +func _eval_expected_dialogue(cond: Dictionary) -> bool: + var contains: String = str(cond.get("contains", "")) + if GameState.current_dialogue == null: + return false + var text: String = str(GameState.current_dialogue.get("speech", "")) + return text.find(contains) >= 0 + + +func _eval_expected_interaction_verb(cond: Dictionary) -> bool: + var entity_id: int = int(cond.get("entity_id", -1)) + var verb: String = str(cond.get("verb", "")) + for interaction in GameState.nearby_interactions: + if not interaction is Dictionary: + continue + if int(interaction.get("entity_id", -1)) != entity_id: + continue + var verbs: Array = interaction.get("verbs", []) + for v in verbs: + if not v is Dictionary: + continue + if str(v.get("label", "")) == verb or str(v.get("kind", "")) == verb: + if v.get("available", true): + return true + return false + + +# -- Helpers ------------------------------------------------------------------- + +func _find_entity(entity_id: int) -> bool: + for entity in GameState.visible_entities: + if not entity is Dictionary: + continue + if int(entity.get("entity_id", -1)) == entity_id: + return true + return false + + +# -- YAML parsing (checklist-specific) ----------------------------------------- +# Handles the constrained checklist YAML format: top-level key:value pairs, +# a conditions array of flat dictionaries. No nested arrays or anchors. + +func _load_checklist_file(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + push_warning("ChecklistEvaluator: cannot open %s" % path) + return {} + var text := file.get_as_text() + file.close() + return parse_checklist_yaml(text) + + +static func parse_checklist_yaml(text: String) -> Dictionary: + var result := {} + var conditions: Array = [] + var current_item: Dictionary = {} + var in_conditions := false + + for line in text.split("\n"): + var stripped := line.strip_edges(false, true) + if stripped.is_empty() or stripped.strip_edges().begins_with("#"): + continue + + var indent := line.length() - line.lstrip(" ").length() + var content := stripped.strip_edges() + + # Detect conditions: array header + if content == "conditions:": + in_conditions = true + continue + + if not in_conditions: + # Top-level key: value + var colon := content.find(":") + if colon >= 0: + var key := content.substr(0, colon).strip_edges() + var val_str := content.substr(colon + 1).strip_edges() + result[key] = _parse_value(val_str) + else: + if content.begins_with("- "): + # New array item — flush previous + if not current_item.is_empty(): + conditions.append(current_item) + current_item = {} + var rest := content.substr(2).strip_edges() + var colon := rest.find(":") + if colon >= 0: + var key := rest.substr(0, colon).strip_edges() + var val_str := rest.substr(colon + 1).strip_edges() + current_item[key] = _parse_value(val_str) + elif indent >= 2 and not current_item.is_empty(): + # Continuation of current array item + var colon := content.find(":") + if colon >= 0: + var key := content.substr(0, colon).strip_edges() + var val_str := content.substr(colon + 1).strip_edges() + current_item[key] = _parse_value(val_str) + elif indent == 0: + # Back to top level — shouldn't happen in valid checklist YAML + in_conditions = false + if not current_item.is_empty(): + conditions.append(current_item) + current_item = {} + var colon := content.find(":") + if colon >= 0: + var key := content.substr(0, colon).strip_edges() + var val_str := content.substr(colon + 1).strip_edges() + result[key] = _parse_value(val_str) + + # Flush last item + if not current_item.is_empty(): + conditions.append(current_item) + + if not conditions.is_empty(): + result["conditions"] = conditions + + return result + + +static func _parse_value(val: String) -> Variant: + if val.is_empty(): + return "" + + # Strip inline comments (not inside quotes) + if not val.begins_with("\""): + var comment_pos := val.find(" #") + if comment_pos >= 0: + val = val.substr(0, comment_pos).strip_edges() + + # Quoted string + if val.begins_with("\""): + var end_quote := val.find("\"", 1) + if end_quote > 0: + return val.substr(1, end_quote - 1) + return val.substr(1) + + # Boolean + if val == "true": + return true + if val == "false": + return false + + # Float (contains decimal point) + if val.contains(".") and val.is_valid_float(): + return val.to_float() + + # Integer + if val.is_valid_int(): + return val.to_int() + + # Plain string + return val diff --git a/client/scripts/main.gd b/client/scripts/main.gd index e92f30849..85344e8d4 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -13,12 +13,14 @@ extends Node2D @onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7 @onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7 @onready var gauntlet_hud = $UILayer/GauntletHUD # #496: room timer + personal bests +@onready var checklist_overlay = $UILayer/ChecklistOverlay # #503: auto-checklist progress @onready var bug_report_dialog = $ModalLayer/BugReportDialog # #495: F12 WRONG button var _last_dialogue_npc_id: int = -1 # D-064: NPC entity_id for WalkAway input var _camera_anchored: bool = false var _last_monologue_tick: int = -1 # Prevent re-consuming monologue when same tick polled twice var _last_dialogue_tick: int = -1 +var _flash_rect: ColorRect = null # #502: ephemeral screen flash overlay func _ready() -> void: print("The Settled Reach — client initialized") @@ -98,6 +100,10 @@ func _process(_delta: float) -> void: if gauntlet_hud and gauntlet_hud.has_method("update_from_state"): gauntlet_hud.update_from_state() + # #503: Update checklist overlay (auto-checklist progress tracking) + if checklist_overlay and checklist_overlay.has_method("update_from_state"): + checklist_overlay.update_from_state() + # Show monologue if server sent one this tick (#414) _consume_monologue() @@ -159,6 +165,10 @@ func _consume_monologue() -> void: _last_monologue_tick = GameState.current_tick var mono: Dictionary = GameState.current_monologue monologue_display.show_monologue(mono.get("text", ""), mono.get("duration_seconds", 5.0)) + # #502: Amber flash on room reset + var mono_id: String = mono.get("id", "") + if mono_id.begins_with("room_reset"): + _screen_flash(Constants.ENTITY_COLOR_POI, 0.15) GameState.current_monologue = null @@ -218,3 +228,18 @@ func _on_dialogue_dismissed() -> void: func _on_connection_state_changed(old_state: SimBridge.ConnectionState, new_state: SimBridge.ConnectionState) -> void: if new_state == SimBridge.ConnectionState.DISCONNECTED and gauntlet_hud: gauntlet_hud.finalize() + + +# #502: Full-screen color flash — fades from color to transparent over duration. +# Used for room reset amber flash. Creates ephemeral ColorRect on UILayer. +func _screen_flash(color: Color, duration: float) -> void: + if _flash_rect and is_instance_valid(_flash_rect): + _flash_rect.queue_free() + _flash_rect = ColorRect.new() + _flash_rect.color = Color(color.r, color.g, color.b, 0.4) + _flash_rect.anchors_preset = Control.PRESET_FULL_RECT + _flash_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE + $UILayer.add_child(_flash_rect) + var tween := create_tween() + tween.tween_property(_flash_rect, "color:a", 0.0, duration) + tween.tween_callback(_flash_rect.queue_free) diff --git a/client/scripts/rendering/tile_renderer.gd b/client/scripts/rendering/tile_renderer.gd index 6e2f1f229..94a8453fd 100644 --- a/client/scripts/rendering/tile_renderer.gd +++ b/client/scripts/rendering/tile_renderer.gd @@ -5,14 +5,15 @@ extends TileMapLayer # Uses a programmatic TileSet with placeholder colored rectangles (D-014) # # Tile types (atlas coords in the programmatic source): -# (0,0) = floor — dark gray -# (1,0) = wall — lighter gray -# (2,0) = door — brown -# (3,0) = object — teal +# (0,0) = floor — dark gray +# (1,0) = wall — lighter gray +# (2,0) = door — brown +# (3,0) = object — teal +# (4,0) = reset_plate — amber (#502) const TILE_SIZE: int = Constants.TILE_SIZE -enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 } +enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3, RESET_PLATE = 4 } # Wire-format string to TileType mapping const TILE_TYPE_MAP: Dictionary = { @@ -20,6 +21,7 @@ const TILE_TYPE_MAP: Dictionary = { "wall": TileType.WALL, "door": TileType.DOOR, "object": TileType.OBJECT, + "reset_plate": TileType.RESET_PLATE, } var _initialized: bool = false @@ -36,7 +38,7 @@ func _setup_tileset() -> void: # Create an atlas source backed by a programmatic image var source := TileSetAtlasSource.new() - var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8) + var img := Image.create(TILE_SIZE * 5, TILE_SIZE, false, Image.FORMAT_RGBA8) # Floor (0,0) — dark gray _fill_tile(img, 0, Color(0.18, 0.18, 0.22)) @@ -46,13 +48,15 @@ func _setup_tileset() -> void: _fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15)) # Object (3,0) — teal _fill_tile(img, 3, Color(0.2, 0.45, 0.45)) + # Reset plate (4,0) — amber (#502) + _fill_tile_with_border(img, 4, Color(0.91, 0.77, 0.28), Color(0.65, 0.55, 0.2)) var tex := ImageTexture.create_from_image(img) source.texture = tex source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE) # Create tile entries in the atlas - for i in range(4): + for i in range(5): source.create_tile(Vector2i(i, 0)) var source_id := ts.add_source(source) diff --git a/client/tests/test_checklist.gd b/client/tests/test_checklist.gd new file mode 100644 index 000000000..c6c689e07 --- /dev/null +++ b/client/tests/test_checklist.gd @@ -0,0 +1,692 @@ +## #503: Auto-checklist progress tracking — unit + integration tests. +## +## Tests cover: +## 1. YAML parser: basic types, conditions array, edge cases +## 2. Condition evaluation: all 7 condition types +## 3. Latching: conditions stay met once satisfied +## 4. Room change: per-room conditions reset, cross-room conditions persist +## 5. Overlay: visibility gating on gauntlet_mode +## 6. Integration: snapshot -> GameState -> evaluator -> overlay +## +## Spec ref: D-030 (testability), checklist.schema.json (#497), Sprint 10 Completion Proof. +class_name TestChecklist +extends GdUnitTestSuite + + +var ChecklistOverlayScript = load("res://ui/checklist_overlay.gd") +var ChecklistEvaluatorScript = load("res://scripts/checklist/checklist_evaluator.gd") + + +func before_test() -> void: + SimBridge.reset_test_state() + SimBridge._last_snapshot = null + GameState.current_tick = 0 + GameState.player_position = Vector2.ZERO + GameState.visible_entities = [] + GameState.visible_tiles = [] + GameState.visible_positions = {} + GameState.current_monologue = null + GameState.current_dialogue = null + GameState.nearby_interactions = [] + GameState.game_time = {} + GameState.pending_recognitions = [] + GameState.room_id = null + GameState.gauntlet_mode = false + GameState.player_facing = "North" + GameState.player_stance = "Walk" + GameState.player_inventory = [] + + +# -- YAML Parser Tests --------------------------------------------------------- + +func test_parse_empty_yaml() -> void: + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml("") + assert_that(result.size()).is_equal(0) + + +func test_parse_top_level_string() -> void: + var yaml := "room_id: inventory_warehouse" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("room_id")).is_equal("inventory_warehouse") + + +func test_parse_top_level_quoted_string() -> void: + var yaml := 'description: "Tests D-065 (9-slot inventory)."' + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("description")).is_equal("Tests D-065 (9-slot inventory).") + + +func test_parse_single_condition() -> void: + var yaml := "conditions:\n - id: test-1\n description: \"Test condition\"\n condition_type: player_near\n x: 10\n y: 20\n radius: 3.0" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.has("conditions")).is_true() + var conditions: Array = result["conditions"] + assert_that(conditions.size()).is_equal(1) + assert_that(conditions[0]["id"]).is_equal("test-1") + assert_that(conditions[0]["condition_type"]).is_equal("player_near") + assert_that(conditions[0]["x"]).is_equal(10) + assert_that(conditions[0]["y"]).is_equal(20) + assert_that(conditions[0]["radius"]).is_equal_approx(3.0, 0.001) + + +func test_parse_multiple_conditions() -> void: + var yaml := "conditions:\n - id: cond-a\n condition_type: player_near\n x: 1\n y: 2\n radius: 1.0\n\n - id: cond-b\n condition_type: player_facing\n direction: East" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + var conditions: Array = result["conditions"] + assert_that(conditions.size()).is_equal(2) + assert_that(conditions[0]["id"]).is_equal("cond-a") + assert_that(conditions[1]["id"]).is_equal("cond-b") + assert_that(conditions[1]["direction"]).is_equal("East") + + +func test_parse_comments_ignored() -> void: + var yaml := "# This is a comment\nroom_id: test\n# Another comment\nconditions:\n - id: c1\n condition_type: entity_present\n entity_id: 5" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("room_id")).is_equal("test") + var conditions: Array = result["conditions"] + assert_that(conditions.size()).is_equal(1) + assert_that(conditions[0]["entity_id"]).is_equal(5) + + +func test_parse_integer_and_float_values() -> void: + var yaml := "conditions:\n - id: t\n condition_type: player_near\n x: 42\n y: -3\n radius: 2.5" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + var cond: Dictionary = result["conditions"][0] + assert_that(cond["x"]).is_equal(42) + assert_that(typeof(cond["radius"])).is_equal(TYPE_FLOAT) + + +func test_parse_scope_field() -> void: + var yaml := "scope: cross_room\nconditions:\n - id: cr-1\n condition_type: entity_present\n entity_id: 0" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("scope")).is_equal("cross_room") + + +func test_parse_inline_comment_stripped() -> void: + var yaml := "room_id: test # this is a comment" + var result: Dictionary = ChecklistEvaluatorScript.parse_checklist_yaml(yaml) + assert_that(result.get("room_id")).is_equal("test") + + +# -- Condition Evaluation Tests ------------------------------------------------ + +func _make_evaluator(conditions: Array): + var evaluator = ChecklistEvaluatorScript.new() + evaluator._room_conditions = conditions + evaluator._loaded = true + return evaluator + + +func test_eval_player_near_within_radius() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "near-1", "condition_type": "player_near", + "x": 10, "y": 21, "radius": 2.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near: player at (10,20), target (10,21), radius 2.0 — should be met" + ).is_equal(1) + + +func test_eval_player_near_outside_radius() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "near-2", "condition_type": "player_near", + "x": 10, "y": 30, "radius": 2.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near: player at (10,20), target (10,30), radius 2.0 — should NOT be met" + ).is_equal(0) + + +func test_eval_player_near_exact_boundary() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "near-3", "condition_type": "player_near", + "x": 10, "y": 22, "radius": 2.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near: distance exactly equals radius — should be met (<=)" + ).is_equal(1) + + +func test_eval_player_facing_match() -> void: + GameState.player_facing = "East" + var evaluator = _make_evaluator([{ + "id": "face-1", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_player_facing_no_match() -> void: + GameState.player_facing = "North" + var evaluator = _make_evaluator([{ + "id": "face-2", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_player_facing_diagonal_no_match() -> void: + # 8-directional facing "Northeast" should NOT match "East" or "North" + GameState.player_facing = "Northeast" + var evaluator = _make_evaluator([{ + "id": "face-diag", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_facing: Northeast should NOT match East (exact match only)" + ).is_equal(0) + + +func test_eval_entity_present_found() -> void: + GameState.visible_entities = [ + {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + {"entity_id": 10, "x": 2.0, "y": 2.0, "z": 0, "kind": "Object"}, + ] + var evaluator = _make_evaluator([{ + "id": "present-1", "condition_type": "entity_present", + "entity_id": 10, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_entity_present_not_found() -> void: + GameState.visible_entities = [ + {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + ] + var evaluator = _make_evaluator([{ + "id": "present-2", "condition_type": "entity_present", + "entity_id": 99, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_entity_absent_when_not_visible() -> void: + GameState.visible_entities = [ + {"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + ] + var evaluator = _make_evaluator([{ + "id": "absent-1", "condition_type": "entity_absent", + "entity_id": 99, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "entity_absent: entity 99 not in visible_entities — should be met" + ).is_equal(1) + + +func test_eval_entity_absent_when_visible() -> void: + GameState.visible_entities = [ + {"entity_id": 10, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}, + ] + var evaluator = _make_evaluator([{ + "id": "absent-2", "condition_type": "entity_absent", + "entity_id": 10, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "entity_absent: entity 10 IS visible — should NOT be met" + ).is_equal(0) + + +func test_eval_expected_monologue_match() -> void: + GameState.current_monologue = {"id": "m1", "text": "Something is wrong here.", "duration_seconds": 5.0} + var evaluator = _make_evaluator([{ + "id": "mono-1", "condition_type": "expected_monologue", + "contains": "wrong here", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_expected_monologue_no_match() -> void: + GameState.current_monologue = {"id": "m1", "text": "All clear.", "duration_seconds": 5.0} + var evaluator = _make_evaluator([{ + "id": "mono-2", "condition_type": "expected_monologue", + "contains": "wrong here", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_expected_monologue_null() -> void: + GameState.current_monologue = null + var evaluator = _make_evaluator([{ + "id": "mono-3", "condition_type": "expected_monologue", + "contains": "test", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_monologue: null monologue should not match" + ).is_equal(0) + + +func test_eval_expected_dialogue_match() -> void: + GameState.current_dialogue = {"npc_name": "Kael", "speech": "Who are you?", "options": []} + var evaluator = _make_evaluator([{ + "id": "dlg-1", "condition_type": "expected_dialogue", + "contains": "Who are you", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_expected_dialogue_no_match() -> void: + GameState.current_dialogue = {"npc_name": "Kael", "speech": "Hello.", "options": []} + var evaluator = _make_evaluator([{ + "id": "dlg-2", "condition_type": "expected_dialogue", + "contains": "Goodbye", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_eval_expected_dialogue_null() -> void: + GameState.current_dialogue = null + var evaluator = _make_evaluator([{ + "id": "dlg-3", "condition_type": "expected_dialogue", + "contains": "test", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_dialogue: null dialogue should not match" + ).is_equal(0) + + +func test_eval_interaction_verb_match() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [ + {"kind": "Take", "label": "Pickup", "priority": 1, "available": true}, + {"kind": "Observe", "label": "Examine", "priority": 2, "available": true}, + ], + }] + var evaluator = _make_evaluator([{ + "id": "verb-1", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + +func test_eval_interaction_verb_by_kind() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [ + {"kind": "Take", "label": "Pickup", "priority": 1, "available": true}, + ], + }] + var evaluator = _make_evaluator([{ + "id": "verb-kind", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Take", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_interaction_verb: should match by kind='Take' as well as label" + ).is_equal(1) + + +func test_eval_interaction_verb_wrong_entity() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": true}], + }] + var evaluator = _make_evaluator([{ + "id": "verb-wrong", "condition_type": "expected_interaction_verb", + "entity_id": 99, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_interaction_verb: wrong entity_id should not match" + ).is_equal(0) + + +func test_eval_interaction_verb_unavailable() -> void: + GameState.nearby_interactions = [{ + "entity_id": 13, + "entity_type": "Object", + "distance": 1, + "verbs": [{"kind": "Take", "label": "Pickup", "priority": 1, "available": false}], + }] + var evaluator = _make_evaluator([{ + "id": "verb-unavail", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "expected_interaction_verb: unavailable verb should not match" + ).is_equal(0) + + +func test_eval_interaction_verb_no_interactions() -> void: + GameState.nearby_interactions = [] + var evaluator = _make_evaluator([{ + "id": "verb-none", "condition_type": "expected_interaction_verb", + "entity_id": 13, "verb": "Pickup", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +# -- Latching Tests ------------------------------------------------------------ + +func test_latching_condition_stays_met() -> void: + # Condition met on first evaluate, stays met even when state changes. + GameState.player_facing = "East" + var evaluator = _make_evaluator([{ + "id": "latch-1", "condition_type": "player_facing", + "direction": "East", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + # Change state so condition would be false if re-evaluated fresh + GameState.player_facing = "North" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "Latched condition should stay met even after state changes" + ).is_equal(1) + + +func test_latching_monologue_transient() -> void: + # Monologue appears for one tick, then disappears. Condition should latch. + var evaluator = _make_evaluator([{ + "id": "mono-latch", "condition_type": "expected_monologue", + "contains": "recalibrated", + }]) + + # Tick 1: no monologue + GameState.current_monologue = null + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + # Tick 2: monologue fires + GameState.current_monologue = {"id": "m1", "text": "Systems recalibrated.", "duration_seconds": 3.0} + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + # Tick 3: monologue consumed (null) + GameState.current_monologue = null + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "Monologue condition should stay latched after monologue disappears" + ).is_equal(1) + + +func test_multiple_conditions_partial_latching() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "condition_type": "player_facing", "direction": "East"}, + {"id": "c2", "condition_type": "entity_present", "entity_id": 5}, + {"id": "c3", "condition_type": "player_near", "x": 50, "y": 50, "radius": 1.0}, + ]) + + # Tick 1: only facing matches + GameState.player_facing = "East" + GameState.visible_entities = [] + GameState.player_position = Vector2(0, 0) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + assert_that(evaluator.get_total_count()).is_equal(3) + + # Tick 2: entity also visible + GameState.visible_entities = [{"entity_id": 5, "x": 1.0, "y": 1.0, "z": 0, "kind": "Npc"}] + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(2) + + # Tick 3: player moves to target + GameState.player_position = Vector2(50.0, 50.0) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(3) + assert_that(evaluator.is_complete()).is_true() + + +# -- Room Change Tests --------------------------------------------------------- + +func test_room_change_resets_per_room_conditions() -> void: + var evaluator = ChecklistEvaluatorScript.new() + # Manually set conditions to avoid file loading + evaluator._room_conditions = [ + {"id": "r1-c1", "condition_type": "player_facing", "direction": "East"}, + ] + evaluator._loaded = true + GameState.player_facing = "East" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + + # Simulate room change by loading a new "room" + evaluator._current_room_id = "old_room" + evaluator._room_conditions = [ + {"id": "r2-c1", "condition_type": "player_facing", "direction": "North"}, + ] + # Clear latches for the new room (simulating load_room behavior) + evaluator._latched.clear() + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "After room change, old latches should be cleared; new condition not met" + ).is_equal(0) + + +func test_reset_clears_all_state() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "condition_type": "player_facing", "direction": "East"}, + ]) + GameState.player_facing = "East" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(1) + evaluator.reset() + assert_that(evaluator.is_loaded()).is_false() + assert_that(evaluator.get_met_count()).is_equal(0) + assert_that(evaluator.get_total_count()).is_equal(0) + + +# -- get_results Tests --------------------------------------------------------- + +func test_get_results_structure() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "description": "Test condition", "condition_type": "player_facing", "direction": "North"}, + ]) + GameState.player_facing = "North" + evaluator.evaluate() + var results: Array = evaluator.get_results() + assert_that(results.size()).is_equal(1) + assert_that(results[0]["id"]).is_equal("c1") + assert_that(results[0]["description"]).is_equal("Test condition") + assert_that(results[0]["condition_type"]).is_equal("player_facing") + assert_that(results[0]["met"]).is_true() + + +func test_get_results_unmet() -> void: + var evaluator = _make_evaluator([ + {"id": "c1", "description": "Test", "condition_type": "player_facing", "direction": "South"}, + ]) + GameState.player_facing = "North" + evaluator.evaluate() + var results: Array = evaluator.get_results() + assert_that(results[0]["met"]).is_false() + + +# -- Overlay Visibility Tests -------------------------------------------------- + +func _make_checklist_overlay() -> Control: + var overlay = Control.new() + overlay.set_script(ChecklistOverlayScript) + auto_free(overlay) + add_child(overlay) + return overlay + + +func test_overlay_hidden_in_non_gauntlet_mode() -> void: + var overlay := _make_checklist_overlay() + GameState.gauntlet_mode = false + overlay.update_from_state() + assert_that(overlay.visible).override_failure_message( + "Checklist overlay must be hidden in non-gauntlet mode" + ).is_false() + + +func test_overlay_visible_in_gauntlet_mode() -> void: + var overlay := _make_checklist_overlay() + GameState.gauntlet_mode = true + GameState.room_id = "test_room" + overlay.update_from_state() + assert_that(overlay.visible).override_failure_message( + "Checklist overlay must be visible in gauntlet mode" + ).is_true() + + +func test_overlay_hides_on_gauntlet_deactivation() -> void: + var overlay := _make_checklist_overlay() + GameState.gauntlet_mode = true + GameState.room_id = "test_room" + overlay.update_from_state() + assert_that(overlay.visible).is_true() + GameState.gauntlet_mode = false + overlay.update_from_state() + assert_that(overlay.visible).override_failure_message( + "Overlay must hide when gauntlet mode deactivates" + ).is_false() + + +func test_overlay_evaluator_accessible() -> void: + var overlay := _make_checklist_overlay() + var evaluator = overlay.get_evaluator() + assert_that(evaluator).override_failure_message( + "Overlay should expose evaluator via get_evaluator()" + ).is_not_null() + + +func test_overlay_in_main_scene() -> void: + var scene: PackedScene = load("res://scenes/main.tscn") + var instance: Node = scene.instantiate() + auto_free(instance) + add_child(instance) + instance._process(0.016) + + var overlay: Node = _find_node_recursive(instance, "ChecklistOverlay") + assert_that(overlay).override_failure_message( + "ChecklistOverlay node should exist in main scene tree" + ).is_not_null() + if overlay is CanvasItem: + assert_that((overlay as CanvasItem).visible).override_failure_message( + "ChecklistOverlay should be hidden by default (non-gauntlet mode)" + ).is_false() + + +# -- Integration: Snapshot -> Evaluation ---------------------------------------- + +func test_integration_snapshot_to_evaluator() -> void: + # Integration test: GameState snapshot data -> evaluator -> correct results. + # Tests the evaluator directly (overlay wiring tested separately). + var evaluator = ChecklistEvaluatorScript.new() + evaluator._room_conditions = [ + {"id": "int-1", "description": "Player entity present", "condition_type": "entity_present", "entity_id": 1}, + {"id": "int-2", "description": "Player faces East", "condition_type": "player_facing", "direction": "East"}, + ] + evaluator._loaded = true + + # Simulate gauntlet snapshot with player entity + GameState.visible_entities = [ + {"entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player"}, + ] + GameState.player_facing = "North" + + evaluator.evaluate() + + # Entity present should be met, facing should not + assert_that(evaluator.get_met_count()).is_equal(1) + assert_that(evaluator.get_total_count()).is_equal(2) + + # Change facing — second condition should also latch + GameState.player_facing = "East" + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(2) + assert_that(evaluator.is_complete()).is_true() + + # Verify results array contains both conditions as met + var results: Array = evaluator.get_results() + for r in results: + assert_that(r["met"]).override_failure_message( + "Condition '%s' should be met after snapshot sequence" % r["id"] + ).is_true() + + +# -- Edge Cases ---------------------------------------------------------------- + +func test_empty_entity_list_entity_present() -> void: + GameState.visible_entities = [] + var evaluator = _make_evaluator([{ + "id": "edge-empty", "condition_type": "entity_present", + "entity_id": 0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).is_equal(0) + + +func test_empty_entity_list_entity_absent() -> void: + GameState.visible_entities = [] + var evaluator = _make_evaluator([{ + "id": "edge-absent-empty", "condition_type": "entity_absent", + "entity_id": 99, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "entity_absent with empty visible_entities should be met" + ).is_equal(1) + + +func test_zero_radius_player_near() -> void: + GameState.player_position = Vector2(10.0, 20.0) + var evaluator = _make_evaluator([{ + "id": "edge-zero-radius", "condition_type": "player_near", + "x": 10, "y": 20, "radius": 0.0, + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "player_near with radius 0 at exact position should be met" + ).is_equal(1) + + +func test_unknown_condition_type() -> void: + var evaluator = _make_evaluator([{ + "id": "edge-unknown", "condition_type": "nonexistent_type", + }]) + evaluator.evaluate() + assert_that(evaluator.get_met_count()).override_failure_message( + "Unknown condition type should not be met" + ).is_equal(0) + + +func test_no_conditions_loaded() -> void: + var evaluator = ChecklistEvaluatorScript.new() + assert_that(evaluator.is_loaded()).is_false() + assert_that(evaluator.get_total_count()).is_equal(0) + assert_that(evaluator.get_met_count()).is_equal(0) + assert_that(evaluator.is_complete()).is_false() + + +# -- Helper: recursive node search -------------------------------------------- + +func _find_node_recursive(root: Node, target_name: String) -> Node: + if root.name == target_name: + return root + for child in root.get_children(): + var found := _find_node_recursive(child, target_name) + if found != null: + return found + return null diff --git a/client/ui/checklist_overlay.gd b/client/ui/checklist_overlay.gd new file mode 100644 index 000000000..7f7479a0b --- /dev/null +++ b/client/ui/checklist_overlay.gd @@ -0,0 +1,132 @@ +extends Control + +## #503: Auto-checklist HUD overlay — shows condition progress in Gauntlet mode. +## Renders below the GauntletHUD timer. Each condition shows a check/dash + description. +## Only visible in gauntlet_mode. Latched conditions stay checked. +## +## Spec ref: D-030 (testability), #503, Sprint 10 Completion Proof. + +const _ChecklistEvaluator = preload("res://scripts/checklist/checklist_evaluator.gd") + +const BG_COLOR := Color(0.05, 0.05, 0.08, 0.45) +const MET_COLOR := Color("#6bc9a6") # Friendly green — condition met +const UNMET_COLOR := Color("#8890a0") # Dim grey — condition pending +const HEADER_COLOR := Color("#c8d0e0") # Insert text color — header/summary +const COMPLETE_COLOR := Color("#e8c547") # Amber — all conditions met +const FONT_SIZE := 11 +const LINE_HEIGHT := 16 +const PADDING := Vector2(8, 6) +const MAX_DESC_CHARS := 52 # Truncate long descriptions + +var _evaluator = null # ChecklistEvaluator instance +var _last_room_id: Variant = null + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + _evaluator = _ChecklistEvaluator.new() + + +func update_from_state() -> void: + if not GameState.gauntlet_mode: + if visible: + visible = false + return + + if not visible: + visible = true + + var room_id: Variant = GameState.room_id + if room_id == null: + if _evaluator.is_loaded(): + _evaluator.reset() + _last_room_id = null + queue_redraw() + return + + # Load checklist on room change + if room_id != _last_room_id: + _evaluator.load_room(str(room_id)) + _last_room_id = room_id + + # Evaluate conditions against current snapshot + _evaluator.evaluate() + queue_redraw() + + +func _draw() -> void: + if _evaluator == null or not _evaluator.is_loaded(): + return + + var font := get_theme_default_font() + var results: Array = _evaluator.get_results() + if results.is_empty(): + return + + var met_count: int = _evaluator.get_met_count() + var total_count: int = _evaluator.get_total_count() + var all_complete: bool = _evaluator.is_complete() + + # Header line: "CHECK: 5/8" + var header_text := "CHECK: %d/%d" % [met_count, total_count] + var header_color: Color = COMPLETE_COLOR if all_complete else HEADER_COLOR + + # Calculate box height: header + one line per condition + padding + var line_count: int = 1 + results.size() + var box_height: float = PADDING.y * 2 + line_count * LINE_HEIGHT + + # Calculate box width from longest line + var max_width: float = font.get_string_size(header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x + for r in results: + var desc: String = r.get("description", r.get("id", "")) + if desc.length() > MAX_DESC_CHARS: + desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..." + var prefix: String = "[x] " if r.get("met", false) else "[ ] " + var line_width: float = font.get_string_size(prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE).x + if line_width > max_width: + max_width = line_width + + var box_width: float = max_width + PADDING.x * 2 + + # Background + draw_rect(Rect2(Vector2.ZERO, Vector2(box_width, box_height)), BG_COLOR) + + # Header + var y: float = PADDING.y + FONT_SIZE + draw_string(font, Vector2(PADDING.x, y), header_text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, header_color) + + # Condition lines + for r in results: + y += LINE_HEIGHT + var is_met: bool = r.get("met", false) + var prefix: String = "[x] " if is_met else "[ ] " + var desc: String = r.get("description", r.get("id", "")) + if desc.length() > MAX_DESC_CHARS: + desc = desc.substr(0, MAX_DESC_CHARS - 1) + "..." + var color: Color = MET_COLOR if is_met else UNMET_COLOR + draw_string(font, Vector2(PADDING.x, y), prefix + desc, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color) + + +# -- Public API --------------------------------------------------------------- + +func get_evaluator(): + return _evaluator + + +func get_met_count() -> int: + if _evaluator == null: + return 0 + return _evaluator.get_met_count() + + +func get_total_count() -> int: + if _evaluator == null: + return 0 + return _evaluator.get_total_count() + + +func is_complete() -> bool: + if _evaluator == null: + return false + return _evaluator.is_complete() diff --git a/client/ui/checklist_overlay.tscn b/client/ui/checklist_overlay.tscn new file mode 100644 index 000000000..d31e0534d --- /dev/null +++ b/client/ui/checklist_overlay.tscn @@ -0,0 +1,17 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ui/checklist_overlay.gd" id="1_checklist"] + +; #503: Auto-checklist overlay — below GauntletHUD timer, right-aligned +[node name="ChecklistOverlay" type="Control"] +layout_mode = 3 +anchors_preset = 1 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -420.0 +offset_top = 78.0 +offset_right = -16.0 +offset_bottom = 400.0 +grow_horizontal = 0 +mouse_filter = 2 +script = ExtResource("1_checklist") diff --git a/client/ui/world_radial.gd b/client/ui/world_radial.gd index 2f1057f09..7311e3289 100644 --- a/client/ui/world_radial.gd +++ b/client/ui/world_radial.gd @@ -120,24 +120,24 @@ func _confirm_selection() -> void: func _activate_insert() -> void: - # TODO(v7): replace PAUSE toggle with dedicated ToggleInsert action in protocol + # #518/D-058: Send PauseSimulation when insert opens. Idempotent — + # if already paused (e.g. Gauntlet interlude), server ignores duplicate. if not _insert_active: _insert_active = true - _send_pause() - - -func _send_pause() -> void: - SimBridge.send_input({ - "action": InputMapper.Action.PAUSE, - "timestamp_msec": Time.get_ticks_msec(), - }) + SimBridge.send_input({ + "action": InputMapper.Action.PAUSE, + "timestamp_msec": Time.get_ticks_msec(), + }) func deactivate_insert() -> void: - # Called when closing insert view — send Pause again (toggle) + # #518/D-058: Send ResumeSimulation when insert closes. if _insert_active: _insert_active = false - _send_pause() + SimBridge.send_input({ + "action": InputMapper.Action.UNPAUSE, + "timestamp_msec": Time.get_ticks_msec(), + }) func _draw() -> void: @@ -174,7 +174,7 @@ func _draw() -> void: # Label var label: String = SPOKE_NAMES.get(spoke, "") - var font := ThemeDB.fallback_font + var font := get_theme_default_font() var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11) var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0) draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)