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:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user