From 5c0a61870f20f32af6e997d25ccc9ec759dff00d Mon Sep 17 00:00:00 2001 From: coder-bot Date: Mon, 17 Aug 2026 21:46:23 +0000 Subject: [PATCH] WIP checkpoint: Implement chunk streaming system in GDScript --- scripts/chunk_scene.gd | 170 ++++++++++++ scripts/chunk_stream_manager.gd | 477 ++++++++++++++++++-------------- 2 files changed, 443 insertions(+), 204 deletions(-) create mode 100644 scripts/chunk_scene.gd diff --git a/scripts/chunk_scene.gd b/scripts/chunk_scene.gd new file mode 100644 index 0000000..f19cf9b --- /dev/null +++ b/scripts/chunk_scene.gd @@ -0,0 +1,170 @@ +class_name ChunkScene +extends Node3D +## A visual representation of a single loaded world chunk. +## +## This node is instantiated by [ChunkStreamManager] when a chunk is loaded. +## It holds the tile layers, world objects, and metadata for one chunk of the +## world. The scene is responsible for rendering the chunk's visual content +## and exposing its gameplay objects to the rest of the game. +## +## ## Structure +## +## ``` +## ChunkScene (Node3D) +## ├── TileLayer (Node3D) # one per tile layer (ground, walls, decor, ...) +## ├── StaticObject (Node3D) # props, geometry, interactables +## └── DynamicObject (Node3D) # spawnable entities (templates) +## ``` +## +## ## Lifecycle +## +## 1. [ChunkStreamManager] requests chunk data from the server. +## 2. Server responds with binary payload. +## 3. [ChunkStreamManager] decodes the payload and creates a [ChunkScene]. +## 4. [ChunkScene] populates itself with tile layers and objects. +## 5. When the player moves out of range, [ChunkStreamManager] calls +## [method ChunkScene.queue_free] to unload it. + +## The chunk coordinate this scene represents. +var coord: Vector3i = Vector3i.ZERO + +## The canonical chunk key. +var key: String = "" + +## World-space bounding box of this chunk. +var bounds: AABB = AABB() + +## Format version of the chunk data. +var format_version: int = 1 + +## Revision counter from the server. +var revision: int = 0 + +## List of tile layers in this chunk. +var tile_layers: Array[TileLayer] = [] + +## List of world objects (static + dynamic) in this chunk. +var world_objects: Array[WorldObject] = [] + +## Content moderation flags for this chunk. +var content_flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new() + +## Whether this chunk has been modified locally (not yet synced to server). +var is_dirty: bool = false + + +func _ready() -> void: + # Position this chunk scene at its world-space origin + global_position = bounds.position + # Set up collision shape for the chunk bounds + _setup_collision() + + +func _setup_collision() -> void: + # Add a collision shape for the chunk's bounding box + var collision := CollisionShape3D.new() + var shape := BoxShape3D.new() + shape.size = bounds.size + collision.shape = shape + collision.name = "ChunkBounds" + add_child(collision) + + +## Add a tile layer to this chunk scene. +func add_tile_layer(layer: TileLayer) -> void: + tile_layers.append(layer) + var layer_node := TileLayerNode.new() + layer_node.tile_layer = layer + layer_node.chunk_coord = coord + add_child(layer_node) + + +## Add a world object to this chunk scene. +func add_object(obj: WorldObject) -> void: + world_objects.append(obj) + if obj.is_dynamic: + _add_dynamic_object(obj) + else: + _add_static_object(obj) + + +func _add_static_object(obj: WorldObject) -> void: + var static_obj := StaticObjectNode.new() + static_obj.world_object = obj + static_obj.position = obj.position + static_obj.rotation = obj.rotation + static_obj.scale = obj.scale + static_obj.name = "static_%s" % obj.id + add_child(static_obj) + + +func _add_dynamic_object(obj: WorldObject) -> void: + var dynamic_obj := DynamicObjectNode.new() + dynamic_obj.world_object = obj + dynamic_obj.position = obj.position + dynamic_obj.rotation = obj.rotation + dynamic_obj.scale = obj.scale + dynamic_obj.name = "dynamic_%s" % obj.id + add_child(dynamic_obj) + + +## Get a world object by its ID. +func get_object_by_id(object_id: String) -> WorldObject: + for obj in world_objects: + if obj.id == object_id: + return obj + return null + + +## Get all static objects in this chunk. +func get_static_objects() -> Array[WorldObject]: + var result: Array[WorldObject] = [] + for obj in world_objects: + if not obj.is_dynamic: + result.append(obj) + return result + + +## Get all dynamic object templates in this chunk. +func get_dynamic_templates() -> Array[WorldObject]: + var result: Array[WorldObject] = [] + for obj in world_objects: + if obj.is_dynamic: + result.append(obj) + return result + + +## Mark this chunk as dirty (locally modified). +func mark_dirty() -> void: + is_dirty = true + + +## Clear all objects and tile layers from this chunk. +func clear() -> void: + for child in get_children(): + if child is TileLayerNode or child is StaticObjectNode or child is DynamicObjectNode: + child.queue_free() + tile_layers.clear() + world_objects.clear() + + +## Get the world-space position of a tile within this chunk. +func get_tile_world_position(tile_x: int, tile_y: int, tile_z: int = 0) -> Vector3: + return bounds.position + Vector3( + tile_x * 1.0, # Tile size is 1 world unit + tile_y * 1.0, + tile_z * 1.0 + ) + + +## Check if a world-space position is within this chunk's bounds. +func contains_position(pos: Vector3) -> bool: + return bounds.has_point(pos) + + +## Check if a chunk coordinate is adjacent to this chunk. +func is_adjacent(other_coord: Vector3i) -> bool: + var dx := abs(coord.x - other_coord.x) + var dy := abs(coord.y - other_coord.y) + var dz := abs(coord.z - other_coord.z) + return dx <= 1 and dy <= 1 and dz <= 1 and (dx + dy + dz) > 0 diff --git a/scripts/chunk_stream_manager.gd b/scripts/chunk_stream_manager.gd index 40b833a..27d04bc 100644 --- a/scripts/chunk_stream_manager.gd +++ b/scripts/chunk_stream_manager.gd @@ -1,229 +1,339 @@ class_name ChunkStreamManager extends Node -## Manages chunk load/unload based on player position. +## Manages loading and unloading of world chunks 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 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. ## -## This implements the streaming system described in docs/architecture/world.md -## section 5.6 (Streaming & Caching). +## ## 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 +## ``` -## Radius (in chunks) around the player within which chunks are loaded. -const VIEW_RADIUS: int = 4 +## 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 -## 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 +## 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 -## Number of chunks to load/unload per frame (prevents frame spikes). -const CHUNKS_PER_FRAME: int = 4 +## Maximum number of chunks to unload per frame. +@export var max_unloads_per_frame: int = 4 -## Signal emitted when a chunk is successfully loaded. -signal chunk_loaded(chunk_key: String, chunk_scene: Node3D) +## 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 -## Signal emitted when a chunk is unloaded and freed. -signal chunk_unloaded(chunk_key: String) +## Emitted when a chunk finishes loading. +signal chunk_loaded(chunk_coord: Vector3i, chunk_scene: Node) -## Signal emitted when a chunk load fails. -signal chunk_load_failed(chunk_key: String, error: String) +## Emitted when a chunk is unloaded. +signal chunk_unloaded(chunk_coord: Vector3i) -## The player node whose position drives chunk streaming. +## 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 -## Set of currently loaded chunk keys. -var loaded_chunks: Dictionary = {} +## The current player chunk coordinate. +var _player_chunk_coord: Vector3i = Vector3i.ZERO -## Set of chunk keys that are currently being loaded (async). -var loading_chunks: Dictionary = {} +## Map of loaded chunk coordinates to their scene nodes. +var _loaded_chunks: Dictionary = {} -## The player's position at the last evaluation. -var last_player_position: Vector3 = Vector3.ZERO +## Queue of chunk coordinates pending load, sorted by distance from player. +var _load_queue: Array[Vector3i] = [] -## Whether streaming is active (player exists and is in-game). +## 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 -## Reference to the server connection for chunk requests. -var server: NetworkedMultiplayerENet = null +## 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: - last_player_position = Vector3.ZERO + _process_thread = Thread.create(_streaming_loop, Thread.PRIORITY_LOW) -## 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 +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 -## 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: +func _update_streaming() -> void: + if player == null or not is_inside_tree(): 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: + var current_coord := Chunk.coord_for_position(player.global_position) + if current_coord == _player_chunk_coord: return - last_player_position = current_pos - - # Evaluate which chunks need to be loaded/unloaded. - _evaluate_chunks() + _player_chunk_coord = current_coord + _rebuild_queues() -## Evaluate which chunks to load and which to unload. -func _evaluate_chunks() -> void: - if player == null: - return +func _rebuild_queues() -> void: + var target_set := _compute_target_chunks() - var player_chunk_coord := Chunk.coord_for_position(player.global_position) - var chunks_to_load: PackedStringArray = [] - var chunks_to_unload: PackedStringArray = [] + # 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) - # Determine which chunks should be loaded. - for loaded_key in loaded_chunks.keys(): - var coord := _parse_chunk_key(loaded_key) - if coord == null: - continue + # Sort by distance from player (closest first) + to_load.sort_custom(_compare_distance) + _load_queue = to_load - var dist := _chunk_distance(player_chunk_coord, coord) - if dist > VIEW_RADIUS: - chunks_to_unload.append(loaded_key) + # 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) - # 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 + 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 ) - - # 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 + target[coord] = coord + return target -## 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) +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_chunks[chunk_key] = true + _loading_in_progress[coord] = true - # Request chunk from server via RPC. - server.rpc("request_chunk", chunk_key) + # 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) -## 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) +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: - push_error("Invalid chunk key: %s" % chunk_key) return - var world_id := _get_world_id() - var room_id := _get_room_id() + _loading_in_progress.erase(coord) - # Create the chunk scene. + # 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.chunk_key = chunk_key chunk_scene.coord = coord - chunk_scene.world_id = world_id - chunk_scene.room_id = room_id + chunk_scene.key = Chunk.build_key("", "", coord) + chunk_scene.bounds = Chunk.compute_bounds(coord) + _add_loaded_chunk(coord, chunk_scene) - # Decode payload if available. - if not payload.is_empty(): - chunk_scene.decode_payload(payload, checksum) - # Add to world tree. +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) - loaded_chunks[chunk_key] = chunk_scene - loading_chunks.erase(chunk_key) - - chunk_loaded.emit(chunk_key, chunk_scene) + _loading_in_progress.erase(coord) + chunk_loaded.emit(coord, 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: +func _unload_chunk(coord: Vector3i) -> void: + if not _loaded_chunks.has(coord): return - loaded_chunks.erase(chunk_key) - + var chunk_scene = _loaded_chunks[coord] 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) + _loaded_chunks.erase(coord) + chunk_unloaded.emit(coord) -## 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)) +func _on_player_position_changed() -> void: + """Call this when the player position changes significantly.""" + if player != null: + _update_streaming() -## Parse a chunk key string into its coordinate components. -static func _parse_chunk_key(key: String) -> Vector3i: +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 @@ -232,44 +342,3 @@ static func _parse_chunk_key(key: String) -> Vector3i: 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()