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 = {} ## Internal: time accumulator for throttled streaming updates. var _update_accumulator: float = 0.0 ## Internal: interval between streaming updates (seconds). const UPDATE_INTERVAL: float = 0.1 func _ready() -> void: # Streaming is driven from _process (main thread) so we can safely # manipulate the scene tree. No background thread is used. set_process(true) func _process(delta: float) -> void: if not is_active or player == null: return _update_accumulator += delta if _update_accumulator < UPDATE_INTERVAL: return _update_accumulator = 0.0 _update_streaming() _process_load_queue() _process_unload_queue() streaming_updated.emit() func _update_streaming() -> void: if player == null or not is_inside_tree(): return var current_coord: Vector3i = 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: Dictionary = _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: Dictionary = {} var half: int = streaming_radius for dx in range(-half, half + 1): for dy in range(-half, half + 1): for dz in range(-half, half + 1): var coord: Vector3i = 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: int = _chunk_distance_squared(_player_chunk_coord, a) var dist_b: int = _chunk_distance_squared(_player_chunk_coord, b) return dist_a < dist_b func _chunk_distance_squared(a: Vector3i, b: Vector3i) -> int: var dx: int = a.x - b.x var dy: int = a.y - b.y var dz: int = a.z - b.z return dx * dx + dy * dy + dz * dz func _process_load_queue() -> void: var count: int = 0 while count < max_loads_per_frame and not _load_queue.is_empty(): var coord: Vector3i = _load_queue.pop_front() _load_chunk(coord) count += 1 func _process_unload_queue() -> void: var count: int = 0 while count < max_unloads_per_frame and not _unload_queue.is_empty(): var coord: Vector3i = _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: Vector3i = _parse_coord_from_key(key) if coord == null: return _loading_in_progress.erase(coord) # Decode the binary chunk data var chunk_data: ChunkDataDecoder.ChunkPayload = 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 = 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 = ChunkScene.new() chunk_scene.coord = coord chunk_scene.key = data.key chunk_scene.bounds = data.bounds # ChunkDataDecoder.ChunkPayload's field is named `version`, not # `format_version` -- this was a hard crash ("Invalid access to # property or key 'format_version'") the first time real decoded data # ever reached this dormant method (via HttpChunkSource). chunk_scene.format_version = data.version chunk_scene.revision = data.revision # Add tiles for layer in data.tile_layers: var tile_layer: TileLayer = 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 = 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: Node = _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: Array[Vector3i] = get_all_loaded_coords() for coord in coords: _unload_chunk(coord) _load_queue.clear() _unload_queue.clear() func _parse_coord_from_key(key: String) -> Variant: """Parse chunk coordinate from a canonical chunk key.""" # String.split() returns PackedStringArray, not Array[String] -- those # are different container types with no implicit conversion, so this # raised "Trying to assign a value of type 'PackedStringArray' to a # variable of type 'Array[String]'" the moment real data reached this # dormant method for the first time (via HttpChunkSource's # _on_chunk_data_received call -- nothing exercised this path before). var parts: PackedStringArray = key.split(":") if parts.size() < 5: return null return Vector3i( int(parts[2]), int(parts[3]), int(parts[4]) )