Merge remote-tracking branch 'origin/client'
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -12,14 +12,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
- Symmetric shadowcasting (#110, #359) — Albert Ford algorithm with rational fraction slopes, benchmarked 1.2-10.5x faster than recursive, symmetry guaranteed (D-035)
|
||||
- ObserverSnapshot v2 schema (#358, #25) — version field, GameTime, FacingDirection, VisibleTile, VisibilitySector types, visibility tag on entities
|
||||
- D-035 decision record — symmetric shadowcasting selected over recursive (resolves Q-018)
|
||||
- Tile rendering engine (#129) — programmatic TileSet with floor/wall/door/object placeholders, renders from snapshot tile data
|
||||
- Fog overlay rendering (#131) — three visibility states (visible/fog-edge/hidden) via TileMapLayer overlay
|
||||
- Camera lock to character (#116) — Camera2D smoothing at 2x zoom, locked to player position (D-015)
|
||||
- Test room environment — 8x8 room with corridor and Manhattan-distance visibility for development without server
|
||||
- `ticket team` command and `--team` filter — comma-separated team assignment for tickets (server, client, joint, content)
|
||||
|
||||
### Changed
|
||||
- `start-sprint` skill now requires plan mode — agent must create and get approval for a concrete sprint plan before starting implementation
|
||||
|
||||
### Fixed
|
||||
- Entity renderer protocol field mismatches — "id"→"entity_id", "type"→"kind.variant", "position"→x/y fields now match Protocol.decode_entity() output
|
||||
- Entity centering — entities (24x24) now centered within 32px tiles instead of top-left aligned
|
||||
- `start-sprint` skill uses `git rev-parse --show-toplevel` for worktree-safe absolute paths — fixes "No such file or directory" errors on team branches
|
||||
|
||||
### Changed
|
||||
- Background clear color set to near-black for unexplored areas (was default Godot gray)
|
||||
- Scene render order: Tiles → FogOverlay → Entities (fog covers tiles, entities render on top)
|
||||
- FogOverlay node type changed from Node2D to TileMapLayer for tile-based fog rendering
|
||||
- GameState now stores visible_tiles and visible_positions from snapshots
|
||||
- Test snapshot includes player entity (kind "Player"), second NPC entity, tile data, and visibility data
|
||||
|
||||
### Added
|
||||
- Sprint 2 "See" briefings (server, client, joint) — fog of perception through the bridge
|
||||
- `/plan-sprint` skill — automates sprint planning workflow and briefing file generation
|
||||
|
||||
@@ -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).
|
||||
@@ -20,7 +23,20 @@ func apply_snapshot(snapshot: Dictionary) -> void:
|
||||
if snapshot.has("entities"):
|
||||
visible_entities = snapshot.entities
|
||||
# Derive player position from the player entity
|
||||
var found_player := false
|
||||
for entity in visible_entities:
|
||||
if entity.has("entity_id") and entity.entity_id == player_entity_id:
|
||||
player_position = Vector2(entity.x, entity.y)
|
||||
found_player = true
|
||||
break
|
||||
if not found_player and visible_entities.size() > 0:
|
||||
push_warning("GameState: player entity_id %d not found in %d entities" % [
|
||||
player_entity_id, visible_entities.size()])
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class_name Constants
|
||||
|
||||
# Shared constants used across rendering, game state, and protocol layers.
|
||||
|
||||
## Tile size in pixels — all renderers and coordinate conversions use this.
|
||||
const TILE_SIZE: int = 32
|
||||
@@ -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 * Constants.TILE_SIZE
|
||||
|
||||
# Send queued input to simulation
|
||||
var inputs = InputMapper.flush_queue()
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
class_name EntityRenderer
|
||||
extends Node2D
|
||||
|
||||
# Entity renderer — manages entity sprites under the Entities node
|
||||
# Creates/updates/removes Sprite2D children based on entity data
|
||||
# Creates/updates/removes ColorRect children based on entity data
|
||||
# Entity format (from Protocol): {entity_id, x, y, z, kind: {variant, data}}
|
||||
|
||||
const TILE_SIZE: int = 32
|
||||
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 = {} # id -> Node2D mapping
|
||||
var entity_nodes: Dictionary = {} # entity_id -> Node2D mapping
|
||||
|
||||
func _ready() -> void:
|
||||
print("EntityRenderer: Initialized")
|
||||
@@ -16,10 +20,10 @@ func update_entities(entities: Array) -> void:
|
||||
|
||||
# Create or update entities
|
||||
for entity_data in entities:
|
||||
if not entity_data.has("id"):
|
||||
if not entity_data.has("entity_id"):
|
||||
continue
|
||||
|
||||
var entity_id = entity_data.id
|
||||
var entity_id = entity_data.entity_id
|
||||
active_ids.append(entity_id)
|
||||
|
||||
# Create entity node if it doesn't exist
|
||||
@@ -41,14 +45,19 @@ func update_entities(entities: Array) -> void:
|
||||
func _create_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
var entity_node = ColorRect.new()
|
||||
entity_node.name = "Entity_" + str(entity_id)
|
||||
entity_node.size = Vector2(32, 32)
|
||||
entity_node.pivot_offset = Vector2(16, 16)
|
||||
entity_node.size = Vector2(ENTITY_SIZE, ENTITY_SIZE)
|
||||
entity_node.pivot_offset = Vector2(ENTITY_SIZE / 2.0, ENTITY_SIZE / 2.0)
|
||||
|
||||
# Color based on type
|
||||
if entity_data.get("type") == "npc":
|
||||
entity_node.color = Color(0.3, 0.6, 0.9) # Blue for NPCs
|
||||
else:
|
||||
entity_node.color = Color(0.8, 0.8, 0.8) # Gray for unknown
|
||||
# Color based on entity kind (from Protocol decoded format).
|
||||
# TODO(#130): replace with D-033 relationship colors (teal/green/amber/red).
|
||||
var kind_variant: String = entity_data.get("kind", {}).get("variant", "")
|
||||
match kind_variant:
|
||||
"Npc":
|
||||
entity_node.color = Color(0.3, 0.6, 0.9) # Placeholder blue
|
||||
"Player":
|
||||
entity_node.color = Color(0.88, 0.91, 1.0) # Placeholder light blue
|
||||
_:
|
||||
entity_node.color = Color(0.8, 0.8, 0.8) # Gray for unknown
|
||||
|
||||
add_child(entity_node)
|
||||
entity_nodes[entity_id] = entity_node
|
||||
@@ -62,13 +71,12 @@ func _update_entity_node(entity_id: int, entity_data: Dictionary) -> void:
|
||||
|
||||
var entity_node = entity_nodes[entity_id]
|
||||
|
||||
# Update position
|
||||
if entity_data.has("position"):
|
||||
var pos = entity_data.position
|
||||
if pos is Array and pos.size() >= 2:
|
||||
entity_node.position = Vector2(pos[0] * TILE_SIZE, pos[1] * TILE_SIZE)
|
||||
else:
|
||||
push_warning("EntityRenderer: malformed position for entity %s" % entity_id)
|
||||
# Update position from x, y fields (Protocol format), centered within tile
|
||||
if entity_data.has("x") and entity_data.has("y"):
|
||||
entity_node.position = Vector2(
|
||||
entity_data.x * TILE_SIZE + ENTITY_OFFSET,
|
||||
entity_data.y * TILE_SIZE + ENTITY_OFFSET
|
||||
)
|
||||
|
||||
# Remove an entity node
|
||||
func _remove_entity_node(entity_id: int) -> void:
|
||||
|
||||
@@ -1,16 +1,88 @@
|
||||
extends Node2D
|
||||
class_name FogRenderer
|
||||
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)
|
||||
#
|
||||
# Note: fog-edge uses 8-directional neighbors for visual smoothness.
|
||||
# Actual visibility boundaries come from the server's shadowcasting (D-011).
|
||||
# Fog-returns-over-time (D-011 decay) is tracked in #113, not here.
|
||||
|
||||
const TILE_SIZE: int = Constants.TILE_SIZE
|
||||
|
||||
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
|
||||
img.fill_rect(Rect2i(0, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 1.0))
|
||||
# Fog edge (1,0) — semi-transparent dark
|
||||
img.fill_rect(Rect2i(TILE_SIZE, 0, TILE_SIZE, TILE_SIZE), 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.
|
||||
# player_pos reserved for future fog-decay tracking (#113).
|
||||
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 (8-directional neighbors of visible tiles)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
class_name TileRenderer
|
||||
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 = Constants.TILE_SIZE
|
||||
|
||||
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):
|
||||
push_warning("TileRenderer: unknown tile type '%s' at (%d, %d)" % [
|
||||
tile_type_str, tile_data.x, tile_data.y])
|
||||
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 rect := Rect2i(tile_index * TILE_SIZE, 0, TILE_SIZE, TILE_SIZE)
|
||||
img.fill_rect(rect, 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
|
||||
# Border (full tile)
|
||||
img.fill_rect(Rect2i(x_offset, 0, TILE_SIZE, TILE_SIZE), border)
|
||||
# Fill (inset by 1px)
|
||||
img.fill_rect(Rect2i(x_offset + 1, 1, TILE_SIZE - 2, TILE_SIZE - 2), 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 _last_tick: int = -1
|
||||
|
||||
func _ready() -> void:
|
||||
print("WorldRenderer: Initialized")
|
||||
|
||||
# Called each frame to update visuals from game state
|
||||
# Called each frame to update visuals from game state.
|
||||
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
|
||||
func update_from_state() -> void:
|
||||
var tick := GameState.current_tick
|
||||
if tick == _last_tick:
|
||||
return
|
||||
_last_tick = tick
|
||||
|
||||
# Update tiles (may include new chunks or modified tiles)
|
||||
if tile_renderer and tile_renderer.has_method("update_tiles"):
|
||||
if GameState.visible_tiles.size() > 0:
|
||||
tile_renderer.update_tiles(GameState.visible_tiles)
|
||||
if fog_renderer and fog_renderer.has_method("register_tile_positions"):
|
||||
fog_renderer.register_tile_positions(GameState.visible_tiles)
|
||||
|
||||
# Update fog overlay from visibility data
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
## D-030 Layer 1: Tests for rendering components (tile, fog, entity renderers)
|
||||
## Validates renderers handle snapshot data correctly.
|
||||
class_name TestRendering
|
||||
extends GdUnitTestSuite
|
||||
|
||||
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
|
||||
var FogRendererScript = load("res://scripts/rendering/fog_renderer.gd")
|
||||
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
|
||||
|
||||
# -- Test data matching Protocol decoded format --
|
||||
|
||||
var _test_tiles: Array = [
|
||||
{"x": 0, "y": 0, "z": 0, "type": "floor"},
|
||||
{"x": 1, "y": 0, "z": 0, "type": "wall"},
|
||||
{"x": 2, "y": 0, "z": 0, "type": "door"},
|
||||
{"x": 3, "y": 0, "z": 0, "type": "object"},
|
||||
{"x": 0, "y": 1, "z": 0, "type": "floor"},
|
||||
]
|
||||
|
||||
var _test_entities: Array = [
|
||||
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": {"variant": "Player", "data": null}},
|
||||
{"entity_id": 2, "x": 7.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
|
||||
]
|
||||
|
||||
|
||||
# -- Constants --
|
||||
|
||||
func test_tile_size_constant() -> void:
|
||||
assert_that(Constants.TILE_SIZE).is_equal(32)
|
||||
|
||||
|
||||
# -- GameState: tile and visibility data --
|
||||
|
||||
func test_game_state_tick_advances() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "tiles": _test_tiles})
|
||||
assert_that(GameState.current_tick).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 2, "tiles": _test_tiles})
|
||||
assert_that(GameState.current_tick).is_equal(2)
|
||||
|
||||
func test_game_state_stores_tiles() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "tiles": _test_tiles})
|
||||
assert_that(GameState.visible_tiles.size()).is_equal(5)
|
||||
|
||||
func test_game_state_stores_visible_positions() -> void:
|
||||
var positions := [{"x": 5, "y": 5}, {"x": 6, "y": 5}]
|
||||
GameState.apply_snapshot({"tick": 1, "visible_positions": positions})
|
||||
assert_that(GameState.visible_positions.size()).is_equal(2)
|
||||
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
|
||||
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
|
||||
|
||||
func test_game_state_clears_old_visible_positions() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "visible_positions": [{"x": 1, "y": 1}]})
|
||||
assert_that(GameState.visible_positions.size()).is_equal(1)
|
||||
GameState.apply_snapshot({"tick": 2, "visible_positions": [{"x": 2, "y": 2}, {"x": 3, "y": 3}]})
|
||||
assert_that(GameState.visible_positions.size()).is_equal(2)
|
||||
assert_that(GameState.visible_positions.has(Vector2i(1, 1))).is_false()
|
||||
|
||||
func test_game_state_same_count_different_tiles_new_tick() -> void:
|
||||
var tiles_a := [{"x": 0, "y": 0, "z": 0, "type": "floor"}]
|
||||
var tiles_b := [{"x": 0, "y": 0, "z": 0, "type": "door"}]
|
||||
GameState.apply_snapshot({"tick": 1, "tiles": tiles_a})
|
||||
assert_that(GameState.visible_tiles[0].type).is_equal("floor")
|
||||
GameState.apply_snapshot({"tick": 2, "tiles": tiles_b})
|
||||
assert_that(GameState.current_tick).is_equal(2)
|
||||
assert_that(GameState.visible_tiles[0].type).is_equal("door")
|
||||
|
||||
func test_game_state_same_count_different_visibility_new_tick() -> void:
|
||||
GameState.apply_snapshot({"tick": 1, "visible_positions": [{"x": 1, "y": 1}]})
|
||||
assert_that(GameState.visible_positions.has(Vector2i(1, 1))).is_true()
|
||||
GameState.apply_snapshot({"tick": 2, "visible_positions": [{"x": 2, "y": 2}]})
|
||||
assert_that(GameState.current_tick).is_equal(2)
|
||||
assert_that(GameState.visible_positions.has(Vector2i(1, 1))).is_false()
|
||||
assert_that(GameState.visible_positions.has(Vector2i(2, 2))).is_true()
|
||||
|
||||
func test_game_state_warns_on_missing_player() -> void:
|
||||
GameState.player_entity_id = 999
|
||||
GameState.player_position = Vector2(5, 5)
|
||||
GameState.apply_snapshot({"tick": 1, "entities": _test_entities})
|
||||
assert_that(GameState.player_position).is_equal(Vector2(5, 5))
|
||||
|
||||
|
||||
# -- SimBridge: test data completeness --
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_tiles() -> void:
|
||||
SimBridge._test_tick = 0
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("tiles")).is_true()
|
||||
assert_that(snap.tiles.size()).is_greater(0)
|
||||
var tile = snap.tiles[0]
|
||||
assert_that(tile.has("x")).is_true()
|
||||
assert_that(tile.has("y")).is_true()
|
||||
assert_that(tile.has("type")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_visible_positions() -> void:
|
||||
SimBridge._test_tick = 0
|
||||
var snap = SimBridge._test_snapshot()
|
||||
assert_that(snap.has("visible_positions")).is_true()
|
||||
assert_that(snap.visible_positions.size()).is_greater(0)
|
||||
var pos = snap.visible_positions[0]
|
||||
assert_that(pos.has("x")).is_true()
|
||||
assert_that(pos.has("y")).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_player_entity() -> void:
|
||||
SimBridge._test_tick = 0
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var has_player := false
|
||||
for entity in snap.entities:
|
||||
if entity.kind.variant == "Player":
|
||||
has_player = true
|
||||
break
|
||||
assert_that(has_player).is_true()
|
||||
|
||||
func test_sim_bridge_test_snapshot_has_npc() -> void:
|
||||
SimBridge._test_tick = 0
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var has_npc := false
|
||||
for entity in snap.entities:
|
||||
if entity.kind.variant == "Npc":
|
||||
has_npc = true
|
||||
break
|
||||
assert_that(has_npc).is_true()
|
||||
|
||||
func test_sim_bridge_test_tiles_contain_all_types() -> void:
|
||||
SimBridge._test_tick = 0
|
||||
var snap = SimBridge._test_snapshot()
|
||||
var types: Dictionary = {}
|
||||
for tile in snap.tiles:
|
||||
types[tile.type] = true
|
||||
assert_that(types.has("floor")).is_true()
|
||||
assert_that(types.has("wall")).is_true()
|
||||
assert_that(types.has("door")).is_true()
|
||||
|
||||
|
||||
# -- EntityRenderer: lifecycle --
|
||||
|
||||
func _make_entity_renderer() -> Node2D:
|
||||
var renderer = Node2D.new()
|
||||
renderer.set_script(EntityRendererScript)
|
||||
add_child(renderer)
|
||||
return renderer
|
||||
|
||||
func test_entity_renderer_creates_nodes() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(2)
|
||||
assert_that(renderer.entity_nodes.has(1)).is_true()
|
||||
assert_that(renderer.entity_nodes.has(2)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_removes_stale_entities() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(2)
|
||||
|
||||
renderer.update_entities([_test_entities[0]])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(1)
|
||||
assert_that(renderer.entity_nodes.has(1)).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_positions_centered() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([_test_entities[0]])
|
||||
|
||||
var node = renderer.entity_nodes[1]
|
||||
var offset: float = (Constants.TILE_SIZE - 24) / 2.0
|
||||
var expected_x: float = 5.0 * Constants.TILE_SIZE + offset
|
||||
var expected_y: float = 5.0 * Constants.TILE_SIZE + offset
|
||||
assert_that(node.position.x).is_equal_approx(expected_x, 0.01)
|
||||
assert_that(node.position.y).is_equal_approx(expected_y, 0.01)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_skips_missing_entity_id() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities([{"x": 1.0, "y": 1.0, "z": 0, "kind": {"variant": "Npc", "data": null}}])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_player_color_differs_from_npc() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
|
||||
var player_node = renderer.entity_nodes[1] as ColorRect
|
||||
var npc_node = renderer.entity_nodes[2] as ColorRect
|
||||
assert_that(player_node.color != npc_node.color).is_true()
|
||||
renderer.queue_free()
|
||||
|
||||
func test_entity_renderer_empty_entities_clears_all() -> void:
|
||||
var renderer := _make_entity_renderer()
|
||||
renderer.update_entities(_test_entities)
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(2)
|
||||
|
||||
renderer.update_entities([])
|
||||
assert_that(renderer.entity_nodes.size()).is_equal(0)
|
||||
renderer.queue_free()
|
||||
|
||||
|
||||
# -- FogRenderer: position registration --
|
||||
|
||||
func _make_fog_renderer() -> TileMapLayer:
|
||||
var fog = TileMapLayer.new()
|
||||
fog.set_script(FogRendererScript)
|
||||
return fog
|
||||
|
||||
func test_fog_renderer_registers_positions() -> void:
|
||||
var fog := _make_fog_renderer()
|
||||
fog.register_tile_positions(_test_tiles)
|
||||
assert_that(fog._all_tile_positions.size()).is_equal(5)
|
||||
assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_true()
|
||||
assert_that(fog._all_tile_positions.has(Vector2i(3, 0))).is_true()
|
||||
assert_that(fog._all_tile_positions.has(Vector2i(0, 1))).is_true()
|
||||
fog.free()
|
||||
|
||||
func test_fog_renderer_clears_on_re_register() -> void:
|
||||
var fog := _make_fog_renderer()
|
||||
fog.register_tile_positions(_test_tiles)
|
||||
assert_that(fog._all_tile_positions.size()).is_equal(5)
|
||||
|
||||
fog.register_tile_positions([{"x": 10, "y": 10, "z": 0, "type": "floor"}])
|
||||
assert_that(fog._all_tile_positions.size()).is_equal(1)
|
||||
assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_false()
|
||||
fog.free()
|
||||
|
||||
func test_fog_renderer_handles_empty_data() -> void:
|
||||
var fog := _make_fog_renderer()
|
||||
fog._initialized = true
|
||||
fog.update_fog({}, Vector2.ZERO)
|
||||
fog.register_tile_positions([])
|
||||
assert_that(fog._all_tile_positions.size()).is_equal(0)
|
||||
fog.free()
|
||||
|
||||
|
||||
# -- TileRenderer: tile type constants --
|
||||
|
||||
func test_tile_type_map_covers_required_types() -> void:
|
||||
var tile_script = TileRendererScript
|
||||
var required := ["floor", "wall", "door", "object"]
|
||||
for tile_type in required:
|
||||
assert_that(tile_script.TILE_TYPE_MAP.has(tile_type)).is_true()
|
||||
Reference in New Issue
Block a user