664 lines
22 KiB
GDScript
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()
|