from __future__ import annotations import random from dataclasses import dataclass, field from engine.defs import DefRegistry from engine.entity import EntityPosition from engine.map import Map from engine.map_loader import MapLoader from engine.world import World from engine.worldgen.terrain import generate_terrain_map GENERATED_WORLD_SIZE = 32 # width == height, tiles @dataclass(frozen=True) class WorldTemplate: """One selectable starting-world option on the 'create world' screen. Most templates are just a fixed map file this engine ships with; map_file=None marks a procedurally-generated one instead (see build_world_from_template's dispatch on it, and engine/worldgen/terrain.py for the actual generator). """ id: str name: str description: str = "" map_file: str | None = None # resources/defs/maps/*.json, loaded via MapLoader AVAILABLE_WORLD_TEMPLATES: list[WorldTemplate] = [ WorldTemplate( id="test_map", name="Test Map", description="A small sample field with a docked, rotatable two-deck ship - exercises " "the engine's core mechanics (embedding, steering, staircases, weather).", map_file="harbor_world.json", ), WorldTemplate( id="generated", name="Generated World", description="A fresh Perlin-noise heightmap: rolling terrain (water, sand, dirt, mud, " "stone), slopes between elevations, and the occasional cave at a steep cliff.", map_file=None, ), ] @dataclass class WorldCreationMenu: """Pure interaction state for the 'create world' screen: pick which starting world template to build a fresh World from. No rendering here - same boundary as StartScreen/CharacterMenu (see their docstrings in this module and engine/ui.py) - this is the state/logic side: which templates exist, which is selected, and handing back the chosen template once confirmed. """ templates: list[WorldTemplate] = field(default_factory=lambda: list(AVAILABLE_WORLD_TEMPLATES)) selected_index: int | None = None def select(self, index: int) -> None: if 0 <= index < len(self.templates): self.selected_index = index def move(self, delta: int) -> None: if not self.templates: return current = self.selected_index if self.selected_index is not None else 0 self.select((current + delta) % len(self.templates)) def selected_template(self) -> WorldTemplate | None: if self.selected_index is None: return None return self.templates[self.selected_index] def find_spawn_position(m: Map, registry: DefRegistry, rng: random.Random | None = None) -> EntityPosition | None: """A random walkable, unroofed, dry (non-water) tile to drop a new character onto - used for any template, not just generated ones, so a hand-authored map's exact layout doesn't need to be known in advance. Falls back to any exposed walkable tile (even water) and then to any walkable tile at all, so this only returns None for a map with nowhere to stand. """ rng = rng or random.Random() candidates_dry = [] candidates_exposed = [] candidates_any = [] for z in range(m.depth): for y in range(m.height): for x in range(m.width): tile = m.get_tile(x, y, z) if not tile.is_walkable(registry): continue candidates_any.append((x, y, z)) if tile.roof is None: candidates_exposed.append((x, y, z)) if tile.layer_id("flooring") != "water": candidates_dry.append((x, y, z)) for candidates in (candidates_dry, candidates_exposed, candidates_any): if candidates: return EntityPosition(m.map_id, *rng.choice(candidates)) return None def build_world_from_template( template: WorldTemplate, registry: DefRegistry, map_loader: MapLoader, seed: int | None = None, ) -> World: """Builds an empty (player-less) World from a template - the "confirm" action once a WorldCreationMenu selection is made. Spawning/joining a character into it is a separate step - see find_spawn_position above and engine/start_screen.py::join_world_with_character. """ world = World(registry=registry) if template.map_file is None: seed = seed if seed is not None else random.randint(0, 2**31 - 1) root_map = generate_terrain_map(template.id, GENERATED_WORLD_SIZE, GENERATED_WORLD_SIZE, seed) else: root_map = map_loader.load(template.map_file) world.add_map(root_map) for embedding in root_map.embeddings: world.add_map(embedding.child_map) return world