fd88cef32f
Implements the client-side half of docs/architecture/world_persistence_impl.md
(the backend API in backend/app/routers/{world_content,internal}.py and
game_servers.py's --api-url/--server-token/_await_ready were already merged):
- scripts/http_chunk_source.gd: new HttpChunkSource adapter satisfying
ChunkStreamManager.set_server_rpc()'s seam. Fetches chunks over HTTP from
the public world_content API (JWT via ApiClient), with ETag/If-None-Match
(304) support backed by a user://chunk_cache/<checksum> cache, and a
bounds-query list_chunks() helper (§8.1's chunk index). A 404 (unwritten
coordinate) renders empty instead of erroring, per §8.1.
- scripts/server.gd: startup now parses --api-url/--server-token, and
before binding the ENet port fetches the world's manifest + the
spawn-neighbourhood chunks from /api/internal (§7.2/§7.3/§8.3), decodes
them, and builds real StaticBody3D collision from solid tiles + non-
walkable objects. Falls back to test_level.tscn if the backend is
unreachable, the world has no rooms, or the room has zero chunks -- never
hangs or crashes. Writes "ready"/"content" (plus diagnostic
chunks_loaded/collision_shapes counts) into the status file that
game_servers.py's _await_ready() already reads.
- scripts/api_client.gd: exposes a `base_url` getter so other client code
building its own requests (HttpChunkSource; server.gd doesn't use this
autoload at all, since its auth model is the per-spawn server token, not
a player JWT) can target the same backend.
Also fixes three real, previously-dormant bugs this work's first real
exercise of these code paths uncovered (nothing before this called
encode_binary() or fed real data through set_server_rpc()):
- chunk_data_decoder.gd: `_build_metadata_section` never wrapped its
output with the section id/length header every other section builder
uses, so `encode_binary()` produced corrupt payloads the decoder read as
a single garbage-length section ("truncated section body").
- chunk_stream_manager.gd: `_parse_coord_from_key` assigned a
PackedStringArray (String.split()'s real return type) to an
Array[String]-typed variable -- a hard crash.
- chunk_stream_manager.gd: `_instantiate_chunk_scene_with_data` read a
nonexistent `data.format_version` field; ChunkPayload's field is
`version`.
Adds scripts/smoke_test_http_chunk_source.gd, a real end-to-end smoke test
(register, create world, PUT a real chunk, stream it back through
ChunkStreamManager + HttpChunkSource, plus the unwritten-coordinate and
304-refetch paths).
Verified against a real backend + Postgres:
- Seeded real chunk content, joined through the real
GameServerSupervisor spawn path: status file reports
content=loaded, chunks_loaded=1, collision_shapes=33.
- A room with zero chunks and an unreachable backend (closed port) both
fall back to test_level.tscn cleanly (content=fallback, ready=true, no
hang/crash).
- Full existing Godot smoke suite (16 suites, including
smoke_test_enter_world.gd and smoke_test_network_client.gd) passes with
no regressions.
- Backend pytest (132/132) untouched and still green -- no backend/
changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
369 lines
11 KiB
GDScript
369 lines
11 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: Vector3i = 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: Dictionary = _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: Dictionary = {}
|
|
var half: int = 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 = 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: int = _chunk_distance_squared(_player_chunk_coord, a)
|
|
var dist_b: int = _chunk_distance_squared(_player_chunk_coord, b)
|
|
return dist_a < dist_b
|
|
|
|
|
|
func _chunk_distance_squared(a: Vector3i, b: Vector3i) -> int:
|
|
var dx: int = a.x - b.x
|
|
var dy: int = a.y - b.y
|
|
var dz: int = a.z - b.z
|
|
return dx * dx + dy * dy + dz * dz
|
|
|
|
|
|
func _process_load_queue() -> void:
|
|
var count: int = 0
|
|
while count < max_loads_per_frame and not _load_queue.is_empty():
|
|
var coord: Vector3i = _load_queue.pop_front()
|
|
_load_chunk(coord)
|
|
count += 1
|
|
|
|
|
|
func _process_unload_queue() -> void:
|
|
var count: int = 0
|
|
while count < max_unloads_per_frame and not _unload_queue.is_empty():
|
|
var coord: Vector3i = _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: Vector3i = _parse_coord_from_key(key)
|
|
if coord == null:
|
|
return
|
|
|
|
_loading_in_progress.erase(coord)
|
|
|
|
# Decode the binary chunk data
|
|
var chunk_data: ChunkDataDecoder.ChunkPayload = 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 = 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 = ChunkScene.new()
|
|
chunk_scene.coord = coord
|
|
chunk_scene.key = data.key
|
|
chunk_scene.bounds = data.bounds
|
|
# ChunkDataDecoder.ChunkPayload's field is named `version`, not
|
|
# `format_version` -- this was a hard crash ("Invalid access to
|
|
# property or key 'format_version'") the first time real decoded data
|
|
# ever reached this dormant method (via HttpChunkSource).
|
|
chunk_scene.format_version = data.version
|
|
chunk_scene.revision = data.revision
|
|
|
|
# Add tiles
|
|
for layer in data.tile_layers:
|
|
var tile_layer: TileLayer = 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 = 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: Node = _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: Array[Vector3i] = get_all_loaded_coords()
|
|
for coord in coords:
|
|
_unload_chunk(coord)
|
|
_load_queue.clear()
|
|
_unload_queue.clear()
|
|
|
|
|
|
func _parse_coord_from_key(key: String) -> Variant:
|
|
"""Parse chunk coordinate from a canonical chunk key."""
|
|
# String.split() returns PackedStringArray, not Array[String] -- those
|
|
# are different container types with no implicit conversion, so this
|
|
# raised "Trying to assign a value of type 'PackedStringArray' to a
|
|
# variable of type 'Array[String]'" the moment real data reached this
|
|
# dormant method for the first time (via HttpChunkSource's
|
|
# _on_chunk_data_received call -- nothing exercised this path before).
|
|
var parts: PackedStringArray = key.split(":")
|
|
if parts.size() < 5:
|
|
return null
|
|
return Vector3i(
|
|
int(parts[2]),
|
|
int(parts[3]),
|
|
int(parts[4])
|
|
)
|