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()