feat(fog): dwarven cartographer's-fog for unexplored tiles

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 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-06 06:35:04 -07:00
parent c22b497a27
commit 30bcde26d5
3 changed files with 105 additions and 7 deletions

View file

@ -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": {

View file

@ -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)

View file

@ -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)