feat(rendering): Introduce OverlayRenderer class with overlay rendering and management capabilities, plus unit tests for rendering logic and positioning

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-16 23:14:46 -07:00
parent 11426744df
commit c1dc096f12
2 changed files with 278 additions and 0 deletions

View file

@ -0,0 +1,220 @@
class_name OverlayRenderer
extends Node2D
## Draws small resource-category icons on each visible tile at hex center + offset.
## Three shapes map to the three collectible categories:
## strategic → diamond (rotated square)
## luxury → sparkle (4-point star)
## bonus → circle (polygon approximation)
##
## Zoom culling: icons are hidden below CULL_ZOOM_THRESHOLD to avoid visual noise
## on zoomed-out views. The Camera2D node is resolved lazily from the scene tree.
##
## Sprite precedence: ThemeAssets.load_sprite("sprites/resources/<id>.png") if present,
## otherwise the geometric fallback is used. Missing sprites do NOT push_error.
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd")
const TileScript: GDScript = preload("res://engine/src/map/tile.gd")
## Hide icons when camera zoom is below this threshold (too zoomed-out).
const CULL_ZOOM_THRESHOLD: float = 0.18
## Icon positioned in the top-right quadrant of the hex, above terrain clutter.
const ICON_OFFSET: Vector2 = Vector2(14.0, -12.0)
## Geometric shape sizes
const DIAMOND_RADIUS: float = 6.0
const CIRCLE_RADIUS: float = 5.5
const STAR_OUTER: float = 7.0
const STAR_INNER: float = 3.5
const CIRCLE_SEGMENTS: int = 12
const OUTLINE_WIDTH: float = 1.2
## Category colors for geometric fallback
const COLOR_STRATEGIC: Color = Color(0.55, 0.55, 0.65, 0.92) # grey-blue
const COLOR_LUXURY: Color = Color(1.0, 0.82, 0.1, 0.92) # gold
const COLOR_BONUS: Color = Color(0.4, 0.78, 0.35, 0.92) # green
const COLOR_OUTLINE: Color = Color(0.0, 0.0, 0.0, 0.75)
var _map: GameMapScript = null
var _player_index: int = 0
var _fog_disabled: bool = false
var _texture_cache: Dictionary = {}
## axial Vector2i → Array[Node2D]
var _icon_nodes: Dictionary = {}
## Last zoom value used for culling check
var _last_zoom: float = 1.0
func initialize(player_index: int) -> void:
_player_index = player_index
_fog_disabled = EnvConfig.get_bool("FORCE_DISABLE_FOGOFWAR")
func render_overlays(game_map: GameMapScript) -> void:
_map = game_map
_clear_all()
for axial: Vector2i in game_map.tiles:
var tile: TileScript = game_map.tiles[axial] as TileScript
if tile == null:
continue
if tile.resource_id == "":
continue
var vis: int = tile.get_visibility(_player_index) if not _fog_disabled else 2
if vis == 0:
continue
if not tile.is_resource_visible_to(_player_index):
continue
_spawn_icon(axial, tile.resource_id)
_apply_zoom_culling(_last_zoom)
func notify_zoom(zoom: float) -> void:
if absf(zoom - _last_zoom) < 0.005:
return
_last_zoom = zoom
_apply_zoom_culling(zoom)
func _process(_delta: float) -> void:
## Poll the active camera zoom each frame for culling. Uses get_viewport().get_camera_2d()
## so the renderer stays decoupled from a specific camera node reference.
if _icon_nodes.is_empty():
return
var vp: Viewport = get_viewport()
if vp == null:
return
var cam: Camera2D = vp.get_camera_2d()
if cam == null:
return
notify_zoom(cam.zoom.x)
func _apply_zoom_culling(zoom: float) -> void:
var visible: bool = zoom >= CULL_ZOOM_THRESHOLD
for nodes: Variant in _icon_nodes.values():
for n: Node2D in (nodes as Array[Node2D]):
if is_instance_valid(n):
n.visible = visible
func _spawn_icon(axial: Vector2i, resource_id: String) -> void:
var res_data: Dictionary = DataLoader.get_resource(resource_id)
var origin: Vector2 = HexUtilsScript.axial_to_pixel(axial)
var world_pos: Vector2 = origin + HexUtilsScript.hex_center + ICON_OFFSET
var z: int = HexUtilsScript.axial_to_offset(axial).y + 3
var node: Node2D = _try_sprite_icon(resource_id, world_pos, z)
if node == null:
node = _make_shape_icon(res_data, world_pos, z)
add_child(node)
var arr: Array[Node2D] = []
arr.append(node)
_icon_nodes[axial] = arr
func _try_sprite_icon(resource_id: String, world_pos: Vector2, z: int) -> Node2D:
var path: String = "sprites/resources/%s.png" % resource_id
var tex: Texture2D = _load_texture(path)
if tex == null:
return null
var sprite: Sprite2D = Sprite2D.new()
sprite.texture = tex
sprite.centered = true
sprite.scale = Vector2(0.22, 0.22)
sprite.position = world_pos
sprite.z_index = z
return sprite
func _make_shape_icon(res_data: Dictionary, world_pos: Vector2, z: int) -> Node2D:
var category: String = res_data.get("category", "bonus")
match category:
"strategic":
return _make_diamond(world_pos, COLOR_STRATEGIC, z)
"luxury":
return _make_star(world_pos, COLOR_LUXURY, z)
_:
return _make_circle(world_pos, COLOR_BONUS, z)
func _make_diamond(center: Vector2, color: Color, z: int) -> Node2D:
var r: float = DIAMOND_RADIUS
var outline: Polygon2D = Polygon2D.new()
var rp: float = r + OUTLINE_WIDTH
outline.polygon = PackedVector2Array([
Vector2(0, -rp), Vector2(rp, 0), Vector2(0, rp), Vector2(-rp, 0),
])
outline.color = COLOR_OUTLINE
outline.position = center
outline.z_index = z
var fill: Polygon2D = Polygon2D.new()
fill.polygon = PackedVector2Array([
Vector2(0, -r), Vector2(r, 0), Vector2(0, r), Vector2(-r, 0),
])
fill.color = color
outline.add_child(fill)
return outline
func _make_star(center: Vector2, color: Color, z: int) -> Node2D:
var pts: PackedVector2Array = PackedVector2Array()
pts.resize(8)
for i: int in 4:
var angle_out: float = (TAU / 4.0) * i - TAU / 8.0
var angle_in: float = angle_out + TAU / 8.0
pts[i * 2] = Vector2(cos(angle_out), sin(angle_out)) * STAR_OUTER
pts[i * 2 + 1] = Vector2(cos(angle_in), sin(angle_in)) * STAR_INNER
var outline: Polygon2D = Polygon2D.new()
var outline_pts: PackedVector2Array = PackedVector2Array()
outline_pts.resize(8)
for i: int in 8:
outline_pts[i] = pts[i] * (1.0 + OUTLINE_WIDTH / STAR_OUTER)
outline.polygon = outline_pts
outline.color = COLOR_OUTLINE
outline.position = center
outline.z_index = z
var fill: Polygon2D = Polygon2D.new()
fill.polygon = pts
fill.color = color
outline.add_child(fill)
return outline
func _make_circle(center: Vector2, color: Color, z: int) -> Node2D:
var outline_pts: PackedVector2Array = PackedVector2Array()
outline_pts.resize(CIRCLE_SEGMENTS)
var fill_pts: PackedVector2Array = PackedVector2Array()
fill_pts.resize(CIRCLE_SEGMENTS)
var rp: float = CIRCLE_RADIUS + OUTLINE_WIDTH
for i: int in CIRCLE_SEGMENTS:
var a: float = (TAU / CIRCLE_SEGMENTS) * i
outline_pts[i] = Vector2(cos(a), sin(a)) * rp
fill_pts[i] = Vector2(cos(a), sin(a)) * CIRCLE_RADIUS
var outline: Polygon2D = Polygon2D.new()
outline.polygon = outline_pts
outline.color = COLOR_OUTLINE
outline.position = center
outline.z_index = z
var fill: Polygon2D = Polygon2D.new()
fill.polygon = fill_pts
fill.color = color
outline.add_child(fill)
return outline
func _load_texture(relative_path: String) -> Texture2D:
if _texture_cache.has(relative_path):
return _texture_cache[relative_path] as Texture2D
var tex: Texture2D = ThemeAssets.load_sprite(relative_path)
_texture_cache[relative_path] = tex
return tex
func _clear_all() -> void:
for nodes: Variant in _icon_nodes.values():
for n: Node2D in (nodes as Array[Node2D]):
if is_instance_valid(n):
n.queue_free()
_icon_nodes.clear()

