fix(client): entity lerp, fog shader safety, type fixes

Entity renderer: add framerate-independent position lerping so entities
slide between tiles instead of snapping. Tuned for Sprint snappiness
and Walk/Careful fluidity.

Fog shader: set ColorRect to transparent fallback so a shader load
failure doesn't paint solid white over the world. Track camera position
(not player position) so fog stays synced during smooth camera pan.
Restructure GLSL to avoid early return (some GPU drivers miscompile it).

Minor type fixes: typed Array[Vector2] in cursor_renderer tick drawing,
untyped Array in inventory_grid to avoid Godot typed-array cast issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 23:26:23 +01:00
co-authored by Claude Opus 4.6
parent bc875d607f
commit 8bf7f6e610
5 changed files with 84 additions and 41 deletions
+2 -2
View File
@@ -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)
+38 -4
View File
@@ -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:
+13 -3
View File
@@ -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
+30 -31
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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