Room reset (#502): amber reset_plate tile type in TileRenderer, 0.15s screen flash on room_reset monologue, 'Reset Room' verb via existing nearby_interactions. Insert pause (#518, D-058): explicit PauseSimulation on insert open, ResumeSimulation on close. Replaces toggle-style pause with idempotent pair per D-058. Auto-checklist (#503): ChecklistEvaluator parses room YAML, evaluates 7 condition types against GameState with latching. ChecklistOverlay renders progress in gauntlet mode only. 48 tests covering parser, evaluation, latching, visibility, and integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user