View file

@ -0,0 +1,58 @@
extends GutTest
## Tests for OverlayRenderer icon position math and zoom-culling logic.
## Tests only the pure RefCounted-accessible math; no scene-tree rendering.
const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd")
const OverlayRendererScript: GDScript = preload(
"res://engine/src/rendering/overlay_renderer.gd"
)
func test_icon_world_position_matches_hex_center_plus_offset() -> void:
var axial: Vector2i = Vector2i(2, 3)
var origin: Vector2 = HexUtilsScript.axial_to_pixel(axial)
var expected: Vector2 = origin + HexUtilsScript.hex_center + OverlayRendererScript.ICON_OFFSET
var actual: Vector2 = origin + HexUtilsScript.hex_center + OverlayRendererScript.ICON_OFFSET
assert_eq(actual, expected, "icon position must equal hex center + ICON_OFFSET")
func test_icon_offset_is_top_right_quadrant() -> void:
var offset: Vector2 = OverlayRendererScript.ICON_OFFSET
assert_gt(offset.x, 0.0, "ICON_OFFSET.x must be positive (right side of hex)")
assert_lt(offset.y, 0.0, "ICON_OFFSET.y must be negative (above hex center)")
func test_cull_zoom_threshold_is_reasonable() -> void:
var threshold: float = OverlayRendererScript.CULL_ZOOM_THRESHOLD
assert_gt(threshold, 0.05, "cull threshold must be above extreme zoom-out")
assert_lt(threshold, 0.5, "cull threshold must not hide icons at normal play zoom")
func test_notify_zoom_culls_below_threshold() -> void:
var renderer: Node = OverlayRendererScript.new()
add_child(renderer)
# Simulate an icon node being tracked
var dummy: Node2D = Node2D.new()
add_child(dummy)
renderer._last_zoom = 1.0
# Directly inject into _icon_nodes so notify_zoom has something to cull
renderer._icon_nodes[Vector2i(0, 0)] = [dummy] as Array[Node2D]
renderer.notify_zoom(0.05)
assert_false(dummy.visible, "icons must be hidden below CULL_ZOOM_THRESHOLD")
renderer.notify_zoom(1.0)
assert_true(dummy.visible, "icons must be visible above CULL_ZOOM_THRESHOLD")
dummy.queue_free()
renderer.queue_free()
func test_notify_zoom_skips_update_when_delta_tiny() -> void:
var renderer: Node = OverlayRendererScript.new()
add_child(renderer)
renderer._last_zoom = 0.5
# Call with negligible change — _last_zoom must remain unchanged
renderer.notify_zoom(0.5001)
assert_almost_eq(renderer._last_zoom, 0.5, 0.001, "tiny zoom delta must be ignored")
renderer.queue_free()