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:
parent
11426744df
commit
c1dc096f12
2 changed files with 278 additions and 0 deletions
220
src/game/engine/src/rendering/overlay_renderer.gd
Normal file
220
src/game/engine/src/rendering/overlay_renderer.gd
Normal 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()
|
||||
58
src/game/engine/tests/unit/test_overlay_renderer.gd
Normal file
58
src/game/engine/tests/unit/test_overlay_renderer.gd
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue