diff --git a/client/tests/test_atlas_overlays.gd b/client/tests/test_atlas_overlays.gd new file mode 100644 index 000000000..6151f469a --- /dev/null +++ b/client/tests/test_atlas_overlays.gd @@ -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) diff --git a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd index d02631d0d..31c171f55 100644 --- a/client/ui/implant/apps/atlas/atlas_marker_overlay.gd +++ b/client/ui/implant/apps/atlas/atlas_marker_overlay.gd @@ -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() diff --git a/client/ui/implant/apps/atlas/atlas_viewer.gd b/client/ui/implant/apps/atlas/atlas_viewer.gd index e967eee2e..a6a8f5a21 100644 --- a/client/ui/implant/apps/atlas/atlas_viewer.gd +++ b/client/ui/implant/apps/atlas/atlas_viewer.gd @@ -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: