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/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/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_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_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()