Files
settled-reach/client/ui/implant/atlas_viewer.gd
T

664 lines
22 KiB
GDScript

class_name AtlasViewer
extends Control
## Atlas regional viewer — heightmap PNG with pan/zoom + marker overlay (#835, D-191).
##
## Lives as a child of AtlasPanel, shown at Level.HEIGHTMAP_VIEWER. Receives
## body/system context from AtlasPanel via show_body(). Emits back_pressed and
## economics_link_requested signals so AtlasPanel can route them.
##
## Design notes:
## - Heightmap texture is drawn on a Node2D _canvas child. Pan = _canvas.position,
## zoom = _canvas.scale. MarkerOverlay is a child of _canvas so markers auto-
## follow the same transform.
## - markers.json schema (D-191 §8): cities, roads, railroads, pois, plus rivers,
## oceans, mountain_ranges with `center: [row, col]` and optional names.
## - Empty markers case (server #832/#833 not yet shipped): bare heightmap renders
## fine, no sidebar opens, overlays draw nothing.
## - Overlays (#836) plug into _overlay_visibility dict and _draw_overlays().
##
## Navigation:
## Mouse drag pan the map
## Mouse wheel zoom in / out (centered on cursor)
## Click city open city data panel
## R reset view
## Esc back to body entry
signal back_pressed
signal economics_link_requested(system_id: String)
const MIN_ZOOM: float = 0.5
const MAX_ZOOM: float = 8.0
const ZOOM_STEP: float = 1.15
const PANEL_WIDTH: float = 320.0
const PANEL_MARGIN: float = 16.0
# ── Colors ────────────────────────────────────────────────────────────────────
const COLOR_BG: Color = Color("#0d1117")
const COLOR_HEIGHTMAP_TINT: Color = Color(0.85, 0.88, 0.95, 1.0)
const COLOR_CITY: Color = Color("#f0d060")
const COLOR_CITY_HOVER: Color = Color("#ffffff")
const COLOR_CITY_LABEL: Color = Color("#c8d0e0")
const COLOR_ROAD: Color = Color("#d89040")
const COLOR_RAIL: Color = Color("#7888a0")
const COLOR_POI: Color = Color("#70c0d8")
const COLOR_FEATURE_LABEL: Color = Color(0.78, 0.82, 0.92, 0.65)
const COLOR_GATE_MARKER: Color = Color("#b0e0ff")
const COLOR_POLITICAL_ZONE: Color = Color(0.25, 0.45, 0.65, 0.12)
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
const COLOR_EMPTY_NOTICE: Color = Color("#445566")
# ── Overlay definitions (single source of truth, review #7) ─────────────────
## Overlay catalogue consumed by both AtlasMarkerOverlay (renders) and
## AtlasOverlayBar (exposes as toggle buttons). Groups map to the D-181 signal
## visibility ladder: always = public, toggle = observable, locked = semi-
## private / private. Keep this table in sync with D-191 §7.
const OVERLAY_DEFS: Array = [
{
"id": "terrain",
"label": "TER",
"group": "always",
"tooltip": "Terrain — base heightmap. Always visible."
},
{
"id": "infrastructure",
"label": "INF",
"group": "always",
"tooltip": "Infrastructure — roads + rail. Always visible."
},
{
"id": "named_features",
"label": "NAM",
"group": "always",
"tooltip": "Named features — rivers, oceans, ranges. Always visible."
},
{
"id": "gate_markers",
"label": "GAT",
"group": "always",
"tooltip": "Gate + spaceport markers. Always visible."
},
{
"id": "political_zones",
"label": "POL",
"group": "always",
"tooltip": "Political zones — currency-zone bands. Always visible."
},
{
"id": "population_density",
"label": "POP",
"group": "toggle",
"tooltip": "Population density (D-181 public)."
},
{
"id": "production_zones",
"label": "PRD",
"group": "toggle",
"tooltip": "Production zones (D-181 observable — requires presence)."
},
{
"id": "shadow_economy",
"label": "SHD",
"group": "toggle",
"tooltip": "Shadow economy zones (D-181 observable)."
},
{
"id": "corp_presence",
"label": "CRP",
"group": "toggle",
"tooltip": "Corporate presence — Tier 1 only (D-181 observable)."
},
{
"id": "stockpile_weeks",
"label": "STK",
"group": "locked",
"tooltip": "Stockpile weeks — LOCKED. Needs corporate contact (D-181 semi-private)."
},
{
"id": "production_vs_baseline",
"label": "BSL",
"group": "locked",
"tooltip": "Production vs baseline — LOCKED. Needs insider access (D-181 private)."
},
]
# ── Context (set by AtlasPanel.show_body) ─────────────────────────────────────
var _body: Dictionary = {}
var _system: Dictionary = {}
var _implant_theme = null
# ── Heightmap + markers ───────────────────────────────────────────────────────
var _heightmap_texture: Texture2D = null
var _markers: Dictionary = {}
var _grid_w: float = 512.0
var _grid_h: float = 256.0
var _tex_w: float = 1024.0
var _tex_h: float = 512.0
# ── Pan/zoom state ────────────────────────────────────────────────────────────
var _view_offset: Vector2 = Vector2.ZERO
var _view_zoom: float = 1.0
var _dragging: bool = false
var _drag_start_mouse: Vector2
var _drag_start_offset: Vector2
# ── Selection ─────────────────────────────────────────────────────────────────
var _selected_city: Dictionary = {}
var _hovered_city: Dictionary = {}
# ── Overlay visibility (#836 plugs in here) ───────────────────────────────────
## Runtime state derived from OVERLAY_DEFS in _ready() — always-on overlays
## start true, toggleables false, locked entries are tracked separately and
## can't be flipped by set_overlay_visible().
var _overlay_visibility: Dictionary = {}
var _overlay_locked: Dictionary = {}
# ── Child nodes ───────────────────────────────────────────────────────────────
var _canvas: Node2D = null # transformed node holding heightmap + markers
var _overlay_node: AtlasMarkerOverlay = null # draws on top of heightmap
var _city_panel = null # ImplantPanel sidebar (city data)
var _empty_notice = null # ImplantPanel shown when heightmap missing
var _overlay_bar = null # #836 overlay toggle bar (no class_name, review #8)
var _screen_header: ImplantHeader = null # top-left title/hint (D-169 composition)
func _ready() -> void:
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = Control.GROW_DIRECTION_BOTH
grow_vertical = Control.GROW_DIRECTION_BOTH
mouse_filter = Control.MOUSE_FILTER_STOP
focus_mode = Control.FOCUS_ALL
_implant_theme = load("res://ui/implant/default_implant.tres")
for def: Dictionary in OVERLAY_DEFS:
var group: String = def["group"]
_overlay_visibility[def["id"]] = (group == "always")
if group == "locked":
_overlay_locked[def["id"]] = true
# Build transformed canvas that holds heightmap + marker overlay
_canvas = Node2D.new()
_canvas.name = "AtlasCanvas"
add_child(_canvas)
_overlay_node = AtlasMarkerOverlay.new()
_overlay_node.name = "MarkerOverlay"
_overlay_node.viewer = self
_canvas.add_child(_overlay_node)
_build_screen_header()
_build_city_panel()
_build_empty_notice()
_build_overlay_bar()
## Called by AtlasPanel when entering the viewer for a specific body.
func show_body(body: Dictionary, system: Dictionary) -> void:
_body = body
_system = system
_selected_city = {}
_hovered_city = {}
_load_heightmap()
_load_markers()
_fit_to_view()
_refresh_screen_header()
_city_panel.visible = false
_empty_notice.visible = (_heightmap_texture == null)
grab_focus()
queue_redraw()
_overlay_node.queue_redraw()
## #836 hook — toggle an overlay. Locked overlays are no-ops.
func set_overlay_visible(overlay_id: String, visible_state: bool) -> void:
if _overlay_locked.get(overlay_id, false):
return
if not _overlay_visibility.has(overlay_id):
push_warning("AtlasViewer: unknown overlay id '%s'" % overlay_id)
return
_overlay_visibility[overlay_id] = visible_state
_overlay_node.queue_redraw()
func is_overlay_visible(overlay_id: String) -> bool:
return bool(_overlay_visibility.get(overlay_id, false))
func is_overlay_locked(overlay_id: String) -> bool:
return bool(_overlay_locked.get(overlay_id, false))
func get_heightmap_texture() -> Texture2D:
return _heightmap_texture
func get_markers() -> Dictionary:
return _markers
func get_hovered_city() -> Dictionary:
return _hovered_city
func get_selected_city() -> Dictionary:
return _selected_city
func get_overlay_defs() -> Array:
return OVERLAY_DEFS
# =============================================================================
# Data loading
# =============================================================================
func _load_heightmap() -> void:
# Reset to sentinel defaults so a body with no heightmap (or that fails to
# load) doesn't inherit the previous body's dimensions — otherwise
# grid_to_canvas would project markers using the prior texture size
# (review #2).
_heightmap_texture = null
_tex_w = 1024.0
_tex_h = 512.0
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var path: String = str(ref)
if not path.begins_with("res://"):
path = "res://" + path.lstrip("/")
if not ResourceLoader.exists(path):
push_warning("AtlasViewer: terrain_reference not found at %s" % path)
return
var tex: Variant = load(path)
if tex is Texture2D:
_heightmap_texture = tex
_tex_w = float(_heightmap_texture.get_width())
_tex_h = float(_heightmap_texture.get_height())
func _load_markers() -> void:
_markers = {}
# Reset grid dims alongside _tex_* in _load_heightmap so we start from a
# known baseline regardless of which body ran previously.
_grid_w = _tex_w
_grid_h = _tex_h
var ref: Variant = _body.get("terrain_reference")
if ref == null or str(ref).is_empty():
return
var hm_path: String = str(ref)
if not hm_path.begins_with("res://"):
hm_path = "res://" + hm_path.lstrip("/")
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
if not FileAccess.file_exists(markers_path):
return
var f := FileAccess.open(markers_path, FileAccess.READ)
if f == null:
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
f.close()
if not (parsed is Dictionary):
return
_markers = parsed
var grid: Dictionary = _markers.get("grid", {})
_grid_w = float(grid.get("w", _tex_w))
_grid_h = float(grid.get("h", _tex_h))
# =============================================================================
# View transform
# =============================================================================
func _fit_to_view() -> void:
if _heightmap_texture == null:
_view_zoom = 1.0
_view_offset = Vector2.ZERO
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
return
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
var avail: Vector2 = sz - Vector2(0, 60) # leave space for header
var fit_x: float = avail.x / _tex_w
var fit_y: float = avail.y / _tex_h
_view_zoom = clampf(minf(fit_x, fit_y) * 0.92, MIN_ZOOM, MAX_ZOOM)
var scaled: Vector2 = Vector2(_tex_w, _tex_h) * _view_zoom
_view_offset = (sz - scaled) * 0.5 + Vector2(0, 20)
_apply_transform()
func _apply_transform() -> void:
_canvas.position = _view_offset
_canvas.scale = Vector2(_view_zoom, _view_zoom)
queue_redraw()
_overlay_node.queue_redraw()
func _zoom_at(mouse_pos: Vector2, factor: float) -> void:
var new_zoom: float = clampf(_view_zoom * factor, MIN_ZOOM, MAX_ZOOM)
if is_equal_approx(new_zoom, _view_zoom):
return
# Keep the texture point under cursor fixed while zooming
var local_before: Vector2 = (mouse_pos - _view_offset) / _view_zoom
_view_zoom = new_zoom
_view_offset = mouse_pos - local_before * _view_zoom
_apply_transform()
## Convert grid coordinates (from markers.json) to canvas-space (texture pixels).
func grid_to_canvas(grid_point: Vector2) -> Vector2:
if _grid_w <= 0.0 or _grid_h <= 0.0:
return Vector2.ZERO
return Vector2(grid_point.x / _grid_w * _tex_w, grid_point.y / _grid_h * _tex_h)
func canvas_to_screen(canvas_point: Vector2) -> Vector2:
return canvas_point * _view_zoom + _view_offset
func screen_to_canvas(screen_point: Vector2) -> Vector2:
if _view_zoom == 0.0:
return Vector2.ZERO
return (screen_point - _view_offset) / _view_zoom
# =============================================================================
# Drawing (background + header)
# =============================================================================
func _draw() -> void:
draw_rect(Rect2(Vector2.ZERO, get_rect().size), COLOR_BG)
func _build_screen_header() -> void:
# D-169: compose the title + hint from ImplantHeader so the implant theme
# drives fonts/colors. Review #4 flagged the draw_string() approach as a
# theme-swap invariant violation.
_screen_header = ImplantHeader.new()
_screen_header.position = Vector2(PANEL_MARGIN, 16.0)
_screen_header.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_screen_header)
if _implant_theme:
_screen_header.apply_implant_theme(_implant_theme)
func _refresh_screen_header() -> void:
if _screen_header == null:
return
var body_name: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", "—"))
var sys_name: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", "—"))
var title: String = "ATLAS — %s · %s" % [body_name.to_upper(), sys_name.to_upper()]
var hint: String = "drag pan · wheel zoom · r reset · click city data · esc back"
_screen_header.set_content(title, hint)
## Safely extract a string field from a dict, falling back when missing or null.
static func _dict_str(d: Dictionary, key: String, fallback: String) -> String:
var v: Variant = d.get(key)
if v == null:
return fallback
var s: String = str(v)
if s.is_empty():
return fallback
return s
# =============================================================================
# Input
# =============================================================================
func _gui_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.is_echo():
_handle_key(event as InputEventKey)
return
if _heightmap_texture == null:
return
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
_zoom_at(mb.position, ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
_zoom_at(mb.position, 1.0 / ZOOM_STEP)
elif mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
if not _try_click_city(mb.position):
_dragging = true
_drag_start_mouse = mb.position
_drag_start_offset = _view_offset
else:
_dragging = false
elif event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
if _dragging:
_view_offset = _drag_start_offset + (mm.position - _drag_start_mouse)
_apply_transform()
else:
_update_hover(mm.position)
func _handle_key(event: InputEventKey) -> void:
match event.keycode:
KEY_ESCAPE:
if _selected_city.size() > 0:
_selected_city = {}
_city_panel.visible = false
queue_redraw()
else:
back_pressed.emit()
KEY_R:
_fit_to_view()
func _try_click_city(screen_pos: Vector2) -> bool:
var city: Dictionary = _find_city_at(screen_pos)
if city.is_empty():
return false
_selected_city = city
_rebuild_city_panel()
_city_panel.visible = true
queue_redraw()
return true
func _update_hover(screen_pos: Vector2) -> void:
var new_hover: Dictionary = _find_city_at(screen_pos)
if new_hover != _hovered_city:
_hovered_city = new_hover
_overlay_node.queue_redraw()
func _find_city_at(screen_pos: Vector2) -> Dictionary:
var cities: Array = _markers.get("cities", [])
if cities.is_empty():
return {}
var hit_radius: float = 12.0
for c: Dictionary in cities:
var canvas_pt: Vector2 = city_canvas_pos(c)
var screen_pt: Vector2 = canvas_to_screen(canvas_pt)
if screen_pos.distance_to(screen_pt) <= hit_radius:
return c
return {}
## Extract canvas-space position for a city marker. Supports `pos: [x,y]`,
## `lat`/`lon` (equirectangular), and legacy `center: [row,col]`. Public so
## AtlasMarkerOverlay doesn't need to reach into private state (review #5).
func city_canvas_pos(city: Dictionary) -> Vector2:
if city.has("pos") and city["pos"] is Array and city["pos"].size() >= 2:
return grid_to_canvas(Vector2(float(city["pos"][0]), float(city["pos"][1])))
if city.has("lat") and city.has("lon"):
var lon: float = float(city["lon"])
var lat: float = float(city["lat"])
return Vector2((lon + 180.0) / 360.0 * _tex_w, (90.0 - lat) / 180.0 * _tex_h)
if city.has("center") and city["center"] is Array and city["center"].size() >= 2:
# center is [row, col] in grid space
return grid_to_canvas(Vector2(float(city["center"][1]), float(city["center"][0])))
return Vector2.ZERO
# =============================================================================
# City data panel (sidebar)
# =============================================================================
func _build_city_panel() -> void:
_city_panel = ImplantPanel.new()
_city_panel.name = "CityPanel"
_city_panel.theme_resource = _implant_theme
_city_panel.custom_minimum_size.x = PANEL_WIDTH
_city_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_city_panel.visible = false
add_child(_city_panel)
_position_city_panel()
func _position_city_panel() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_city_panel.position = Vector2(sz.x - PANEL_WIDTH - PANEL_MARGIN, 60.0)
func _rebuild_city_panel() -> void:
if not _city_panel:
return
_city_panel.clear()
_position_city_panel()
if _selected_city.is_empty():
return
var c: Dictionary = _selected_city
var name_str: String = _dict_str(c, "name", "(unnamed)")
var tier: int = int(c.get("population_tier", 0))
var func_label: String = _dict_str(c, "primary_function", "").replace("_", " ")
var is_gate: bool = bool(c.get("gate_terminal", false))
var sys_label: String = _dict_str(_system, "proper_name", _dict_str(_system, "system_id", ""))
var body_label: String = _dict_str(_body, "proper_name", _dict_str(_body, "body_id", ""))
_city_panel.add_component(ImplantHeader.new(name_str, sys_label + " · " + body_label))
_city_panel.add_component(ImplantSeparator.new())
if tier > 0:
_city_panel.add_component(ImplantDataRow.new("pop tier %d" % tier))
if not func_label.is_empty():
_city_panel.add_component(ImplantDataRow.new("function " + func_label))
if is_gate:
_city_panel.add_component(ImplantDataRow.new("gate terminal"))
# Cross-system facts
var czone: String = str(_system.get("currency_zone", "")).replace("_", " ")
if not czone.is_empty():
_city_panel.add_component(ImplantDataRow.new("currency " + czone))
var commission: bool = bool(c.get("commission_presence", false))
_city_panel.add_component(
ImplantDataRow.new("commission " + ("yes" if commission else "no"))
)
var shadow: Variant = c.get("shadow_economy_zone")
if shadow != null and not str(shadow).is_empty():
_city_panel.add_component(ImplantDataRow.new("shadow zone " + str(shadow)))
var gate_dist: Variant = c.get("gate_distance_hops")
if gate_dist != null:
_city_panel.add_component(ImplantDataRow.new("gate distance %d hops" % int(gate_dist)))
_city_panel.add_component(ImplantSeparator.new())
_city_panel.add_component(ImplantTextBlock.new("n open economics monitor for this system"))
_city_panel.add_component(ImplantTextBlock.new("esc close"))
func _unhandled_key_input(event: InputEvent) -> void:
if not visible:
return
if not (event is InputEventKey) or not event.pressed or event.is_echo():
return
var ek := event as InputEventKey
if ek.keycode != KEY_N:
return
# Always consume KEY_N while the viewer is visible — otherwise it would
# fall through to main.gd's global economics monitor toggle and close the
# fullscreen atlas as a side effect (review #6).
get_viewport().set_input_as_handled()
if _selected_city.size() > 0:
var sys_id: String = _system.get("system_id", "")
if not sys_id.is_empty():
economics_link_requested.emit(sys_id)
# =============================================================================
# Empty-state notice
# =============================================================================
func _build_empty_notice() -> void:
_empty_notice = ImplantPanel.new()
_empty_notice.name = "EmptyNotice"
_empty_notice.theme_resource = _implant_theme
_empty_notice.custom_minimum_size.x = 400.0
_empty_notice.mouse_filter = Control.MOUSE_FILTER_IGNORE
_empty_notice.visible = false
add_child(_empty_notice)
_empty_notice.add_component(
ImplantHeader.new("TERRAIN DATA PENDING", "regional atlas unavailable")
)
_empty_notice.add_component(ImplantSeparator.new())
var notice_text: String = (
"The server atlas pipeline has not yet populated terrain_reference for this body (#839). "
+ "Once regenerated, the heightmap and markers will render here."
)
_empty_notice.add_component(ImplantTextBlock.new(notice_text))
_empty_notice.add_component(ImplantSeparator.new())
_empty_notice.add_component(ImplantTextBlock.new("esc back to orbital view"))
_position_empty_notice()
func _position_empty_notice() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
_empty_notice.position = Vector2((sz.x - 400.0) * 0.5, sz.y * 0.35)
# =============================================================================
# Overlay toggle bar (#836)
# =============================================================================
func _build_overlay_bar() -> void:
var BarScript := load("res://ui/implant/atlas_overlay_bar.gd")
_overlay_bar = BarScript.new(self)
_overlay_bar.name = "OverlayBar"
add_child(_overlay_bar)
_position_overlay_bar()
func _position_overlay_bar() -> void:
var sz: Vector2 = get_rect().size
if sz == Vector2.ZERO:
sz = Vector2(1280.0, 720.0)
# Top-right, leaving room for the header text
var bar_w: float = _overlay_bar.size.x if _overlay_bar.size.x > 0.0 else 520.0
_overlay_bar.position = Vector2(sz.x - bar_w - PANEL_MARGIN, PANEL_MARGIN)
func _notification(what: int) -> void:
if what == NOTIFICATION_RESIZED:
if _city_panel:
_position_city_panel()
if _empty_notice:
_position_empty_notice()
if _overlay_bar:
_position_overlay_bar()