Mi2dRPGamEng/engine/world_creation.py

126 lines
4.7 KiB
Python

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