From 30bcde26d5a4474336b2515d3966c77035ad7cda Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 6 Jun 2026 06:35:04 -0700 Subject: [PATCH] feat(fog): dwarven cartographer's-fog for unexplored tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flat-black unexplored fog with an antique-cartography treatment (Civ-VI style), themed as dwarven 'unmapped vellum/slate' — reveals no real terrain, only a stylized stone surface. - fog_renderer.gd: unexplored tiles painted with a procedurally generated opaque vellum texture (FastNoiseLite domain-warped FBM → warm dark slate→parchment gradient), generated synchronously at init so there is no reveal-before-ready leak. No binary art asset required. Visible/seen paths unchanged; frontier stays a clean opaque edge. - design-tokens.json -> ui_theme.tres: fog.unexplored 000000ff -> 1a160fff (warm 'unmapped vellum' tone) so the minimap unexplored cover matches. Render-verified on apricot (iter_7q proof, fog enabled): undiscovered renders as warm stone/vellum on map + minimap, no terrain bleed, clean frontier, lit tiles unaffected. gdlint clean. Co-Authored-By: Claude Opus 4.8 --- .project/designs/design-tokens.json | 4 +- public/games/age-of-dwarves/ui_theme.tres | 2 +- src/game/engine/src/rendering/fog_renderer.gd | 106 +++++++++++++++++- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/.project/designs/design-tokens.json b/.project/designs/design-tokens.json index 45710721..897b2fbc 100644 --- a/.project/designs/design-tokens.json +++ b/.project/designs/design-tokens.json @@ -267,9 +267,9 @@ "$description": "Seen but not currently visible tiles — 70% black" }, "unexplored": { - "$value": "#000000ff", + "$value": "#1a160fff", "$type": "color", - "$description": "Never-seen tiles — fully opaque black (undiscovered terrain must never be visible on map or minimap)" + "$description": "Never-seen tiles — opaque warm 'unmapped vellum' slate (matches the dwarven-cartography fog texture on the main map). Must never reveal real terrain on map or minimap." } }, "player": { diff --git a/public/games/age-of-dwarves/ui_theme.tres b/public/games/age-of-dwarves/ui_theme.tres index 76d4a0c1..05f67fdf 100644 --- a/public/games/age-of-dwarves/ui_theme.tres +++ b/public/games/age-of-dwarves/ui_theme.tres @@ -102,7 +102,7 @@ corner_radius_bottom_left = 2 corner_radius_bottom_right = 2 [resource] -metadata/tokens = "{\"accent.gold\":\"d9a020\",\"accent.goldBright\":\"d9b33f\",\"accent.goldPress\":\"ffd14d\",\"accent.goldResource\":\"f2d133\",\"accent.ping\":\"ffd973\",\"accent.sage\":\"66b866\",\"accent.science\":\"66bfff\",\"background.base\":\"1a1410\",\"background.deepest\":\"171219\",\"background.happiness\":\"0f0d07\",\"background.hud\":\"00000099\",\"background.list\":\"120e1e\",\"background.listSelected\":\"3f2d0d\",\"background.menu\":\"0e0a17\",\"background.overlay\":\"0000009e\",\"background.panel\":\"17121e\",\"background.raised\":\"2a2018\",\"background.surface\":\"221a14\",\"border.divider\":\"99731f80\",\"border.focus\":\"d9b340ff\",\"border.happiness\":\"b39940d9\",\"border.list\":\"4d4014b2\",\"border.listSelected\":\"d9b340cc\",\"border.panel\":\"73591fcc\",\"button.bgHover\":\"331a0d\",\"button.bgNormal\":\"1f1733\",\"button.bgPressed\":\"472f0f\",\"climate.cold\":\"1a4dff\",\"climate.hot\":\"ff260d\",\"climate.textCold\":\"66b3ff\",\"climate.textNeutral\":\"d9e0d9\",\"climate.textWarming\":\"ff731a\",\"climate.warm\":\"26cc40\",\"fog.explored\":\"000000b2\",\"fog.unexplored\":\"000000ff\",\"guide.bgPrimary\":\"1a1410\",\"guide.bgSecondary\":\"221a14\",\"guide.bgTertiary\":\"2a2018\",\"guide.dwarfAccent\":\"8b6a1a\",\"guide.dwarfPrimary\":\"c07040\",\"guide.dwarfPrimaryDark\":\"8a4a28\",\"guide.dwarfPrimaryLight\":\"e09868\",\"guide.textMuted\":\"7a6048\",\"guide.textPrimary\":\"f0e4d0\",\"guide.textSecondary\":\"b8a078\",\"player.blue\":\"3366ff\",\"player.brown\":\"806659\",\"player.cyan\":\"1accd9\",\"player.gray\":\"999999\",\"player.green\":\"33cc4d\",\"player.magenta\":\"cc4d80\",\"player.navy\":\"4d4d99\",\"player.orange\":\"e6801a\",\"player.purple\":\"b24de6\",\"player.red\":\"e63333\",\"player.sage\":\"66b366\",\"player.yellow\":\"e6cc1a\",\"semantic.diplomacy\":\"e68c73\",\"semantic.goldenAge\":\"ffeb66\",\"semantic.negative\":\"d95940\",\"semantic.positive\":\"66e666\",\"semantic.trade\":\"ccbf73\",\"semantic.warning\":\"e69933\",\"text.button\":\"e0d199\",\"text.buttonHover\":\"ffeb80\",\"text.buttonPressed\":\"ffffb3\",\"text.disabled\":\"80806680\",\"text.muted\":\"b2b2b2\",\"text.primary\":\"e0d8c8\",\"text.secondary\":\"bfb7a6\",\"text.title\":\"f2d973\"}" +metadata/tokens = "{\"accent.gold\":\"d9a020\",\"accent.goldBright\":\"d9b33f\",\"accent.goldPress\":\"ffd14d\",\"accent.goldResource\":\"f2d133\",\"accent.ping\":\"ffd973\",\"accent.sage\":\"66b866\",\"accent.science\":\"66bfff\",\"background.base\":\"1a1410\",\"background.deepest\":\"171219\",\"background.happiness\":\"0f0d07\",\"background.hud\":\"00000099\",\"background.list\":\"120e1e\",\"background.listSelected\":\"3f2d0d\",\"background.menu\":\"0e0a17\",\"background.overlay\":\"0000009e\",\"background.panel\":\"17121e\",\"background.raised\":\"2a2018\",\"background.surface\":\"221a14\",\"border.divider\":\"99731f80\",\"border.focus\":\"d9b340ff\",\"border.happiness\":\"b39940d9\",\"border.list\":\"4d4014b2\",\"border.listSelected\":\"d9b340cc\",\"border.panel\":\"73591fcc\",\"button.bgHover\":\"331a0d\",\"button.bgNormal\":\"1f1733\",\"button.bgPressed\":\"472f0f\",\"climate.cold\":\"1a4dff\",\"climate.hot\":\"ff260d\",\"climate.textCold\":\"66b3ff\",\"climate.textNeutral\":\"d9e0d9\",\"climate.textWarming\":\"ff731a\",\"climate.warm\":\"26cc40\",\"fog.explored\":\"000000b2\",\"fog.unexplored\":\"1a160fff\",\"guide.bgPrimary\":\"1a1410\",\"guide.bgSecondary\":\"221a14\",\"guide.bgTertiary\":\"2a2018\",\"guide.dwarfAccent\":\"8b6a1a\",\"guide.dwarfPrimary\":\"c07040\",\"guide.dwarfPrimaryDark\":\"8a4a28\",\"guide.dwarfPrimaryLight\":\"e09868\",\"guide.textMuted\":\"7a6048\",\"guide.textPrimary\":\"f0e4d0\",\"guide.textSecondary\":\"b8a078\",\"player.blue\":\"3366ff\",\"player.brown\":\"806659\",\"player.cyan\":\"1accd9\",\"player.gray\":\"999999\",\"player.green\":\"33cc4d\",\"player.magenta\":\"cc4d80\",\"player.navy\":\"4d4d99\",\"player.orange\":\"e6801a\",\"player.purple\":\"b24de6\",\"player.red\":\"e63333\",\"player.sage\":\"66b366\",\"player.yellow\":\"e6cc1a\",\"semantic.diplomacy\":\"e68c73\",\"semantic.goldenAge\":\"ffeb66\",\"semantic.negative\":\"d95940\",\"semantic.positive\":\"66e666\",\"semantic.trade\":\"ccbf73\",\"semantic.warning\":\"e69933\",\"text.button\":\"e0d199\",\"text.buttonHover\":\"ffeb80\",\"text.buttonPressed\":\"ffffb3\",\"text.disabled\":\"80806680\",\"text.muted\":\"b2b2b2\",\"text.primary\":\"e0d8c8\",\"text.secondary\":\"bfb7a6\",\"text.title\":\"f2d973\"}" Button/colors/font_color = Color(0.878431, 0.819608, 0.6, 1) Button/colors/font_hover_color = Color(1, 0.921569, 0.501961, 1) Button/colors/font_pressed_color = Color(1, 1, 0.701961, 1) diff --git a/src/game/engine/src/rendering/fog_renderer.gd b/src/game/engine/src/rendering/fog_renderer.gd index 38034a96..e5adef79 100644 --- a/src/game/engine/src/rendering/fog_renderer.gd +++ b/src/game/engine/src/rendering/fog_renderer.gd @@ -4,8 +4,10 @@ extends Node2D ## creating a smooth gradient at the fog boundary instead of a hard cutoff. ## ## Three visibility states per tile: -## 0 = Unexplored: fully black overlay (terrain hidden) -## 1 = Fog: semi-transparent dark overlay (terrain visible but dimmed) +## 0 = Unexplored: opaque dwarven-vellum "unmapped" overlay (terrain hidden). +## The antique-cartography treatment — a stylized stone/parchment surface +## that reveals NO real terrain, resources, or units (see _build_unexplored_texture). +## 1 = Fog: semi-transparent dark overlay (last-seen terrain visible but dimmed) ## 2 = Visible: no overlay (full brightness) const HexUtilsScript: GDScript = preload("res://engine/src/map/hex_utils.gd") @@ -16,11 +18,17 @@ const FOG_UNEXPLORED: int = 0 const FOG_FOGGED: int = 1 const FOG_VISIBLE: int = 2 -const UNEXPLORED_COLOR: Color = Color(0.0, 0.0, 0.0, 1.0) +## Fallback flat tone if the procedural vellum texture fails to generate — kept +## opaque + on-theme so undiscovered terrain is never revealed regardless. +const UNEXPLORED_COLOR: Color = Color(0.10, 0.085, 0.06, 1.0) const FOGGED_COLOR: Color = Color(0.0, 0.0, 0.0, 0.55) ## Alpha at vertices that border a visible tile (soft edge fade). const EDGE_FADE_ALPHA: float = 0.0 +## Procedural dwarven-vellum texture for unexplored ("unmapped") tiles — the +## antique-cartography fog treatment. Generated once at init. +const VELLUM_TEX_SIZE: int = 128 + ## Each flat-top hex vertex i is the corner shared by the two neighbors ## flanking that vertex. Computed from the hex polygon vertex order: ## vertex 0 (96,0) top-left → NW (dir 2) and NE (dir 1) @@ -48,6 +56,12 @@ var _player_index: int = 0 ## Nullable — cleared on clear(), assigned on initialize(). var _game_map: GameMapScript = null +## Procedural vellum texture + cached per-hex UVs / opaque vertex colors, +## built once on first initialize(). +var _unexplored_tex: ImageTexture = null +var _hex_uv: PackedVector2Array = PackedVector2Array() +var _white6: PackedColorArray = PackedColorArray() + func _ready() -> void: if EnvConfig.get_bool("FORCE_DISABLE_FOGOFWAR"): @@ -63,6 +77,11 @@ func initialize(game_map: GameMapScript, player_index: int) -> void: if fog_disabled: return + if _unexplored_tex == null: + _unexplored_tex = _build_unexplored_texture() + _hex_uv = _build_hex_uv() + _white6 = _build_opaque_white() + var tiles: Dictionary = game_map.tiles for axial: Vector2i in tiles: var tile: TileScript = tiles[axial] as TileScript @@ -93,8 +112,12 @@ func update_tile_fog(axial: Vector2i, visibility_state: int) -> void: node.visible = false # Neighbors on the fog side need their edge vertices re-softened. _refresh_neighbor_edges(axial) + elif visibility_state == FOG_UNEXPLORED and _unexplored_tex != null: + node.visible = true + _apply_unexplored_texture(node) else: node.visible = true + node.texture = null node.vertex_colors = _build_vertex_colors(axial, visibility_state) else: _create_fog_polygon(axial, visibility_state) @@ -120,6 +143,8 @@ func _create_fog_polygon(axial: Vector2i, visibility_state: int) -> void: if visibility_state == FOG_VISIBLE: polygon.visible = false + elif visibility_state == FOG_UNEXPLORED and _unexplored_tex != null: + _apply_unexplored_texture(polygon) else: polygon.vertex_colors = _build_vertex_colors(axial, visibility_state) @@ -127,6 +152,14 @@ func _create_fog_polygon(axial: Vector2i, visibility_state: int) -> void: _fog_nodes[axial] = polygon +## Paint an unexplored tile with the opaque dwarven-vellum texture. Reveals no +## real terrain — the texture is a stylized "unmapped" stone/parchment surface. +func _apply_unexplored_texture(polygon: Polygon2D) -> void: + polygon.texture = _unexplored_tex + polygon.uv = _hex_uv + polygon.vertex_colors = _white6 + + ## Build per-vertex colors for a fog polygon at axial, softening vertices ## that border at least one visible neighbor tile. func _build_vertex_colors(axial: Vector2i, visibility_state: int) -> PackedColorArray: @@ -171,7 +204,13 @@ func _refresh_neighbor_edges(visible_axial: Vector2i) -> void: if nb_tile == null: continue var vis: int = _get_tile_visibility(nb_tile) - nb_node.vertex_colors = _build_vertex_colors(nb_axial, vis) + # Unexplored neighbors keep the opaque vellum texture — only fogged tiles + # participate in edge softening. + if vis == FOG_UNEXPLORED and _unexplored_tex != null: + _apply_unexplored_texture(nb_node) + else: + nb_node.texture = null + nb_node.vertex_colors = _build_vertex_colors(nb_axial, vis) func _get_fog_color(visibility_state: int) -> Color: @@ -204,3 +243,62 @@ func _on_tile_visibility_changed(tile_pos: Vector2i, player_index: int, state: i if player_index != _player_index: return update_tile_fog(tile_pos, state) + + +## Cache an opaque-white 6-vertex color array (texture shown as-authored). +func _build_opaque_white() -> PackedColorArray: + var c: PackedColorArray = PackedColorArray() + c.resize(6) + for i: int in 6: + c[i] = Color.WHITE + return c + + +## Map each hex-polygon vertex (local pixel coords) onto the full vellum texture +## so every unexplored tile shows the complete stone/parchment surface. +func _build_hex_uv() -> PackedVector2Array: + var poly: PackedVector2Array = HexUtilsScript.hex_polygon + var min_v: Vector2 = Vector2(INF, INF) + var max_v: Vector2 = Vector2(-INF, -INF) + for v: Vector2 in poly: + min_v = min_v.min(v) + max_v = max_v.max(v) + var span: Vector2 = (max_v - min_v).max(Vector2.ONE) + var uv: PackedVector2Array = PackedVector2Array() + uv.resize(poly.size()) + for i: int in poly.size(): + uv[i] = (poly[i] - min_v) / span * float(VELLUM_TEX_SIZE) + return uv + + +## Generate the opaque dwarven-vellum "unmapped" texture synchronously (so there +## is no reveal-before-ready leak). Domain-warped fractal noise mapped through a +## warm dark slate→parchment gradient: reads as aged, unread stone — never as +## real terrain. Self-contained — no binary art asset required. +func _build_unexplored_texture() -> ImageTexture: + var noise: FastNoiseLite = FastNoiseLite.new() + noise.seed = 1337 + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.frequency = 0.035 + noise.fractal_type = FastNoiseLite.FRACTAL_FBM + noise.fractal_octaves = 4 + noise.domain_warp_enabled = true + noise.domain_warp_amplitude = 35.0 + var n_img: Image = noise.get_image(VELLUM_TEX_SIZE, VELLUM_TEX_SIZE, false, false, true) + + var ramp: Gradient = Gradient.new() + ramp.offsets = PackedFloat32Array([0.0, 0.45, 0.75, 1.0]) + ramp.colors = PackedColorArray( + [ + Color(0.05, 0.045, 0.035), # deep shadow between veins + Color(0.10, 0.085, 0.06), # base unmapped slate + Color(0.155, 0.12, 0.075), # aged parchment + Color(0.24, 0.185, 0.105), # gold-brown vein highlight (ties to UI gold) + ] + ) + + var out: Image = Image.create(VELLUM_TEX_SIZE, VELLUM_TEX_SIZE, false, Image.FORMAT_RGB8) + for y: int in VELLUM_TEX_SIZE: + for x: int in VELLUM_TEX_SIZE: + out.set_pixel(x, y, ramp.sample(n_img.get_pixel(x, y).r)) + return ImageTexture.create_from_image(out)