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) )