diff --git a/.gitignore b/.gitignore index dd2198a..98542a1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ # Godot 4 editor cache/import directory .godot/ + +# Godot export artifacts +export/ +*.tmp + +# OS +.DS_Store +Thumbs.db diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..a1be944 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://main_scene"] + +[ext_resource type="Script" path="res://scripts/chunk_stream_manager.gd" id="1_chunk_stream"] + +[node name="Main" type="Node3D"] +script = ExtResource("1_chunk_stream") diff --git a/scripts/chunk.gd b/scripts/chunk.gd new file mode 100644 index 0000000..bb9d8cf --- /dev/null +++ b/scripts/chunk.gd @@ -0,0 +1,105 @@ +class_name Chunk +extends Node3D +## A single world chunk. Holds the chunk coordinate, bounds, and the +## visual/gameplay content for that region of the world. +## +## Chunks are the atomic unit of world geometry, gameplay objects, and +## persistence (see docs/architecture/world.md). The server loads and saves +## whole chunks; clients stream the chunks around them. + +## Default chunk dimensions in world units (tiles). Matches the constants in +## docs/architecture/world.md section 2.2. +const CHUNK_SIZE_X: int = 32 +const CHUNK_SIZE_Y: int = 32 +const CHUNK_SIZE_Z: int = 1 + +## World-space origin of chunk coordinate (0,0). +const CHUNK_ORIGIN := Vector3(0.0, 0.0, 0.0) + +## Chunk coordinate (cx, cy, cz) in chunk space. +var coord := Vector3i.ZERO + +## The canonical chunk key: "{world_id}:{room_id}:{cx}:{cy}:{cz}". +var key: String = "" + +## World and room identifiers this chunk belongs to. +var world_id: String = "" +var room_id: String = "" + +## Format version of the chunk payload (currently 1). +var format_version: int = 1 + +## Revision counter, incremented on every write. Used for optimistic +## concurrency and change detection. +var revision: int = 0 + +## Whether this chunk has been modified in memory but not yet flushed. +var dirty: bool = false + +## Container for static objects (props, geometry) in this chunk. +var objects: Array[Node3D] = [] + +## Container for dynamic object templates (spawnable entities). +var dynamic_templates: Array[Node3D] = [] + +## The world-space bounding box of this chunk. +var bounds: AABB + + +func _init(p_coord: Vector3i = Vector3i.ZERO, p_world_id: String = "", p_room_id: String = "") -> void: + coord = p_coord + world_id = p_world_id + room_id = p_room_id + key = build_key(world_id, room_id, coord) + bounds = compute_bounds(coord) + + +## Build the canonical chunk key from its components. +static func build_key(p_world_id: String, p_room_id: String, p_coord: Vector3i) -> String: + return "%s:%s:%d:%d:%d" % [p_world_id, p_room_id, p_coord.x, p_coord.y, p_coord.z] + + +## Compute the world-space bounding box for a chunk coordinate. +static func compute_bounds(p_coord: Vector3i) -> AABB: + var min_pos := Vector3( + CHUNK_ORIGIN.x + p_coord.x * CHUNK_SIZE_X, + CHUNK_ORIGIN.y + p_coord.y * CHUNK_SIZE_Y, + CHUNK_ORIGIN.z + p_coord.z * CHUNK_SIZE_Z + ) + return AABB(min_pos, Vector3(CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z)) + + +## Compute the chunk coordinate that contains a world-space position. +static func coord_for_position(p_pos: Vector3) -> Vector3i: + return Vector3i( + int(floor((p_pos.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)), + int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)), + int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z)) + ) + + +## Add a static object to this chunk. +func add_object(p_object: Node3D) -> void: + objects.append(p_object) + add_child(p_object) + + +## Add a dynamic object template to this chunk. +func add_dynamic_template(p_template: Node3D) -> void: + dynamic_templates.append(p_template) + add_child(p_template) + + +## Remove all objects from this chunk. +func clear_objects() -> void: + for obj in objects: + if is_instance_valid(obj): + obj.queue_free() + objects.clear() + dynamic_templates.clear() + + +## Mark this chunk as dirty (modified but not yet persisted). +func mark_dirty() -> void: + dirty = true + revision += 1 diff --git a/scripts/chunk.gd.uid b/scripts/chunk.gd.uid new file mode 100644 index 0000000..85995ed --- /dev/null +++ b/scripts/chunk.gd.uid @@ -0,0 +1 @@ +uid://dc6j7kqjitc6g diff --git a/scripts/chunk_data_decoder.gd b/scripts/chunk_data_decoder.gd new file mode 100644 index 0000000..9e19863 --- /dev/null +++ b/scripts/chunk_data_decoder.gd @@ -0,0 +1,667 @@ +class_name ChunkDataDecoder +extends RefCounted +## Decodes chunk binary payloads (and JSON) into logical chunk data. +## +## Implements the binary format spec from docs/architecture/world.md §3.6. +## Both the Godot client and Python backend use this class to ensure +## identical encoding/decoding behavior. +## +## ## Binary Format Overview +## +## ``` +## Header (48 bytes): +## [0:4] magic "AGPC" +## [4:6] version (uint16 LE) +## [6:8] header_len (uint16 LE) +## [8:12] flags (uint32 LE) +## [12:16] payload_len (uint32 LE) +## [16:48] checksum (SHA-256 hex, 32 bytes) +## +## Payload Body (sections): +## [section_id:1][section_len:4][data:section_len] +## ``` + + +## Content flags parsed from a chunk. +class ContentFlags: + ## Severity levels: 0=none, 1=mild, 2=moderate, 3=severe + var violence: int = 0 + var sexual: int = 0 + var language: int = 0 + var substance: int = 0 + var gambling: int = 0 + var horror: int = 0 + var hate: int = 0 + var custom: int = 0 + var mature: bool = false + var sources: Array[String] = [] + + func is_mature() -> bool: + return mature + + func has_severe_content() -> bool: + return (violence >= 3 or sexual >= 3 or language >= 3 or + substance >= 3 or gambling >= 3 or horror >= 3 or + hate >= 3 or custom >= 3) + + func get_max_severity() -> int: + return maxi( + violence, maxi(sexual, maxi(language, maxi(substance, + maxi(gambling, maxi(horror, maxi(hate, custom))))))) + + func to_dict() -> Dictionary: + return { + "categories": { + "violence": violence, + "sexual": sexual, + "language": language, + "substance": substance, + "gambling": gambling, + "horror": horror, + "hate": hate, + "custom": custom, + }, + "mature": mature, + "sources": sources, + } + + func from_dict(d: Dictionary) -> void: + if d.has("categories"): + violence = d["categories"].get("violence", 0) + sexual = d["categories"].get("sexual", 0) + language = d["categories"].get("language", 0) + substance = d["categories"].get("substance", 0) + gambling = d["categories"].get("gambling", 0) + horror = d["categories"].get("horror", 0) + hate = d["categories"].get("hate", 0) + custom = d["categories"].get("custom", 0) + mature = d.get("mature", false) + sources = d.get("sources", []) + + +## A tile layer definition (palette + dense tile indices). +class TileLayerData: + var name: String = "" + var layer_id: int = 0 + var width: int = 0 + var height: int = 0 + var depth: int = 1 + var palette: Array[TileDef] = [] + var tiles: PackedInt32Array = PackedInt32Array() + + func tile_count() -> int: + return width * height * depth + + func get_tile(x: int, y: int, z: int = 0) -> int: + if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth: + return 0 # Out of bounds returns empty tile + var idx := (z * height + y) * width + x + if idx < tiles.size(): + return tiles[idx] + return 0 + + func get_tile_def(tile_id: int) -> TileDef: + for def in palette: + if def.id == tile_id: + return def + return null + + +## A world object parsed from chunk data. +class WorldObjectData: + var id: String = "" + var type: String = "" + var asset_id: String = "" + var position: Vector3 = Vector3.ZERO + var rotation: Vector4 = Vector4(0, 0, 0, 1) + var scale: Vector3 = Vector3.ONE + var properties: Dictionary = {} + var dynamic: bool = false + var flags: ContentFlags = ContentFlags.new() + + +## The complete decoded chunk payload. +class ChunkPayload: + var version: int = 1 + var key: String = "" + var world_id: String = "" + var room_id: String = "" + var coord: Vector3i = Vector3i.ZERO + var bounds: AABB = AABB() + var tile_layers: Array[TileLayerData] = [] + var objects: Array[WorldObjectData] = [] + var flags: ContentFlags = ContentFlags.new() + var created_at: int = 0 # Unix timestamp + var updated_at: int = 0 # Unix timestamp + var revision: int = 0 + var author: String = "" + var checksum: String = "" + + +# ───────────────── Binary Decoding ───────────────── + +static var MAGIC := PackedByteArray([0x41, 0x47, 0x50, 0x43]) # "AGPC" +const HEADER_SIZE: int = 48 +const SECTION_META: int = 0x01 +const SECTION_TILE: int = 0x02 +const SECTION_OBJECT: int = 0x03 +const SECTION_FLAGS: int = 0x04 +const SECTION_REVISION_META: int = 0x05 + + +## Decode a binary chunk payload into a ChunkPayload. +## Returns null if the data is invalid or version is unsupported. +static func decode_binary(data: PackedByteArray) -> ChunkPayload: + if data.size() < HEADER_SIZE: + push_error("ChunkDataDecoder: Data too short for header (%d bytes)" % data.size()) + return null + + # Verify magic + var magic := PackedByteArray(data.slice(0, 4)) + if magic != MAGIC: + push_error("ChunkDataDecoder: Invalid magic bytes") + return null + + # Read header fields + var version := _read_uint16(data, 4) + if version != 1: + push_error("ChunkDataDecoder: Unsupported chunk version %d" % version) + return null + + var header_len := _read_uint16(data, 6) + var flags := _read_uint32(data, 8) + var payload_len := _read_uint32(data, 12) + var checksum := _read_hex_string(data, 16, 32) + + # Verify payload length + if data.size() < HEADER_SIZE + payload_len: + push_error("ChunkDataDecoder: Payload too short") + return null + + var payload := data.slice(HEADER_SIZE, HEADER_SIZE + payload_len) + + # Verify checksum + var computed_checksum := _sha256_hex(payload) + if computed_checksum != checksum: + push_error("ChunkDataDecoder: Checksum mismatch") + # Continue anyway for resilience + + var result := ChunkPayload.new() + result.version = version + result.checksum = checksum + + # Parse sections + var offset := 0 + while offset < payload.size(): + if offset + 5 > payload.size(): + break + + var section_id := payload[offset] + var section_len := _read_uint32(payload, offset + 1) + + if offset + 5 + section_len > payload.size(): + break + + var section_data := payload.slice(offset + 5, offset + 5 + section_len) + + match section_id: + SECTION_META: + _parse_metadata_section(section_data, result) + SECTION_TILE: + _parse_tile_section(section_data, result) + SECTION_OBJECT: + _parse_object_section(section_data, result) + SECTION_FLAGS: + _parse_flags_section(section_data, result.flags) + SECTION_REVISION_META: + _parse_revision_meta_section(section_data, result) + + offset += 5 + section_len + + return result + + +## Encode a ChunkPayload into binary format. +static func encode_binary(payload: ChunkPayload) -> PackedByteArray: + var sections := PackedByteArray() + + # Build metadata section + sections.append_array(_build_metadata_section(payload)) + # Build tile sections + for layer in payload.tile_layers: + sections.append_array(_build_tile_section(layer)) + # Build object sections + for obj in payload.objects: + sections.append_array(_build_object_section(obj)) + # Build flags section + sections.append_array(_build_flags_section(payload.flags)) + # Build revision meta section + sections.append_array(_build_revision_meta_section(payload)) + + var payload_bytes := PackedByteArray(sections) + var checksum := _sha256_hex(payload_bytes) + + # Build header + var header := PackedByteArray() + header.append_array(MAGIC) + header.append_array(_write_uint16(1)) # version + header.append_array(_write_uint16(0)) # header_len (after this field) + header.append_array(_write_uint32(0)) # flags + header.append_array(_write_uint32(payload_bytes.size())) + header.append_array(_hex_to_bytes(checksum)) + + return header + payload_bytes + + +# ───────────────── Section Parsers ───────────────── + +static func _parse_metadata_section(data: PackedByteArray, result: ChunkPayload) -> void: + var offset := 0 + + # world_id (length-prefixed string) + var world_id_len := _read_uint16(data, offset) + offset += 2 + result.world_id = data.slice(offset, offset + world_id_len).get_string_from_utf8() + offset += world_id_len + + # room_id (length-prefixed string) + var room_id_len := _read_uint16(data, offset) + offset += 2 + result.room_id = data.slice(offset, offset + room_id_len).get_string_from_utf8() + offset += room_id_len + + # coord (3 × int32) + result.coord = Vector3i( + _read_int32(data, offset), + _read_int32(data, offset + 4), + _read_int32(data, offset + 8) + ) + offset += 12 + + # bounds (6 × float32) + var min_pos := Vector3( + _read_float32(data, offset), + _read_float32(data, offset + 4), + _read_float32(data, offset + 8) + ) + var max_pos := Vector3( + _read_float32(data, offset + 12), + _read_float32(data, offset + 16), + _read_float32(data, offset + 20) + ) + result.bounds = AABB(min_pos, max_pos - min_pos) + + +static func _parse_tile_section(data: PackedByteArray, result: ChunkPayload) -> void: + var layer := TileLayerData.new() + var offset := 0 + + # name (length-prefixed string) + var name_len := _read_uint16(data, offset) + offset += 2 + layer.name = data.slice(offset, offset + name_len).get_string_from_utf8() + offset += name_len + + # layer_id (uint16) + layer.layer_id = _read_uint16(data, offset) + offset += 2 + + # dimensions (3 × uint16) + layer.width = _read_uint16(data, offset) + offset += 2 + layer.height = _read_uint16(data, offset) + offset += 2 + layer.depth = _read_uint16(data, offset) + offset += 2 + + # palette + var palette_count := _read_uint16(data, offset) + offset += 2 + for _i in range(palette_count): + var tile_def := TileDef.new() + tile_def.id = _read_uint16(data, offset) + offset += 2 + + var asset_id_len := _read_uint16(data, offset) + offset += 2 + tile_def.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8() + offset += asset_id_len + + tile_def.solid = data[offset] != 0 + offset += 1 + + tile_def.flags = _read_uint32(data, offset) + offset += 4 + + tile_def.variants = data[offset] + offset += 1 + + layer.palette.append(tile_def) + + # tiles + var tile_count := _read_uint32(data, offset) + offset += 4 + for i in range(tile_count): + layer.tiles.append(_read_uint16(data, offset)) + offset += 2 + + result.tile_layers.append(layer) + + +static func _parse_object_section(data: PackedByteArray, result: ChunkPayload) -> void: + var obj := WorldObjectData.new() + var offset := 0 + + # id (length-prefixed string) + var id_len := _read_uint16(data, offset) + offset += 2 + obj.id = data.slice(offset, offset + id_len).get_string_from_utf8() + offset += id_len + + # type (length-prefixed string) + var type_len := _read_uint16(data, offset) + offset += 2 + obj.type = data.slice(offset, offset + type_len).get_string_from_utf8() + offset += type_len + + # asset_id (length-prefixed string) + var asset_id_len := _read_uint16(data, offset) + offset += 2 + obj.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8() + offset += asset_id_len + + # position (3 × float32) + obj.position = Vector3( + _read_float32(data, offset), + _read_float32(data, offset + 4), + _read_float32(data, offset + 8) + ) + offset += 12 + + # rotation (4 × float32) + obj.rotation = Vector4( + _read_float32(data, offset), + _read_float32(data, offset + 4), + _read_float32(data, offset + 8), + _read_float32(data, offset + 12) + ) + offset += 16 + + # scale (3 × float32) + obj.scale = Vector3( + _read_float32(data, offset), + _read_float32(data, offset + 4), + _read_float32(data, offset + 8) + ) + offset += 12 + + # dynamic (uint8) + obj.dynamic = data[offset] != 0 + offset += 1 + + # properties (length-prefixed JSON string) + var props_len := _read_uint16(data, offset) + offset += 2 + var props_str := data.slice(offset, offset + props_len).get_string_from_utf8() + offset += props_len + obj.properties = JSON.parse_string(props_str) or {} + + # flags (ContentFlags) + obj.flags.violence = data[offset] + offset += 1 + obj.flags.sexual = data[offset] + offset += 1 + obj.flags.language = data[offset] + offset += 1 + obj.flags.substance = data[offset] + offset += 1 + obj.flags.gambling = data[offset] + offset += 1 + obj.flags.horror = data[offset] + offset += 1 + obj.flags.hate = data[offset] + offset += 1 + obj.flags.custom = data[offset] + offset += 1 + obj.flags.mature = data[offset] != 0 + offset += 1 + + var source_count := _read_uint16(data, offset) + offset += 2 + for _i in range(source_count): + var src_len := _read_uint16(data, offset) + offset += 2 + obj.flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8()) + offset += src_len + + result.objects.append(obj) + + +static func _parse_flags_section(data: PackedByteArray, flags: ContentFlags) -> void: + flags.violence = data[0] + flags.sexual = data[1] + flags.language = data[2] + flags.substance = data[3] + flags.gambling = data[4] + flags.horror = data[5] + flags.hate = data[6] + flags.custom = data[7] + flags.mature = data[8] != 0 + + var offset := 9 + var source_count := _read_uint16(data, offset) + offset += 2 + for _i in range(source_count): + var src_len := _read_uint16(data, offset) + offset += 2 + flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8()) + offset += src_len + + +static func _parse_revision_meta_section(data: PackedByteArray, result: ChunkPayload) -> void: + var offset := 0 + # created_at (int64 unix) + result.created_at = _read_int64(data, offset) + offset += 8 + # updated_at (int64 unix) + result.updated_at = _read_int64(data, offset) + offset += 8 + # revision (uint32) + result.revision = _read_uint32(data, offset) + offset += 4 + # author (length-prefixed string) + var author_len := _read_uint16(data, offset) + offset += 2 + result.author = data.slice(offset, offset + author_len).get_string_from_utf8() + + +# ───────────────── Section Builders ───────────────── + +static func _build_metadata_section(payload: ChunkPayload) -> PackedByteArray: + var data := PackedByteArray() + data.append_array(_write_string(payload.world_id)) + data.append_array(_write_string(payload.room_id)) + data.append_array(_write_int32(payload.coord.x)) + data.append_array(_write_int32(payload.coord.y)) + data.append_array(_write_int32(payload.coord.z)) + data.append_array(_write_float32(payload.bounds.position.x)) + data.append_array(_write_float32(payload.bounds.position.y)) + data.append_array(_write_float32(payload.bounds.position.z)) + data.append_array(_write_float32(payload.bounds.size.x)) + data.append_array(_write_float32(payload.bounds.size.y)) + data.append_array(_write_float32(payload.bounds.size.z)) + return data + + +static func _build_tile_section(layer: TileLayerData) -> PackedByteArray: + var data := PackedByteArray() + data.append_array(_write_string(layer.name)) + data.append_array(_write_uint16(layer.layer_id)) + data.append_array(_write_uint16(layer.width)) + data.append_array(_write_uint16(layer.height)) + data.append_array(_write_uint16(layer.depth)) + data.append_array(_write_uint16(layer.palette.size())) + + for tile_def in layer.palette: + data.append_array(_write_uint16(tile_def.id)) + data.append_array(_write_string(tile_def.asset_id)) + data.append(tile_def.solid as int) + data.append_array(_write_uint32(tile_def.flags)) + data.append(tile_def.variants) + + data.append_array(_write_uint32(layer.tiles.size())) + for tile_id in layer.tiles: + data.append_array(_write_uint16(tile_id)) + + return _wrap_section(data, SECTION_TILE) + + +static func _build_object_section(obj: WorldObjectData) -> PackedByteArray: + var data := PackedByteArray() + data.append_array(_write_string(obj.id)) + data.append_array(_write_string(obj.type)) + data.append_array(_write_string(obj.asset_id)) + data.append_array(_write_float32(obj.position.x)) + data.append_array(_write_float32(obj.position.y)) + data.append_array(_write_float32(obj.position.z)) + data.append_array(_write_float32(obj.rotation.x)) + data.append_array(_write_float32(obj.rotation.y)) + data.append_array(_write_float32(obj.rotation.z)) + data.append_array(_write_float32(obj.rotation.w)) + data.append_array(_write_float32(obj.scale.x)) + data.append_array(_write_float32(obj.scale.y)) + data.append_array(_write_float32(obj.scale.z)) + data.append(obj.dynamic as int) + + var props_json := JSON.stringify(obj.properties, " ") + data.append_array(_write_string(props_json)) + + data.append(obj.flags.violence) + data.append(obj.flags.sexual) + data.append(obj.flags.language) + data.append(obj.flags.substance) + data.append(obj.flags.gambling) + data.append(obj.flags.horror) + data.append(obj.flags.hate) + data.append(obj.flags.custom) + data.append(obj.flags.mature as int) + data.append_array(_write_uint16(obj.flags.sources.size())) + for src in obj.flags.sources: + data.append_array(_write_string(src)) + + return _wrap_section(data, SECTION_OBJECT) + + +static func _build_flags_section(flags: ContentFlags) -> PackedByteArray: + var data := PackedByteArray() + data.append(flags.violence) + data.append(flags.sexual) + data.append(flags.language) + data.append(flags.substance) + data.append(flags.gambling) + data.append(flags.horror) + data.append(flags.hate) + data.append(flags.custom) + data.append(flags.mature as int) + data.append_array(_write_uint16(flags.sources.size())) + for src in flags.sources: + data.append_array(_write_string(src)) + return _wrap_section(data, SECTION_FLAGS) + + +static func _build_revision_meta_section(payload: ChunkPayload) -> PackedByteArray: + var data := PackedByteArray() + data.append_array(_write_int64(payload.created_at)) + data.append_array(_write_int64(payload.updated_at)) + data.append_array(_write_uint32(payload.revision)) + data.append_array(_write_string(payload.author)) + return _wrap_section(data, SECTION_REVISION_META) + + +static func _wrap_section(data: PackedByteArray, section_id: int) -> PackedByteArray: + var wrapped := PackedByteArray() + wrapped.append(section_id) + wrapped.append_array(_write_uint32(data.size())) + wrapped.append_array(data) + return wrapped + + +# ───────────────── Binary Helpers ───────────────── + +static func _read_uint16(data: PackedByteArray, offset: int) -> int: + return data[offset] | (data[offset + 1] << 8) + +static func _read_uint32(data: PackedByteArray, offset: int) -> int: + return (data[offset] | (data[offset + 1] << 8) | + (data[offset + 2] << 16) | (data[offset + 3] << 24)) + +static func _read_int32(data: PackedByteArray, offset: int) -> int: + var val := _read_uint32(data, offset) + if val >= 0x80000000: + return val - 0x100000000 + return val + +static func _read_int64(data: PackedByteArray, offset: int) -> int: + var lo := _read_uint32(data, offset) + var hi := _read_uint32(data, offset + 4) + return hi * 0x100000000 + lo + +static func _read_float32(data: PackedByteArray, offset: int) -> float: + var bits := _read_uint32(data, offset) + return bits as float # GDScript float is 64-bit; bits repr works for IEEE 754 + +static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String: + var hex_chars := "0123456789abcdef" + var result := "" + for i in range(length): + var byte_val := data[offset + i] + result += hex_chars[byte_val >> 4] + result += hex_chars[byte_val & 0x0F] + return result + + +static func _write_uint16(val: int) -> PackedByteArray: + return PackedByteArray([(val & 0xFF), (val >> 8) & 0xFF]) + +static func _write_uint32(val: int) -> PackedByteArray: + return PackedByteArray([ + val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF + ]) + +static func _write_int32(val: int) -> PackedByteArray: + return _write_uint32(val) + +static func _write_int64(val: int) -> PackedByteArray: + var lo := val & 0xFFFFFFFF + var hi := (val >> 32) & 0xFFFFFFFF + return _write_uint32(lo) + _write_uint32(hi) + +static func _write_float32(val: float) -> PackedByteArray: + var bits := val as int + return _write_uint32(bits) + +static func _write_string(s: String) -> PackedByteArray: + var bytes := s.to_utf8_buffer() + return _write_uint16(bytes.size()) + bytes + +static func _hex_to_bytes(hex_str: String) -> PackedByteArray: + var result := PackedByteArray() + for i in range(0, hex_str.length(), 2): + var byte_val := ("0x" + hex_str.substr(i, 2)).hex_to_int() + result.append(byte_val) + return result + + +# ───────────────── SHA-256 ───────────────── + +static func _sha256_hex(data: PackedByteArray) -> String: + var ctx := HashingContext.new() + ctx.start(HashingContext.HASH_SHA256) + ctx.update(data) + var hash := ctx.finish() + var hex_chars := "0123456789abcdef" + var result := "" + for byte_val in hash: + result += hex_chars[(byte_val >> 4) & 0x0F] + result += hex_chars[byte_val & 0x0F] + return result diff --git a/scripts/chunk_data_decoder.gd.uid b/scripts/chunk_data_decoder.gd.uid new file mode 100644 index 0000000..f938701 --- /dev/null +++ b/scripts/chunk_data_decoder.gd.uid @@ -0,0 +1 @@ +uid://bv445xrd6f5tk diff --git a/scripts/chunk_scene.gd b/scripts/chunk_scene.gd new file mode 100644 index 0000000..5ac66eb --- /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.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w) + 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.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w) + 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: int = abs(coord.x - other_coord.x) + var dy: int = abs(coord.y - other_coord.y) + var dz: int = 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_scene.gd.uid b/scripts/chunk_scene.gd.uid new file mode 100644 index 0000000..8f1e1d0 --- /dev/null +++ b/scripts/chunk_scene.gd.uid @@ -0,0 +1 @@ +uid://d4gh4h5iey6xb diff --git a/scripts/chunk_stream_manager.gd b/scripts/chunk_stream_manager.gd new file mode 100644 index 0000000..8c6802e --- /dev/null +++ b/scripts/chunk_stream_manager.gd @@ -0,0 +1,358 @@ +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 + chunk_scene.format_version = data.format_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.""" + var parts: Array[String] = key.split(":") + if parts.size() < 5: + return null + return Vector3i( + int(parts[2]), + int(parts[3]), + int(parts[4]) + ) diff --git a/scripts/chunk_stream_manager.gd.uid b/scripts/chunk_stream_manager.gd.uid new file mode 100644 index 0000000..7f35dca --- /dev/null +++ b/scripts/chunk_stream_manager.gd.uid @@ -0,0 +1 @@ +uid://8yokqwe23c23 diff --git a/scripts/dynamic_object_node.gd b/scripts/dynamic_object_node.gd new file mode 100644 index 0000000..f37edad --- /dev/null +++ b/scripts/dynamic_object_node.gd @@ -0,0 +1,68 @@ +class_name DynamicObjectNode +extends Node3D +## A visual representation of a dynamic (spawnable) world object template. +## +## This node is instantiated by [ChunkScene] for each dynamic [WorldObject]. +## Dynamic objects are stored as *templates* in the chunk; their runtime state +## (position, health, inventory) is managed by the entity system, not by this +## node. This node renders the template's visual representation so the object +## is visible in the world. +## +## ## Structure +## +## ``` +## DynamicObjectNode (Node3D) +## └── MeshInstance3D # the object's visual mesh +## ``` + +## The world object data this node represents. +var world_object: WorldObject = null + +## The loaded mesh instance (or placeholder). +var _mesh_instance: MeshInstance3D = null + + +func _ready() -> void: + if world_object == null: + return + _load_mesh() + + +## Load and attach the object's mesh. +func _load_mesh() -> void: + _mesh_instance = MeshInstance3D.new() + _mesh_instance.name = "mesh" + + var mesh := _load_asset_mesh(world_object.asset_id) + if mesh == null: + mesh = _make_placeholder_mesh() + + _mesh_instance.mesh = mesh + add_child(_mesh_instance) + + +## Attempt to load a mesh from the asset path. +func _load_asset_mesh(asset_id: String) -> Mesh: + if asset_id.is_empty(): + return null + var path := "res://assets/%s" % asset_id + if ResourceLoader.exists(path): + var res := load(path) + if res is Mesh: + return res + return null + + +## Create a simple placeholder sphere mesh when the real asset is unavailable. +func _make_placeholder_mesh() -> Mesh: + var mesh := SphereMesh.new() + mesh.radius = 0.5 + mesh.height = 1.0 + return mesh + + +## Get the world object's display name. +func get_display_name() -> String: + if world_object == null: + return "" + return world_object.get_display_name() diff --git a/scripts/dynamic_object_node.gd.uid b/scripts/dynamic_object_node.gd.uid new file mode 100644 index 0000000..b24b92a --- /dev/null +++ b/scripts/dynamic_object_node.gd.uid @@ -0,0 +1 @@ +uid://dyjqcstdnr4u1 diff --git a/scripts/static_object_node.gd b/scripts/static_object_node.gd new file mode 100644 index 0000000..5a4bea8 --- /dev/null +++ b/scripts/static_object_node.gd @@ -0,0 +1,67 @@ +class_name StaticObjectNode +extends Node3D +## A visual representation of a static world object within a chunk. +## +## This node is instantiated by [ChunkScene] for each static (non-dynamic) +## [WorldObject]. It renders the object's mesh (referenced by `asset_id`) at +## the object's transform. If the asset is not yet loaded, a placeholder mesh +## is shown. +## +## ## Structure +## +## ``` +## StaticObjectNode (Node3D) +## └── MeshInstance3D # the object's visual mesh +## ``` + +## The world object data this node represents. +var world_object: WorldObject = null + +## The loaded mesh instance (or placeholder). +var _mesh_instance: MeshInstance3D = null + + +func _ready() -> void: + if world_object == null: + return + _load_mesh() + + +## Load and attach the object's mesh. +func _load_mesh() -> void: + _mesh_instance = MeshInstance3D.new() + _mesh_instance.name = "mesh" + + # Try to load the asset by its id (path). Fall back to a placeholder. + var mesh := _load_asset_mesh(world_object.asset_id) + if mesh == null: + mesh = _make_placeholder_mesh() + + _mesh_instance.mesh = mesh + add_child(_mesh_instance) + + +## Attempt to load a mesh from the asset path. +func _load_asset_mesh(asset_id: String) -> Mesh: + if asset_id.is_empty(): + return null + var path := "res://assets/%s" % asset_id + if ResourceLoader.exists(path): + var res := load(path) + if res is Mesh: + return res + return null + + +## Create a simple placeholder box mesh when the real asset is unavailable. +func _make_placeholder_mesh() -> Mesh: + var mesh := BoxMesh.new() + mesh.size = Vector3(1.0, 1.0, 1.0) + return mesh + + +## Get the world object's display name. +func get_display_name() -> String: + if world_object == null: + return "" + return world_object.get_display_name() diff --git a/scripts/static_object_node.gd.uid b/scripts/static_object_node.gd.uid new file mode 100644 index 0000000..8f99b0c --- /dev/null +++ b/scripts/static_object_node.gd.uid @@ -0,0 +1 @@ +uid://chf6mruev1kc4 diff --git a/scripts/tile_def.gd b/scripts/tile_def.gd new file mode 100644 index 0000000..0fa7475 --- /dev/null +++ b/scripts/tile_def.gd @@ -0,0 +1,42 @@ +class_name TileDef +extends RefCounted +## A single tile definition (palette entry) within a tile layer. +## +## Maps a tile ID to its asset reference and gameplay flags. See +## docs/architecture/world.md §3.2. + +## Tile ID (index into the layer palette). 0 is reserved for empty/air. +var id: int = 0 + +## Reference to the tile/terrain asset. +var asset_id: String = "" + +## Whether this tile has collision. +var solid: bool = false + +## Bitmask flags: bit 0 = walkable, bit 1 = climbable, bit 2 = liquid. +var flags: int = 0 + +## Number of visual variants for this tile. +var variants: int = 1 + + +func _init(p_id: int = 0, p_asset_id: String = "", p_solid: bool = false, + p_flags: int = 0, p_variants: int = 1) -> void: + id = p_id + asset_id = p_asset_id + solid = p_solid + flags = p_flags + variants = p_variants + + +func is_walkable() -> bool: + return (flags & 0x01) != 0 + + +func is_climbable() -> bool: + return (flags & 0x02) != 0 + + +func is_liquid() -> bool: + return (flags & 0x04) != 0 diff --git a/scripts/tile_def.gd.uid b/scripts/tile_def.gd.uid new file mode 100644 index 0000000..94401fc --- /dev/null +++ b/scripts/tile_def.gd.uid @@ -0,0 +1 @@ +uid://bavmpbmthilbx diff --git a/scripts/tile_layer.gd b/scripts/tile_layer.gd new file mode 100644 index 0000000..c3815b9 --- /dev/null +++ b/scripts/tile_layer.gd @@ -0,0 +1,83 @@ +class_name TileLayer +extends RefCounted +## A single tile layer within a chunk. +## +## Holds the dense grid of tile IDs, the palette mapping tile IDs to tile +## definitions, and the layer metadata. This is the runtime representation of +## the `TileLayer` from the chunk data format (see docs/architecture/world.md +## §3.2). + +## Layer name (e.g. "ground", "walls", "decor"). +var name: String = "" + +## Numeric layer id, unique within the chunk. +var layer_id: int = 0 + +## Grid dimensions in tiles. +var width: int = 0 +var height: int = 0 +var depth: int = 1 + +## Palette mapping tile IDs to tile definitions (shared per layer). +var palette: Array[TileDef] = [] + +## Dense, row-major tile IDs. Length = width * height * depth. +var tiles: PackedInt32Array = PackedInt32Array() + + +func _init( + p_layer_name: String = "", + p_layer_id: int = 0, + p_width: int = 0, + p_height: int = 0, + p_depth: int = 1 +) -> void: + name = p_layer_name + layer_id = p_layer_id + width = p_width + height = p_height + depth = p_depth + + +## Total number of tiles in this layer. +func tile_count() -> int: + return width * height * depth + + +## Get the tile ID at (x, y, z). Returns 0 (empty) for out-of-bounds. +func get_tile(x: int, y: int, z: int = 0) -> int: + if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth: + return 0 + var idx := (z * height + y) * width + x + if idx < tiles.size(): + return tiles[idx] + return 0 + + +## Set the tile ID at (x, y, z). No-op if out of bounds. +func set_tile(x: int, y: int, z: int, tile_id: int) -> void: + if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth: + return + var idx := (z * height + y) * width + x + if idx < tiles.size(): + tiles[idx] = tile_id + + +## Get the tile definition for a tile ID, or null if not in the palette. +func get_tile_def(tile_id: int) -> TileDef: + for def in palette: + if def.id == tile_id: + return def + return null + + +## Whether the tile at (x, y, z) is solid (collision). +func is_solid(x: int, y: int, z: int = 0) -> bool: + var def := get_tile_def(get_tile(x, y, z)) + return def != null and def.solid + + +## Whether the tile at (x, y, z) is walkable. +func is_walkable(x: int, y: int, z: int = 0) -> bool: + var def := get_tile_def(get_tile(x, y, z)) + return def != null and def.is_walkable() diff --git a/scripts/tile_layer.gd.uid b/scripts/tile_layer.gd.uid new file mode 100644 index 0000000..34c0c4a --- /dev/null +++ b/scripts/tile_layer.gd.uid @@ -0,0 +1 @@ +uid://came2ygke3ajs diff --git a/scripts/tile_layer_node.gd b/scripts/tile_layer_node.gd new file mode 100644 index 0000000..0162de8 --- /dev/null +++ b/scripts/tile_layer_node.gd @@ -0,0 +1,112 @@ +class_name TileLayerNode +extends Node3D +## A visual representation of a single tile layer within a chunk. +## +## This node is instantiated by [ChunkScene] for each tile layer in a chunk. +## It renders the layer's tiles as instanced meshes (one per tile definition +## in the palette) using [MultiMeshInstance3D] for efficiency. +## +## ## Structure +## +## ``` +## TileLayerNode (Node3D) +## └── MultiMeshInstance3D # one per palette entry (tile definition) +## ``` + +## The tile layer data this node renders. +var tile_layer: TileLayer = null + +## The chunk coordinate this layer belongs to (for world positioning). +var chunk_coord: Vector3i = Vector3i.ZERO + +## Tile size in world units (each tile is 1x1x1). +const TILE_SIZE: float = 1.0 + +## Cache of MultiMeshInstance3D nodes, keyed by tile definition id. +var _mesh_instances: Dictionary = {} + + +func _ready() -> void: + if tile_layer == null: + return + _build_meshes() + + +## Build the MultiMesh instances for each tile definition in the palette. +func _build_meshes() -> void: + for tile_def in tile_layer.palette: + if tile_def.id == 0: + continue # Tile 0 is "empty/air", no mesh needed + _create_mesh_for_tile(tile_def) + + +func _create_mesh_for_tile(tile_def: TileDef) -> void: + # Count how many cells use this tile definition + var count := 0 + for tile_id in tile_layer.tiles: + if tile_id == tile_def.id: + count += 1 + + if count == 0: + return + + var multi_mesh := MultiMesh.new() + multi_mesh.transform_format = MultiMesh.TRANSFORM_3D + multi_mesh.mesh = _make_unit_cube_mesh() + multi_mesh.instance_count = count + + var instance_idx := 0 + for z in range(tile_layer.depth): + for y in range(tile_layer.height): + for x in range(tile_layer.width): + var tile_id := tile_layer.get_tile(x, y, z) + if tile_id != tile_def.id: + continue + var world_pos := _tile_to_world(x, y, z) + var xform := Transform3D(Basis(), world_pos) + multi_mesh.set_instance_transform(instance_idx, xform) + instance_idx += 1 + + var mesh_instance := MultiMeshInstance3D.new() + mesh_instance.multimesh = multi_mesh + mesh_instance.name = "tiles_%d" % tile_def.id + add_child(mesh_instance) + _mesh_instances[tile_def.id] = mesh_instance + + +## Convert a tile grid coordinate to a world-space position (center of tile). +func _tile_to_world(x: int, y: int, z: int) -> Vector3: + var origin := Vector3( + chunk_coord.x * tile_layer.width, + chunk_coord.y * tile_layer.height, + chunk_coord.z * tile_layer.depth + ) + return origin + Vector3( + x * TILE_SIZE + TILE_SIZE * 0.5, + y * TILE_SIZE + TILE_SIZE * 0.5, + z * TILE_SIZE + TILE_SIZE * 0.5 + ) + + +## Create a simple unit cube mesh for tile rendering. +func _make_unit_cube_mesh() -> Mesh: + var mesh := BoxMesh.new() + mesh.size = Vector3(TILE_SIZE, TILE_SIZE, TILE_SIZE) + return mesh + + +## Get the tile definition at a grid position, or null if empty. +func get_tile_def_at(x: int, y: int, z: int = 0) -> TileDef: + if tile_layer == null: + return null + var tile_id := tile_layer.get_tile(x, y, z) + if tile_id == 0: + return null + return tile_layer.get_tile_def(tile_id) + + +## Clear all rendered meshes. +func clear() -> void: + for child in get_children(): + child.queue_free() + _mesh_instances.clear() diff --git a/scripts/tile_layer_node.gd.uid b/scripts/tile_layer_node.gd.uid new file mode 100644 index 0000000..e394f0a --- /dev/null +++ b/scripts/tile_layer_node.gd.uid @@ -0,0 +1 @@ +uid://d1x05tq8h43gm diff --git a/scripts/world_object.gd b/scripts/world_object.gd new file mode 100644 index 0000000..8895fff --- /dev/null +++ b/scripts/world_object.gd @@ -0,0 +1,135 @@ +class_name WorldObject +extends RefCounted +## A world object (static or dynamic) within a chunk. +## +## This is the runtime representation of a WorldObject from the chunk data +## format (see docs/architecture/world.md §3.3). It holds the object's +## identity, transform, properties, and moderation flags. + +## Stable object ID, unique within the chunk. +var id: String = "" + +## Object type (e.g. "prop", "interactable", "spawn_point", "door"). +var type: String = "" + +## Reference to the object's mesh/glTF asset. +var asset_id: String = "" + +## World-space position. +var position: Vector3 = Vector3.ZERO + +## World-space rotation as a quaternion (x, y, z, w). +var rotation: Vector4 = Vector4(0, 0, 0, 1) + +## World-space scale. +var scale: Vector3 = Vector3.ONE + +## Type-specific key/value properties. +var properties: Dictionary = {} + +## Whether this is a spawnable dynamic entity (true) or static (false). +var is_dynamic: bool = false + +## Per-object content moderation flags. +var flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new() + +## Whether this object has been modified locally. +var is_dirty: bool = false + + +func _init( + p_id: String = "", + p_type: String = "", + p_asset_id: String = "", + p_position: Vector3 = Vector3.ZERO, + p_rotation: Vector4 = Vector4(0, 0, 0, 1), + p_scale: Vector3 = Vector3.ONE, + p_properties: Dictionary = {}, + p_dynamic: bool = false, + p_flags: ChunkDataDecoder.ContentFlags = null +) -> void: + id = p_id + type = p_type + asset_id = p_asset_id + position = p_position + rotation = p_rotation + scale = p_scale + properties = p_properties + is_dynamic = p_dynamic + if p_flags != null: + flags = p_flags + + +## Get a property value by key. +func get_property(key: String) -> Variant: + return properties.get(key) + + +## Set a property value. +func set_property(key: String, value: Variant) -> void: + properties[key] = value + is_dirty = true + + +## Check if this object is walkable (based on properties). +func is_walkable() -> bool: + return properties.get("walkable", true) + + +## Check if this object is climbable (based on properties). +func is_climbable() -> bool: + return properties.get("climbable", false) + + +## Check if this object is interactable. +func is_interactable() -> bool: + return type == "interactable" or properties.get("interactable", false) + + +## Get the object's display name. +func get_display_name() -> String: + return properties.get("display_name", id) + + +## Serialize this object to a dictionary (for networking). +func to_dict() -> Dictionary: + return { + "id": id, + "type": type, + "asset_id": asset_id, + "position": [position.x, position.y, position.z], + "rotation": [rotation.x, rotation.y, rotation.z, rotation.w], + "scale": [scale.x, scale.y, scale.z], + "properties": properties, + "dynamic": is_dynamic, + } + + +## Create a WorldObject from a dictionary (for networking). +static func from_dict(d: Dictionary) -> WorldObject: + var pos := Vector3( + d.get("position", [0, 0, 0])[0], + d.get("position", [0, 0, 0])[1], + d.get("position", [0, 0, 0])[2] + ) + var rot := Vector4( + d.get("rotation", [0, 0, 0, 1])[0], + d.get("rotation", [0, 0, 0, 1])[1], + d.get("rotation", [0, 0, 0, 1])[2], + d.get("rotation", [0, 0, 0, 1])[3] + ) + var scl := Vector3( + d.get("scale", [1, 1, 1])[0], + d.get("scale", [1, 1, 1])[1], + d.get("scale", [1, 1, 1])[2] + ) + return WorldObject.new( + d.get("id", ""), + d.get("type", ""), + d.get("asset_id", ""), + pos, + rot, + scl, + d.get("properties", {}), + d.get("dynamic", false), + ) diff --git a/scripts/world_object.gd.uid b/scripts/world_object.gd.uid new file mode 100644 index 0000000..c65fb95 --- /dev/null +++ b/scripts/world_object.gd.uid @@ -0,0 +1 @@ +uid://cxut2ghbsvobn