feat(client): add tile rendering, fog overlay, and camera smoothing
Sprint 2 rendering pipeline: tiles (#129), fog (#131), camera (#116). - tile_renderer.gd: programmatic TileSet with floor/wall/door/object placeholder tiles, renders from snapshot tile data - fog_renderer.gd: TileMapLayer overlay with three visibility states (visible/fog-edge/hidden), computed from visible_positions data - Camera2D: smoothing enabled (speed 6.0), 2x zoom, locked to player - game_state.gd: stores visible_tiles and visible_positions from snapshots - sim_bridge.gd: test data with 8x8 room, corridor, and Manhattan distance visibility for development without server - Scene render order: Tiles -> FogOverlay -> Entities - Background clear color set to near-black for unexplored areas Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -98,3 +98,4 @@ pause={
|
||||
|
||||
renderer/rendering_method="gl_compatibility"
|
||||
renderer/rendering_method.mobile="gl_compatibility"
|
||||
environment/defaults/default_clear_color=Color(0.05, 0.05, 0.08, 1)
|
||||
|
||||
+15
-10
@@ -1,12 +1,13 @@
|
||||
[gd_scene load_steps=8 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
[gd_scene load_steps=9 format=3 uid="uid://bswrmh7w8dbgm"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/entity_renderer.gd" id="3_entity"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/fog_renderer.gd" id="4_fog"]
|
||||
[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="5_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="6_minimap"]
|
||||
[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="7_monologue"]
|
||||
[ext_resource type="Script" path="res://scripts/rendering/tile_renderer.gd" id="5_tile"]
|
||||
[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"]
|
||||
[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"]
|
||||
[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1_main")
|
||||
@@ -15,19 +16,23 @@ script = ExtResource("1_main")
|
||||
script = ExtResource("2_world")
|
||||
|
||||
[node name="TileMapLayer" type="TileMapLayer" parent="World"]
|
||||
script = ExtResource("5_tile")
|
||||
|
||||
[node name="FogOverlay" type="TileMapLayer" parent="World"]
|
||||
script = ExtResource("4_fog")
|
||||
|
||||
[node name="Entities" type="Node2D" parent="World"]
|
||||
script = ExtResource("3_entity")
|
||||
|
||||
[node name="FogOverlay" type="Node2D" parent="World"]
|
||||
script = ExtResource("4_fog")
|
||||
|
||||
[node name="Camera2D" type="Camera2D" parent="."]
|
||||
position_smoothing_enabled = true
|
||||
position_smoothing_speed = 6.0
|
||||
zoom = Vector2(2, 2)
|
||||
|
||||
[node name="UILayer" type="CanvasLayer" parent="."]
|
||||
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("5_hud")]
|
||||
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
|
||||
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("6_minimap")]
|
||||
[node name="Minimap" parent="UILayer" instance=ExtResource("7_minimap")]
|
||||
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("7_monologue")]
|
||||
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
extends Node
|
||||
|
||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities}).
|
||||
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities, tiles}).
|
||||
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
|
||||
# Tiles use format: [{x, y, z, type}].
|
||||
var current_snapshot: Dictionary = {}
|
||||
var current_tick: int = 0
|
||||
var player_position: Vector2 = Vector2.ZERO
|
||||
var visible_entities: Array = []
|
||||
var visible_tiles: Array = []
|
||||
var visible_positions: Dictionary = {} # Vector2i -> true, for fast fog lookups
|
||||
|
||||
# Player entity ID — the first entity is assumed to be the player (will be
|
||||
# refined when the server assigns explicit player entity IDs).
|
||||
@@ -24,3 +27,11 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
if entity.has("entity_id") and entity.entity_id == player_entity_id:
|
||||
player_position = Vector2(entity.x, entity.y)
|
||||
break
|
||||
|
||||
if snapshot.has("tiles"):
|
||||
visible_tiles = snapshot.tiles
|
||||
|
||||
if snapshot.has("visible_positions"):
|
||||
visible_positions.clear()
|
||||
for pos in snapshot.visible_positions:
|
||||
visible_positions[Vector2i(pos.x, pos.y)] = true
|
||||
|
||||
@@ -235,7 +235,7 @@ static func _action_enum_to_wire(action: int) -> String:
|
||||
return ""
|
||||
|
||||
# Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4).
|
||||
# Uses the same {tick, entities} schema as Protocol.decode_snapshot() returns.
|
||||
# Uses the same {tick, entities, tiles} schema as Protocol.decode_snapshot() returns.
|
||||
func _test_snapshot() -> Dictionary:
|
||||
_test_tick += 1
|
||||
return {
|
||||
@@ -246,7 +246,71 @@ func _test_snapshot() -> Dictionary:
|
||||
"x": 10.0,
|
||||
"y": 10.0,
|
||||
"z": 0,
|
||||
"kind": { "variant": "Player", "data": null },
|
||||
},
|
||||
{
|
||||
"entity_id": 2,
|
||||
"x": 12.0,
|
||||
"y": 10.0,
|
||||
"z": 0,
|
||||
"kind": { "variant": "Npc", "data": null },
|
||||
},
|
||||
],
|
||||
"tiles": _test_tiles(),
|
||||
"visible_positions": _test_visible_positions(),
|
||||
}
|
||||
|
||||
# Generate a small test room: 8x6 room with walls, a door, and floor
|
||||
func _test_tiles() -> Array:
|
||||
var tiles: Array = []
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(room_x, room_x + room_w):
|
||||
for y in range(room_y, room_y + room_h):
|
||||
var is_edge := (x == room_x or x == room_x + room_w - 1
|
||||
or y == room_y or y == room_y + room_h - 1)
|
||||
var tile_type: String
|
||||
if is_edge:
|
||||
# Door on the south wall, center
|
||||
if y == room_y + room_h - 1 and x == room_x + room_w / 2:
|
||||
tile_type = "door"
|
||||
else:
|
||||
tile_type = "wall"
|
||||
else:
|
||||
tile_type = "floor"
|
||||
tiles.append({"x": x, "y": y, "z": 0, "type": tile_type})
|
||||
|
||||
# Corridor south of the door
|
||||
var door_x := room_x + room_w / 2
|
||||
for y in range(room_y + room_h, room_y + room_h + 4):
|
||||
tiles.append({"x": door_x - 1, "y": y, "z": 0, "type": "wall"})
|
||||
tiles.append({"x": door_x, "y": y, "z": 0, "type": "floor"})
|
||||
tiles.append({"x": door_x + 1, "y": y, "z": 0, "type": "wall"})
|
||||
|
||||
return tiles
|
||||
|
||||
# Test visibility: player at (10,10) can see tiles within radius 4, blocked by walls
|
||||
func _test_visible_positions() -> Array:
|
||||
var positions: Array = []
|
||||
var player_x := 10
|
||||
var player_y := 10
|
||||
var radius := 4
|
||||
|
||||
# Room bounds (inner floor area)
|
||||
var room_x := 7
|
||||
var room_y := 7
|
||||
var room_w := 8
|
||||
var room_h := 8
|
||||
|
||||
for x in range(player_x - radius, player_x + radius + 1):
|
||||
for y in range(player_y - radius, player_y + radius + 1):
|
||||
var dist := absf(x - player_x) + absf(y - player_y)
|
||||
if dist <= radius:
|
||||
# Walls are visible but block further vision
|
||||
# For test purposes, include all tiles within radius that are inside the room
|
||||
if x >= room_x and x < room_x + room_w and y >= room_y and y < room_y + room_h:
|
||||
positions.append({"x": x, "y": y})
|
||||
return positions
|
||||
|
||||
@@ -21,8 +21,9 @@ func _process(_delta: float) -> void:
|
||||
if world_renderer and world_renderer.has_method("update_from_state"):
|
||||
world_renderer.update_from_state()
|
||||
|
||||
# Track camera to player position (D-015)
|
||||
camera.position = GameState.player_position * 32 # tile-space to pixel-space
|
||||
# Track camera to player position every frame (D-015: locked, no panning)
|
||||
# Camera2D smoothing handles interpolation — we just set the target
|
||||
camera.global_position = GameState.player_position * 32.0
|
||||
|
||||
# Send queued input to simulation
|
||||
var inputs = InputMapper.flush_queue()
|
||||
|
||||
@@ -1,16 +1,89 @@
|
||||
extends Node2D
|
||||
extends TileMapLayer
|
||||
|
||||
# Fog renderer — manages fog of war overlay
|
||||
# Controls visibility based on player position and fog radius
|
||||
# Fog renderer — draws fog overlay on non-visible tiles (D-011)
|
||||
# Three visibility states per tile:
|
||||
# visible = no fog tile (clear)
|
||||
# fog-edge = semi-transparent dark overlay (adjacent to visible)
|
||||
# hidden = opaque black overlay
|
||||
#
|
||||
# Atlas layout:
|
||||
# (0,0) = full fog (opaque black)
|
||||
# (1,0) = fog edge (semi-transparent)
|
||||
|
||||
const TILE_SIZE: int = 32
|
||||
|
||||
var _initialized: bool = false
|
||||
var _all_tile_positions: Dictionary = {} # Vector2i -> true, all known map tiles
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_tileset()
|
||||
_initialized = true
|
||||
print("FogRenderer: Initialized")
|
||||
|
||||
# Update fog visibility (stub for now)
|
||||
func update_fog(fog_data: Dictionary, player_pos: Vector2) -> void:
|
||||
# TODO: Implement fog of war rendering
|
||||
# This will control what the player can see based on:
|
||||
# - fog_data.radius (visibility radius)
|
||||
# - player_pos (center of visible area)
|
||||
# - Perception mode state (affects visibility)
|
||||
pass
|
||||
func _setup_tileset() -> void:
|
||||
var ts := TileSet.new()
|
||||
ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 2, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Full fog (0,0) — opaque black
|
||||
_fill_tile(img, 0, Color(0.02, 0.02, 0.05, 1.0))
|
||||
# Fog edge (1,0) — semi-transparent dark
|
||||
_fill_tile(img, 1, Color(0.02, 0.02, 0.05, 0.6))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
source.create_tile(Vector2i(0, 0))
|
||||
source.create_tile(Vector2i(1, 0))
|
||||
|
||||
ts.add_source(source)
|
||||
tile_set = ts
|
||||
|
||||
# Register all known tile positions (called when tile data arrives)
|
||||
func register_tile_positions(tiles: Array) -> void:
|
||||
_all_tile_positions.clear()
|
||||
for tile_data in tiles:
|
||||
if tile_data.has("x") and tile_data.has("y"):
|
||||
_all_tile_positions[Vector2i(tile_data.x, tile_data.y)] = true
|
||||
|
||||
# Update fog based on visible positions
|
||||
# visible_positions: Dictionary of Vector2i -> true
|
||||
func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
clear()
|
||||
|
||||
if _all_tile_positions.is_empty() or visible_positions.is_empty():
|
||||
return
|
||||
|
||||
# Build set of fog-edge positions (adjacent to visible but not visible themselves)
|
||||
var fog_edge: Dictionary = {}
|
||||
var neighbors := [
|
||||
Vector2i(-1, 0), Vector2i(1, 0), Vector2i(0, -1), Vector2i(0, 1),
|
||||
Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1),
|
||||
]
|
||||
|
||||
for pos in visible_positions:
|
||||
for offset in neighbors:
|
||||
var neighbor_pos: Vector2i = pos + offset
|
||||
if not visible_positions.has(neighbor_pos) and _all_tile_positions.has(neighbor_pos):
|
||||
fog_edge[neighbor_pos] = true
|
||||
|
||||
# Place fog tiles on all known positions that aren't visible
|
||||
for pos in _all_tile_positions:
|
||||
if visible_positions.has(pos):
|
||||
continue # Visible — no fog
|
||||
elif fog_edge.has(pos):
|
||||
set_cell(pos, 0, Vector2i(1, 0)) # Fog edge — semi-transparent
|
||||
else:
|
||||
set_cell(pos, 0, Vector2i(0, 0)) # Full fog — opaque
|
||||
|
||||
func _fill_tile(img: Image, tile_index: int, color: Color) -> void:
|
||||
var x_offset := tile_index * TILE_SIZE
|
||||
for x in range(TILE_SIZE):
|
||||
for y in range(TILE_SIZE):
|
||||
img.set_pixel(x_offset + x, y, color)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
extends TileMapLayer
|
||||
|
||||
# Tile renderer — draws map tiles from ObserverSnapshot tile data
|
||||
# Uses a programmatic TileSet with placeholder colored rectangles (D-014)
|
||||
#
|
||||
# Tile types (atlas coords in the programmatic source):
|
||||
# (0,0) = floor — dark gray
|
||||
# (1,0) = wall — lighter gray
|
||||
# (2,0) = door — brown
|
||||
# (3,0) = object — teal
|
||||
|
||||
const TILE_SIZE: int = 32
|
||||
|
||||
enum TileType { FLOOR = 0, WALL = 1, DOOR = 2, OBJECT = 3 }
|
||||
|
||||
# Wire-format string to TileType mapping
|
||||
const TILE_TYPE_MAP: Dictionary = {
|
||||
"floor": TileType.FLOOR,
|
||||
"wall": TileType.WALL,
|
||||
"door": TileType.DOOR,
|
||||
"object": TileType.OBJECT,
|
||||
}
|
||||
|
||||
var _initialized: bool = false
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_tileset()
|
||||
_initialized = true
|
||||
print("TileRenderer: Initialized")
|
||||
|
||||
# Build a programmatic TileSet with colored placeholder tiles
|
||||
func _setup_tileset() -> void:
|
||||
var ts := TileSet.new()
|
||||
ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create an atlas source backed by a programmatic image
|
||||
var source := TileSetAtlasSource.new()
|
||||
var img := Image.create(TILE_SIZE * 4, TILE_SIZE, false, Image.FORMAT_RGBA8)
|
||||
|
||||
# Floor (0,0) — dark gray
|
||||
_fill_tile(img, 0, Color(0.18, 0.18, 0.22))
|
||||
# Wall (1,0) — lighter gray with subtle border
|
||||
_fill_tile_with_border(img, 1, Color(0.4, 0.4, 0.45), Color(0.25, 0.25, 0.3))
|
||||
# Door (2,0) — brown
|
||||
_fill_tile_with_border(img, 2, Color(0.5, 0.35, 0.2), Color(0.35, 0.25, 0.15))
|
||||
# Object (3,0) — teal
|
||||
_fill_tile(img, 3, Color(0.2, 0.45, 0.45))
|
||||
|
||||
var tex := ImageTexture.create_from_image(img)
|
||||
source.texture = tex
|
||||
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
|
||||
|
||||
# Create tile entries in the atlas
|
||||
for i in range(4):
|
||||
source.create_tile(Vector2i(i, 0))
|
||||
|
||||
var source_id := ts.add_source(source)
|
||||
tile_set = ts
|
||||
|
||||
# Update tiles from snapshot data
|
||||
# tiles: Array of {x: int, y: int, z: int, type: String}
|
||||
func update_tiles(tiles: Array) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
|
||||
clear()
|
||||
|
||||
for tile_data in tiles:
|
||||
if not tile_data.has("x") or not tile_data.has("y") or not tile_data.has("type"):
|
||||
continue
|
||||
|
||||
var tile_type_str: String = tile_data.type
|
||||
if not TILE_TYPE_MAP.has(tile_type_str):
|
||||
continue
|
||||
|
||||
var atlas_x: int = TILE_TYPE_MAP[tile_type_str]
|
||||
var coords := Vector2i(tile_data.x, tile_data.y)
|
||||
set_cell(coords, 0, Vector2i(atlas_x, 0))
|
||||
|
||||
# Fill a tile region with a solid color
|
||||
func _fill_tile(img: Image, tile_index: int, color: Color) -> void:
|
||||
var x_offset := tile_index * TILE_SIZE
|
||||
for x in range(TILE_SIZE):
|
||||
for y in range(TILE_SIZE):
|
||||
img.set_pixel(x_offset + x, y, color)
|
||||
|
||||
# Fill a tile region with a color and a 1px border
|
||||
func _fill_tile_with_border(img: Image, tile_index: int, fill: Color, border: Color) -> void:
|
||||
var x_offset := tile_index * TILE_SIZE
|
||||
for x in range(TILE_SIZE):
|
||||
for y in range(TILE_SIZE):
|
||||
if x == 0 or y == 0 or x == TILE_SIZE - 1 or y == TILE_SIZE - 1:
|
||||
img.set_pixel(x_offset + x, y, border)
|
||||
else:
|
||||
img.set_pixel(x_offset + x, y, fill)
|
||||
@@ -2,19 +2,36 @@ extends Node2D
|
||||
|
||||
# World renderer — manages all visual representation from GameState
|
||||
# Attached to the World node in main.tscn
|
||||
# Render order (scene tree): TileMapLayer -> FogOverlay -> Entities
|
||||
|
||||
@onready var entity_renderer = $Entities
|
||||
@onready var tile_renderer = $TileMapLayer
|
||||
@onready var fog_renderer = $FogOverlay
|
||||
@onready var entity_renderer = $Entities
|
||||
|
||||
var _tiles_dirty: bool = true
|
||||
|
||||
func _ready() -> void:
|
||||
print("WorldRenderer: Initialized")
|
||||
|
||||
# Called each frame to update visuals from game state
|
||||
func update_from_state() -> void:
|
||||
# Update tiles (only when tile data changes)
|
||||
if tile_renderer and tile_renderer.has_method("update_tiles"):
|
||||
if _tiles_dirty and GameState.visible_tiles.size() > 0:
|
||||
tile_renderer.update_tiles(GameState.visible_tiles)
|
||||
# Register tile positions with fog renderer for coverage
|
||||
if fog_renderer and fog_renderer.has_method("register_tile_positions"):
|
||||
fog_renderer.register_tile_positions(GameState.visible_tiles)
|
||||
_tiles_dirty = false
|
||||
|
||||
# Update fog overlay
|
||||
if fog_renderer and fog_renderer.has_method("update_fog"):
|
||||
fog_renderer.update_fog(GameState.visible_positions, GameState.player_position)
|
||||
|
||||
# Update entity sprites
|
||||
if entity_renderer and entity_renderer.has_method("update_entities"):
|
||||
entity_renderer.update_entities(GameState.visible_entities)
|
||||
|
||||
# Update fog overlay (fog data will come in D-020 expansion)
|
||||
if fog_renderer and fog_renderer.has_method("update_fog"):
|
||||
fog_renderer.update_fog({}, GameState.player_position)
|
||||
# Mark tiles as needing re-render (call when tile data changes significantly)
|
||||
func invalidate_tiles() -> void:
|
||||
_tiles_dirty = true
|
||||
|
||||
Reference in New Issue
Block a user