class_name ChunkStreamManager extends Node ## Manages loading and unloading of world chunks based on player position. ## ## 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. ## ## ## 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 ## ``` ## 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 ## 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 ## Maximum number of chunks to unload per frame. @export var max_unloads_per_frame: int = 4 ## 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 ## Emitted when a chunk finishes loading. signal chunk_loaded(chunk_coord: Vector3i, chunk_scene: Node) ## Emitted when a chunk is unloaded. signal chunk_unloaded(chunk_coord: Vector3i) ## 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 ## The current player chunk coordinate. var _player_chunk_coord: Vector3i = Vector3i.ZERO ## Map of loaded chunk coordinates to their scene nodes. var _loaded_chunks: Dictionary = {} ## Queue of chunk coordinates pending load, sorted by distance from player. var _load_queue: Array[Vector3i] = [] ## 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 ## 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: _process_thread = Thread.create(_streaming_loop, Thread.PRIORITY_LOW) 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 func _update_streaming() -> void: if player == null or not is_inside_tree(): return var current_coord := Chunk.coord_for_position(player.global_position) if current_coord == _player_chunk_coord: return _player_chunk_coord = current_coord _rebuild_queues() func _rebuild_queues() -> void: var target_set := _compute_target_chunks() # 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) # Sort by distance from player (closest first) to_load.sort_custom(_compare_distance) _load_queue = to_load # 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) 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 ) target[coord] = coord return target 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_in_progress[coord] = true # 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) 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: return _loading_in_progress.erase(coord) # 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.coord = coord chunk_scene.key = Chunk.build_key("", "", coord) chunk_scene.bounds = Chunk.compute_bounds(coord) _add_loaded_chunk(coord, chunk_scene) 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) _loading_in_progress.erase(coord) chunk_loaded.emit(coord, chunk_scene) func _unload_chunk(coord: Vector3i) -> void: if not _loaded_chunks.has(coord): return var chunk_scene = _loaded_chunks[coord] if is_instance_valid(chunk_scene): chunk_scene.queue_free() _loaded_chunks.erase(coord) chunk_unloaded.emit(coord) func _on_player_position_changed() -> void: """Call this when the player position changes significantly.""" if player != null: _update_streaming() 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 return Vector3i( int(parts[2]), int(parts[3]), int(parts[4]) )