feat(ui): atlas generation overlays — Layer-1 rivers/basins/attractors (#960)

Render the proxied Layer1Output (D-225) as toggleable Atlas overlays. The
viewer stores the decoded Layer1Output via set_generation_layer1() and the
marker overlay draws three new layers gated by their visibility flags:

  RVR  river_cells as dots, confluences as small circles, mouths as
       double-ring sea-terminus markers
  BAS  drainage-basin boundaries as thin closed polylines + faint fill
  ATR  geographic attractors — shape by attractor_type (Araminta's 7-shape
       vocabulary), color by sub_biome, size by strength; <0.15 culled

Layer-1 positions are [row, col] in the 512x256 working grid, which matches
the viewer's grid_to_canvas transform, so they project directly onto the
heightmap. OVERLAY_DEFS gains gen_l1_rivers/basins/attractors (toggle group)
so AtlasOverlayBar auto-exposes them as toggle buttons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:02:02 +02:00
co-authored by Claude Opus 4.7
parent 03d0264eb0
commit 33a2ed6aae
3 changed files with 208 additions and 5 deletions
+25
View File
@@ -0,0 +1,25 @@
## Tests for the Phase-4 generation overlays in the Atlas viewer (#960, D-225).
## Referencing AtlasViewer forces both it and AtlasMarkerOverlay to compile, so
## this also guards against parse errors in the overlay rendering code.
class_name TestAtlasOverlays
extends GdUnitTestSuite
func test_generation_overlays_registered() -> void:
var ids: Array = []
for d: Dictionary in AtlasViewer.OVERLAY_DEFS:
ids.append(d["id"])
assert_that(ids).contains(["gen_l1_rivers", "gen_l1_basins", "gen_l1_attractors"])
func test_generation_data_round_trips() -> void:
var v: AtlasViewer = auto_free(AtlasViewer.new())
assert_that(v.get_generation_layer1()).is_null()
var mock := {
"body_id": "GJ1c",
"river_network": {"river_cells": [], "confluences": [], "mouths": []},
"drainage_basins": [],
"attractors": [],
}
v.set_generation_layer1(mock)
assert_that(v.get_generation_layer1()).is_equal(mock)
@@ -31,6 +31,12 @@ const COLOR_POP_HEAT: Color = Color(0.95, 0.35, 0.25, 0.22)
const COLOR_PRODUCTION: Color = Color(0.40, 0.80, 0.55, 0.18)
const COLOR_SHADOW: Color = Color(0.35, 0.20, 0.50, 0.22)
const COLOR_CORP: Color = Color(0.85, 0.65, 0.20, 0.75)
# Generation overlays (#960, D-225, Araminta's encoding).
const COLOR_GEN_RIVER: Color = Color(0.35, 0.60, 0.90, 0.75)
const COLOR_GEN_MOUTH: Color = Color(0.50, 0.80, 1.0, 0.90)
const COLOR_GEN_BASIN_FILL: Color = Color(0.20, 0.35, 0.55, 0.06)
const COLOR_GEN_BASIN_LINE: Color = Color(0.45, 0.65, 0.85, 0.45)
const GEN_ATTRACTOR_MIN_STRENGTH: float = 0.15
const RAIL_DASH_ON: float = 6.0
const RAIL_DASH_OFF: float = 4.0
@@ -92,6 +98,17 @@ func _draw() -> void:
if viewer.is_overlay_visible("corp_presence"):
_draw_corp_presence(markers)
# Generation-cascade layers (#960, D-225) — from the proxied Layer1Output.
# Draw order: basins (area) under rivers under attractors (point anchors).
var layer1: Variant = viewer.get_generation_layer1()
if layer1 is Dictionary:
if viewer.is_overlay_visible("gen_l1_basins"):
_draw_gen_basins(layer1)
if viewer.is_overlay_visible("gen_l1_rivers"):
_draw_gen_rivers(layer1)
if viewer.is_overlay_visible("gen_l1_attractors"):
_draw_gen_attractors(layer1)
# POIs (non-gate first, then gates on top if enabled)
_draw_pois(markers)
@@ -242,7 +259,6 @@ static func _city_key(city: Dictionary) -> String:
# Province boundaries (D-205, #927)
# =============================================================================
const COLOR_PROVINCE_BORDER: Color = Color(0.45, 0.65, 0.85, 0.55)
const COLOR_PROVINCE_FILL: Color = Color(0.25, 0.45, 0.65, 0.08)
const PROVINCE_BORDER_WIDTH: float = 1.2
@@ -251,7 +267,16 @@ const PROVINCE_BORDER_WIDTH: float = 1.2
func _draw_province_boundaries(markers: Dictionary) -> void:
var provinces: Array = markers.get("provinces", [])
if provinces.is_empty():
draw_rect(Rect2(Vector2.ZERO, Vector2(viewer.get_heightmap_texture().get_width(), viewer.get_heightmap_texture().get_height())), COLOR_POLITICAL)
draw_rect(
Rect2(
Vector2.ZERO,
Vector2(
viewer.get_heightmap_texture().get_width(),
viewer.get_heightmap_texture().get_height()
)
),
COLOR_POLITICAL
)
return
for prov: Dictionary in provinces:
var path: Array = prov.get("path", [])
@@ -329,6 +354,121 @@ func _draw_corp_presence(markers: Dictionary) -> void:
# Helpers
# =============================================================================
# =============================================================================
# Generation-cascade overlays (#960, D-225)
# =============================================================================
## Map a Layer-1 [row, col] grid position to canvas space.
func _gen_pos(rc: Variant) -> Vector2:
return viewer.grid_to_canvas(Vector2(float(rc[1]), float(rc[0])))
func _draw_gen_rivers(layer1: Dictionary) -> void:
var rn: Variant = layer1.get("river_network")
if not rn is Dictionary:
return
for c: Variant in rn.get("river_cells", []):
if c is Array and c.size() >= 2:
draw_circle(_gen_pos(c), 1.2, COLOR_GEN_RIVER)
for cf: Variant in rn.get("confluences", []):
if cf is Array and cf.size() >= 2:
draw_circle(_gen_pos(cf), 3.0, COLOR_GEN_RIVER)
for m: Variant in rn.get("mouths", []):
if m is Array and m.size() >= 2:
var p: Vector2 = _gen_pos(m)
# Double ring = sea terminus (high-value anchor).
draw_arc(p, 5.0, 0.0, TAU, 18, COLOR_GEN_MOUTH, 1.5)
var halo := Color(COLOR_GEN_MOUTH.r, COLOR_GEN_MOUTH.g, COLOR_GEN_MOUTH.b, 0.30)
draw_arc(p, 8.0, 0.0, TAU, 22, halo, 1.0)
func _draw_gen_basins(layer1: Dictionary) -> void:
for b: Variant in layer1.get("drainage_basins", []):
if not b is Dictionary:
continue
var boundary: Array = b.get("boundary", [])
var pts: PackedVector2Array = PackedVector2Array()
for pt: Variant in boundary:
if pt is Array and pt.size() >= 2:
pts.append(_gen_pos(pt))
if pts.size() < 2:
continue
if pts.size() >= 3:
draw_colored_polygon(pts, COLOR_GEN_BASIN_FILL)
var loop: PackedVector2Array = pts.duplicate()
loop.append(pts[0])
draw_polyline(loop, COLOR_GEN_BASIN_LINE, 0.8, true)
func _draw_gen_attractors(layer1: Dictionary) -> void:
for a: Variant in layer1.get("attractors", []):
if not a is Dictionary:
continue
var strength: float = float(a.get("strength", 0.0))
if strength < GEN_ATTRACTOR_MIN_STRENGTH:
continue
var pos_rc: Variant = a.get("position")
if not pos_rc is Array or pos_rc.size() < 2:
continue
var size: float = 5.0 + strength * 4.0
var color: Color = _sub_biome_color(str(a.get("sub_biome", "")))
_draw_attractor_shape(str(a.get("attractor_type", "")), _gen_pos(pos_rc), size, color)
## Sub-biome → marker color (Araminta's palette). Color is additive info; shape
## carries the attractor-type identity (survives monochrome capture).
func _sub_biome_color(sub_biome: String) -> Color:
match sub_biome:
"TropicalWet", "CoastalLowland":
return Color(0.25, 0.72, 0.65, 0.90) # teal — coastal/tropical
"TemperateForest", "TemperateGrassland":
return Color(0.45, 0.68, 0.45, 0.90) # sage — temperate
"Desert", "Savanna":
return Color(0.78, 0.62, 0.35, 0.90) # sand — arid
"Alpine":
return Color(0.52, 0.58, 0.72, 0.90) # slate — alpine
"Wetland":
return Color(0.40, 0.60, 0.52, 0.90) # muted teal — wetland
"Tundra", "BorealForest":
return Color(0.55, 0.68, 0.82, 0.90) # cool grey-blue — cold
_:
return Color(0.72, 0.72, 0.76, 0.90) # default grey
## Attractor type → marker shape (Araminta's vocabulary, 7 types).
func _draw_attractor_shape(atype: String, pos: Vector2, size: float, color: Color) -> void:
match atype:
"RiverMouth":
draw_circle(pos, size, color)
"CoastalAccess":
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
draw_circle(pos, size * 0.5, color)
"RiverCrossing":
_draw_diamond(pos, size, color)
"ValleyFloor":
_draw_triangle(pos, size, color, true)
"PassEntrance":
_draw_triangle(pos, size, color, false)
"LakeShore":
draw_arc(pos, size, 0.0, TAU, 18, color, 1.5)
"PlainCenter":
draw_rect(Rect2(pos - Vector2(size, size) * 0.7, Vector2(size, size) * 1.4), color)
_:
draw_circle(pos, size, color)
func _draw_triangle(pos: Vector2, size: float, color: Color, point_down: bool) -> void:
var dir: float = 1.0 if point_down else -1.0
var pts: PackedVector2Array = PackedVector2Array(
[
pos + Vector2(0.0, size * dir),
pos + Vector2(-size, -size * dir),
pos + Vector2(size, -size * dir),
]
)
draw_colored_polygon(pts, color)
func _path_to_canvas(path: Array) -> PackedVector2Array:
var out: PackedVector2Array = PackedVector2Array()
+41 -3
View File
@@ -116,6 +116,24 @@ const OVERLAY_DEFS: Array = [
"group": "locked",
"tooltip": "Production vs baseline — LOCKED. Needs insider access (D-181 private)."
},
{
"id": "gen_l1_rivers",
"label": "RVR",
"group": "toggle",
"tooltip": "Layer 1 — river network (generation overlay, #960)."
},
{
"id": "gen_l1_basins",
"label": "BAS",
"group": "toggle",
"tooltip": "Layer 1 — drainage basins (generation overlay, #960)."
},
{
"id": "gen_l1_attractors",
"label": "ATR",
"group": "toggle",
"tooltip": "Layer 1 — geographic attractors (generation overlay, #960)."
},
]
# ── Context (set by show_body) ─────────────────────────────────────────────────
@@ -126,6 +144,7 @@ var _implant_theme = null
# ── Heightmap + markers ───────────────────────────────────────────────────────
var _heightmap_texture: Texture2D = null
var _markers: Dictionary = {}
var _generation_layer1: Variant = null # #960: Layer1Output from the proxy (D-225)
var _grid_w: float = 512.0
var _grid_h: float = 256.0
var _tex_w: float = 1024.0
@@ -234,6 +253,18 @@ func get_markers() -> Dictionary:
return _markers
## #960: store the Layer-1 generation output (from atlas_layers_received) and
## redraw the overlay. The marker overlay reads it via get_generation_layer1().
func set_generation_layer1(layer1: Variant) -> void:
_generation_layer1 = layer1
if _overlay_node:
_overlay_node.queue_redraw()
func get_generation_layer1() -> Variant:
return _generation_layer1
func get_hovered_city() -> Dictionary:
return _hovered_city
@@ -267,7 +298,9 @@ func _load_heightmap() -> void:
# color reliefmap.png that sits beside it.
var path: String = str(ref).get_base_dir() + "/reliefmap.png"
if not path.begins_with("/"):
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
var project_root: String = (
ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
)
path = project_root + "/" + path
if not FileAccess.file_exists(path):
push_warning("AtlasViewer: reliefmap not found at %s" % path)
@@ -291,7 +324,9 @@ func _load_markers() -> void:
return
var hm_path: String = str(ref)
if not hm_path.begins_with("/"):
var project_root: String = ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
var project_root: String = (
ProjectSettings.globalize_path("res://").get_base_dir().get_base_dir()
)
hm_path = project_root + "/" + hm_path
var dir: String = hm_path.get_base_dir()
var markers_path: String = dir + "/markers.json"
@@ -434,7 +469,10 @@ func _gui_input(event: InputEvent) -> void:
if _heightmap_texture == null:
return
if event is InputEventMouseButton and _is_over_ui((event as InputEventMouseButton).global_position):
if (
event is InputEventMouseButton
and _is_over_ui((event as InputEventMouseButton).global_position)
):
return
if event is InputEventMouseButton: