359 lines
10 KiB
GDScript
359 lines
10 KiB
GDScript
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 := 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(-half, half + 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])
|
|
)
|