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
Showing only changes of commit cc27d73ca6 - Show all commits
+275
View File
@@ -0,0 +1,275 @@
class_name ChunkStreamManager
extends Node
## Manages chunk load/unload 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 implements the streaming system described in docs/architecture/world.md
## section 5.6 (Streaming & Caching).
## Radius (in chunks) around the player within which chunks are loaded.
const VIEW_RADIUS: int = 4
## 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
## Number of chunks to load/unload per frame (prevents frame spikes).
const CHUNKS_PER_FRAME: int = 4
## Signal emitted when a chunk is successfully loaded.
signal chunk_loaded(chunk_key: String, chunk_scene: Node3D)
## Signal emitted when a chunk is unloaded and freed.
signal chunk_unloaded(chunk_key: String)
## Signal emitted when a chunk load fails.
signal chunk_load_failed(chunk_key: String, error: String)
## The player node whose position drives chunk streaming.
var player: Node3D = null
## Set of currently loaded chunk keys.
var loaded_chunks: Dictionary = {}
## Set of chunk keys that are currently being loaded (async).
var loading_chunks: Dictionary = {}
## The player's position at the last evaluation.
var last_player_position: Vector3 = Vector3.ZERO
## Whether streaming is active (player exists and is in-game).
var is_active: bool = false
## Reference to the server connection for chunk requests.
var server: NetworkedMultiplayerENet = null
func _ready() -> void:
last_player_position = Vector3.ZERO
## 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
## 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:
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:
return
last_player_position = current_pos
# Evaluate which chunks need to be loaded/unloaded.
_evaluate_chunks()
## Evaluate which chunks to load and which to unload.
func _evaluate_chunks() -> void:
if player == null:
return
var player_chunk_coord := Chunk.coord_for_position(player.global_position)
var chunks_to_load: PackedStringArray = []
var chunks_to_unload: PackedStringArray = []
# Determine which chunks should be loaded.
for loaded_key in loaded_chunks.keys():
var coord := _parse_chunk_key(loaded_key)
if coord == null:
continue
var dist := _chunk_distance(player_chunk_coord, coord)
if dist > VIEW_RADIUS:
chunks_to_unload.append(loaded_key)
# 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
)
# 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
## 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)
return
loading_chunks[chunk_key] = true
# Request chunk from server via RPC.
server.rpc("request_chunk", chunk_key)
## 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)
if coord == null:
push_error("Invalid chunk key: %s" % chunk_key)
return
var world_id := _get_world_id()
var room_id := _get_room_id()
# Create the chunk scene.
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
# Decode payload if available.
if not payload.is_empty():
chunk_scene.decode_payload(payload, checksum)
# Add to world tree.
add_child(chunk_scene)
loaded_chunks[chunk_key] = chunk_scene
loading_chunks.erase(chunk_key)
chunk_loaded.emit(chunk_key, 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:
return
loaded_chunks.erase(chunk_key)
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)
## 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))
## Parse a chunk key string into its coordinate components.
static func _parse_chunk_key(key: String) -> Vector3i:
var parts := key.split(":")
if parts.size() < 5:
return null
return Vector3i(
int(parts[2]),
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()