Address review feedback: Implement chunk streaming system in GDScript #20
@@ -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()
|
||||
Reference in New Issue
Block a user