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 # DEPRECATED: peripheral sector removed in Sprint 22 (#569). # Retained — tests still reference it. 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. # Low saturation is intentional (D-046): tints are subtle — distinguishable as warm/cool/neutral # in side-by-side comparison, not garish. The "Hopper test" validates this. # 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 ## Debug flag — when true, fog.gdshader renders raw exploration texture ## as colored overlay (green=visible, blue=explored, red=unexplored). ## Toggle via FogState.debug_exploration = true in the console. var debug_exploration: bool = false ## Deterministic shader time for visual test captures. ## When >= 0, fog_shader.gd uses this instead of Time.get_ticks_msec(). ## Set before settle frames so noise phase is reproducible across runs. var override_time: float = -1.0 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 # empty on first call (_ready); guard at line 104 skips copy 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. # Use visible_positions (always populated from server snapshots) instead of # visible_tiles, which stays empty in live server mode because the server # sends tile_kind but game_state.gd's population check expects "type". var positions: Dictionary = GameState.visible_positions if positions.size() > 0: var new_bounds := _grow_bounds_from_positions(positions) if new_bounds != map_bounds: _resize(new_bounds) var ox: int = map_bounds.position.x var oy: int = map_bounds.position.y # 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 # #585: BoundaryWall margin tiles — fog lifts so wall content composites correctly, # but NOT in visible_positions so they don't persist as explored memory. for pos in GameState.boundary_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. # visible_tiles carries zone_id per tile (populated from snapshot "tiles" or # "visible_tiles" with type field — see game_state.gd apply_snapshot). var tiles := GameState.visible_tiles 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 values _prev_visible = positions.duplicate() func _grow_bounds_from_positions(positions: Dictionary) -> Rect2i: ## Compute bounds from visible_positions (Dictionary[Vector2i, bool]). var min_x := 999999 var min_y := 999999 var max_x := -999999 var max_y := -999999 for pos in positions: min_x = mini(min_x, pos.x) min_y = mini(min_y, pos.y) max_x = maxi(max_x, pos.x) max_y = maxi(max_y, pos.y) if min_x > max_x: return map_bounds var tile_bounds := Rect2i(min_x - 8, min_y - 8, max_x - min_x + 17, max_y - min_y + 17) if map_bounds.size.x <= 1 and map_bounds.size.y <= 1: return tile_bounds return map_bounds.merge(tile_bounds)