diff --git a/client/scripts/autoloads/fog_state.gd b/client/scripts/autoloads/fog_state.gd index 64388e241..46f607c79 100644 --- a/client/scripts/autoloads/fog_state.gd +++ b/client/scripts/autoloads/fog_state.gd @@ -35,6 +35,11 @@ func _ready() -> void: func _resize(bounds: Rect2i) -> void: + var old_bounds := map_bounds + var old_exp := _exp_bytes + var old_w := _width + var old_h := _height + map_bounds = bounds _width = maxi(bounds.size.x, 1) _height = maxi(bounds.size.y, 1) @@ -49,6 +54,21 @@ func _resize(bounds: Rect2i) -> void: _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) @@ -61,29 +81,29 @@ func _resize(bounds: Rect2i) -> void: func update_from_state() -> void: - # Resize if map bounds changed + # 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 := _compute_bounds(tiles) + 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 - 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 + # 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 - var sector: String = sectors.get(pos, "Forward") - _vis_bytes[py * _width + px] = VIS_FORWARD if sector == "Forward" else VIS_PERIPHERAL + _vis_bytes[py * _width + px] = VIS_FORWARD _vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes) visibility_texture.update(_vis_image) @@ -110,7 +130,10 @@ func update_from_state() -> void: _prev_visible = positions.duplicate() -func _compute_bounds(tiles: Array) -> Rect2i: +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 @@ -124,6 +147,10 @@ func _compute_bounds(tiles: Array) -> Rect2i: 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) + return map_bounds # 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) + 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) diff --git a/client/shaders/fog.gdshader b/client/shaders/fog.gdshader index dbcda4abd..467858a93 100644 --- a/client/shaders/fog.gdshader +++ b/client/shaders/fog.gdshader @@ -1,11 +1,9 @@ shader_type canvas_item; -// D-059: 5-layer fog shader. Composites over world content (layers 0-4). -// Layer 1: Clear (vision cone) — transparent, soft gradient edge -// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle -// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle -// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5) -// Layer 5: Unexplored, no maps — solid near-black #12141a +// D-059: 3-layer fog shader. Composites over world content. +// Layer 1: Clear (forward cone) — transparent, soft gradient edge +// Layer 2: Explored fog — light overlay indicating "not fresh", art preserved +// Layer 3: Unexplored — solid near-black #12141a uniform sampler2D visibility_tex : filter_linear, repeat_disable; uniform sampler2D exploration_tex : filter_linear, repeat_disable; @@ -18,25 +16,11 @@ uniform vec2 map_size; // map_bounds.size (tiles) uniform float tile_size; // Pixels per sim tile uniform float time; // Seconds since start -// D-059 fog layer colors const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05); -// D-059 thresholds -// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0 -const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear -// Lowered from 0.55 to capture blur-extended gradient tiles in the peripheral -// branch. The 7x7 Gaussian (sigma 2.0) produces values down to ~0.08 at -// 3 tiles from the LOS boundary — together with bilinear interpolation this -// yields the 6-8 sim tile soft edge specified by D-059/D-066. -const float PERIPHERAL_LOW = 0.08; - -// D-059: Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0). -// The visibility texture is 1 texel per sim tile with discrete values -// (0 / 180 / 255). Bilinear filtering alone only smooths across ~1 tile. -// This blur spreads the LOS boundary into a 3-4 tile radius gradient; -// combined with smoothstep transitions, produces 6-8 tile soft edge. -// 49 taps from a tiny (~128x128) fully-cached texture — well under 1ms. +// Soft gradient via 7x7 Gaussian blur on visibility (sigma 2.0). +// Spreads the cone boundary into a 3-4 tile radius gradient for soft edges. float sample_visibility(vec2 uv) { vec2 t = 1.0 / map_size; float sum = 0.0; @@ -53,14 +37,11 @@ float sample_visibility(vec2 uv) { } void fragment() { - // Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates vec2 world_px = rect_pos + UV * rect_sz; vec2 tile = world_px / tile_size; - - // Map tile coordinate to texture UV vec2 tex_uv = (tile - map_offset) / map_size; - // Outside known map → unexplored + // Outside known map -> unexplored if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) { COLOR = vec4(UNEXPLORED_COLOR, 1.0); } else { @@ -68,44 +49,34 @@ void fragment() { float vis = sample_visibility(tex_uv); float explored = texture(exploration_tex, tex_uv).r; - // Don't bleed gradient into never-explored tiles — keep them Layer 5 + // Don't bleed gradient into never-explored tiles if (explored < 0.01 && vis_raw < 0.01) { vis = 0.0; } - if (vis > CLEAR_THRESHOLD) { - // Layer 1: Clear — soft edge gradient - // Alpha matches Layer 2 at boundary (coverage=1.0 → 0.25) for continuity - float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis); - COLOR = vec4(DARK_OVERLAY, 0.25 * (1.0 - edge)); - } else if (vis > PERIPHERAL_LOW || explored > 0.3) { - // Layers 2-3: smooth blend between peripheral fog and deep fog. - // Same gradient approach as the forward cone — smoothstep over the - // blur radius eliminates stair-stepped tile edges at the back of - // the vision cone. - - // Layer 2: peripheral fog - float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r; - float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis); - float noise_weight = 1.0 - smoothstep(0.5, 1.0, coverage); - float l2_alpha = mix(0.38, 0.25, coverage) + noise_val * 0.05 * noise_weight; - - // Layer 3: deep fog (explored, no longer in LOS) - // D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle - vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb; - float noise_deep = texture(noise_tex, tile * 0.015 + vec2(time * 0.06, time * 0.045)).r; - vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1); - float l3_alpha = mix(0.62, 0.76, noise_deep); - - // Back-edge gradient: blend Layer 2 → Layer 3 over the blur radius. - // PERIPHERAL_LOW * 3.0 ≈ 0.24 — matches the 3-4 tile Gaussian tail. - float back_edge = smoothstep(0.0, PERIPHERAL_LOW * 3.0, vis); - float alpha = mix(l3_alpha, l2_alpha, back_edge); - vec3 color = mix(tint_color, DARK_OVERLAY, back_edge); - COLOR = vec4(color, alpha); - } else { - // Layer 5: Unexplored, no maps — information zero + if (explored < 0.01 && vis < 0.01) { + // Unexplored: solid near-black COLOR = vec4(UNEXPLORED_COLOR, 1.0); + } else { + // Continuous blend: clear vision (vis=1) -> light fog (vis=0). + // The Gaussian blur creates a smooth 3-4 tile soft gradient + // at the cone edge — no hard boundary. + + // Light fog: subtle animated overlay, preserves all art/info + float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r; + float fog_alpha = 0.28 + noise_val * 0.04; + + // Clarity ramp from the blurred visibility + float clarity = smoothstep(0.0, 0.85, vis); + float alpha = mix(fog_alpha, 0.0, clarity); + vec3 color = mix(DARK_OVERLAY, vec3(0.0), clarity); + + // Soft edge between explored and unexplored + float exp_fade = smoothstep(0.0, 0.3, explored); + alpha = mix(1.0, alpha, exp_fade); + color = mix(UNEXPLORED_COLOR, color, exp_fade); + + COLOR = vec4(color, alpha); } } }