242 lines
11 KiB
Python
242 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import math
|
|
from collections import defaultdict
|
|
|
|
import numpy as np
|
|
import wgpu
|
|
|
|
from engine.camera import OrthoCamera
|
|
from engine.character import Character
|
|
from engine.defs import DefRegistry
|
|
from engine.map import Map, rotate_point
|
|
from engine.placeholder_textures import DEPTH_SHADOW_TEXTURE
|
|
from engine.render.pipeline import QuadPipeline
|
|
from engine.render.texture import TextureCache
|
|
from engine.tile import TILE_LAYERS
|
|
from engine.world import World
|
|
|
|
CLEAR_COLOR = {"r": 0.05, "g": 0.05, "b": 0.08, "a": 1.0}
|
|
CHARACTER_SLOT_ORDER = ("head", "torso", "left_arm", "right_arm", "left_leg", "right_leg")
|
|
MAX_DEPTH_SHADOW_LAYERS = 4 # caps how dark a very deep gap gets - see visible_z_and_depth
|
|
|
|
|
|
def visible_z_and_depth(m: Map, x: int, y: int, reference_z: int) -> tuple[int | None, int]:
|
|
"""Which z-level is actually visible looking straight down column (x, y) from
|
|
`reference_z` (the camera/player's own level), and how many levels below reference_z it
|
|
is - 0 if it's exactly at reference_z, >0 if the view had to look through open air (a gap
|
|
with nothing generated there - see Tile.has_any_layer) to find real ground.
|
|
|
|
This is what makes height visible top-down, Dwarf-Fortress-style: your own level renders
|
|
normally, and looking into a lower area through a gap shows it progressively darker the
|
|
deeper it is (see Renderer._draw_depth_shadow). Returns (None, 0) if there's a genuine void
|
|
all the way down - nothing generated in this column at or below reference_z at all.
|
|
"""
|
|
if not m.in_bounds(x, y, reference_z):
|
|
return None, 0
|
|
if m.get_tile(x, y, reference_z).has_any_layer():
|
|
return reference_z, 0
|
|
for z in range(math.floor(reference_z) - 1, -1, -1):
|
|
if m.get_tile(x, y, z).has_any_layer():
|
|
return z, reference_z - z
|
|
return None, 0
|
|
|
|
|
|
class Renderer:
|
|
def __init__(self, device, pipeline: QuadPipeline, texture_cache: TextureCache, registry: DefRegistry):
|
|
self.device = device
|
|
self.pipeline = pipeline
|
|
self.texture_cache = texture_cache
|
|
self.registry = registry
|
|
self._texture_bind_groups: dict[str, object] = {}
|
|
self.ui_camera_buffer, self.ui_camera_bind_group = pipeline.create_camera_binding()
|
|
|
|
def _bind_group_for(self, relative_path: str):
|
|
if relative_path not in self._texture_bind_groups:
|
|
view = self.texture_cache.get_view(relative_path)
|
|
self._texture_bind_groups[relative_path] = self.pipeline.make_texture_bind_group(view)
|
|
return self._texture_bind_groups[relative_path]
|
|
|
|
def render_frame(
|
|
self, context, world: World, camera: OrthoCamera, ui_instances: dict[str, list[float]] | None = None
|
|
) -> None:
|
|
scale, offset = camera.scale_offset()
|
|
self.pipeline.update_camera(scale, offset)
|
|
|
|
buckets: dict[str, list[float]] = defaultdict(list)
|
|
if world.player is not None and world.player.position is not None:
|
|
active_map = world.maps[world.player.position.map_id]
|
|
reference_z = math.floor(world.player.position.z)
|
|
self._draw_map(active_map, (0, 0, 0), 0, world, buckets, reference_z)
|
|
|
|
# Screen-space overlay (menus, HUD) - a second camera transform (pixel space, not tile
|
|
# space) sharing the same buffer/instance mechanics as world content. See
|
|
# engine/render/ui_draw.py for how callers build `ui_instances`.
|
|
ui_scale = (2.0 / max(camera.viewport_w, 1), -2.0 / max(camera.viewport_h, 1))
|
|
ui_offset = (-1.0, 1.0)
|
|
self.pipeline.write_camera(self.ui_camera_buffer, ui_scale, ui_offset)
|
|
|
|
texture = context.get_current_texture()
|
|
view = texture.create_view()
|
|
encoder = self.device.create_command_encoder()
|
|
pass_encoder = encoder.begin_render_pass(
|
|
color_attachments=[
|
|
{
|
|
"view": view,
|
|
"clear_value": CLEAR_COLOR,
|
|
"load_op": wgpu.LoadOp.clear,
|
|
"store_op": wgpu.StoreOp.store,
|
|
}
|
|
]
|
|
)
|
|
pass_encoder.set_pipeline(self.pipeline.pipeline)
|
|
pass_encoder.set_bind_group(0, self.pipeline.camera_bind_group)
|
|
pass_encoder.set_vertex_buffer(0, self.pipeline.quad_vertex_buffer)
|
|
pass_encoder.set_index_buffer(self.pipeline.quad_index_buffer, wgpu.IndexFormat.uint16)
|
|
|
|
for relative_path, flat_instances in buckets.items():
|
|
instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4)
|
|
instance_buffer = self.pipeline.make_instance_buffer(instances)
|
|
pass_encoder.set_bind_group(1, self._bind_group_for(relative_path))
|
|
pass_encoder.set_vertex_buffer(1, instance_buffer)
|
|
pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances))
|
|
|
|
if ui_instances:
|
|
pass_encoder.set_bind_group(0, self.ui_camera_bind_group)
|
|
for relative_path, flat_instances in ui_instances.items():
|
|
if not flat_instances:
|
|
continue
|
|
instances = np.array(flat_instances, dtype=np.float32).reshape(-1, 4)
|
|
instance_buffer = self.pipeline.make_instance_buffer(instances)
|
|
pass_encoder.set_bind_group(1, self._bind_group_for(relative_path))
|
|
pass_encoder.set_vertex_buffer(1, instance_buffer)
|
|
pass_encoder.draw_indexed(self.pipeline.quad_index_count, len(instances))
|
|
|
|
pass_encoder.end()
|
|
self.device.queue.submit([encoder.finish()])
|
|
|
|
def _draw_map(
|
|
self,
|
|
m: Map,
|
|
offset: tuple[int, int, int],
|
|
rotation: int,
|
|
world: World,
|
|
buckets: dict[str, list[float]],
|
|
reference_z: int = 0,
|
|
) -> None:
|
|
# Only the map the player is actually standing on gets the single-visible-level,
|
|
# depth-shaded treatment - visible_embeddings already means this is the only map ever
|
|
# reached by this recursion in practice (see its own docstring), but a fallback that
|
|
# just draws every z-level stacked keeps this correct if that ever changes.
|
|
is_players_map = (
|
|
world.player is not None and world.player.position is not None and world.player.position.map_id == m.map_id
|
|
)
|
|
|
|
if is_players_map:
|
|
for y in range(m.height):
|
|
for x in range(m.width):
|
|
z, depth = visible_z_and_depth(m, x, y, reference_z)
|
|
if z is None:
|
|
continue
|
|
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
|
|
self._draw_ground_items(m, x, y, z, offset, rotation, world, buckets)
|
|
if depth > 0:
|
|
self._draw_depth_shadow(m, x, y, offset, rotation, depth, buckets)
|
|
else:
|
|
for z in range(m.depth):
|
|
for y in range(m.height):
|
|
for x in range(m.width):
|
|
self._draw_tile_layers(m, x, y, z, offset, rotation, buckets)
|
|
self._draw_ground_items(m, x, y, z, offset, rotation, world, buckets)
|
|
|
|
for entity in world.entities_on_map(m.map_id):
|
|
if isinstance(entity, Character):
|
|
self._draw_character(entity, offset, rotation, m.width, m.height, buckets)
|
|
|
|
for embedding in world.visible_embeddings(m):
|
|
arx, ary = rotate_point(embedding.anchor[0], embedding.anchor[1], m.width, m.height, rotation)
|
|
child_offset = (offset[0] + arx, offset[1] + ary, offset[2] + embedding.anchor[2])
|
|
child_rotation = (rotation + embedding.rotation) % 4
|
|
self._draw_map(embedding.child_map, child_offset, child_rotation, world, buckets, reference_z)
|
|
|
|
def _draw_tile_layers(
|
|
self, m: Map, x: int, y: int, z: int, offset: tuple[int, int, int], rotation: int, buckets: dict[str, list[float]]
|
|
) -> None:
|
|
tile = m.get_tile(x, y, z)
|
|
for layer in TILE_LAYERS:
|
|
def_id = tile.layer_id(layer)
|
|
if def_id is None:
|
|
continue
|
|
layer_def = self.registry.get_tile_layer_def(layer, def_id)
|
|
if layer_def is None or not layer_def.texture:
|
|
continue
|
|
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
|
|
buckets[layer_def.texture].extend((float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0))
|
|
|
|
def _draw_ground_items(
|
|
self,
|
|
m: Map,
|
|
x: int,
|
|
y: int,
|
|
z: int,
|
|
offset: tuple[int, int, int],
|
|
rotation: int,
|
|
world: World,
|
|
buckets: dict[str, list[float]],
|
|
) -> None:
|
|
"""Draws the most-recently-dropped ground item at this tile as a small inset icon, so
|
|
there's something to actually see before picking it up (see resources/logic/pickup.py).
|
|
Only ever one icon per tile regardless of how many items are piled there - a known,
|
|
deliberate simplification, not a full stacked-loot visualization.
|
|
"""
|
|
items = world.ground_items_at(m.map_id, x, y, z)
|
|
if not items:
|
|
return
|
|
item_def = self.registry.get_item_def(items[-1].def_id)
|
|
if not item_def.texture:
|
|
return
|
|
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
|
|
inset = 0.2
|
|
size = 1.0 - inset * 2
|
|
buckets[item_def.texture].extend(
|
|
(float(offset[0] + rx + inset), float(offset[1] + ry + inset), size, size)
|
|
)
|
|
|
|
def _draw_depth_shadow(
|
|
self, m: Map, x: int, y: int, offset: tuple[int, int, int], rotation: int, depth: int, buckets: dict[str, list[float]]
|
|
) -> None:
|
|
"""Stacks `depth` (capped) copies of the same translucent-black overlay on one tile -
|
|
alpha blending compounds them, so more copies == darker, without any shader/pipeline
|
|
changes (see engine/placeholder_textures.py::DEPTH_SHADOW_TEXTURE).
|
|
"""
|
|
rx, ry = rotate_point(x, y, m.width, m.height, rotation)
|
|
position = (float(offset[0] + rx), float(offset[1] + ry), 1.0, 1.0)
|
|
for _ in range(min(depth, MAX_DEPTH_SHADOW_LAYERS)):
|
|
buckets[DEPTH_SHADOW_TEXTURE].extend(position)
|
|
|
|
def _draw_character(
|
|
self,
|
|
character: Character,
|
|
offset: tuple[int, int, int],
|
|
rotation: int,
|
|
map_width: int,
|
|
map_height: int,
|
|
buckets: dict[str, list[float]],
|
|
) -> None:
|
|
assert character.position is not None
|
|
rx, ry = rotate_point(character.position.x, character.position.y, map_width, map_height, rotation)
|
|
base_x = offset[0] + rx
|
|
base_y = offset[1] + ry
|
|
resolved = character.resolved_body_parts()
|
|
row_h = 1.0 / len(CHARACTER_SLOT_ORDER)
|
|
for i, slot in enumerate(CHARACTER_SLOT_ORDER):
|
|
part = resolved.get(slot)
|
|
if part is None:
|
|
continue
|
|
part_def = self.registry.get_body_part_def(part.part_def_id)
|
|
if not part_def.texture:
|
|
continue
|
|
buckets[part_def.texture].extend(
|
|
(base_x + 0.1, base_y + i * row_h + row_h * 0.05, 0.8, row_h * 0.9)
|
|
)
|