diff --git a/src/game/engine/src/rendering/overlay_renderer.gd b/src/game/engine/src/rendering/overlay_renderer.gd new file mode 100644 index 00000000..7f979e15 --- /dev/null +++ b/src/game/engine/src/rendering/overlay_renderer.gd @@ -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/.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() diff --git a/src/game/engine/tests/unit/test_overlay_renderer.gd b/src/game/engine/tests/unit/test_overlay_renderer.gd new file mode 100644 index 00000000..13608a10 --- /dev/null +++ b/src/game/engine/tests/unit/test_overlay_renderer.gd @@ -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()