From cc27d73ca661ba2c069f60653526624771926796 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 21:36:13 +0000 Subject: [PATCH] WIP checkpoint: Implement chunk streaming system in GDScript --- scripts/chunk_stream_manager.gd | 275 ++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 scripts/chunk_stream_manager.gd diff --git a/scripts/chunk_stream_manager.gd b/scripts/chunk_stream_manager.gd new file mode 100644 index 0000000..40b833a --- /dev/null +++ b/scripts/chunk_stream_manager.gd @@ -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()