feat(ui): AtlasAgentInterface — observe/act named-intent control channel (D-226, T-971)
D-226 layer 4, rebuilt against the post-D-255 stepped Atlas after the Phase-1 reconciliation (the original intent list targeted the retired continuous-zoom viewer). Eleven intents, each backed by the exact production handler a click calls — select/open for systems and bodies (extracted shared by-id tails so click and intent paths are one code path), scroll_rung, reset_view, back, open/close_atlas, set_overlay — plus two new first-class capabilities: jump_to_center (the fixed-center revisit pattern proven by five eyeball drivers, via a new StepCanvasViewer.jump_to seam that reuses _scroll_rung's exact request tail — same extent cap, same cache keys) and get_current_canvas_summary (allocation-light reads off the raw wire dict, courses-by-class, draw-matched settlement dedup — no PNG decode). Contract shape: dumb AtlasAgentBridge autoload holding the app handle (untyped per the parse-order rule), all logic in the static AtlasAgentInterface class. observe() is side-effect-free: current state + a generic Control-walk affordance tree. In-process consumers only this ticket (documented); the committed reference driver (atlas_agent_driver.gd, InputSwallower + settle-until-ready from the T-1157 inventory) replaces the scratch eyeball drivers as the sanctioned headless-drive pattern. select_city/open_regional dropped with recorded rationale (no settlement hit-test affordance exists post-D-255) — diff on the ticket. 33 new tests across three suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
class_name AtlasAgentInterface
|
||||
extends RefCounted
|
||||
|
||||
## Atlas agent control channel (T-971, D-226 item 4) — observe/act named-
|
||||
## intent navigation over the REAL Atlas UI. Turns the five hand-rolled
|
||||
## T-1183 eyeball-review scratch drivers into one production, testable
|
||||
## contract: `observe()` returns a JSON-safe Dictionary of (a) the current
|
||||
## data state and (b) a walkable UI affordance tree; `act(intent, params)`
|
||||
## dispatches to a named semantic intent, each backed by the EXACT method a
|
||||
## real click/keypress calls in production — never a synthesized
|
||||
## `_gui_input`/pixel event. Every method here is `static` (no instance
|
||||
## state) taking an `AtlasApp` reference explicitly — see
|
||||
## atlas_agent_bridge.gd's own doc for why the stable entry point is a dumb
|
||||
## autoload holding that reference rather than this class itself being an
|
||||
## autoload (the CLAUDE.md autoload parse-order rule: this file has a
|
||||
## `class_name`, so an autoload referencing it directly at top level/`_ready()`
|
||||
## would fail to resolve before this script is registered).
|
||||
##
|
||||
## **Reconciled against the current stepped-rung API (T-971 Phase 1, this
|
||||
## ticket's own confirmed re-scope) — NOT the original D-226 list, which was
|
||||
## written against the retired continuous-zoom AtlasWindowViewer:**
|
||||
##
|
||||
## - **`select_city`/`open_regional` DROPPED.** Post-D-255, settlements are
|
||||
## `settlement_id` cell values inside a fetched step-canvas array
|
||||
## (StepCanvasAnnotationLayer._draw_settlements()), not a separate
|
||||
## clickable list — there is no server- or client-side "select settlement
|
||||
## X" affordance to wire this intent to today. Faking one (e.g. picking the
|
||||
## nearest cell to a guessed screen position) would be worse than omitting
|
||||
## it: it would exercise a code path no real click can reach. A
|
||||
## settlement-hit-test intent is a new ticket, filed only once a real
|
||||
## consumer needs it — not speculatively here.
|
||||
## - **`open_atlas`/`close_atlas` cover opening the app itself** — D-226's
|
||||
## original list implicitly assumed the Atlas was already open; an
|
||||
## in-process driver needs to open it too (HudGroups.open_app/close_app).
|
||||
## - **`jump_to_center` is NEW** — the T-1183 eyeball-driver "fixed-center
|
||||
## revisit" pattern (record a prior run's actual derived world center,
|
||||
## re-request it literally rather than repeating a cursor gesture that
|
||||
## would re-derive a merely-similar one) made a first-class intent, backed
|
||||
## by the new StepCanvasViewer.jump_to() production seam. This is the
|
||||
## highest-value intent in the set for T-1157's future capture harness.
|
||||
## - **`get_layer_data_summary`'s actual post-D-255 shape** is
|
||||
## StepCanvasViewer.get_current_canvas_summary()'s own return —
|
||||
## rung/world_center/held_extent/canvas dimensions/course/cliff/settlement
|
||||
## counts. D-226's original "attractor/river/basin counts" phrasing is
|
||||
## itself stale (attractors don't exist post-D-255; "basin" was never a
|
||||
## step-canvas field) — courses/cliffs/settlements are what the wire
|
||||
## actually carries.
|
||||
##
|
||||
## **TRANSPORT SCOPE (this ticket): in-process consumers only.** Every real
|
||||
## consumer today — gdUnit suites, a `-s` SceneTree headless driver, the
|
||||
## T-1157 capture harness — calls these static methods directly, in the same
|
||||
## process as the running AtlasApp. There is no network/stdio listener here;
|
||||
## D-226's original "terminal/curl" framing is deferred to a follow-up ticket
|
||||
## when a genuinely remote consumer exists. This keeps the surface small and
|
||||
## delivers the QA value (turning eyeball review into a scripted sweep)
|
||||
## immediately. See client/tests/atlas_agent_driver.gd for the sanctioned
|
||||
## reference `-s` driver this ticket ships alongside the interface itself.
|
||||
##
|
||||
## **observe() is side-effect-free** — no request firing, no state mutation,
|
||||
## purely reads already-held view/screen state (mirrors get_layer_data_summary's
|
||||
## own "without rendering" requirement one level up: observing never triggers
|
||||
## a fetch).
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# observe()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Full observation snapshot: current screen id + its own data state, the
|
||||
## walkable affordance tree (every reachable toggle/button under the current
|
||||
## screen), and — when "regional" is current — the step-canvas layer data
|
||||
## summary. `app` is the live AtlasApp (typically
|
||||
## AtlasAgentBridge.current_app, loaded by the caller via `load()` per the
|
||||
## autoload parse-order rule).
|
||||
static func observe(app: Node) -> Dictionary:
|
||||
if app == null:
|
||||
return {"error": "no AtlasApp instance available"}
|
||||
var screen_id: String = app.current_screen_id()
|
||||
return {
|
||||
"screen": screen_id,
|
||||
"data": _observe_data_state(app, screen_id),
|
||||
"affordances": _walk_affordances(app),
|
||||
}
|
||||
|
||||
|
||||
## The "current data state shown" half of observe() — per-screen, since each
|
||||
## screen's own state shape differs (D-226's own split: "current data state
|
||||
## shown" vs "the affordance tree" are two distinct halves of one observe()
|
||||
## call, not folded together).
|
||||
static func _observe_data_state(app: Node, screen_id: String) -> Dictionary:
|
||||
match screen_id:
|
||||
"reach":
|
||||
return {"has_selection": app.get_screen("reach").has_selection()}
|
||||
"system":
|
||||
var system_screen: Node = app.get_screen("system")
|
||||
return {
|
||||
"in_orbital": system_screen.is_in_orbital(),
|
||||
"current_system": system_screen.current_system(),
|
||||
"has_body_panel_open": system_screen.has_body_panel_open(),
|
||||
"has_station_panel_open": system_screen.has_station_panel_open(),
|
||||
}
|
||||
"regional":
|
||||
var viewer: Variant = _get_viewer(app)
|
||||
if viewer == null:
|
||||
return {}
|
||||
return viewer.get_current_canvas_summary()
|
||||
_:
|
||||
return {} # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
## Generic Control-tree walk (D-226's own requirement: "so economics/saves
|
||||
## plug in later" — never a per-screen hand-written affordance list). Walks
|
||||
## the CURRENT screen's own Control subtree (not the whole app — an
|
||||
## inactive screen's controls aren't real affordances right now) collecting
|
||||
## every Button/toggle-capable node: id (node name), label (text), state
|
||||
## (pressed/disabled), locked (disabled). Buttons with no distinguishing text
|
||||
## still get an entry (id falls back to the node's own scene path) so the
|
||||
## tree is never silently incomplete.
|
||||
static func _walk_affordances(app: Node) -> Array:
|
||||
var screen: Node = app.get_screen(app.current_screen_id())
|
||||
if screen == null:
|
||||
return []
|
||||
var out: Array = []
|
||||
_walk_affordances_recursive(screen, out)
|
||||
return out
|
||||
|
||||
|
||||
static func _walk_affordances_recursive(node: Node, out: Array) -> void:
|
||||
if node is Button:
|
||||
var b: Button = node
|
||||
out.append({
|
||||
"id": String(b.name),
|
||||
"label": b.text,
|
||||
"pressed": b.button_pressed,
|
||||
"locked": b.disabled,
|
||||
})
|
||||
for child in node.get_children():
|
||||
_walk_affordances_recursive(child, out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# act()
|
||||
# =============================================================================
|
||||
|
||||
|
||||
## Dispatch a named semantic intent. `params` is a JSON-safe Dictionary;
|
||||
## returns a JSON-safe Dictionary result (at minimum {"ok": bool}, plus
|
||||
## intent-specific fields). Unknown intents return {"ok": false,
|
||||
## "error": "..."} rather than pushing an error/crashing — this channel is
|
||||
## meant to be driven by a fallible external caller (a curl-style JSON body,
|
||||
## a test fixture), and a malformed intent name is exactly the kind of input
|
||||
## it must handle gracefully.
|
||||
static func act(app: Node, intent: String, params: Dictionary = {}) -> Dictionary:
|
||||
if app == null and intent != "open_atlas":
|
||||
return {"ok": false, "error": "no AtlasApp instance available"}
|
||||
match intent:
|
||||
"open_atlas":
|
||||
HudGroups.open_app("implant/map")
|
||||
return {"ok": true}
|
||||
"close_atlas":
|
||||
HudGroups.close_app()
|
||||
return {"ok": true}
|
||||
"select_system":
|
||||
app.get_screen("reach").select_system_by_id(str(params.get("system_id", "")))
|
||||
return {"ok": true}
|
||||
"open_system":
|
||||
app.get_screen("reach").open_system_by_id(str(params.get("system_id", "")))
|
||||
return {"ok": true}
|
||||
"select_body":
|
||||
app.get_screen("system").select_body_by_id(str(params.get("body_id", "")))
|
||||
return {"ok": true}
|
||||
"open_body":
|
||||
app.get_screen("system").open_body_by_id(str(params.get("body_id", "")))
|
||||
return {"ok": true}
|
||||
"scroll_rung":
|
||||
return _act_scroll_rung(app, params)
|
||||
"jump_to_center":
|
||||
return _act_jump_to_center(app, params)
|
||||
"reset_view":
|
||||
var viewer: Variant = _get_viewer(app)
|
||||
if viewer == null:
|
||||
return {"ok": false, "error": "regional screen not active"}
|
||||
viewer._reset_to_global()
|
||||
return {"ok": true}
|
||||
"set_overlay":
|
||||
var overlay_viewer: Variant = _get_viewer(app)
|
||||
if overlay_viewer == null:
|
||||
return {"ok": false, "error": "regional screen not active"}
|
||||
overlay_viewer.set_overlay_visible(
|
||||
str(params.get("overlay_id", "")), bool(params.get("visible", true))
|
||||
)
|
||||
return {"ok": true}
|
||||
"back":
|
||||
app.nav.pop()
|
||||
return {"ok": true}
|
||||
_:
|
||||
return {"ok": false, "error": "unknown intent '%s'" % intent} # gdlint:ignore = max-returns
|
||||
|
||||
|
||||
## `scroll_rung` — direction is required; cursor_local defaults to a
|
||||
## reasonable canvas-center guess (Vector2(400, 300), matching this cluster's
|
||||
## own gdUnit test fixtures' convention, see test_step_canvas_viewer.gd) since
|
||||
## an agent driver has no real cursor position to anchor on.
|
||||
static func _act_scroll_rung(app: Node, params: Dictionary) -> Dictionary:
|
||||
var viewer: Variant = _get_viewer(app)
|
||||
if viewer == null:
|
||||
return {"ok": false, "error": "regional screen not active"}
|
||||
var direction: int = int(params.get("direction", 1))
|
||||
var cursor_raw: Variant = params.get("cursor_local")
|
||||
var cursor_local: Vector2 = (
|
||||
Vector2(cursor_raw[0], cursor_raw[1])
|
||||
if cursor_raw is Array and (cursor_raw as Array).size() >= 2
|
||||
else Vector2(400.0, 300.0)
|
||||
)
|
||||
viewer._scroll_rung(direction, cursor_local)
|
||||
return {"ok": true, "rung": viewer.get_held_rung()}
|
||||
|
||||
|
||||
## `jump_to_center` — the fixed-center revisit intent (T-1183 pattern, made
|
||||
## first-class). `world_center` is required (`[x, y]` world metres);
|
||||
## `rung` is optional (keeps the currently-held rung when omitted, matching
|
||||
## StepCanvasViewer.jump_to()'s own default). Goes through jump_to(), which
|
||||
## itself falls through to the SAME _fire_request()/_request_extent() path
|
||||
## every other navigation intent uses — no parallel request-building code
|
||||
## exists in this file.
|
||||
static func _act_jump_to_center(app: Node, params: Dictionary) -> Dictionary:
|
||||
var viewer: Variant = _get_viewer(app)
|
||||
if viewer == null:
|
||||
return {"ok": false, "error": "regional screen not active"}
|
||||
var center_raw: Variant = params.get("world_center")
|
||||
if not (center_raw is Array and (center_raw as Array).size() >= 2):
|
||||
return {"ok": false, "error": "jump_to_center requires world_center: [x, y]"}
|
||||
var world_center := Vector2(float(center_raw[0]), float(center_raw[1]))
|
||||
var rung: String = str(params.get("rung", ""))
|
||||
viewer.jump_to(world_center, rung)
|
||||
return {"ok": true, "rung": viewer.get_held_rung(), "world_center": [world_center.x, world_center.y]}
|
||||
|
||||
|
||||
## Shared "regional" screen -> StepCanvasViewer accessor — returns null if
|
||||
## "regional" isn't the registered screen (defensive; every intent that needs
|
||||
## the viewer checks this rather than assuming the screen tree shape).
|
||||
static func _get_viewer(app: Node) -> Variant:
|
||||
var regional_screen: Node = app.get_screen("regional")
|
||||
if regional_screen == null:
|
||||
return null
|
||||
return regional_screen.get_viewer()
|
||||
@@ -33,6 +33,12 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func on_install() -> void:
|
||||
# T-971: publish this instance to the dumb AtlasAgentBridge autoload holder
|
||||
# so AtlasAgentInterface (a static class, loaded on demand by whatever
|
||||
# in-process driver calls it) has a stable address to start from — see
|
||||
# atlas_agent_bridge.gd's own doc for why this is a plain field set, not a
|
||||
# class_name-typed autoload reference.
|
||||
AtlasAgentBridge.current_app = self
|
||||
_load_system_data()
|
||||
# T-949: the star map now arrives over the bridge, possibly after this app
|
||||
# is already installed (or before the handshake completes at all) — refresh
|
||||
|
||||
@@ -474,16 +474,36 @@ func _gui_input(event: InputEvent) -> void:
|
||||
|
||||
func _handle_reach_click(pos: Vector2) -> void:
|
||||
var sid := _find_nearest_reach_system(pos)
|
||||
_reach_selected = sid
|
||||
_rebuild_reach_info_panel()
|
||||
_dirty = true
|
||||
select_system_by_id(sid)
|
||||
|
||||
|
||||
func _handle_reach_double_click(pos: Vector2) -> void:
|
||||
var sid := _find_nearest_reach_system(pos)
|
||||
if not sid.is_empty():
|
||||
_reach_selected = sid
|
||||
system_selected.emit(sid)
|
||||
open_system_by_id(sid)
|
||||
|
||||
|
||||
## T-971 (AtlasAgentInterface `select_system` intent) — the SAME state
|
||||
## mutation _handle_reach_click() performs (single-click equivalent: sets the
|
||||
## selection + rebuilds the info panel, does not navigate), reachable by id
|
||||
## directly rather than a screen position. An empty/unknown id clears the
|
||||
## selection (mirrors what a click on empty space already does via
|
||||
## `_find_nearest_reach_system`'s own "" fallback).
|
||||
func select_system_by_id(system_id: String) -> void:
|
||||
_reach_selected = system_id
|
||||
_rebuild_reach_info_panel()
|
||||
_dirty = true
|
||||
|
||||
|
||||
## T-971 (AtlasAgentInterface `open_system` intent) — the SAME state mutation
|
||||
## _handle_reach_double_click() performs (double-click/Enter equivalent:
|
||||
## selects AND emits system_selected, which AtlasApp._on_system_selected()
|
||||
## turns into a nav.push("system", ...)). A no-op for an unrecognized id,
|
||||
## matching the double-click handler's own empty-id guard.
|
||||
func open_system_by_id(system_id: String) -> void:
|
||||
if system_id.is_empty():
|
||||
return
|
||||
_reach_selected = system_id
|
||||
system_selected.emit(system_id)
|
||||
|
||||
|
||||
func _update_reach_hover(pos: Vector2) -> void:
|
||||
|
||||
@@ -47,5 +47,15 @@ func leave() -> void:
|
||||
pass
|
||||
|
||||
|
||||
## T-971 test/agent seam: exposes the owned StepCanvasViewer so
|
||||
## AtlasAgentInterface can drive rung-transport intents (scroll_rung,
|
||||
## jump_to, set_overlay, get_current_canvas_summary) without this screen
|
||||
## having to grow a forwarding method per viewer capability — matches the
|
||||
## get_request()/get_disk_cache() accessor-chain precedent StepCanvasViewer
|
||||
## itself already established for the same reason.
|
||||
func get_viewer() -> Variant:
|
||||
return _viewer
|
||||
|
||||
|
||||
func _on_viewer_back() -> void:
|
||||
back_requested.emit()
|
||||
|
||||
@@ -377,27 +377,14 @@ func _gui_input(event: InputEvent) -> void:
|
||||
|
||||
func _handle_orbital_double_click(pos: Vector2) -> void:
|
||||
var bid: String = _find_nearest_body(pos)
|
||||
if not bid.is_empty():
|
||||
for b: Dictionary in _orbital_bodies:
|
||||
if str(b.get("body_id", "")) == bid:
|
||||
body_selected.emit(b)
|
||||
return
|
||||
open_body_by_id(bid)
|
||||
|
||||
|
||||
func _handle_orbital_click(pos: Vector2) -> void:
|
||||
var bid: String = _find_nearest_body(pos)
|
||||
if not bid.is_empty():
|
||||
for b: Dictionary in _orbital_bodies:
|
||||
if str(b.get("body_id", "")) == bid:
|
||||
_selected_body = b
|
||||
_selected_station = {}
|
||||
if _station_panel:
|
||||
_station_panel.visible = false
|
||||
_rebuild_body_panel()
|
||||
if _body_panel:
|
||||
_body_panel.visible = true
|
||||
_dirty = true
|
||||
return
|
||||
select_body_by_id(bid)
|
||||
return
|
||||
|
||||
var sid: String = _find_nearest_station(pos)
|
||||
if not sid.is_empty():
|
||||
@@ -416,6 +403,39 @@ func _handle_orbital_click(pos: Vector2) -> void:
|
||||
_dirty = true
|
||||
|
||||
|
||||
## T-971 (AtlasAgentInterface `select_body` intent) — the SAME state
|
||||
## mutation the body branch of _handle_orbital_click() performs (opens the
|
||||
## body info panel, does not navigate), reachable by id directly. A no-op for
|
||||
## an unrecognized id (mirrors the click handler's own "no match under the
|
||||
## cursor" fall-through to _close_detail_panels(), except an agent-driven
|
||||
## select never had a "click landed on empty space" case to fall through to —
|
||||
## an unrecognized id is a caller error, not a deselect gesture, so it is
|
||||
## simply ignored here rather than clearing an unrelated selection).
|
||||
func select_body_by_id(body_id: String) -> void:
|
||||
for b: Dictionary in _orbital_bodies:
|
||||
if str(b.get("body_id", "")) == body_id:
|
||||
_selected_body = b
|
||||
_selected_station = {}
|
||||
if _station_panel:
|
||||
_station_panel.visible = false
|
||||
_rebuild_body_panel()
|
||||
if _body_panel:
|
||||
_body_panel.visible = true
|
||||
_dirty = true
|
||||
return
|
||||
|
||||
|
||||
## T-971 (AtlasAgentInterface `open_body` intent) — the SAME state mutation
|
||||
## _handle_orbital_double_click() performs (double-click equivalent: emits
|
||||
## body_selected, which AtlasApp._on_body_selected() turns into
|
||||
## nav.push("regional", ...)). A no-op for an unrecognized id.
|
||||
func open_body_by_id(body_id: String) -> void:
|
||||
for b: Dictionary in _orbital_bodies:
|
||||
if str(b.get("body_id", "")) == body_id:
|
||||
body_selected.emit(b)
|
||||
return
|
||||
|
||||
|
||||
func _close_detail_panels() -> void:
|
||||
_selected_body = {}
|
||||
_selected_station = {}
|
||||
|
||||
@@ -133,6 +133,13 @@ var _canvas_scale: float = 1.0
|
||||
# ── Overlay visibility ─────────────────────────────────────────────────────
|
||||
var _overlay_visibility: Dictionary = {}
|
||||
|
||||
## The last-adopted (Ready-response OR cache-hit) canvas Dictionary — held
|
||||
## here (not read from `_terrain_layer._canvas_ref`, which stays private to
|
||||
## that node) so get_current_canvas_summary() has its own reference. Set in
|
||||
## _on_canvas_ready() alongside the terrain/annotation layer handoff, same
|
||||
## single adoption point every other per-canvas field on this class uses.
|
||||
var _current_canvas_data: Variant = null # decoded EncodedStepCanvas Dictionary, or null pre-arrival
|
||||
|
||||
# ── Child nodes ────────────────────────────────────────────────────────────
|
||||
var _canvas: Node2D = null
|
||||
var _terrain_layer: StepCanvasTerrainLayer = null
|
||||
@@ -209,6 +216,7 @@ func enter(body: Dictionary, system: Dictionary) -> void:
|
||||
_view_offset = Vector2.ZERO
|
||||
_request.reset()
|
||||
_annotation_layer.clear_frame()
|
||||
_current_canvas_data = null
|
||||
_fire_request()
|
||||
_refresh_screen_header()
|
||||
if _legend_panel:
|
||||
@@ -274,6 +282,73 @@ func get_overlay_defs() -> Array:
|
||||
return OVERLAY_DEFS
|
||||
|
||||
|
||||
## T-971 (D-226 item 4, `get_layer_data_summary`) — a public, allocation-light
|
||||
## read of the currently-held canvas's shape, with NO rendering/PNG-decode
|
||||
## triggered (every field read here comes straight off the raw decoded
|
||||
## EncodedStepCanvas dict — step_canvas_protocol.gd's own shape — before
|
||||
## StepCanvasTerrainLayer ever touches Image.load_png_from_buffer()). A
|
||||
## proper public accessor, not a reach into `_terrain_layer._canvas_ref`
|
||||
## (which stays private to that node) — see `_current_canvas_data`'s own doc
|
||||
## for why the viewer holds its own reference instead.
|
||||
##
|
||||
## D-226's original "attractor/river/basin counts" phrasing is itself stale
|
||||
## post-D-255: attractors don't exist on this wire, and "basin" was never a
|
||||
## step-canvas field — the real countable features are courses (rivers,
|
||||
## `class`-tagged), cliffs, and settlements (dedup'd exactly like
|
||||
## StepCanvasAnnotationLayer._draw_settlements() dedups them, so the count
|
||||
## always matches what's actually drawn). Called potentially once per step in
|
||||
## a sweep loop (T-1157), so this is a single linear walk of already-small
|
||||
## sparse lists — no per-cell work beyond the settlement dedup scan
|
||||
## `_draw_settlements()` already pays for anyway.
|
||||
func get_current_canvas_summary() -> Dictionary:
|
||||
if not _current_canvas_data is Dictionary:
|
||||
return {
|
||||
"has_canvas": false,
|
||||
"rung": _held_rung,
|
||||
"world_center": [_world_center.x, _world_center.y],
|
||||
"held_extent": [_held_extent.x, _held_extent.y],
|
||||
}
|
||||
var d: Dictionary = _current_canvas_data
|
||||
var courses: Array = d.get("courses", [])
|
||||
var course_count_by_class: Dictionary = {}
|
||||
for course_raw: Variant in courses:
|
||||
if not course_raw is Dictionary:
|
||||
continue
|
||||
var cls: int = int((course_raw as Dictionary).get("class", -1))
|
||||
course_count_by_class[cls] = int(course_count_by_class.get(cls, 0)) + 1
|
||||
return {
|
||||
"has_canvas": true,
|
||||
"rung": _held_rung,
|
||||
"world_center": [_world_center.x, _world_center.y],
|
||||
"held_extent": [_held_extent.x, _held_extent.y],
|
||||
"canvas_width": int(d.get("width", 0)),
|
||||
"canvas_height": int(d.get("height", 0)),
|
||||
"course_count": courses.size(),
|
||||
"course_count_by_class": course_count_by_class,
|
||||
"cliff_count": (d.get("cliffs", []) as Array).size(),
|
||||
"settlement_count": _count_distinct_settlements(d),
|
||||
}
|
||||
|
||||
|
||||
## Mirrors StepCanvasAnnotationLayer._draw_settlements()'s own dedup
|
||||
## discipline exactly (distinct non-zero ids only) so this count always
|
||||
## matches what the player actually sees drawn — never a raw non-zero CELL
|
||||
## count, which would overcount every settlement whose footprint covers more
|
||||
## than one gridunit.
|
||||
static func _count_distinct_settlements(canvas: Dictionary) -> int:
|
||||
var settlement_id: Variant = canvas.get("settlement_id")
|
||||
if not (settlement_id is Array or settlement_id is PackedByteArray):
|
||||
return 0
|
||||
var seen: Dictionary = {}
|
||||
var ids: Array = settlement_id
|
||||
for i in range(ids.size()):
|
||||
var sid: int = int(ids[i])
|
||||
if sid == 0:
|
||||
continue
|
||||
seen[sid] = true
|
||||
return seen.size()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request lifecycle
|
||||
# =============================================================================
|
||||
@@ -324,6 +399,7 @@ func _on_canvas_ready(canvas: Dictionary) -> void:
|
||||
_held_extent = _request.get_held_extent()
|
||||
if _held_rung == StepCanvasTransport.RUNG_GLOBAL:
|
||||
_global_body_extent = _held_extent
|
||||
_current_canvas_data = canvas
|
||||
_rebuild_terrain_texture(canvas)
|
||||
_annotation_layer.set_frame(canvas, _world_center, _held_rung, _held_extent)
|
||||
_recompute_canvas_transform()
|
||||
@@ -451,6 +527,49 @@ func _is_global_view_drifted() -> bool:
|
||||
return _world_center != Vector2.ZERO or _view_offset != _centered_view_offset()
|
||||
|
||||
|
||||
## Fixed-center revisit (T-971, D-226 item 4 — the AtlasAgentInterface
|
||||
## `jump_to_center` intent; also the T-1183 eyeball-driver "record a prior
|
||||
## run's actual derived centers and re-request them literally" pattern, made
|
||||
## a first-class production seam rather than five hand-rolled scratch
|
||||
## drivers). Directly sets rung + center — no cursor-anchored derivation,
|
||||
## since a caller here is handing us the ALREADY-DERIVED world point to land
|
||||
## on (the whole reason this exists: a comparison run must hit run 1's exact
|
||||
## cache key, not repeat a gesture that re-derives a merely-similar one).
|
||||
##
|
||||
## Constraint honored (Jeroen's ruling): this selects center+rung ONLY, then
|
||||
## falls through to the SAME tail every other navigation uses —
|
||||
## `_fire_request()` (which reads extent from `_request_extent()`, the same
|
||||
## T-1189 body-cap/viewport-fit math every other request goes through),
|
||||
## `_refresh_screen_header()`, `queue_redraw()`. No parallel request-building
|
||||
## code exists here; a `jump_to` request is indistinguishable, cache-key-wise,
|
||||
## from a `_scroll_rung`/`enter` request that happened to land on the same
|
||||
## rung/center.
|
||||
##
|
||||
## `rung` defaults to the empty string, meaning "keep the currently held
|
||||
## rung" — a caller jumping within the same rung (the common revisit case)
|
||||
## need not repeat it. An unrecognized rung name is rejected (push_warning,
|
||||
## no-op) rather than silently coerced to Global — same defensive posture
|
||||
## StepCanvasTransport.index_for_rung() already documents for its own
|
||||
## out-of-range read.
|
||||
func jump_to(world_center: Vector2, rung: String = "") -> void:
|
||||
var target_rung: String = rung if not rung.is_empty() else _held_rung
|
||||
var target_index: int = StepCanvasTransport.index_for_rung(target_rung)
|
||||
if target_index < 0:
|
||||
push_warning("StepCanvasViewer: jump_to unrecognized rung '%s'" % rung)
|
||||
return
|
||||
_rung_index = target_index
|
||||
_held_rung = target_rung
|
||||
_world_center = world_center if _held_rung != StepCanvasTransport.RUNG_GLOBAL else Vector2.ZERO
|
||||
_view_offset = Vector2.ZERO
|
||||
_fire_request()
|
||||
_refresh_screen_header()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func get_world_center() -> Vector2:
|
||||
return _world_center
|
||||
|
||||
|
||||
## The letterbox-centered `_view_offset` for the CURRENTLY held canvas
|
||||
## footprint/scale — the "no pan drift" baseline _is_global_view_drifted()
|
||||
## compares against, what a hard reset restores, and what
|
||||
|
||||
@@ -130,6 +130,16 @@ func current_screen_id() -> String:
|
||||
return _current_screen_id
|
||||
|
||||
|
||||
## T-971 (AtlasAgentInterface, D-226 item 4): a generic, read-only screen
|
||||
## lookup by id — the same `_screens` dict register_screen() already
|
||||
## maintains, exposed so an agent-control channel can reach a specific
|
||||
## screen instance (StepCanvasViewer via RegionalScreen.get_viewer(), etc.)
|
||||
## without every ImplantApp subclass growing its own bespoke getters. Returns
|
||||
## null for an unregistered id, matching Dictionary.get()'s own default.
|
||||
func get_screen(screen_id: String) -> Variant:
|
||||
return _screens.get(screen_id, null)
|
||||
|
||||
|
||||
func _on_screen_changed(new_id: String) -> void:
|
||||
if _current_screen_id != new_id:
|
||||
var old: Control = _screens.get(_current_screen_id, null)
|
||||
|
||||
Reference in New Issue
Block a user