Address review feedback: Implement chunk streaming system in GDScript #20

Merged
reviewer-bot merged 21 commits from agent/bd718b31b595 into main 2026-08-18 14:41:08 +00:00
2 changed files with 443 additions and 204 deletions
Showing only changes of commit 5c0a61870f - Show all commits
+170
View File
@@ -0,0 +1,170 @@
class_name ChunkScene
extends Node3D
## A visual representation of a single loaded world chunk.
##
## This node is instantiated by [ChunkStreamManager] when a chunk is loaded.
## It holds the tile layers, world objects, and metadata for one chunk of the
## world. The scene is responsible for rendering the chunk's visual content
## and exposing its gameplay objects to the rest of the game.
##
## ## Structure
##
## ```
## ChunkScene (Node3D)
## ├── TileLayer (Node3D) # one per tile layer (ground, walls, decor, ...)
## ├── StaticObject (Node3D) # props, geometry, interactables
## └── DynamicObject (Node3D) # spawnable entities (templates)
## ```
##
## ## Lifecycle
##
## 1. [ChunkStreamManager] requests chunk data from the server.
## 2. Server responds with binary payload.
## 3. [ChunkStreamManager] decodes the payload and creates a [ChunkScene].
## 4. [ChunkScene] populates itself with tile layers and objects.
## 5. When the player moves out of range, [ChunkStreamManager] calls
## [method ChunkScene.queue_free] to unload it.
## The chunk coordinate this scene represents.
var coord: Vector3i = Vector3i.ZERO
## The canonical chunk key.
var key: String = ""
## World-space bounding box of this chunk.
var bounds: AABB = AABB()
## Format version of the chunk data.
var format_version: int = 1
## Revision counter from the server.
var revision: int = 0
## List of tile layers in this chunk.
var tile_layers: Array[TileLayer] = []
## List of world objects (static + dynamic) in this chunk.
var world_objects: Array[WorldObject] = []
## Content moderation flags for this chunk.
var content_flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
## Whether this chunk has been modified locally (not yet synced to server).
var is_dirty: bool = false
func _ready() -> void:
# Position this chunk scene at its world-space origin
global_position = bounds.position
# Set up collision shape for the chunk bounds
_setup_collision()
func _setup_collision() -> void:
# Add a collision shape for the chunk's bounding box
var collision := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = bounds.size
collision.shape = shape
collision.name = "ChunkBounds"
add_child(collision)
## Add a tile layer to this chunk scene.
func add_tile_layer(layer: TileLayer) -> void:
tile_layers.append(layer)
var layer_node := TileLayerNode.new()
layer_node.tile_layer = layer
layer_node.chunk_coord = coord
add_child(layer_node)
## Add a world object to this chunk scene.
func add_object(obj: WorldObject) -> void:
world_objects.append(obj)
if obj.is_dynamic:
_add_dynamic_object(obj)
else:
_add_static_object(obj)
func _add_static_object(obj: WorldObject) -> void:
var static_obj := StaticObjectNode.new()
static_obj.world_object = obj
static_obj.position = obj.position
static_obj.rotation = obj.rotation
static_obj.scale = obj.scale
static_obj.name = "static_%s" % obj.id
add_child(static_obj)
func _add_dynamic_object(obj: WorldObject) -> void:
var dynamic_obj := DynamicObjectNode.new()
dynamic_obj.world_object = obj
dynamic_obj.position = obj.position
dynamic_obj.rotation = obj.rotation
dynamic_obj.scale = obj.scale
dynamic_obj.name = "dynamic_%s" % obj.id
add_child(dynamic_obj)
## Get a world object by its ID.
func get_object_by_id(object_id: String) -> WorldObject:
for obj in world_objects:
if obj.id == object_id:
return obj
return null
## Get all static objects in this chunk.
func get_static_objects() -> Array[WorldObject]:
var result: Array[WorldObject] = []
for obj in world_objects:
if not obj.is_dynamic:
result.append(obj)
return result
## Get all dynamic object templates in this chunk.
func get_dynamic_templates() -> Array[WorldObject]:
var result: Array[WorldObject] = []
for obj in world_objects:
if obj.is_dynamic:
result.append(obj)
return result
## Mark this chunk as dirty (locally modified).
func mark_dirty() -> void:
is_dirty = true
## Clear all objects and tile layers from this chunk.
func clear() -> void:
for child in get_children():
if child is TileLayerNode or child is StaticObjectNode or child is DynamicObjectNode:
child.queue_free()
tile_layers.clear()
world_objects.clear()
## Get the world-space position of a tile within this chunk.
func get_tile_world_position(tile_x: int, tile_y: int, tile_z: int = 0) -> Vector3:
return bounds.position + Vector3(
tile_x * 1.0, # Tile size is 1 world unit
tile_y * 1.0,
tile_z * 1.0
)
## Check if a world-space position is within this chunk's bounds.
func contains_position(pos: Vector3) -> bool:
return bounds.has_point(pos)
## Check if a chunk coordinate is adjacent to this chunk.
func is_adjacent(other_coord: Vector3i) -> bool:
var dx := abs(coord.x - other_coord.x)
var dy := abs(coord.y - other_coord.y)
var dz := abs(coord.z - other_coord.z)
return dx <= 1 and dy <= 1 and dz <= 1 and (dx + dy + dz) > 0
+273 -204
View File
@@ -1,229 +1,339 @@
class_name ChunkStreamManager
extends Node
## Manages chunk load/unload based on player position.
## Manages loading and unloading of world chunks based on player position.
##
## Chunks are loaded from the server and instantiated as [ChunkScene] nodes.
## The manager maintains a view radius around the player and only loads chunks
## within that radius. Chunks outside the radius are unloaded to save memory.
## This is the central orchestrator for the chunk streaming system. It monitors
## the player's position, determines which chunks should be loaded, requests
## chunk data from the server, instantiates ChunkScene nodes, and unloads
## chunks that fall outside the streaming radius.
##
## This implements the streaming system described in docs/architecture/world.md
## section 5.6 (Streaming & Caching).
## ## Design
##
## - **Streaming radius** defines how many chunks around the player are kept
## loaded. Chunks beyond this radius are unloaded to save memory and CPU.
## - **Load priority** ensures chunks closer to the player are loaded first,
## preventing visual pop-in at the edge of the view.
## - **Server communication** uses the RPCs defined in docs/architecture/world.md
## (§5.3): `request_chunk`, `chunk_data`, `chunk_invalidated`.
##
## ## Usage
##
## Add this node to your scene tree and set the `player` reference:
## ```gdscript
## var stream_manager: ChunkStreamManager = $ChunkStreamManager
## stream_manager.player = $Player
## stream_manager.streaming_radius = 4 # chunks
## ```
## Radius (in chunks) around the player within which chunks are loaded.
const VIEW_RADIUS: int = 4
## Number of chunks (radius) to keep loaded around the player.
## A radius of 2 means a 5x5 grid of chunks centered on the player.
## Higher values increase memory usage but reduce streaming pop-in.
@export var streaming_radius: int = 3
## Minimum distance (in chunks) the player must move before the manager
## re-evaluates which chunks need to be loaded/unloaded. This prevents
## excessive chunk churn when the player is standing still.
const MIN_MOVE_DISTANCE: float = 1.0
## Maximum number of chunks to load per frame. Prevents frame spikes during
## rapid movement or when entering a new area.
@export var max_loads_per_frame: int = 4
## Number of chunks to load/unload per frame (prevents frame spikes).
const CHUNKS_PER_FRAME: int = 4
## Maximum number of chunks to unload per frame.
@export var max_unloads_per_frame: int = 4
## Signal emitted when a chunk is successfully loaded.
signal chunk_loaded(chunk_key: String, chunk_scene: Node3D)
## Distance (in chunks) from the streaming radius edge before a chunk is
## considered for unloading. Prevents rapid load/unload cycling.
@export var unload_margin: int = 1
## Signal emitted when a chunk is unloaded and freed.
signal chunk_unloaded(chunk_key: String)
## Emitted when a chunk finishes loading.
signal chunk_loaded(chunk_coord: Vector3i, chunk_scene: Node)
## Signal emitted when a chunk load fails.
signal chunk_load_failed(chunk_key: String, error: String)
## Emitted when a chunk is unloaded.
signal chunk_unloaded(chunk_coord: Vector3i)
## The player node whose position drives chunk streaming.
## Emitted when a chunk fails to load (e.g., server error).
signal chunk_load_failed(chunk_coord: Vector3i, error: String)
## Emitted when the player moves far enough to require a streaming update.
signal streaming_updated
## Reference to the player node. Must be set before the manager starts.
var player: Node3D = null
## Set of currently loaded chunk keys.
var loaded_chunks: Dictionary = {}
## The current player chunk coordinate.
var _player_chunk_coord: Vector3i = Vector3i.ZERO
## Set of chunk keys that are currently being loaded (async).
var loading_chunks: Dictionary = {}
## Map of loaded chunk coordinates to their scene nodes.
var _loaded_chunks: Dictionary = {}
## The player's position at the last evaluation.
var last_player_position: Vector3 = Vector3.ZERO
## Queue of chunk coordinates pending load, sorted by distance from player.
var _load_queue: Array[Vector3i] = []
## Whether streaming is active (player exists and is in-game).
## Queue of chunk coordinates pending unload.
var _unload_queue: Array[Vector3i] = []
## Whether streaming is currently active (player is set and in game).
var is_active: bool = false
## Reference to the server connection for chunk requests.
var server: NetworkedMultiplayerENet = null
## The server RPC interface for chunk data. Set by the networking layer.
var _server_rpc: Object = null
## Internal: track which chunks are currently being loaded (to avoid duplicates).
var _loading_in_progress: Dictionary = {}
func _ready() -> void:
last_player_position = Vector3.ZERO
_process_thread = Thread.create(_streaming_loop, Thread.PRIORITY_LOW)
## Set the player node that drives chunk streaming.
func set_player(p_player: Node3D) -> void:
player = p_player
if player != null:
last_player_position = player.global_position
is_active = true
else:
is_active = false
func _streaming_loop() -> void:
while is_inside_tree():
_update_streaming()
_process_load_queue()
_process_unload_queue()
streaming_updated.emit()
await get_tree().create_timer(0.1).timeout # Check every 100ms
## Set the network server reference for chunk requests.
func set_server(p_server: NetworkedMultiplayerENet) -> void:
server = p_server
## Called every frame to evaluate chunk loading/unloading.
func _process(_delta: float) -> void:
if not is_active or player == null:
func _update_streaming() -> void:
if player == null or not is_inside_tree():
return
# Check if player has moved enough to warrant re-evaluation.
var current_pos := player.global_position
var move_distance := (current_pos - last_player_position).length()
if move_distance < MIN_MOVE_DISTANCE:
var current_coord := Chunk.coord_for_position(player.global_position)
if current_coord == _player_chunk_coord:
return
last_player_position = current_pos
# Evaluate which chunks need to be loaded/unloaded.
_evaluate_chunks()
_player_chunk_coord = current_coord
_rebuild_queues()
## Evaluate which chunks to load and which to unload.
func _evaluate_chunks() -> void:
if player == null:
return
func _rebuild_queues() -> void:
var target_set := _compute_target_chunks()
var player_chunk_coord := Chunk.coord_for_position(player.global_position)
var chunks_to_load: PackedStringArray = []
var chunks_to_unload: PackedStringArray = []
# Determine which chunks need to be loaded
var to_load: Array[Vector3i] = []
for coord in target_set:
if not _loaded_chunks.has(coord) and not _loading_in_progress.has(coord):
to_load.append(coord)
# Determine which chunks should be loaded.
for loaded_key in loaded_chunks.keys():
var coord := _parse_chunk_key(loaded_key)
if coord == null:
continue
# Sort by distance from player (closest first)
to_load.sort_custom(_compare_distance)
_load_queue = to_load
var dist := _chunk_distance(player_chunk_coord, coord)
if dist > VIEW_RADIUS:
chunks_to_unload.append(loaded_key)
# Determine which chunks need to be unloaded
var to_unload: Array[Vector3i] = []
for coord in _loaded_chunks:
if not target_set.has(coord):
to_unload.append(coord)
# Determine which chunks should be loaded.
for dx in range(-VIEW_RADIUS, VIEW_RADIUS + 1):
for dy in range(-VIEW_RADIUS, VIEW_RADIUS + 1):
for dz in range(-1, 2):
var target_coord := Vector3i(
player_chunk_coord.x + dx,
player_chunk_coord.y + dy,
player_chunk_coord.z + dz
to_unload.sort_custom(_compare_distance)
_unload_queue = to_unload
func _compute_target_chunks() -> Dictionary:
"""Compute the set of chunk coordinates that should be loaded."""
var target := {}
var half := streaming_radius
for dx in range(-half, half + 1):
for dy in range(-half, half + 1):
for dz in range(-Chunk.CHUNK_SIZE_Z, Chunk.CHUNK_SIZE_Z + 1):
var coord := Vector3i(
_player_chunk_coord.x + dx,
_player_chunk_coord.y + dy,
_player_chunk_coord.z + dz
)
# Skip chunks outside the spherical view radius.
if _chunk_distance(player_chunk_coord, target_coord) > VIEW_RADIUS:
continue
var world_id := _get_world_id()
var room_id := _get_room_id()
var key := Chunk.build_key(world_id, room_id, target_coord)
if key not in loaded_chunks and key not in loading_chunks:
chunks_to_load.append(key)
# Unload chunks (up to CHUNKS_PER_FRAME per frame).
var unload_count := 0
for key in chunks_to_unload:
if unload_count >= CHUNKS_PER_FRAME:
break
_unload_chunk(key)
unload_count += 1
# Load chunks (up to CHUNKS_PER_FRAME per frame).
var load_count := 0
for key in chunks_to_load:
if load_count >= CHUNKS_PER_FRAME:
break
_load_chunk(key)
load_count += 1
target[coord] = coord
return target
## Load a chunk by requesting it from the server and instantiating it.
func _load_chunk(chunk_key: String) -> void:
if server == null:
# No server available; create a placeholder chunk.
_instantiate_chunk(chunk_key)
func _compare_distance(a: Vector3i, b: Vector3i) -> bool:
"""Compare chunk coordinates by distance from player chunk."""
var dist_a := _chunk_distance_squared(_player_chunk_coord, a)
var dist_b := _chunk_distance_squared(_player_chunk_coord, b)
return dist_a < dist_b
func _chunk_distance_squared(a: Vector3i, b: Vector3i) -> int:
var dx := a.x - b.x
var dy := a.y - b.y
var dz := a.z - b.z
return dx * dx + dy * dy + dz * dz
func _process_load_queue() -> void:
var count := 0
while count < max_loads_per_frame and not _load_queue.is_empty():
var coord := _load_queue.pop_front()
_load_chunk(coord)
count += 1
func _process_unload_queue() -> void:
var count := 0
while count < max_unloads_per_frame and not _unload_queue.is_empty():
var coord := _unload_queue.pop_front()
_unload_chunk(coord)
count += 1
func _load_chunk(coord: Vector3i) -> void:
if _loading_in_progress.has(coord):
return
loading_chunks[chunk_key] = true
_loading_in_progress[coord] = true
# Request chunk from server via RPC.
server.rpc("request_chunk", chunk_key)
# Request chunk data from server via RPC
if _server_rpc != null and _server_rpc.has_method("request_chunk"):
_server_rpc.request_chunk(Chunk.build_key(
"", "", coord
))
else:
# Fallback: try to load from local cache or create empty chunk
_instantiate_chunk_scene(coord)
## Handle the server's chunk_data response.
@rpc("any_peer", "call_local", "reliable")
func _rpc_chunk_data(chunk_key: String, payload: PackedByteArray, checksum: String) -> void:
loading_chunks.erase(chunk_key)
_instantiate_chunk(chunk_key, payload, checksum)
## Handle chunk load failure from server.
@rpc("any_peer", "call_local", "reliable")
func _rpc_chunk_load_error(chunk_key: String, error_msg: String) -> void:
loading_chunks.erase(chunk_key)
chunk_load_failed.emit(chunk_key, error_msg)
## Instantiate a chunk scene from data (either cached or from server).
func _instantiate_chunk(chunk_key: String, payload: PackedByteArray = PackedByteArray(), checksum: String = "") -> void:
var coord := _parse_chunk_key(chunk_key)
func _on_chunk_data_received(key: String, binary_data: PackedByteArray) -> void:
"""Called by the networking layer when chunk data arrives from server."""
var coord := _parse_coord_from_key(key)
if coord == null:
push_error("Invalid chunk key: %s" % chunk_key)
return
var world_id := _get_world_id()
var room_id := _get_room_id()
_loading_in_progress.erase(coord)
# Create the chunk scene.
# Decode the binary chunk data
var chunk_data := ChunkDataDecoder.decode_binary(binary_data)
if chunk_data == null:
chunk_load_failed.emit(coord, "Failed to decode chunk data")
return
# Instantiate the chunk scene with the decoded data
_instantiate_chunk_scene_with_data(coord, chunk_data)
func _on_chunk_load_error(coord: Vector3i, error_msg: String) -> void:
"""Called when the server reports a chunk load error."""
_loading_in_progress.erase(coord)
chunk_load_failed.emit(coord, error_msg)
func _instantiate_chunk_scene(coord: Vector3i) -> void:
"""Create an empty chunk scene for the given coordinate."""
var chunk_scene := ChunkScene.new()
chunk_scene.chunk_key = chunk_key
chunk_scene.coord = coord
chunk_scene.world_id = world_id
chunk_scene.room_id = room_id
chunk_scene.key = Chunk.build_key("", "", coord)
chunk_scene.bounds = Chunk.compute_bounds(coord)
_add_loaded_chunk(coord, chunk_scene)
# Decode payload if available.
if not payload.is_empty():
chunk_scene.decode_payload(payload, checksum)
# Add to world tree.
func _instantiate_chunk_scene_with_data(coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void:
"""Create a chunk scene populated with server data."""
var chunk_scene := ChunkScene.new()
chunk_scene.coord = coord
chunk_scene.key = data.key
chunk_scene.bounds = data.bounds
chunk_scene.format_version = data.format_version
chunk_scene.revision = data.revision
# Add tiles
for layer in data.tile_layers:
var tile_layer := TileLayer.new()
tile_layer.name = layer.name
tile_layer.layer_id = layer.layer_id
tile_layer.width = layer.width
tile_layer.height = layer.height
tile_layer.depth = layer.depth
tile_layer.palette = layer.palette
tile_layer.tiles = layer.tiles
chunk_scene.add_tile_layer(tile_layer)
# Add objects
for obj_data in data.objects:
var world_obj := WorldObject.new()
world_obj.id = obj_data.id
world_obj.type = obj_data.type
world_obj.asset_id = obj_data.asset_id
world_obj.position = obj_data.position
world_obj.rotation = obj_data.rotation
world_obj.scale = obj_data.scale
world_obj.properties = obj_data.properties
world_obj.is_dynamic = obj_data.dynamic
world_obj.flags = obj_data.flags
chunk_scene.add_object(world_obj)
# Add content flags
chunk_scene.content_flags = data.flags
_add_loaded_chunk(coord, chunk_scene)
func _add_loaded_chunk(coord: Vector3i, chunk_scene: Node) -> void:
_loaded_chunks[coord] = chunk_scene
add_child(chunk_scene)
loaded_chunks[chunk_key] = chunk_scene
loading_chunks.erase(chunk_key)
chunk_loaded.emit(chunk_key, chunk_scene)
_loading_in_progress.erase(coord)
chunk_loaded.emit(coord, chunk_scene)
## Unload a chunk by freeing its scene.
func _unload_chunk(chunk_key: String) -> void:
var chunk_scene = loaded_chunks.get(chunk_key)
if chunk_scene == null:
func _unload_chunk(coord: Vector3i) -> void:
if not _loaded_chunks.has(coord):
return
loaded_chunks.erase(chunk_key)
var chunk_scene = _loaded_chunks[coord]
if is_instance_valid(chunk_scene):
chunk_scene.queue_free()
# Wait for tree removal before freeing.
await chunk_scene.tree_exited
chunk_unloaded.emit(chunk_key)
_loaded_chunks.erase(coord)
chunk_unloaded.emit(coord)
## Get the distance between two chunk coordinates (Manhattan distance in chunk space).
static func _chunk_distance(a: Vector3i, b: Vector3i) -> int:
var dx := absi(a.x - b.x)
var dy := absi(a.y - b.y)
var dz := absi(a.z - b.z)
# Use Chebyshev distance (max of axis distances) for square view radius.
return maxi(dx, maxi(dy, dz))
func _on_player_position_changed() -> void:
"""Call this when the player position changes significantly."""
if player != null:
_update_streaming()
## Parse a chunk key string into its coordinate components.
static func _parse_chunk_key(key: String) -> Vector3i:
func set_server_rpc(rpc_interface: Object) -> void:
"""Set the server RPC interface for chunk requests."""
_server_rpc = rpc_interface
func get_loaded_chunk(coord: Vector3i) -> Node:
"""Get a loaded chunk scene by coordinate, or null if not loaded."""
return _loaded_chunks.get(coord, null)
func is_chunk_loaded(coord: Vector3i) -> bool:
"""Check if a chunk is currently loaded."""
return _loaded_chunks.has(coord)
func get_loaded_chunk_count() -> int:
"""Get the number of currently loaded chunks."""
return _loaded_chunks.size()
func get_all_loaded_coords() -> Array[Vector3i]:
"""Get all currently loaded chunk coordinates."""
var coords: Array[Vector3i] = []
for key in _loaded_chunks:
coords.append(key)
return coords
func force_reload_chunk(coord: Vector3i) -> void:
"""Force reload a specific chunk (e.g., after server update)."""
if _loaded_chunks.has(coord):
_unload_chunk(coord)
if not _loading_in_progress.has(coord):
_load_chunk(coord)
func clear_all_chunks() -> void:
"""Unload all chunks immediately."""
var coords := get_all_loaded_coords()
for coord in coords:
_unload_chunk(coord)
_load_queue.clear()
_unload_queue.clear()
func _parse_coord_from_key(key: String) -> Vector3i:
"""Parse chunk coordinate from a canonical chunk key."""
var parts := key.split(":")
if parts.size() < 5:
return null
@@ -232,44 +342,3 @@ static func _parse_chunk_key(key: String) -> Vector3i:
int(parts[3]),
int(parts[4])
)
## Get the current world ID (override in subclass or set via property).
func _get_world_id() -> String:
return "w_0001"
## Get the current room ID (override in subclass or set via property).
func _get_room_id() -> String:
return "r_0001"
## Get the list of currently loaded chunk keys.
func get_loaded_chunk_keys() -> PackedStringArray:
return loaded_chunks.keys()
## Check if a specific chunk is currently loaded.
func is_chunk_loaded(chunk_key: String) -> bool:
return chunk_key in loaded_chunks
## Force reload a specific chunk (unloads and re-loads it).
func reload_chunk(chunk_key: String) -> void:
if chunk_key in loaded_chunks:
_unload_chunk(chunk_key)
if chunk_key not in loading_chunks:
_load_chunk(chunk_key)
## Clear all loaded chunks (useful for world transitions).
func clear_all_chunks() -> void:
var keys := loaded_chunks.keys()
for key in keys:
_unload_chunk(key)
loading_chunks.clear()
## Get the number of currently loaded chunks.
func get_loaded_chunk_count() -> int:
return loaded_chunks.size()