diff --git a/client/scripts/rendering/cursor_renderer.gd b/client/scripts/rendering/cursor_renderer.gd index a286d2c08..a68036ff9 100644 --- a/client/scripts/rendering/cursor_renderer.gd +++ b/client/scripts/rendering/cursor_renderer.gd @@ -214,8 +214,8 @@ func _draw() -> void: func _draw_ticks(color: Color, thickness: float) -> void: - var dirs := [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT] - for dir in dirs: + var dirs: Array[Vector2] = [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT] + for dir: Vector2 in dirs: var d := dir.rotated(_rot) draw_line(d * _gap, d * (_gap + _len), color, thickness, true) diff --git a/client/scripts/rendering/entity_renderer.gd b/client/scripts/rendering/entity_renderer.gd index c1a9afbb0..5582d66e7 100644 --- a/client/scripts/rendering/entity_renderer.gd +++ b/client/scripts/rendering/entity_renderer.gd @@ -5,6 +5,10 @@ extends Node2D # Creates/updates/removes ColorRect children based on entity data # Entity format (from Protocol v2): {entity_id, x, y, z, kind: {variant, data}, visibility} # +# Position lerping: entity sprites smoothly slide between tiles instead of snapping. +# The server moves entities in discrete tile steps; the lerp makes this look fluid. +# Speed is tuned so Sprint feels snappy and Walk/Careful/Crouch feel deliberate. +# # D-033 colors: Phase 1 defaults by entity kind. Phase 2 (#361) will derive # color from RelationshipState via the knowledge graph. @@ -12,11 +16,31 @@ const TILE_SIZE: int = Constants.TILE_SIZE const ENTITY_SIZE: int = 24 const ENTITY_OFFSET: float = (TILE_SIZE - ENTITY_SIZE) / 2.0 # center within tile -var entity_nodes: Dictionary = {} # entity_id -> Node2D mapping +# Lerp speed — framerate-independent exponential smoothing. +# At 12.0: ~70% there after 0.1s, ~95% after 0.25s. +# Fast enough for Sprint snappiness, slow enough for Walk to show sliding. +const LERP_SPEED: float = 12.0 + +var entity_nodes: Dictionary = {} # entity_id -> Node2D +var _entity_targets: Dictionary = {} # entity_id -> Vector2 (target pixel position) func _ready() -> void: print("EntityRenderer: Initialized") + +func _process(delta: float) -> void: + # Lerp all entity visual positions toward their targets each frame. + # Uses framerate-independent exponential smoothing. + var weight := 1.0 - exp(-LERP_SPEED * delta) + for entity_id in entity_nodes.keys(): + if not _entity_targets.has(entity_id): + continue + var node = entity_nodes[entity_id] + var target: Vector2 = _entity_targets[entity_id] + if not node.position.is_equal_approx(target): + node.position = node.position.lerp(target, weight) + + # Update entities from snapshot data func update_entities(entities: Array) -> void: var active_ids: Array = [] @@ -62,19 +86,28 @@ func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void: if entity_id == GameState.player_entity_id: _add_facing_indicator(entity_node) + # Snap to initial position (no lerp on first appearance) + if entity_data.has("x") and entity_data.has("y"): + var target := Vector2( + floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET, + floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET + ) + entity_node.position = target + _entity_targets[entity_id] = target + _update_entity_node(entity_id, entity_data) -# Update an existing entity node (position, visibility dimming, facing) +# Update an existing entity node (target position, visibility dimming, facing) func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void: if not entity_nodes.has(entity_id): return var entity_node = entity_nodes[entity_id] - # Update position from x, y fields (Protocol format), centered within tile. + # Update target position — the lerp in _process() will smoothly move there. # Server sends tile-center coords (tile 16 → 16.5), floor to get tile index. if entity_data.has("x") and entity_data.has("y"): - entity_node.position = Vector2( + _entity_targets[entity_id] = Vector2( floorf(entity_data.x) * TILE_SIZE + ENTITY_OFFSET, floorf(entity_data.y) * TILE_SIZE + ENTITY_OFFSET ) @@ -100,6 +133,7 @@ func _remove_entity_node(entity_id: int) -> void: var entity_node = entity_nodes[entity_id] entity_node.queue_free() entity_nodes.erase(entity_id) + _entity_targets.erase(entity_id) # D-033 color by entity kind — delegates to Constants.color_for_entity_kind static func _color_for_kind(entity_data: Dictionary) -> Color: diff --git a/client/scripts/rendering/fog_shader.gd b/client/scripts/rendering/fog_shader.gd index 2c4f1f748..dab5b8677 100644 --- a/client/scripts/rendering/fog_shader.gd +++ b/client/scripts/rendering/fog_shader.gd @@ -11,13 +11,18 @@ const TILE_SIZE := float(Constants.TILE_SIZE) func _ready() -> void: - # Create the fog overlay ColorRect + # Create the fog overlay ColorRect — transparent fallback so a shader failure + # doesn't paint solid white over the entire world (z:900 covers everything). _fog_rect = ColorRect.new() _fog_rect.name = "FogRect" + _fog_rect.color = Color.TRANSPARENT add_child(_fog_rect) # Load shader and create material var shader := load("res://shaders/fog.gdshader") as Shader + if shader == null: + push_error("FogShader: failed to load res://shaders/fog.gdshader — fog disabled") + return _shader_mat = ShaderMaterial.new() _shader_mat.shader = shader _fog_rect.material = _shader_mat @@ -38,14 +43,19 @@ func _ready() -> void: func update_fog() -> void: + if _shader_mat == null: + return # Shader failed to load — fog disabled + # 1. Update FogState textures from GameState FogState.update_from_state() - # 2. Position ColorRect to cover the current viewport + # 2. Position ColorRect to cover the current viewport. + # Use the camera's actual position (not player position) so the fog rect + # tracks the smoothed camera and never desynchronizes during smooth pan. var vp_size := get_viewport().get_visible_rect().size var cam := get_viewport().get_camera_2d() var zoom := cam.zoom if cam else Vector2(2.0, 2.0) - var camera_pos := GameState.player_position * TILE_SIZE + var camera_pos := cam.global_position if cam else GameState.player_position * TILE_SIZE var half_view := vp_size / (2.0 * zoom) _fog_rect.position = camera_pos - half_view _fog_rect.size = vp_size / zoom diff --git a/client/shaders/fog.gdshader b/client/shaders/fog.gdshader index f63af203f..f39a9b721 100644 --- a/client/shaders/fog.gdshader +++ b/client/shaders/fog.gdshader @@ -38,37 +38,36 @@ void fragment() { // 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); - return; - } - - float vis = texture(visibility_tex, tex_uv).r; - float explored = texture(exploration_tex, tex_uv).r; - - if (vis > PERIPHERAL_LOW) { - // In or near vision cone - if (vis > CLEAR_THRESHOLD) { - // Layer 1: Clear — soft edge gradient - float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis); - COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge); - } else { - // Layer 2: Light fog (peripheral + forward edge) - // D-059: animated Perlin noise, 8-10s cycle - 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); - // Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge - float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1; - COLOR = vec4(DARK_OVERLAY, alpha); - } - } else if (explored > 0.3) { - // Layer 3: Deep fog (previously 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_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r; - vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1); - float alpha = mix(0.78, 0.90, noise_val); // Fog breathes - COLOR = vec4(tint_color, alpha); } else { - // Layer 5: Unexplored, no maps — information zero - COLOR = vec4(UNEXPLORED_COLOR, 1.0); + float vis = texture(visibility_tex, tex_uv).r; + float explored = texture(exploration_tex, tex_uv).r; + + if (vis > PERIPHERAL_LOW) { + // In or near vision cone + if (vis > CLEAR_THRESHOLD) { + // Layer 1: Clear — soft edge gradient + float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis); + COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge); + } else { + // Layer 2: Light fog (peripheral + forward edge) + // D-059: animated Perlin noise, 8-10s cycle + 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); + // Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge + float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1; + COLOR = vec4(DARK_OVERLAY, alpha); + } + } else if (explored > 0.3) { + // Layer 3: Deep fog (previously 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_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r; + vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1); + float alpha = mix(0.78, 0.90, noise_val); // Fog breathes + COLOR = vec4(tint_color, alpha); + } else { + // Layer 5: Unexplored, no maps — information zero + COLOR = vec4(UNEXPLORED_COLOR, 1.0); + } } } diff --git a/client/ui/inventory_grid.gd b/client/ui/inventory_grid.gd index 04f28870b..8820451f8 100644 --- a/client/ui/inventory_grid.gd +++ b/client/ui/inventory_grid.gd @@ -20,7 +20,7 @@ const SLOT_TEXT := Color("#c8d0e0") const SLOT_TEXT_DIM := Color("#8b8ba0") const HOTKEY_SIZE := 10 -var _slots: Array[Dictionary] = [] # [{item_id, name, slot}] from GameState +var _slots: Array = [] # [{item_id, name, slot}] from GameState var _slot_nodes: Array[Control] = [] var _selected_slot: int = -1