Files
settled-reach/client/ui/minimap.gd
T
jpmschweitzerandClaude Opus 4.6 879ef6092f fix(ui): address PR #115 review — 8 items
- Fix stale test path UILayer/HUD → InsertOverlay/HUD
- Fix D-record citation D-051 → D-049 in main.tscn
- Add null guards to hud.gd update methods (pre-_ready safety)
- Minimap: dirty-flag queue_redraw instead of per-frame
- Remove redundant _panel.size.x, debug print
- Fix DebugOverlay/GauntletHUD positioning comments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:45:47 +02:00

228 lines
7.4 KiB
GDScript

class_name MinimapRenderer
extends Control
## Minimap — diegetic neural insert overlay. D-013, D-049 z-layer 6.
##
## Player always centered. Fixed-north (no rotation per D-015).
## Nearby POIs (within MINIMAP_RADIUS sim tiles): colored dot/shape at scaled position.
## Distant POIs (beyond radius): directional arrow at circle border pointing toward POI.
## Frame renders always — the insert is on even when no POIs are discovered.
##
## POI categories → shapes:
## danger / threat / hostile → diamond (ENTITY_COLOR_HOSTILE, red)
## evidence / note / clue → square (ENTITY_COLOR_POI, amber)
## contact / npc / person → circle (ENTITY_COLOR_UNKNOWN, teal)
## location / place / venue → circle (INSERT_COLOR_TEXT, white-blue)
## (default) → circle (INSERT_COLOR_TEXT)
## Sim tiles visible within the minimap circle. POIs beyond this show as border arrows.
const MINIMAP_RADIUS: float = 24.0
# Visual parameters
const FRAME_WIDTH: float = 1.2
const PLAYER_DOT_RADIUS: float = 3.5
const POI_DOT_RADIUS: float = 3.0
const ARROW_HALF: float = 4.5
const ARROW_LEN: float = 7.0
const NORTH_TICK_LEN: float = 8.0
const CARDINAL_TICK_LEN: float = 4.0
# Colors — insert palette from Constants, tuned for the circular minimap frame
const COLOR_BG: Color = Color(0.04, 0.07, 0.12, 0.82)
const COLOR_FRAME: Color = Color(0.784, 0.816, 0.878, 0.55) # INSERT_COLOR_TEXT at reduced alpha
const COLOR_NORTH: Color = Color(0.784, 0.816, 0.878, 0.9) # Brighter for N tick
const COLOR_CARDINAL: Color = Color(0.784, 0.816, 0.878, 0.4) # Dimmer E/S/W ticks
const COLOR_PLAYER: Color = Constants.ENTITY_COLOR_PLAYER
var _insert_active: bool = true
# D-169: StyleBoxFlat matching ImplantPanel aesthetic — drawn as container frame behind the circle.
var _container_style: StyleBoxFlat = null
# Dirty flag — only queue_redraw() when player position or POI list changes.
var _dirty: bool = true
var _last_player_pos: Vector2 = Vector2(INF, INF)
var _last_poi_count: int = -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
set_process(true)
_init_container_style()
func _init_container_style() -> void:
var t := load("res://ui/implant/default_implant.tres") as ImplantTheme
if not t:
return
_container_style = StyleBoxFlat.new()
_container_style.bg_color = t.panel_bg
_container_style.border_color = t.separator
_container_style.set_border_width_all(int(t.border_width))
_container_style.set_content_margin_all(0)
func _process(_delta: float) -> void:
if not _insert_active:
return
var pos := GameState.player_position
var poi_count := GameState.discovered_pois.size()
if pos != _last_player_pos or poi_count != _last_poi_count:
_last_player_pos = pos
_last_poi_count = poi_count
_dirty = true
if _dirty:
_dirty = false
queue_redraw()
## Called from main.gd when GameState.insert_active changes.
## Hides the minimap overlay when the neural insert is inactive.
func set_insert_active(active: bool) -> void:
_insert_active = active
visible = active
if active:
_dirty = true
func _draw() -> void:
# D-169: ImplantPanel-style container frame behind the circular minimap.
# Gives the minimap the same panel border aesthetic as other implant components.
if _container_style:
_container_style.draw(get_canvas_item(), Rect2(Vector2.ZERO, get_rect().size))
var sz: Vector2 = get_rect().size
var center := sz / 2.0
# Outer radius: fill control with 1px edge padding
var outer_r: float = minf(sz.x, sz.y) / 2.0 - 1.0
# --- Background fill ---
draw_circle(center, outer_r, COLOR_BG)
# --- Frame ring ---
draw_arc(center, outer_r, 0.0, TAU, 64, COLOR_FRAME, FRAME_WIDTH, true)
# --- Cardinal ticks (N brighter, E/S/W dimmer) ---
_draw_cardinal_ticks(center, outer_r)
# --- Player dot at center ---
draw_circle(center, PLAYER_DOT_RADIUS, COLOR_PLAYER)
# Soft bloom ring
draw_arc(
center,
PLAYER_DOT_RADIUS + 1.5,
0.0,
TAU,
32,
Color(COLOR_PLAYER.r, COLOR_PLAYER.g, COLOR_PLAYER.b, 0.22),
1.0,
true
)
# --- POIs ---
var pois: Array = GameState.discovered_pois
if pois.is_empty():
return
var px: float = GameState.player_position.x
var py: float = GameState.player_position.y
# Pixels per sim tile within the inner drawable area
var inner_r: float = outer_r - FRAME_WIDTH
var scale: float = inner_r / MINIMAP_RADIUS
for poi in pois:
if not poi is Dictionary:
continue
if not poi.has("x") or not poi.has("y"):
continue
var dx: float = float(poi.x) - px
var dy: float = float(poi.y) - py
var dist: float = sqrt(dx * dx + dy * dy)
var category: String = poi.get("poi_category", "")
var color: Color = _category_color(category)
if dist < 0.01:
# POI at exact player position — draw at center offset slightly
_draw_poi_shape(center + Vector2(0.0, -POI_DOT_RADIUS - 2.0), color, category)
elif dist <= MINIMAP_RADIUS:
# Nearby: project to screen position within circle
var poi_screen := center + Vector2(dx, dy) * scale
# Hard-clamp to inner circle boundary (guards floating-point edge cases)
var rel := poi_screen - center
if rel.length() > inner_r - POI_DOT_RADIUS - 1.0:
poi_screen = center + rel.normalized() * (inner_r - POI_DOT_RADIUS - 1.0)
_draw_poi_shape(poi_screen, color, category)
else:
# Distant: arrow at border pointing toward POI direction
var dir := Vector2(dx, dy).normalized()
var arrow_tip := center + dir * (inner_r - 2.0)
_draw_border_arrow(arrow_tip, dir, color)
func _draw_cardinal_ticks(center: Vector2, outer_r: float) -> void:
# North tick — longer, brighter, the fixed-north indicator
var n_dir := Vector2(0.0, -1.0)
draw_line(
center + n_dir * (outer_r - NORTH_TICK_LEN),
center + n_dir * outer_r,
COLOR_NORTH,
FRAME_WIDTH + 0.5,
true
)
# East (PI/2), South (PI), West (3PI/2) — shorter, dimmer
for angle in [PI / 2.0, PI, 3.0 * PI / 2.0]:
var dir := Vector2(cos(angle), sin(angle))
draw_line(
center + dir * (outer_r - CARDINAL_TICK_LEN),
center + dir * outer_r,
COLOR_CARDINAL,
FRAME_WIDTH,
true
)
func _draw_poi_shape(pos: Vector2, color: Color, category: String) -> void:
match category.to_lower():
"danger", "threat", "hostile":
# Diamond for danger
var s: float = POI_DOT_RADIUS + 1.0
draw_polygon(
PackedVector2Array(
[
pos + Vector2(0.0, -s),
pos + Vector2(s, 0.0),
pos + Vector2(0.0, s),
pos + Vector2(-s, 0.0)
]
),
PackedColorArray([color, color, color, color])
)
"evidence", "note", "clue":
# Square for evidence/clue
var s: float = POI_DOT_RADIUS - 0.5
draw_rect(Rect2(pos - Vector2(s, s), Vector2(s * 2.0, s * 2.0)), color)
_:
draw_circle(pos, POI_DOT_RADIUS, color)
## Arrow tip at `tip`, pointing in `dir`. Arrow body extends ARROW_LEN back from tip.
func _draw_border_arrow(tip: Vector2, dir: Vector2, color: Color) -> void:
var perp := Vector2(-dir.y, dir.x)
var base_center := tip - dir * ARROW_LEN
draw_polygon(
PackedVector2Array([tip, base_center - perp * ARROW_HALF, base_center + perp * ARROW_HALF]),
PackedColorArray([color, color, color])
)
func _category_color(category: String) -> Color:
match category.to_lower():
"danger", "threat", "hostile":
return Constants.ENTITY_COLOR_HOSTILE # #d45d5d — red
"evidence", "note", "clue":
return Constants.ENTITY_COLOR_POI # #e8c547 — amber
"contact", "npc", "person":
return Constants.ENTITY_COLOR_UNKNOWN # #4a9ebb — teal
_:
return Constants.INSERT_COLOR_TEXT # #c8d0e0 — white-blue