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) 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 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: 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) _exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes) exploration_texture = ImageTexture.create_from_image(_exp_image) # Zone tint — neutral dark for Sprint 6 (zone metadata deferred) _tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8) _tint_image.fill(Color(0.05, 0.05, 0.08)) zone_tint_texture = ImageTexture.create_from_image(_tint_image) _prev_visible.clear() func update_from_state() -> void: # Resize if map bounds changed var tiles := GameState.visible_tiles if tiles.size() > 0: var new_bounds := _compute_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 var sectors: Dictionary = GameState.visibility_sectors # TODO(v0.2): gradual decay over game-time instead of immediate EXP_VISIBLE→EXP_EXPLORED # 1. Clear visibility, then write current LOS _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 var sector: String = sectors.get(pos, "Forward") _vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL _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) # Shallow copy — correct for Dictionary values _prev_visible = positions.duplicate() func _compute_bounds(tiles: Array) -> Rect2i: 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 Rect2i(0, 0, 1, 1) # Margin for fog gradient bleed at edges return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)