Files
settled-reach/client/scripts/autoloads/fog_state.gd
T
jpmschweitzerandClaude Opus 4.6 dddfceeb5a fix(assets): tune fog shader alpha and add zone temperature tint per D-059
Ticket #563. Light fog alpha tuned to 0.25-0.35 range (was 0.25-0.55),
deep fog alpha set to 0.55-0.70 with zone temperature tint from
zone_tint_tex (bar=warm #2a1f15, hub=cool #1a1f2e, corridor=neutral
#1a1a1a). Two Perlin noise cycles: 8-10s light, 15-20s deep.
Zone tint texture now populated per-tile from server zone_id in
fog_state.gd with preservation across texture resizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 14:11:56 +01:00

229 lines
8.9 KiB
GDScript

extends Node
## Fog texture state — visibility/exploration/zone tint images updated from GameState.
## Read by fog_shader.gd for shader uniforms. Not a renderer — pure data.
## Architecture: docs/architecture/fog-shader-spec.md | D-059
# Fog texture byte values — visibility and exploration layers.
# Used by fog shader to distinguish visual treatment per tile.
# Test assertions reference these: assert_that(byte).is_equal(FogState.VIS_FORWARD)
const VIS_HIDDEN: int = 0 # Not in LOS — fully fogged
const VIS_PERIPHERAL: int = 180 # In LOS, peripheral sector — light fog dimming
const VIS_FORWARD: int = 255 # In LOS, forward sector — clear vision
const EXP_UNEXPLORED: int = 0 # Never seen — total darkness
const EXP_EXPLORED: int = 128 # Previously seen, now out of LOS — deep fog
const EXP_VISIBLE: int = 255 # Currently in LOS — clear (written each frame)
# Zone temperature tints (D-059 + D-046, Sprint 22) — keyed by zone_id string from server.
# Matches audio_manager.gd ZONE_ASSETS zone_id strings for consistent zone semantics.
# Colors are dark tints used as the fog overlay in the deep fog zone:
# hub/workplace: #1a1f2e (cool blue-dark — terminal, institutional)
# bar: #2a1f15 (warm amber-dark — social, inhabited)
# corridor: #1a1a1a (neutral dark — transitional, maintenance)
const ZONE_TINTS: Dictionary = {
"hub": Color(0.102, 0.122, 0.180), # #1a1f2e — cool blue-dark
"workplace": Color(0.102, 0.122, 0.180), # same as hub
"bar": Color(0.165, 0.122, 0.082), # #2a1f15 — warm amber-dark
"corridor": Color(0.102, 0.102, 0.102), # #1a1a1a — neutral dark
}
const ZONE_TINT_DEFAULT: Color = Color(0.102, 0.102, 0.102) # #1a1a1a neutral
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
var zone_tint_texture: ImageTexture
var _vis_bytes: PackedByteArray
var _exp_bytes: PackedByteArray
var _vis_image: Image
var _exp_image: Image
var _tint_image: Image
# Zone tint stored as 3-channel RGB bytes (R, G, B per pixel) for preservation across resizes
var _tint_bytes: PackedByteArray
var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
func _ready() -> void:
_resize(Rect2i(0, 0, 64, 64))
func _resize(bounds: Rect2i) -> void:
var old_bounds := map_bounds
var old_exp := _exp_bytes
var old_tint := _tint_bytes
var old_w := _width
var old_h := _height
map_bounds = bounds
_width = maxi(bounds.size.x, 1)
_height = maxi(bounds.size.y, 1)
var sz := _width * _height
_vis_bytes = PackedByteArray()
_vis_bytes.resize(sz)
_vis_bytes.fill(VIS_HIDDEN)
_vis_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture = ImageTexture.create_from_image(_vis_image)
_exp_bytes = PackedByteArray()
_exp_bytes.resize(sz)
_exp_bytes.fill(EXP_UNEXPLORED)
# Preserve exploration data from old bounds into new bounds
if old_exp.size() > 0 and old_w > 0 and old_h > 0:
var dx: int = old_bounds.position.x - bounds.position.x
var dy: int = old_bounds.position.y - bounds.position.y
for oy in range(old_h):
var ny: int = oy + dy
if ny < 0 or ny >= _height:
continue
for ox in range(old_w):
var nx: int = ox + dx
if nx < 0 or nx >= _width:
continue
var old_val: int = old_exp[oy * old_w + ox]
if old_val > EXP_UNEXPLORED:
_exp_bytes[ny * _width + nx] = old_val
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image)
# Zone tint — 3 bytes per pixel (RGB), default neutral dark
var tint_sz := sz * 3
_tint_bytes = PackedByteArray()
_tint_bytes.resize(tint_sz)
var default_r := int(ZONE_TINT_DEFAULT.r * 255.0)
var default_g := int(ZONE_TINT_DEFAULT.g * 255.0)
var default_b := int(ZONE_TINT_DEFAULT.b * 255.0)
for i in range(sz):
_tint_bytes[i * 3 + 0] = default_r
_tint_bytes[i * 3 + 1] = default_g
_tint_bytes[i * 3 + 2] = default_b
# Preserve zone tint data from old bounds (zone tints are stable — tile zone never changes)
if old_tint.size() > 0 and old_w > 0 and old_h > 0:
var dx: int = old_bounds.position.x - bounds.position.x
var dy: int = old_bounds.position.y - bounds.position.y
for oy in range(old_h):
var ny: int = oy + dy
if ny < 0 or ny >= _height:
continue
for ox in range(old_w):
var nx: int = ox + dx
if nx < 0 or nx >= _width:
continue
var old_idx := (oy * old_w + ox) * 3
var new_idx := (ny * _width + nx) * 3
_tint_bytes[new_idx + 0] = old_tint[old_idx + 0]
_tint_bytes[new_idx + 1] = old_tint[old_idx + 1]
_tint_bytes[new_idx + 2] = old_tint[old_idx + 2]
_tint_image = Image.create_from_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
func update_from_state() -> void:
# Grow bounds to include newly visible tiles — never shrink, so explored
# tiles behind the player stay in the texture and render as deep fog
# instead of black. Exploration data is preserved across resizes.
var tiles := GameState.visible_tiles
if tiles.size() > 0:
var new_bounds := _grow_bounds(tiles)
if new_bounds != map_bounds:
_resize(new_bounds)
var ox: int = map_bounds.position.x
var oy: int = map_bounds.position.y
var positions: Dictionary = GameState.visible_positions
# TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED
# 1. Clear visibility, then write current LOS (all tiles are Forward)
_vis_bytes.fill(VIS_HIDDEN)
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
_vis_bytes[py * _width + px] = VIS_FORWARD
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture.update(_vis_image)
# 2. Exploration: tiles leaving LOS decay to EXP_EXPLORED, visible tiles stay EXP_VISIBLE
# Only touch tiles that changed (O(visible) not O(map_size))
for pos in _prev_visible:
if not positions.has(pos):
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
var idx: int = py * _width + px
if _exp_bytes[idx] > EXP_EXPLORED:
_exp_bytes[idx] = EXP_EXPLORED
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
_exp_bytes[py * _width + px] = EXP_VISIBLE
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image)
# 3. Zone tint: write zone temperature color for currently visible tiles.
# Zone data is stable (tile zone never changes) so we only write on first sight.
# Data persists in _tint_bytes across frames and across resizes.
var tint_dirty := false
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
var zone_id: String = str(tile.get("zone_id", ""))
if zone_id.is_empty():
continue
var tint_color: Color = ZONE_TINTS.get(zone_id, ZONE_TINT_DEFAULT)
var px: int = int(tile.x) - ox
var py: int = int(tile.y) - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
var tint_idx := (py * _width + px) * 3
var new_r := int(tint_color.r * 255.0)
var new_g := int(tint_color.g * 255.0)
var new_b := int(tint_color.b * 255.0)
# Only update if different from current (avoid spurious texture uploads)
if _tint_bytes[tint_idx] != new_r or _tint_bytes[tint_idx + 1] != new_g or _tint_bytes[tint_idx + 2] != new_b:
_tint_bytes[tint_idx + 0] = new_r
_tint_bytes[tint_idx + 1] = new_g
_tint_bytes[tint_idx + 2] = new_b
tint_dirty = true
if tint_dirty:
_tint_image.set_data(_width, _height, false, Image.FORMAT_RGB8, _tint_bytes)
zone_tint_texture.update(_tint_image)
# Shallow copy — correct for Dictionary<Vector2i, bool/String> values
_prev_visible = positions.duplicate()
func _grow_bounds(tiles: Array) -> Rect2i:
## Compute bounds that include all currently visible tiles, merged with
## existing bounds so the texture only grows — never shrinks. Explored
## tiles always stay within bounds and render as deep fog, not black.
var min_x := 999999
var min_y := 999999
var max_x := -999999
var max_y := -999999
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
min_x = mini(min_x, int(tile.x))
min_y = mini(min_y, int(tile.y))
max_x = maxi(max_x, int(tile.x))
max_y = maxi(max_y, int(tile.y))
# Guard: all tiles invalid (no x/y) — sentinels would produce negative Rect2i
if min_x > max_x:
return map_bounds
# Margin for fog gradient bleed at edges
var tile_bounds := Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
# Merge with existing bounds — grow only
if map_bounds.size.x <= 1 and map_bounds.size.y <= 1:
return tile_bounds
return map_bounds.merge(tile_bounds)