Merge remote-tracking branch 'origin/client'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-18 18:35:15 +01:00
22 changed files with 1702 additions and 80 deletions
+7
View File
@@ -11,9 +11,16 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Scratchpad: asset generation pipeline idea (registry, status tracking, prompt versioning, pre-sprint cohesion)
- Scratchpad: remote terminal proxy idea for mobile monitoring of Claude Code permission prompts and interactive elements
- `make perf-baseline` — full plugin stack tick benchmark (50 measured ticks, 5 warmup) capturing per-tick timing, entity counts, process RSS, and shadowcast benchmarks; outputs structured JSON to `tests/perf/baseline.json` with `--compare` mode for regression detection (>20% threshold, D-026 budget check)
- Michroma font integration (#517) — Michroma-Regular.ttf as game font with +1px tracking FontVariation, global Theme with cyan-white (#E0F7FA) implant text color, IMPLANT_TEXT_COLOR/DIM/PULSE constants
- Mouse-relative facing and movement (#526, D-054) — mouse position determines facing direction (client-side float), WASD remapped to cursor-relative (W=toward, S=away, A/D=strafe), SET_FACING action sends octant to server, smooth facing indicator rotation
- Room reset client UX (#502) — amber reset_plate tile type, 0.15s screen flash on room reset, 'Reset Room' interaction verb
- Auto-checklist progress tracking (#503) — ChecklistEvaluator parses room YAML and evaluates 7 condition types against GameState with latching, ChecklistOverlay renders progress in gauntlet mode only, 48 new tests
### Changed
- `push-pr` skill now runs `/commit` first when uncommitted changes are detected
- Insert open/close now sends explicit PauseSimulation/ResumeSimulation (#518, D-058) — replaces toggle-style pause with idempotent pair
- Interaction list colors reference Constants.IMPLANT_TEXT_COLOR instead of hardcoded values
- World radial menu uses theme font instead of ThemeDB.fallback_font
### Fixed
- Bidirectional relationship check (#515) — Check 9 tested `target in npc_rels` which missed NPCs with no relationship entries; changed to `target in self.npcs`
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Michroma Project Authors (https://github.com/googlefonts/Michroma-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
[gd_resource type="FontVariation" load_steps=2 format=3]
[ext_resource type="FontFile" path="res://assets/fonts/Michroma-Regular.ttf" id="1_base"]
[resource]
base_font = ExtResource("1_base")
spacing_glyph = 1
spacing_space = 1
+20
View File
@@ -0,0 +1,20 @@
[gd_resource type="Theme" load_steps=2 format=3]
[ext_resource type="FontVariation" path="res://assets/fonts/michroma_ui.tres" id="1_font"]
[resource]
default_font = ExtResource("1_font")
default_font_size = 14
Label/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Label/font_sizes/font_size = 14
RichTextLabel/colors/default_color = Color(0.878, 0.969, 0.98, 1)
RichTextLabel/font_sizes/normal_font_size = 14
Button/colors/font_color = Color(0.878, 0.969, 0.98, 1)
Button/colors/font_hover_color = Color(0.91, 0.773, 0.278, 1)
Button/font_sizes/font_size = 14
LineEdit/colors/font_color = Color(0.878, 0.969, 0.98, 1)
LineEdit/font_sizes/font_size = 14
+5
View File
@@ -24,6 +24,11 @@ UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
AudioManager="*res://scripts/autoloads/audio_manager.gd"
[gui]
theme/custom="res://assets/theme/game_theme.tres"
theme/custom_font="res://assets/fonts/michroma_ui.tres"
[display]
window/size/viewport_width=1920
+7 -3
View File
@@ -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")]
+119 -12
View File
@@ -3,7 +3,11 @@ extends Node
# Semantic actions — NO raw key codes cross the bridge
# Movement uses hold-to-move (polled each frame in _process).
# Discrete actions (interact, stance, etc.) use press events (_unhandled_input).
# Composite diagonals: holding W+D simultaneously → northeast.
#
# D-054: Mouse-relative facing and movement.
# Mouse position determines facing direction (client-side float).
# WASD is relative to facing: W = toward cursor, S = away, A/D = strafe.
# Server receives facing octant only — the full float stays client-side.
#
# Movement throttle: client-side rate limit per stance (D-053).
# Sprint=5/s, Walk=2.5/s, Careful=1.7/s, Crouch=1.25/s.
@@ -15,10 +19,17 @@ enum Action {
INTERACT, USE_PERCEPTION_MODE, OPEN_MENU, PAUSE, UNPAUSE,
TOGGLE_STANCE_UP, TOGGLE_STANCE_DOWN,
BUG_REPORT, # #495: F12 WRONG button — client-only, not sent to server
SET_FACING, # D-054: facing octant update (no movement)
}
var input_queue: Array[Dictionary] = []
# D-054: Client-side facing angle (radians). 0=East, -PI/2=North, PI/2=South.
# Updated every frame from mouse position. EntityRenderer reads this for indicator.
var facing_angle: float = -PI / 2.0 # Default: North
var facing_octant: String = "North" # Derived from facing_angle
var _last_sent_octant: String = "North" # Track to avoid redundant sends
# Minimum milliseconds between movement commands, per stance.
# Tuned so Walk feels like walking, Sprint feels fast but readable.
const MOVE_INTERVAL_MS := {
@@ -31,27 +42,44 @@ var _last_move_msec: int = 0
# Hold-to-move: poll held direction keys each frame, throttled by stance.
# D-054: WASD is now mouse-relative. W = toward cursor, A/D = strafe.
# Server-side cooldown (D-053) is authoritative; this prevents client flooding.
# D-064: movement suppressed during dialogue (walk-away handled by dialogue_box).
func _process(_delta: float) -> void:
# D-054: Update facing angle from mouse position every frame
_update_facing_from_mouse()
if GameState.dialogue_active:
return
var dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
dir.y -= 1
if Input.is_action_pressed("move_south"):
dir.y += 1
if Input.is_action_pressed("move_east"):
dir.x += 1
if Input.is_action_pressed("move_west"):
dir.x -= 1
if dir != Vector2i.ZERO:
# D-054: Send facing octant to server when it changes (even without movement)
if facing_octant != _last_sent_octant:
_last_sent_octant = facing_octant
input_queue.append({
"action": Action.SET_FACING,
"timestamp_msec": Time.get_ticks_msec(),
"action_data": {"facing": facing_octant},
})
# Poll held WASD keys
var raw_dir := Vector2i.ZERO
if Input.is_action_pressed("move_north"):
raw_dir.y -= 1
if Input.is_action_pressed("move_south"):
raw_dir.y += 1
if Input.is_action_pressed("move_east"):
raw_dir.x += 1
if Input.is_action_pressed("move_west"):
raw_dir.x -= 1
if raw_dir != Vector2i.ZERO:
var now := Time.get_ticks_msec()
var interval: int = MOVE_INTERVAL_MS.get(GameState.player_stance, 200)
if now - _last_move_msec >= interval:
_last_move_msec = now
var action: Action = _dir_to_action(dir)
# D-054: Transform WASD input relative to mouse facing
var world_dir := _wasd_to_world_dir(raw_dir)
var action: Action = _dir_to_action(world_dir)
input_queue.append({
"action": action,
"timestamp_msec": now,
@@ -92,6 +120,85 @@ func flush_queue() -> Array[Dictionary]:
return queue
## Reset facing state to default (North). Use in tests per D-030 testability.
func reset_facing_state() -> void:
facing_angle = -PI / 2.0
facing_octant = "North"
_last_sent_octant = "North"
# D-054: Compute facing angle from mouse position relative to player screen position.
# Uses viewport canvas transform to convert world coords to screen coords.
# Intentional coupling: reads GameState.player_position directly — InputMapper is an
# autoload that runs before game loop rendering, so position is always current-tick.
func _update_facing_from_mouse() -> void:
var vp := get_viewport()
if vp == null:
return
var canvas_xf := vp.get_canvas_transform()
var player_world_px := GameState.player_position * Constants.TILE_SIZE
var player_screen := canvas_xf * player_world_px
var mouse_screen := vp.get_mouse_position()
var delta := mouse_screen - player_screen
# Only update if mouse is meaningfully distant from player (avoid jitter at center)
if delta.length_squared() > 4.0:
facing_angle = delta.angle()
facing_octant = _angle_to_octant(facing_angle)
# D-054: Transform raw WASD input (screen-space) to world direction relative to mouse facing.
# W (+Y up in input, mapped to forward), S (backward), A (strafe left), D (strafe right).
# Raw input: W=(-Y), S=(+Y), A=(-X), D=(+X) in screen coords.
# Forward = facing_angle direction. Output: nearest octant direction vector.
func _wasd_to_world_dir(raw_dir: Vector2i) -> Vector2i:
# Build a continuous direction vector relative to facing.
# raw_dir.y: -1 = W (forward), +1 = S (backward)
# raw_dir.x: -1 = A (strafe left), +1 = D (strafe right)
var forward := Vector2(cos(facing_angle), sin(facing_angle))
var right := Vector2(-forward.y, forward.x) # 90° clockwise
# Combine: forward/back from W/S, strafe from A/D
var world_float := forward * float(-raw_dir.y) + right * float(raw_dir.x)
# Snap to nearest octant direction
return _snap_to_octant_dir(world_float)
# Snap a floating-point direction vector to the nearest of 8 cardinal/diagonal directions.
static func _snap_to_octant_dir(dir: Vector2) -> Vector2i:
if dir.length_squared() < 0.001:
return Vector2i.ZERO
var angle := dir.angle()
# Quantize to nearest 45° (PI/4)
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return Vector2i(1, 0) # East
1: return Vector2i(1, 1) # Southeast
2, -6: return Vector2i(0, 1) # South
3, -5: return Vector2i(-1, 1) # Southwest
4, -4: return Vector2i(-1, 0) # West
-3, 5: return Vector2i(-1, -1) # Northwest
-2: return Vector2i(0, -1) # North
-1: return Vector2i(1, -1) # Northeast
_: return Vector2i.ZERO
# D-054: Convert a facing angle (radians) to the nearest octant name.
# Godot 2D: 0=East, PI/2=South, -PI/2=North.
static func _angle_to_octant(angle: float) -> String:
var octant := roundi(angle / (PI / 4.0))
match octant:
0: return "East"
1: return "Southeast"
2, -6: return "South"
3, -5: return "Southwest"
4, -4: return "West"
-3, 5: return "Northwest"
-2: return "North"
-1: return "Northeast"
_: return "East"
# Map a direction vector to the corresponding movement Action.
# Handles all 8 directions via composite W+D, W+A, etc.
static func _dir_to_action(dir: Vector2i) -> Action:
+12 -2
View File
@@ -177,7 +177,16 @@ func send_input(player_input: Dictionary) -> Error:
var action: int = player_input.get("action", -1)
var wire_name: String = _action_enum_to_wire(action)
if not wire_name.is_empty():
_test_input_queue.append(wire_name)
if wire_name == "SetFacing":
# D-054: Use action_data.facing from the input dict, not InputMapper global
var facing: String = ""
var action_data: Variant = player_input.get("action_data")
if action_data is Dictionary:
facing = str(action_data.get("facing", ""))
if not facing.is_empty():
_test_facing = facing
else:
_test_input_queue.append(wire_name)
return OK
var action_name := _action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
@@ -258,6 +267,8 @@ static func _action_enum_to_wire(action: int) -> String:
return "" # Client-only action, not part of wire protocol
InputMapper.Action.BUG_REPORT:
return "" # Client-only action (#495), not part of wire protocol
InputMapper.Action.SET_FACING:
return "SetFacing" # D-054: facing octant update (no movement)
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
@@ -285,7 +296,6 @@ func _test_snapshot() -> Dictionary:
if _test_is_walkable(new_pos):
_test_player_pos = new_pos
if delta != Vector2i.ZERO:
_test_facing = _delta_to_facing(delta)
# Walk-away dismisses dialogue (D-064)
if _test_in_dialogue:
_test_in_dialogue = false
@@ -0,0 +1,354 @@
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:
# Content directory lives at repo root (content/), one level above the Godot
# project (client/). In editor/dev mode we resolve via the project path.
# In exported builds, content is expected at res://content/ (copied by export
# preset) — the globalize fallback won't exist, so check res:// first.
if DirAccess.dir_exists_absolute("res://content"):
_content_base = ProjectSettings.globalize_path("res://content")
else:
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"]
_warn_empty_ids(_room_conditions, room_path)
# 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"]
_warn_empty_ids(_cross_conditions, cross_path)
_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.
## Conditions with empty id are excluded (invalid, cannot be latched).
func get_results() -> Array:
var results: Array = []
for cond in _room_conditions + _cross_conditions:
var cid: String = cond.get("id", "")
if cid.is_empty():
continue
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 (excludes conditions with empty id).
func get_total_count() -> int:
var count: int = 0
for cond in _room_conditions + _cross_conditions:
if not cond.get("id", "").is_empty():
count += 1
return count
## 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
static func _warn_empty_ids(conditions: Array, path: String) -> void:
for i in conditions.size():
if conditions[i].get("id", "").is_empty():
push_warning("ChecklistEvaluator: condition at index %d in %s has empty id — will be excluded from results" % [i, path])
# -- 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
## x/y and radius are in tile coordinates (matching GameState.player_position),
## not pixels. D-066 dual-scale: YAML authors write tile coords, pixel conversion
## happens only at render time.
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.
#
# Limitation: unquoted values containing " #" are truncated at the comment marker.
# Use quoted strings ("value # with hash") if values must contain literal hashes.
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
+7
View File
@@ -80,3 +80,10 @@ const PERIPHERAL_ALPHA: float = 0.5
# Facing direction indicator
const FACING_INDICATOR_SIZE: float = 6.0
const FACING_INDICATOR_OFFSET: float = 14.0
# #517: Implant UI font color grading — avoid pure white, project through a lens
const IMPLANT_TEXT_COLOR: Color = Color("#E0F7FA") # Cyan-white — primary text
const IMPLANT_TEXT_DIM: Color = Color("#9EBFC4") # Dimmed variant — secondary text
const IMPLANT_PULSE_MIN: float = 0.85 # Alpha pulse floor
const IMPLANT_PULSE_MAX: float = 1.0 # Alpha pulse ceiling
const IMPLANT_PULSE_PERIOD: float = 2.5 # Seconds per pulse cycle
+25
View File
@@ -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)
+5 -15
View File
@@ -119,11 +119,14 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
if not is_equal_approx(entity_node.modulate.a, target_alpha):
entity_node.modulate.a = target_alpha
# v2: Update facing indicator rotation (player entity only)
# D-054: Update facing indicator from client-side mouse angle (not server).
# InputMapper.facing_angle is a continuous float — smoother than octant snapping.
if entity_id == GameState.player_entity_id:
var indicator = entity_node.get_node_or_null("FacingIndicator")
if indicator != null:
indicator.rotation = _facing_to_rotation(GameState.player_facing)
# facing_angle: 0=East, -PI/2=North. Indicator: 0=North (up).
# Rotate from North basis: add PI/2 to convert.
indicator.rotation = InputMapper.facing_angle + PI / 2.0
# Remove an entity node
func _remove_entity_node(entity_id: int) -> void:
@@ -155,16 +158,3 @@ func _add_facing_indicator(parent_node: Control) -> void:
# Position at center of parent ColorRect — rotation around this point
indicator.position = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
parent_node.add_child(indicator)
# Convert facing direction string to rotation in radians (0 = North/up)
static func _facing_to_rotation(facing: String) -> float:
match facing:
"North": return 0.0
"Northeast": return PI / 4.0
"East": return PI / 2.0
"Southeast": return 3.0 * PI / 4.0
"South": return PI
"Southwest": return 5.0 * PI / 4.0
"West": return 3.0 * PI / 2.0
"Northwest": return 7.0 * PI / 4.0
_: return 0.0
+11 -7
View File
@@ -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)
+692
View File
@@ -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
+14 -12
View File
@@ -253,27 +253,29 @@ func test_recognition_transition_progress() -> void:
fog_entities.queue_free()
func test_facing_indicator_rotation_matches_player_facing() -> void:
# P3-T02: Facing indicator rotation matches player_facing from snapshot.
func test_facing_indicator_rotation_matches_input_mapper_angle() -> void:
# P3-T02: D-054 — Facing indicator uses InputMapper.facing_angle (client-side float).
# Indicator rotation = facing_angle + PI/2 (0=North basis).
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var entity := [{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0,
"kind": {"variant": "Player", "data": null}, "visibility": "Forward"}]
renderer.update_entities(entity)
var indicator = renderer.entity_nodes[1].get_node("FacingIndicator")
# Test each cardinal + diagonal direction
var expected := {
"North": 0.0,
"East": PI / 2.0,
"South": PI,
"West": 3.0 * PI / 2.0,
# {facing_angle → expected indicator rotation}
var angles := {
-PI / 2.0: 0.0, # North
0.0: PI / 2.0, # East
PI / 2.0: PI, # South
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2 by Godot)
}
for dir in expected:
GameState.player_facing = dir
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(entity)
assert_that(indicator.rotation).override_failure_message(
"%s: expected rotation %.3f, got %.3f" % [dir, expected[dir], indicator.rotation]
).is_equal_approx(expected[dir], 0.001)
"angle %.3f: expected rotation %.3f, got %.3f" % [angle, angles[angle], indicator.rotation]
).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default
renderer.queue_free()
+119
View File
@@ -0,0 +1,119 @@
## D-054 facing and movement tests — _angle_to_octant, _snap_to_octant_dir,
## _wasd_to_world_dir coverage. All functions are static or use only facing_angle.
##
## Spec ref: D-054 (mouse-relative facing), Sprint 10 Completion Proof.
class_name TestInputMapperFacing
extends GdUnitTestSuite
# -- _angle_to_octant ----------------------------------------------------------
func test_angle_to_octant_east() -> void:
assert_str(InputMapper._angle_to_octant(0.0)).is_equal("East")
func test_angle_to_octant_north() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 2.0)).is_equal("North")
func test_angle_to_octant_south() -> void:
assert_str(InputMapper._angle_to_octant(PI / 2.0)).is_equal("South")
func test_angle_to_octant_west() -> void:
assert_str(InputMapper._angle_to_octant(PI)).is_equal("West")
func test_angle_to_octant_northeast() -> void:
assert_str(InputMapper._angle_to_octant(-PI / 4.0)).is_equal("Northeast")
func test_angle_to_octant_southeast() -> void:
assert_str(InputMapper._angle_to_octant(PI / 4.0)).is_equal("Southeast")
func test_angle_to_octant_southwest() -> void:
assert_str(InputMapper._angle_to_octant(3.0 * PI / 4.0)).is_equal("Southwest")
func test_angle_to_octant_northwest() -> void:
assert_str(InputMapper._angle_to_octant(-3.0 * PI / 4.0)).is_equal("Northwest")
# -- _snap_to_octant_dir -------------------------------------------------------
func test_snap_east() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(1.0, 0.0))).is_equal(Vector2i(1, 0))
func test_snap_north() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, -1.0))).is_equal(Vector2i(0, -1))
func test_snap_south() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.0, 1.0))).is_equal(Vector2i(0, 1))
func test_snap_west() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-1.0, 0.0))).is_equal(Vector2i(-1, 0))
func test_snap_northeast() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.7, -0.7))).is_equal(Vector2i(1, -1))
func test_snap_southwest() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(-0.7, 0.7))).is_equal(Vector2i(-1, 1))
func test_snap_zero_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2.ZERO)).is_equal(Vector2i.ZERO)
func test_snap_tiny_returns_zero() -> void:
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.001, 0.0))).is_equal(Vector2i.ZERO)
func test_snap_diagonal_bias() -> void:
# Slightly more east than north — should snap to northeast
assert_object(InputMapper._snap_to_octant_dir(Vector2(0.8, -0.6))).is_equal(Vector2i(1, -1))
# -- _wasd_to_world_dir --------------------------------------------------------
func test_wasd_forward_facing_east() -> void:
# W pressed, facing east → move east
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_forward_facing_north() -> void:
# W pressed, facing north → move north
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, -1))
func test_wasd_backward_facing_north() -> void:
# S pressed, facing north → move south
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(0, 1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_strafe_right_facing_north() -> void:
# D pressed, facing north → move east
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(1, 0))
func test_wasd_strafe_left_facing_north() -> void:
# A pressed, facing north → move west
InputMapper.facing_angle = -PI / 2.0 # North
var result := InputMapper._wasd_to_world_dir(Vector2i(-1, 0))
assert_object(result).is_equal(Vector2i(-1, 0))
func test_wasd_forward_facing_south() -> void:
# W pressed, facing south → move south
InputMapper.facing_angle = PI / 2.0 # South
var result := InputMapper._wasd_to_world_dir(Vector2i(0, -1))
assert_object(result).is_equal(Vector2i(0, 1))
func test_wasd_diagonal_forward_right_facing_east() -> void:
# W+D pressed, facing east → move southeast
InputMapper.facing_angle = 0.0 # East
var result := InputMapper._wasd_to_world_dir(Vector2i(1, -1))
assert_object(result).is_equal(Vector2i(1, 1))
func test_wasd_strafe_right_facing_west() -> void:
# D pressed, facing west → move north
InputMapper.facing_angle = PI # West
var result := InputMapper._wasd_to_world_dir(Vector2i(1, 0))
assert_object(result).is_equal(Vector2i(0, -1))
func after_test() -> void:
InputMapper.reset_facing_state()
+17 -12
View File
@@ -298,25 +298,30 @@ func test_entity_renderer_player_has_facing_indicator() -> void:
renderer.queue_free()
func test_entity_renderer_facing_indicator_rotation_accuracy() -> void:
# D-054: Facing indicator now reads InputMapper.facing_angle (float), not
# GameState.player_facing (string). Indicator rotation = facing_angle + PI/2.
GameState.player_entity_id = 1
var renderer := _make_entity_renderer()
var directions := {
"North": 0.0,
"Northeast": PI / 4.0,
"East": PI / 2.0,
"Southeast": 3.0 * PI / 4.0,
"South": PI,
"Southwest": 5.0 * PI / 4.0,
"West": 3.0 * PI / 2.0,
"Northwest": 7.0 * PI / 4.0,
# {facing_angle → expected indicator rotation}
# Indicator 0 = North (up). facing_angle 0 = East. So rotation = angle + PI/2.
var angles := {
-PI / 2.0: 0.0, # North
-PI / 4.0: PI / 4.0, # Northeast
0.0: PI / 2.0, # East
PI / 4.0: 3.0 * PI / 4.0, # Southeast
PI / 2.0: PI, # South
3.0 * PI / 4.0: -3.0 * PI / 4.0, # Southwest (Godot normalizes to (-PI, PI])
PI: -PI / 2.0, # West (3PI/2 normalized to -PI/2)
-3.0 * PI / 4.0: -PI / 4.0, # Northwest (-3PI/4 + PI/2 = -PI/4)
}
renderer.update_entities(_test_entities_v2)
var player_node = renderer.entity_nodes[1]
var indicator = player_node.get_node_or_null("FacingIndicator")
for dir_name in directions:
GameState.player_facing = dir_name
for angle in angles:
InputMapper.facing_angle = angle
renderer.update_entities(_test_entities_v2)
assert_that(indicator.rotation).is_equal_approx(directions[dir_name], 0.001)
assert_that(indicator.rotation).is_equal_approx(angles[angle], 0.001)
InputMapper.facing_angle = -PI / 2.0 # Reset to default (North)
renderer.queue_free()
func test_entity_renderer_npc_has_no_facing_indicator() -> void:
+134
View File
@@ -0,0 +1,134 @@
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
var _cached_font: Font = null # Cached to avoid per-frame theme lookup
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
_evaluator = _ChecklistEvaluator.new()
_cached_font = get_theme_default_font()
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: Font = _cached_font if _cached_font else 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()
+17
View File
@@ -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")
+2 -2
View File
@@ -16,8 +16,8 @@ const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
const INSERT_FG := Constants.IMPLANT_TEXT_COLOR
const INSERT_DIM := Constants.IMPLANT_TEXT_DIM
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
var _showing: bool = false
+34 -15
View File
@@ -4,7 +4,9 @@ extends Control
## Insert-styled: geometric lines, thin spokes, nearly transparent.
## Renders on InsertOverlay (CanvasLayer 10).
## Drag-release for power users, click-click for newcomers.
## Insert spoke sends Pause on activate, Pause again on close (toggle).
## Insert spoke sends PauseSimulation on activate (#518/D-058).
## Selecting any non-Insert spoke, cancelling, or pressing Escape calls
## deactivate_insert() which sends ResumeSimulation. Pause/resume are idempotent.
signal spoke_selected(spoke_name: String)
@@ -35,6 +37,7 @@ var _origin: Vector2 = Vector2.ZERO
var _hovered_spoke: int = Spoke.NONE
var _drag_mode: bool = false
var _insert_active: bool = false
var _cached_font: Font = null
func _ready() -> void:
@@ -43,6 +46,7 @@ func _ready() -> void:
size = custom_minimum_size
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
_cached_font = get_theme_default_font()
func _input(event: InputEvent) -> void:
@@ -83,6 +87,15 @@ func _close_menu() -> void:
visible = false
# Handle Escape key to dismiss menu and deactivate insert if active.
func _unhandled_input(event: InputEvent) -> void:
if _open and event.is_action_pressed("ui_cancel"):
if _insert_active:
deactivate_insert()
_close_menu()
get_viewport().set_input_as_handled()
func _update_hover(mouse_pos: Vector2) -> void:
var delta := mouse_pos - _origin
var dist := delta.length()
@@ -115,29 +128,36 @@ func _confirm_selection() -> void:
if _hovered_spoke == Spoke.INSERT:
_activate_insert()
elif _insert_active:
# Selecting any non-Insert spoke closes the insert and resumes sim
deactivate_insert()
elif _insert_active:
# No spoke selected (cancelled) while insert active — close insert
deactivate_insert()
_close_menu()
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,10 +194,9 @@ func _draw() -> void:
# Label
var label: String = SPOKE_NAMES.get(spoke, "")
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var text_size := _cached_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)
draw_string(_cached_font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void: