Files
gta6/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md
T
knsoandClaude Opus 5 062a9af2fd docs: Task 6b für die Kartenbeschriftung, Löcher in Task 7
Die Sichtprüfung nach Task 6 hat gezeigt, dass die Beschriftungen der
Originalkarte zu Geometrie werden — "VICE CITY (MIAMI)" steht als 1.600 m
breiter Schriftzug in der Bucht. Task 6b entfernt sie, bevor Task 7 daraus
Meshes baut. Der Brief nennt drei gemessene Sackgassen (Buntheit, Farbton,
Zusammenhang nur der Straßenmaske) und den Weg, der trägt: auf Regionenebene
über alle Nicht-Wasser-Klassen, wo die Buchstaben eigene Inseln ohne jedes
natürliche Merkmal bilden.

extrudePolygons und flatPolygons in Task 7 verarbeiten jetzt Ringlisten mit
Löchern statt einzelner Ringe, passend zu Ruling 34.

overview.py rendert den ganzen Ausschnitt aus den Chunk-Dateien; damit ist
der Beschriftungsfehler überhaupt erst aufgefallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 17:19:28 +02:00

109 KiB
Raw Blame History

Leonida Vice-City-Ausschnitt: Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ein 3 × 3 km großer, zu Fuß und mit dem Auto spielbarer 3D-Blockout von Vice City und Vice Beach, erzeugt aus den Kacheln der Community-Karte YANIS v15.

Architecture: Python liest die Kartenkacheln einmalig aus und schreibt Vektordaten je 250-m-Chunk. Die Vite-App lädt diese Chunks im Umkreis des Spielers, baut daraus zur Laufzeit Meshes (Land, Straßen, Brücken, Gebäude) und meldet je Chunk genau einen Collider beim three-player-controller an. Der Geometrie-Baustein ist reines JavaScript ohne Browser-Abhängigkeiten und läuft deshalb auch in Node, damit derselbe Code später GLB-Chunks vorbacken kann.

Tech Stack: Node 26, Vite, three 0.184.0, three-mesh-bvh, three-player-controller (lokal), vitest, @gltf-transform/cli 4.5.0 · Python 3.12 über uv mit numpy, scipy, scikit-image, shapely, networkx, pillow, pytest

Spec: docs/superpowers/specs/2026-09-12-leonida-vice-slice-design.md

Global Constraints

  • Eine Spieleinheit ist ein Meter. Umrechnung in three.js: x = X, z = −Y. Norden liegt bei −Z.
  • Ausschnitt A: X −1000 … 2000, Y −500 … 2500. Kacheln: Spalten 60–71, Zeilen 54–65, Dateiname 5,{zeile},{spalte}.jpg.
  • Kartenquelle ist ausschließlich github.com/rolux/gtadb.org beim Commit 4093e8c874e480d47c38b37fe7a404f8b0c5275e. Niemals map.stateofleonida.net abfragen.
  • three exakt auf 0.184.0 pinnen. In vite.config.js sind resolve.dedupe: ['three','three-mesh-bvh'] und optimizeDeps.exclude: ['three-mesh-bvh'] zwingend.
  • Verboten als Abhängigkeit: skan, buildingregulariser, OpenCV, GDAL, rasterio.
  • Chunkgröße 250 m. Laden ab 400 m, Entladen ab 600 m. Pro Frame höchstens ein Chunk-Bau, eine Collider-Anmeldung und vier Abmeldungen.
  • Budgets: mindestens 55 FPS, unter 150 Draw Calls, Chunk-Bau unter 20 ms im Hauptthread.
  • Die Namensnennung im HUD ist Pflicht (CC BY 4.0) und darf in keinem Schritt entfernt werden.
  • Jeder Commit endet mit Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>.

Dateistruktur

Datei Verantwortung
package.json, vite.config.js, index.html App-Gerüst, Abhängigkeiten, Vite-Sonderfälle
src/main.js Renderer, Kamera, Licht, Spielschleife, Zusammenbau
src/world/coords.js Umrechnung Welt ↔ Kachel ↔ Chunk. Reine Funktionen
src/world/geometry.js Chunk-Daten → BufferGeometry (Land, Straßen, Brücken, Gebäude)
src/world/streaming.js Laden und Entladen von Chunks, Collider-Verwaltung
src/world/materials.js Poly-Haven-Materialien, Welt-UVs, Wasser
src/world/props.js Palmen und Requisiten als InstancedMesh
src/characters/index.js Figurenauswahl, Retargeting, Hitbox (aus showcase.js)
src/ui/panel.js, src/ui/hud.js Debug-Panel, Namensnennung, Ladeanzeige
tools/extract/leonida/coords.py dieselbe Umrechnung wie coords.js, Referenz für Tests
tools/extract/leonida/tiles.py Kacheln laden, Cache, Mosaik
tools/extract/leonida/classify.py Palette, Lab-Zuordnung, Auffüllen
tools/extract/leonida/vectors.py Masken → Polygone (Land, Straßen, Gebäude)
tools/extract/leonida/heights.py Höhen aus Wahrzeichen und Vierteln
tools/extract/leonida/emit.py Chunk-JSON, Manifest, Debug-PNG
tools/extract/run.py Ablaufsteuerung
tools/assets/fetch.mjs Poly-Haven-Downloads, Palme wandeln, vereinfachen

Task 1: App-Gerüst mit laufendem Spieler

Files:

  • Create: package.json, vite.config.js, index.html, src/main.js, src/world/coords.js, tests/coords.test.js
  • Modify: .gitmodules (durch git submodule add)
  • Test: tests/coords.test.js

Interfaces:

  • Consumes: nichts

  • Produces: worldToThree(X, Y) -> {x, z}, threeToWorld(x, z) -> {X, Y}, tileForWorld(X, Y) -> {row, col}, chunkForWorld(X, Y) -> {i, j}, CHUNK_SIZE = 250, SLICE = {x0:-1000, x1:2000, y0:-500, y1:2500} aus src/world/coords.js

  • Step 1: Controller als Submodul einbinden

cd /Users/Kenso/Developer/gta6
git -C three-player-controller checkout ac39682   # unveränderter Upstream-Stand
git submodule add https://github.com/hh-hang/three-player-controller.git three-player-controller
git -C three-player-controller checkout leonida/showcase-figuren   # Arbeitskopie zurück auf den Branch

Der Submodul-Eintrag zeigt damit auf ac39682; die Arbeitskopie steht auf dem Branch, damit die Showcase weiter läuft. git status meldet das Submodul dann als „modified content" — das ist erwartet.

  • Step 2: Abhängigkeiten anlegen

package.json:

{
  "name": "leonida",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "test": "vitest run"
  },
  "dependencies": {
    "three": "0.184.0",
    "three-mesh-bvh": "^0.9.14",
    "three-player-controller": "file:three-player-controller",
    "lil-gui": "^0.21.0"
  },
  "devDependencies": {
    "vite": "^7.3.0",
    "vitest": "^3.2.0"
  }
}

vite.config.js:

import { defineConfig } from "vite";

export default defineConfig({
    // Ohne dedupe lädt der Controller three aus seinem eigenen node_modules -> zwei Kopien.
    resolve: { dedupe: ["three", "three-mesh-bvh"] },
    // Der BVH-Worker wird dynamisch aus three-mesh-bvh/src/workers/ geladen und darf nicht vorgebündelt werden.
    optimizeDeps: { exclude: ["three-mesh-bvh", "three-player-controller"] },
    server: { host: true },
});

Dann npm install ausführen.

  • Step 3: Den fehlschlagenden Test schreiben

tests/coords.test.js:

import { describe, expect, it } from "vitest";
import { CHUNK_SIZE, chunkForWorld, threeToWorld, tileForWorld, worldToThree } from "../src/world/coords.js";

describe("coords", () => {
    it("dreht Y zur three-z-Achse", () => {
        expect(worldToThree(1699, 743)).toEqual({ x: 1699, z: -743 });
        expect(threeToWorld(1699, -743)).toEqual({ X: 1699, Y: 743 });
    });

    it("findet die Kachel von Vice Beach", () => {
        expect(tileForWorld(1409.4, 2803.2)).toEqual({ row: 53, col: 69 });
    });

    it("deckt den Ausschnitt mit Spalten 60..71 und Zeilen 54..65 ab", () => {
        expect(tileForWorld(-1000, 2500)).toEqual({ row: 54, col: 60 });
        expect(tileForWorld(1999, -499)).toEqual({ row: 65, col: 71 });
    });

    it("teilt den Ausschnitt in 250-m-Chunks", () => {
        expect(CHUNK_SIZE).toBe(250);
        expect(chunkForWorld(-1000, 2500)).toEqual({ i: 0, j: 0 });
        expect(chunkForWorld(1999, -499)).toEqual({ i: 11, j: 11 });
    });
});
  • Step 4: Test laufen lassen, er muss scheitern

Run: npm test Expected: FAIL, Failed to resolve import "../src/world/coords.js"

  • Step 5: src/world/coords.js schreiben
export const TILE_PX = 256;
export const WORLD_HALF = 16384;      // gtadb-Weltgrenze in Metern
export const CHUNK_SIZE = 250;
export const SLICE = { x0: -1000, x1: 2000, y0: -500, y1: 2500 };
export const CHUNKS_PER_SIDE = (SLICE.x1 - SLICE.x0) / CHUNK_SIZE;   // 12

export const worldToThree = (X, Y) => ({ x: X, z: -Y });
export const threeToWorld = (x, z) => ({ X: x, Y: -z });

export function tileForWorld(X, Y) {
    return {
        row: Math.floor((WORLD_HALF - Y) / TILE_PX),
        col: Math.floor((X + WORLD_HALF) / TILE_PX),
    };
}

export function chunkForWorld(X, Y) {
    return {
        i: Math.floor((X - SLICE.x0) / CHUNK_SIZE),
        j: Math.floor((SLICE.y1 - Y) / CHUNK_SIZE),
    };
}

/** Nordwest-Ecke eines Chunks in Weltkoordinaten. */
export function chunkOrigin(i, j) {
    return { X: SLICE.x0 + i * CHUNK_SIZE, Y: SLICE.y1 - j * CHUNK_SIZE };
}
  • Step 6: Test laufen lassen, er muss bestehen

Run: npm test Expected: PASS, 4 Tests

  • Step 7: Minimale Szene mit Spieler

public/models/ual.glb aus three-player-controller/example/public/glb/ual.glb kopieren. index.html mit <div id="container"> und <script type="module" src="/src/main.js">. src/main.js:

import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, Mesh, MeshStandardMaterial,
         PerspectiveCamera, PlaneGeometry, Scene, Timer, Vector3, WebGLRenderer } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { playerController } from "three-player-controller";

const container = document.querySelector("#container");
const scene = new Scene();
const renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);

const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500);
// Pflicht: der Controller liest controls.domElement für Pointer-Lock und Eingaben.
const controls = new OrbitControls(camera, renderer.domElement);
scene.add(new AmbientLight(0xffffff, 1.5));
const sun = new DirectionalLight(0xffffff, 2);
sun.position.set(60, 120, 40);
sun.castShadow = true;
scene.add(sun);

const ground = new Mesh(new PlaneGeometry(400, 400), new MeshStandardMaterial({ color: 0x9aa88a }));
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

const gltf = await new GLTFLoader().loadAsync("./models/ual.glb");
const player = new playerController();
await player.init({
    scene, camera, controls,
    colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }],
    initPos: new Vector3(0, 2, 0),
    playerModelConfig: {
        model: gltf.scene, animations: gltf.animations, scale: 0.005,
        idleAnim: "Idle_Loop", walkAnim: "Walk_Loop", runAnim: "Sprint_Loop",
        jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
        headBoneName: "Head", rotateY: -Math.PI / 2, speed: 100, runSpeed: 600,
    },
});

const timer = new Timer();
timer.connect(document);
renderer.setAnimationLoop((t) => {
    timer.update(t);
    player.update(Math.min(timer.getDelta(), 1 / 40));
    renderer.render(scene, camera);
});
addEventListener("resize", () => {
    renderer.setSize(container.clientWidth, container.clientHeight);
    camera.aspect = container.clientWidth / container.clientHeight;
    camera.updateProjectionMatrix();
});
  • Step 8: Im Browser prüfen

Run: npm run dev, dann http://localhost:5173 öffnen. Expected: Die Figur steht auf der Fläche, läuft mit WASD und fällt nicht durch. In der Konsole darf nicht „Multiple instances of Three.js" stehen — sonst greift resolve.dedupe nicht.

  • Step 9: Commit
# three-player-controller NICHT mit `git add` erfassen: das würde den Submodul-Pin still
# auf den gerade ausgecheckten Branch-Commit umsetzen. Der Pin wurde in Schritt 1 gesetzt.
git add package.json package-lock.json vite.config.js index.html src tests public/models/ual.glb .gitmodules
git commit -m "feat: App-Gerüst mit Spieler auf Testfläche

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 2: Python-Umgebung, Kacheln laden, Mosaik

Files:

  • Create: tools/extract/pyproject.toml, tools/extract/leonida/__init__.py, tools/extract/leonida/coords.py, tools/extract/leonida/tiles.py, tools/extract/tests/test_coords.py, tools/extract/tests/test_tiles.py
  • Test: tools/extract/tests/

Interfaces:

  • Consumes: nichts

  • Produces: SLICE, TILE_PX, WORLD_HALF, tile_for_world(X, Y) -> (row, col), slice_tiles() -> list[(row, col)], pixel_to_world(px, py) -> (X, Y) aus coords.py; download_tiles(cache: Path) -> None, load_mosaic(cache: Path) -> np.ndarray (Form (3000, 3000, 3), uint8, bereits auf den Ausschnitt beschnitten) aus tiles.py

  • Step 1: Projektdatei anlegen

tools/extract/pyproject.toml:

[project]
name = "leonida-extract"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "numpy>=2.1", "scipy>=1.14", "scikit-image>=0.24",
    "shapely>=2.0", "networkx>=3.3", "pillow>=10.4",
]

[dependency-groups]
dev = ["pytest>=8.3"]

[tool.pytest.ini_options]
testpaths = ["tests"]
# Ohne [build-system] behandelt uv das Projekt als virtuell und installiert das lokale
# Paket nie; pythonpath legt tools/extract für die Tests auf den sys.path.
pythonpath = ["."]

Dann cd tools/extract && uv sync.

  • Step 2: Den fehlschlagenden Test für die Umrechnung schreiben

tools/extract/tests/test_coords.py:

from leonida.coords import SLICE, pixel_to_world, slice_tiles, tile_for_world

def test_tile_of_vice_beach():
    assert tile_for_world(1409.4, 2803.2) == (53, 69)

def test_slice_covers_expected_grid():
    tiles = slice_tiles()
    assert len(tiles) == 144
    assert min(c for _, c in tiles) == 60 and max(c for _, c in tiles) == 71
    assert min(r for r, _ in tiles) == 54 and max(r for r, _ in tiles) == 65

def test_pixel_origin_is_slice_corner():
    # Pixel (0,0) des beschnittenen Mosaiks ist die Nordwest-Ecke des Ausschnitts.
    assert pixel_to_world(0, 0) == (SLICE["x0"], SLICE["y1"])
    assert pixel_to_world(2999, 2999) == (SLICE["x0"] + 2999, SLICE["y1"] - 2999)
  • Step 3: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_coords.py -v Expected: FAIL, ModuleNotFoundError: No module named 'leonida'

  • Step 4: leonida/coords.py schreiben
"""Umrechnung zwischen gtadb-Weltkoordinaten, Kacheln und Mosaikpixeln.

Spiegelt src/world/coords.js. Beide Seiten müssen dieselben Werte liefern.
"""
TILE_PX = 256
WORLD_HALF = 16384
CHUNK_SIZE = 250
SLICE = {"x0": -1000, "x1": 2000, "y0": -500, "y1": 2500}


def tile_for_world(x: float, y: float) -> tuple[int, int]:
    return (int((WORLD_HALF - y) // TILE_PX), int((x + WORLD_HALF) // TILE_PX))


def slice_tiles() -> list[tuple[int, int]]:
    r0, c0 = tile_for_world(SLICE["x0"], SLICE["y1"])
    r1, c1 = tile_for_world(SLICE["x1"] - 1, SLICE["y0"] + 1)
    return [(r, c) for r in range(r0, r1 + 1) for c in range(c0, c1 + 1)]


def mosaic_origin_px() -> tuple[int, int]:
    """Pixelversatz der Ausschnittsecke innerhalb des ungeschnittenen Kachelrasters."""
    r0, c0 = tile_for_world(SLICE["x0"], SLICE["y1"])
    return (SLICE["x0"] + WORLD_HALF - c0 * TILE_PX, (WORLD_HALF - SLICE["y1"]) - r0 * TILE_PX)


def pixel_to_world(px: int, py: int) -> tuple[float, float]:
    return (SLICE["x0"] + px, SLICE["y1"] - py)
  • Step 5: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_coords.py -v Expected: PASS, 3 Tests

  • Step 6: Den fehlschlagenden Test für das Mosaik schreiben

tools/extract/tests/test_tiles.py (ohne Netz, mit eingespeisten Kacheln):

import numpy as np
from leonida.coords import mosaic_origin_px
from leonida.tiles import assemble

def fake_tile(row: int, col: int) -> np.ndarray:
    """Kodiert die globale Pixelposition, damit ein falscher Versatz auffällt."""
    ys, xs = np.mgrid[0:256, 0:256]
    gy = (row * 256 + ys) % 251
    gx = (col * 256 + xs) % 251
    return np.dstack([gy, gx, np.full((256, 256), 7)]).astype(np.uint8)

def test_assemble_crops_to_slice():
    mosaic = assemble(fake_tile)
    assert mosaic.shape == (3000, 3000, 3)

def test_assemble_uses_the_exact_crop_offset():
    # Einfarbige Kacheln würden jeden Versatz von 0 bis 72 px durchgehen lassen.
    ox, oy = mosaic_origin_px()
    assert (ox, oy) == (24, 60)
    mosaic = assemble(fake_tile)
    r0, c0 = 54, 60
    for py, px in [(0, 0), (2999, 2999), (1234, 567)]:
        gy, gx = r0 * 256 + oy + py, c0 * 256 + ox + px
        assert tuple(mosaic[py, px]) == (gy % 251, gx % 251, 7)
  • Step 7: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_tiles.py -v Expected: FAIL, ImportError: cannot import name 'assemble'

  • Step 8: leonida/tiles.py schreiben
import time
import urllib.error
import urllib.request
from pathlib import Path

import numpy as np
from PIL import Image

from .coords import SLICE, TILE_PX, mosaic_origin_px, slice_tiles

COMMIT = "4093e8c874e480d47c38b37fe7a404f8b0c5275e"
BASE = f"https://raw.githubusercontent.com/rolux/gtadb.org/{COMMIT}/maps/tiles/6/yanis%2C15/5"
UA = {"User-Agent": "leonida-extract/0.1 (local hobby project)"}


def download_tiles(cache: Path) -> None:
    """Lädt alle Kacheln des Ausschnitts; vorhandene werden übersprungen."""
    cache.mkdir(parents=True, exist_ok=True)
    missing = []
    for row, col in slice_tiles():
        name = f"5,{row},{col}.jpg"
        dst = cache / name
        if dst.exists():
            continue
        for attempt in range(3):
            try:
                req = urllib.request.Request(f"{BASE}/{name}", headers=UA)
                dst.write_bytes(urllib.request.urlopen(req, timeout=30).read())
                break
            except (urllib.error.URLError, TimeoutError):
                time.sleep(2 ** attempt)
        else:
            missing.append(name)
    if missing:
        raise RuntimeError(f"{len(missing)} Kacheln fehlen: {missing[:5]} ...")


def assemble(load_tile) -> np.ndarray:
    """Setzt die Kacheln zusammen und schneidet auf den Ausschnitt zu."""
    tiles = slice_tiles()
    r0 = min(r for r, _ in tiles)
    c0 = min(c for _, c in tiles)
    rows = max(r for r, _ in tiles) - r0 + 1
    cols = max(c for _, c in tiles) - c0 + 1
    full = np.zeros((rows * TILE_PX, cols * TILE_PX, 3), dtype=np.uint8)
    for row, col in tiles:
        y, x = (row - r0) * TILE_PX, (col - c0) * TILE_PX
        full[y:y + TILE_PX, x:x + TILE_PX] = load_tile(row, col)
    ox, oy = mosaic_origin_px()
    w = SLICE["x1"] - SLICE["x0"]
    h = SLICE["y1"] - SLICE["y0"]
    return full[oy:oy + h, ox:ox + w]


def load_mosaic(cache: Path) -> np.ndarray:
    def load_tile(row: int, col: int) -> np.ndarray:
        with Image.open(cache / f"5,{row},{col}.jpg") as img:
            return np.asarray(img.convert("RGB"))
    return assemble(load_tile)
  • Step 9: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest -v Expected: PASS, 4 Tests

  • Step 10: Echte Kacheln laden und Größe prüfen

Run:

cd tools/extract
uv run python -c "
from pathlib import Path
from leonida.tiles import download_tiles, load_mosaic
c = Path('cache'); download_tiles(c)
m = load_mosaic(c); print(m.shape, m.dtype)
"

Expected: (3000, 3000, 3) uint8, und cache/ enthält 144 Dateien mit rund 1,2 MB.

  • Step 11: Commit
git add tools/extract
git commit -m "feat(extract): Koordinaten, Kachel-Download und Mosaik

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 3: Klassifizierung der Pixel

Files:

  • Create: tools/extract/leonida/classify.py, tools/extract/tests/test_classify.py
  • Test: tools/extract/tests/test_classify.py

Interfaces:

  • Consumes: load_mosaic aus Task 2
  • Produces: CLASSES (dict Name → Index), PALETTE (dict Name → RGB), classify(rgb: np.ndarray) -> np.ndarray (uint8-Labelbild), class_shares(labels) -> dict[str, float]

Die Palette stammt aus den gemessenen Kachelfarben. Sie ist der einzige Ort, an dem Farbwerte stehen.

  • Step 1: Den fehlschlagenden Test schreiben

tools/extract/tests/test_classify.py:

import numpy as np
from leonida.classify import CLASSES, PALETTE, class_shares, classify

def block(color, size=8):
    return np.full((size, size, 3), color, dtype=np.uint8)

def test_pure_colors_hit_their_class():
    for name, color in PALETTE.items():
        labels = classify(block(color))
        assert (labels == CLASSES[name]).all(), name

def test_jpeg_noise_still_classifies():
    rng = np.random.default_rng(0)
    noisy = block(PALETTE["strasse"], 32).astype(np.int16)
    noisy += rng.integers(-10, 11, noisy.shape)          # JPEG-Rauschen
    labels = classify(np.clip(noisy, 0, 255).astype(np.uint8))
    assert (labels == CLASSES["strasse"]).mean() > 0.98

def test_unknown_pixels_are_filled_from_neighbours():
    img = block(PALETTE["stadt"], 16)
    img[7:9, 7:9] = (255, 0, 255)                         # Beschriftung, in keiner Klasse
    labels = classify(img)
    assert (labels == CLASSES["stadt"]).all()

def test_reports_the_share_of_unknown_pixels():
    img = block(PALETTE["stadt"], 16)
    img[7:9, 7:9] = (255, 0, 255)              # 4 von 256 Pixeln passen zu keiner Farbe
    labels, share = classify(img, report_unknown=True)
    assert (labels == CLASSES["stadt"]).all()
    assert abs(share - 4 / 256) < 1e-9

def test_shares_sum_to_one():
    img = np.concatenate([block(PALETTE["wasser"], 8), block(PALETTE["land"], 8)], axis=1)
    shares = class_shares(classify(img))
    assert abs(sum(shares.values()) - 1.0) < 1e-9
    assert abs(shares["wasser"] - 0.5) < 1e-9
  • Step 2: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_classify.py -v Expected: FAIL, ModuleNotFoundError: No module named 'leonida.classify'

  • Step 3: leonida/classify.py schreiben
import numpy as np
from scipy import ndimage as ndi
from skimage.color import rgb2lab

PALETTE = {
    "wasser":     (48, 144, 192),   # gemessen im Mosaik; tiles.py nennt die Kompositionsfarbe
    "land":       (192, 216, 128),
    "gruen":      (139, 195, 74),
    "sand":       (232, 216, 136),
    "stadt":      (216, 216, 216),
    "gebaeude":   (176, 176, 176),
    "strasse":    (80, 80, 80),
    "weg":        (192, 152, 96),
    "spekulativ": (198, 96, 96),
}
CLASSES = {name: i for i, name in enumerate(PALETTE)}
UNKNOWN = 255
MAX_DELTA_E = 22.0     # darüber unbekannt; deckt den Tiefenverlauf des Wassers mit ab


def classify(rgb: np.ndarray, report_unknown: bool = False):
    """Ordnet jedes Pixel der nächsten Palettenfarbe zu; Ausreißer werden aufgefüllt.

    Mit report_unknown=True kommt zusätzlich der Anteil der Pixel zurück, die vor dem
    Auffüllen zu keiner Legendenfarbe passten. Aufgabe 6 bricht darüber bei über 5 % ab.
    """
    lab = rgb2lab(rgb.astype(np.float32) / 255.0)
    ref = rgb2lab(np.array([list(PALETTE.values())], dtype=np.float32) / 255.0)[0]
    dist = np.linalg.norm(lab[:, :, None, :] - ref[None, None, :, :], axis=3)
    labels = dist.argmin(axis=2).astype(np.uint8)
    unknown = dist.min(axis=2) > MAX_DELTA_E
    share = float(unknown.mean())
    if unknown.any():
        # Nächstes bekanntes Pixel übernehmen.
        idx = ndi.distance_transform_edt(unknown, return_distances=False, return_indices=True)
        labels = labels[tuple(idx)]
    return (labels, share) if report_unknown else labels


def class_shares(labels: np.ndarray) -> dict[str, float]:
    total = labels.size
    counts = np.bincount(labels.ravel(), minlength=len(PALETTE))
    return {name: float(counts[i]) / total for name, i in CLASSES.items()}
  • Step 4: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_classify.py -v Expected: PASS, 4 Tests

  • Step 5: Gegen den echten Ausschnitt prüfen

Run:

cd tools/extract
uv run python -c "
from pathlib import Path
from leonida.tiles import load_mosaic
from leonida.classify import classify, class_shares
s = class_shares(classify(load_mosaic(Path('cache'))))
for k, v in sorted(s.items(), key=lambda kv: -kv[1]): print(f'{k:12s} {v*100:5.1f}%')
"

Expected: unbekannt unter 5 %, Wasser rund 33 %, Stadt rund 33 %, Gebäude rund 11 %, Straße rund 12 % (am echten Mosaik gemessen). Weichen die Werte deutlich ab, stimmt die Palette nicht — dann die Farben aus einer Kachel nachmessen, statt die Toleranz zu erhöhen.

  • Step 6: Commit
git add tools/extract/leonida/classify.py tools/extract/tests/test_classify.py
git commit -m "feat(extract): Pixel-Klassifizierung mit Lab-Palette

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 4: Küste, Straßenflächen und Brücken

Files:

  • Create: tools/extract/leonida/vectors.py, tools/extract/tests/test_vectors.py
  • Test: tools/extract/tests/test_vectors.py

Interfaces:

  • Consumes: CLASSES, classify aus Task 3

  • Produces: mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Polygon], land_polygons(labels), road_polygons(labels) -> list[tuple[Polygon, bool]] (Polygon, ist_bruecke), centerlines(labels) -> list[LineString] — alle in Weltkoordinaten (Meter)

  • Step 1: Den fehlschlagenden Test schreiben

tools/extract/tests/test_vectors.py:

import numpy as np
from leonida.classify import CLASSES
from leonida.vectors import land_polygons, mask_polygons, road_polygons

def test_square_becomes_one_polygon_with_right_area():
    mask = np.zeros((100, 100), dtype=bool)
    mask[20:60, 30:80] = True                       # 40 x 50 Pixel = 2000 m²
    polys = mask_polygons(mask, min_area=10, simplify=1.0)
    assert len(polys) == 1
    assert abs(polys[0].area - 2000) < 60           # Vereinfachung darf etwas abweichen

def test_island_keeps_its_lagoon():
    labels = np.full((80, 80), CLASSES["wasser"], dtype=np.uint8)
    labels[10:70, 10:70] = CLASSES["land"]
    labels[30:50, 30:50] = CLASSES["wasser"]        # Lagune
    polys = land_polygons(labels)
    assert len(polys) == 1
    assert len(polys[0].interiors) == 1

def test_road_over_water_is_flagged_as_bridge():
    labels = np.full((60, 60), CLASSES["wasser"], dtype=np.uint8)
    labels[0:10, :] = CLASSES["land"]
    labels[25:31, :] = CLASSES["strasse"]           # Damm quer durchs Wasser
    roads = road_polygons(labels)
    assert len(roads) == 1
    assert roads[0][1] is True

def test_crossing_roads_form_a_single_polygon():
    labels = np.full((60, 60), CLASSES["land"], dtype=np.uint8)
    labels[28:32, :] = CLASSES["strasse"]
    labels[:, 28:32] = CLASSES["strasse"]
    roads = road_polygons(labels)
    assert len(roads) == 1                          # Kreuzung, kein Zerfall in vier Arme
  • Step 2: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_vectors.py -v Expected: FAIL, ModuleNotFoundError: No module named 'leonida.vectors'

  • Step 3: leonida/vectors.py schreiben
import networkx as nx
import numpy as np
from scipy import ndimage as ndi
from shapely.geometry import LineString, Polygon
from shapely.ops import unary_union
from skimage.measure import find_contours
from skimage.morphology import closing, disk, skeletonize

from .classify import CLASSES
from .coords import SLICE

ROAD_CLASSES = ("strasse", "weg", "spekulativ")


def _to_world(contour: np.ndarray) -> list[tuple[float, float]]:
    """Konturpunkte (Zeile, Spalte) → Weltkoordinaten in Metern."""
    return [(SLICE["x0"] + c, SLICE["y1"] - r) for r, c in contour]


def mask_polygons(mask: np.ndarray, min_area: float, simplify: float) -> list[Polygon]:
    padded = np.pad(mask, 1)
    outers, holes = [], []
    for contour in find_contours(padded.astype(float), 0.5):
        ring = _to_world(contour - 1)
        if len(ring) < 4:
            continue
        poly = Polygon(ring).buffer(0)
        if poly.is_empty or poly.area < min_area:
            continue
        (outers if _signed_area(contour - 1) > 0 else holes).append(poly)
    result = []
    for outer in outers:
        inner = [h.exterior.coords for h in holes if outer.contains(h)]
        result.append(Polygon(outer.exterior.coords, inner).buffer(0).simplify(simplify))
    return [p for p in result if p.geom_type == "Polygon" and p.area >= min_area]


def _signed_area(contour: np.ndarray) -> float:
    """Shoelace-Summe in Pixelkoordinaten (Zeile, Spalte).

    find_contours liefert Außenkonturen und Löcher mit entgegengesetztem Umlaufsinn;
    das Vorzeichen trennt sie zuverlässig. Eine Stichprobe der Maske am Mittelpunkt der
    Kontur versagt bei konzentrischen Formen: bei einer Insel mit Lagune liegt der
    Mittelpunkt der Außenkontur selbst in der Lagune.
    """
    row, col = contour[:, 0], contour[:, 1]
    return float(0.5 * np.sum(col * np.roll(row, -1) - np.roll(col, -1) * row))


def land_polygons(labels: np.ndarray) -> list[Polygon]:
    land = labels != CLASSES["wasser"]
    land = ndi.binary_closing(ndi.binary_opening(land, disk(2)), disk(2))
    return mask_polygons(land, min_area=50, simplify=1.0)


def road_mask(labels: np.ndarray) -> np.ndarray:
    mask = np.zeros(labels.shape, dtype=bool)
    for name in ROAD_CLASSES:
        mask |= labels == CLASSES[name]
    # Schließen überbrückt die eingezeichneten Straßennamen.
    return closing(mask, disk(3))


# Wuchs der Landmaske in Pixeln (= Metern) für die Brückenprüfung. Am echten Mosaik
# gemessen: 3-5 px reichen nicht, weil Bordstein/Anti-Aliasing zwischen "land" und der
# angrenzenden Straße auf Karten mit echtem Rauschen mehr Lücke lassen als im
# künstlichen Test. Ist der Wuchs zu knapp, berührt eine ganz normale Küstenstraße
# nirgends "Land" und verkettet sich mit jeder anderen zu knappen Stelle zu einem
# Brückenstück, das (wie der ursprüngliche Fensteransatz) fast die ganze Karte
# überspannt. Ab 8 px reißt diese Kette am echten Mosaik; 10 px lässt zusätzlich Raum
# zum künstlichen Test, dessen 15 Pixel breite Wasserlücke sonst zu knapp anläge.
LAND_REACH_PX = 10

# Reichweite in Pixeln (= Metern) für die "nahe am Wasser"-Prüfung: fern von Land allein
# macht noch keine Brücke (siehe unten). Am echten Mosaik gemessen (WATER_REACH_PX →
# Brücken gesamt / davon "trocken" mit < 10 % Wasser im Umkreis: 6→40/1, 8→40/1,
# 10→41/2, 14→72/17) ist 6 bis 10 ein stabiles Plateau, das ab 14 abbricht und wieder
# trockene Flächen als Brücke mitreißt; 8 liegt in dessen Mitte.
WATER_REACH_PX = 8


def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
    """Straßenflächen, aufgeteilt in Stücke auf Land, Brückenstücke über Wasser und
    breite Flächen an Land, die nur zufällig weit von "Land" entfernt liegen.

    "Land" ist hier die Geländeklasse ohne die Straße selbst, um LAND_REACH_PX nach
    außen gewachsen. Ohne den Ausschluss der Straße wäre das zirkulär: land_polygons()
    zählt "nicht Wasser" als Land, und eine Straße ist nie "wasser" – ein Damm quer
    durchs Meer würde dann fast vollständig mit sich selbst überlappen und nie als
    Brücke auffallen. Der Wuchs sorgt dafür, dass eine Straße, die direkt an echtem
    Land beginnt, dort noch als "auf Land" erkannt wird, statt an ihrer eigenen Kante
    ins Leere zu greifen.

    "Fern von Land" reicht als Brückenkriterium allein nicht aus: eine breite Kreuzungs-
    oder Parkfläche ist überall nur Straße, ihre Mitte liegt also zwangsläufig weiter als
    LAND_REACH_PX von straßenfreiem Land entfernt, obwohl gar kein Wasser in der Nähe
    ist. Ein Stück jenseits von "Land" zählt deshalb nur dann als Brücke, wenn es
    zusätzlich innerhalb WATER_REACH_PX von Wasser liegt; der Rest – fern von Land, aber
    auch fern von Wasser – bleibt "auf Land".
    """
    solid_ground = ndi.binary_dilation(
        (labels != CLASSES["wasser"]) & ~road_mask(labels), disk(LAND_REACH_PX)
    )
    land = unary_union(mask_polygons(solid_ground, min_area=50, simplify=1.0))
    wet = unary_union(mask_polygons(
        ndi.binary_dilation(labels == CLASSES["wasser"], disk(WATER_REACH_PX)),
        min_area=50, simplify=1.0))
    out = []
    for poly in mask_polygons(road_mask(labels), min_area=20, simplify=0.8):
        off = poly.difference(land)
        for part, bridge in ((poly.intersection(land), False),   # auf Land
                             (off.intersection(wet), True),      # über Wasser: Brücke
                             (off.difference(wet), False)):      # breite Fläche an Land
            for piece in getattr(part, "geoms", [part]):
                if piece.geom_type == "Polygon" and piece.area >= 20:
                    out.append((piece, bridge))
    return out


def centerlines(labels: np.ndarray) -> list[LineString]:
    """Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen.

    Eine Kontur um das ein Pixel breite Skelett liefert jede Linie doppelt (sie umrundet
    das Band und läuft auf der anderen Seite zurück). Stattdessen wird das Skelett als
    Pixelgraph gelesen: Skelettpixel sind Knoten, 8er-Nachbarschaft ist eine Kante. Je
    Zusammenhangskomponente werden die Pfade zwischen Knoten mit Grad ungleich 2 verfolgt
    (Enden und Kreuzungen trennen die Äste); ein reiner Ring ohne solche Knoten wird
    einmal umlaufen.
    """
    graph = _skeleton_graph(skeletonize(road_mask(labels)))
    lines = []
    for path in _skeleton_paths(graph):
        line = LineString(_to_world(path)).simplify(1.5)
        if line.length >= 15:
            lines.append(line)
    return lines


def _skeleton_graph(skeleton: np.ndarray) -> nx.Graph:
    """Skelettpixel als Knoten, 8er-Nachbarschaft als Kanten."""
    pixels = set(zip(*np.nonzero(skeleton)))
    graph = nx.Graph()
    graph.add_nodes_from(pixels)
    for r, c in pixels:
        for dr in (-1, 0, 1):
            for dc in (-1, 0, 1):
                neighbour = (r + dr, c + dc)
                if (dr or dc) and neighbour in pixels:
                    graph.add_edge((r, c), neighbour)
    return graph


def _skeleton_paths(graph: nx.Graph) -> list[list[tuple[int, int]]]:
    """Pfade zwischen Enden/Kreuzungen (Grad ≠ 2) je Zusammenhangskomponente;
    ein reiner Ring aus lauter Grad-2-Knoten wird einmal umlaufen."""
    paths, seen = [], set()
    for component in nx.connected_components(graph):
        sub = graph.subgraph(component)
        branch_nodes = [n for n in sub if sub.degree(n) != 2]
        if not branch_nodes:
            start = next(iter(component))
            ring = list(nx.dfs_preorder_nodes(sub, start))
            ring.append(start)
            paths.append(ring)
            continue
        for start in branch_nodes:
            for first in sub.neighbors(start):
                if frozenset((start, first)) in seen:
                    continue
                path, prev, current = [start, first], start, first
                seen.add(frozenset((start, first)))
                while sub.degree(current) == 2:
                    nxt = next(n for n in sub.neighbors(current) if n != prev)
                    seen.add(frozenset((current, nxt)))
                    path.append(nxt)
                    prev, current = current, nxt
                paths.append(path)
    return paths
  • Step 4: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_vectors.py -v Expected: PASS, 5 Tests

  • Step 5: Commit
git add tools/extract/leonida/vectors.py tools/extract/tests/test_vectors.py
git commit -m "feat(extract): Küste, Straßenflächen, Brückenerkennung, Mittellinien

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 5: Gebäude, Höhen und Palmenpunkte

Files:

  • Create: tools/extract/leonida/buildings.py, tools/extract/leonida/heights.py, tools/extract/tests/test_buildings.py, tools/extract/tests/test_heights.py
  • Test: tools/extract/tests/test_buildings.py, tools/extract/tests/test_heights.py

Interfaces:

  • Consumes: mask_polygons, centerlines aus Task 4, CLASSES aus Task 3

  • Produces: regularize(poly) -> Polygon | None, building_polygons(labels) -> list[tuple[Polygon, bool]] (Polygon, spekulativ), assign_heights(polys, landmarks, districts, seed) -> list[dict] (je Gebäude height und source), palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys, seed) -> list[tuple[float, float]]

  • Step 1: Den fehlschlagenden Test für Gebäude schreiben

tools/extract/tests/test_buildings.py:

import numpy as np
import pytest
from shapely.geometry import Polygon
from leonida.buildings import building_polygons, regularize
from leonida.classify import CLASSES

def test_rough_rectangle_snaps_to_rectangle():
    poly = Polygon([(0, 0), (10.4, 0.3), (10.1, 20.2), (-0.2, 19.8)])
    out = regularize(poly)
    assert len(out.exterior.coords) == 5            # geschlossenes Rechteck
    assert abs(out.area - 205) < 25

@pytest.mark.filterwarnings("ignore:.*oriented_envelope.*:RuntimeWarning")
def test_l_shape_is_kept_not_boxed():
    """Das Assert unten ruft minimum_rotated_rectangle selbst auf und löst damit die
    harmlose Shapely-RuntimeWarning aus (Winkelterm 0/0, Rechteck korrekt). Nur dieser
    eine Test filtert sie — nicht die Suite, sonst wäre auch eine echte Division durch
    Null künftig unsichtbar."""
    poly = Polygon([(0, 0), (30, 0), (30, 10), (10, 10), (10, 30), (0, 30)])
    out = regularize(poly)
    assert out.area < 0.8 * out.minimum_rotated_rectangle.area

def test_tiny_polygon_is_dropped():
    assert regularize(Polygon([(0, 0), (2, 0), (2, 2), (0, 2)])) is None

def test_two_buildings_are_separated():
    labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
    labels[10:25, 10:25] = CLASSES["gebaeude"]
    labels[35:50, 35:50] = CLASSES["gebaeude"]
    assert len(building_polygons(labels)) == 2

def test_small_staircase_building_is_not_dropped():
    """regularize darf nicht doppelt vereinfachen.

    mask_polygons liefert Kandidaten bereits mit 0,5 m simplify und mindestens MIN_AREA.
    Ein Grundriss mit gestuftem Eck, der die Rechteckquote verfehlt, wurde bisher ein
    zweites Mal und gröber (1,0 m) vereinfacht und damit unter MIN_AREA gedrückt. Am
    echten Mosaik betraf das 202 von 2.085 Gebäuden.
    """
    labels = np.full((40, 40), CLASSES["stadt"], dtype=np.uint8)
    block = np.array([
        [1, 1, 1, 0],
        [1, 1, 1, 1],
        [1, 1, 1, 1],
        [0, 1, 1, 1],
    ], dtype=bool)
    labels[10:14, 10:14][block] = CLASSES["gebaeude"]
    assert len(building_polygons(labels)) == 1

def test_speculative_flag_matches_pixel_class():
    """Nur aus 'spekulativ' (rot) entstandene Grundrisse sind spekulativ."""
    labels = np.full((60, 60), CLASSES["stadt"], dtype=np.uint8)
    labels[10:25, 10:25] = CLASSES["gebaeude"]      # grau -> kleineres Welt-x
    labels[35:50, 35:50] = CLASSES["spekulativ"]    # rot -> größeres Welt-x
    result = building_polygons(labels)
    assert len(result) == 2
    result.sort(key=lambda item: item[0].centroid.x)
    (_, spec_from_gebaeude), (_, spec_from_spekulativ) = result
    assert spec_from_gebaeude is False
    assert spec_from_spekulativ is True
  • Step 2: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_buildings.py -v Expected: FAIL, ModuleNotFoundError: No module named 'leonida.buildings'

  • Step 3: leonida/buildings.py schreiben
import numpy as np
from shapely.geometry import Polygon

from .classify import CLASSES
from .vectors import mask_polygons

MIN_AREA = 12.0
RECT_COVERAGE = 0.85


def regularize(poly: Polygon) -> Polygon | None:
    """Rechteck, wenn das Polygon sein gedrehtes Hüllrechteck gut ausfüllt; sonst vereinfacht."""
    poly = poly.buffer(0)
    if poly.geom_type != "Polygon" or poly.area < MIN_AREA:
        return None
    rect = poly.minimum_rotated_rectangle
    if rect.area > 0 and poly.area / rect.area >= RECT_COVERAGE:
        return rect
    # Gleiche Toleranz wie mask_polygons: ein zweiter, gröberer Durchgang schrumpfte
    # kleine Grundrisse unter MIN_AREA und verwarf am echten Mosaik 202 von 2.085.
    return poly.simplify(0.5)


def building_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
    out = []
    for name, speculative in (("gebaeude", False), ("spekulativ", True)):
        mask = labels == CLASSES[name]
        for poly in mask_polygons(mask, min_area=MIN_AREA, simplify=0.5):
            reg = regularize(poly)
            if reg is not None:
                out.append((reg, speculative))
    return out

Hinweis: spekulativ liefert sowohl rote Straßen als auch rote Gebäude. Die roten Flächen, die road_polygons bereits als Straße übernommen hat, werden in Task 6 beim Zusammenschreiben abgezogen (Flächen, die zu über 50 % in einem Straßenpolygon liegen, gelten als Straße).

  • Step 4: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_buildings.py -v Expected: PASS, 4 Tests

  • Step 5: Den fehlschlagenden Test für Höhen schreiben

tools/extract/tests/test_heights.py:

from shapely.geometry import Polygon
from leonida.heights import DISTRICT_RANGES, assign_heights

SQUARE = Polygon([(0, 0), (20, 0), (20, 20), (0, 20)])
FAR = Polygon([(500, 500), (520, 500), (520, 520), (500, 520)])

def test_landmark_height_wins():
    res = assign_heights([SQUARE], landmarks=[{"xyz": [10, 10, 0], "height": 180.0}],
                         districts=[], seed=1)
    assert res[0]["height"] == 180.0
    assert res[0]["source"] == "skyline"

def test_district_range_is_used_without_landmark():
    res = assign_heights([SQUARE], landmarks=[],
                         districts=[{"name": "Downtown", "x": 5, "y": 5}], seed=1)
    lo, hi = DISTRICT_RANGES["Downtown"]
    assert lo <= res[0]["height"] <= hi
    assert res[0]["source"] == "district"

def test_same_seed_gives_same_height():
    args = dict(landmarks=[], districts=[{"name": "Downtown", "x": 5, "y": 5}], seed=42)
    assert assign_heights([SQUARE], **args)[0]["height"] == assign_heights([SQUARE], **args)[0]["height"]

def test_unknown_district_falls_back_to_default():
    res = assign_heights([FAR], landmarks=[], districts=[], seed=3)
    lo, hi = DISTRICT_RANGES["default"]
    assert lo <= res[0]["height"] <= hi
  • Step 6: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_heights.py -v Expected: FAIL, ModuleNotFoundError: No module named 'leonida.heights'

  • Step 7: leonida/heights.py schreiben
import hashlib
import math

from shapely.geometry import Point, Polygon

DISTRICT_RANGES = {
    "Downtown": (20.0, 60.0),
    "Ocean Beach": (8.0, 25.0),
    "default": (6.0, 15.0),
}
LANDMARK_SNAP = 20.0      # Meter: so weit darf ein Wahrzeichenpunkt vom Grundriss entfernt liegen


def _seeded_unit(seed: int, key: str) -> float:
    digest = hashlib.sha256(f"{seed}:{key}".encode()).digest()
    return int.from_bytes(digest[:8], "big") / 2 ** 64


def assign_heights(polys: list[Polygon], landmarks: list[dict],
                   districts: list[dict], seed: int) -> list[dict]:
    out = []
    for poly in polys:
        centroid = poly.centroid
        key = f"{centroid.x:.1f},{centroid.y:.1f}"
        hit = None
        for lm in landmarks:
            p = Point(lm["xyz"][0], lm["xyz"][1])
            if poly.contains(p) or poly.distance(p) <= LANDMARK_SNAP:
                if hit is None or lm["height"] > hit["height"]:
                    hit = lm
        if hit is not None:
            out.append({"height": float(hit["height"]), "source": "skyline"})
            continue
        name = "default"
        best = math.inf
        for d in districts:
            dist = math.dist((centroid.x, centroid.y), (d["x"], d["y"]))
            if dist < best:
                best, name = dist, d["name"] if d["name"] in DISTRICT_RANGES else "default"
        lo, hi = DISTRICT_RANGES[name]
        # Kleine Grundrisse bleiben niedriger: Fläche skaliert den oberen Rand.
        area_factor = min(1.0, poly.area / 400.0)
        height = lo + (hi - lo) * _seeded_unit(seed, key) * (0.4 + 0.6 * area_factor)
        out.append({"height": round(height, 1), "source": "district"})
    return out
  • Step 8: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest -v Expected: PASS, alle Tests

  • Step 9: Den fehlschlagenden Test für Palmenpunkte schreiben

An tools/extract/tests/test_heights.py anfügen:

from shapely.geometry import LineString, Point
from leonida.heights import PALM_KERB, palm_points

def test_no_palm_stands_on_the_carriageway():
    """Der Versatz muss vom Fahrbahnrand her gedacht werden, nicht von einem festen Wert.

    Am echten Mosaik hat die Fahrbahn an den Mittellinienpunkten p50 5,4 m, p90 11,7 m
    und p95 14,0 m Halbbreite. Ein fester Versatz von 4 m setzt 60 % der Palmen auf die
    Fahrbahn, einer von 11 m immer noch 12,5 % – und auf der Hälfte der Straßen stünde
    die Palme dann im Nachbargebäude.
    """
    road = Polygon([(0, -9), (200, -9), (200, 9), (0, 9)])        # 18 m breite Fahrbahn
    line = LineString([(0, 0), (200, 0)])
    sand = Polygon([(0, 20), (200, 20), (200, 40), (0, 40)])
    pts = palm_points([sand], [], [line], [], [road], [], seed=1)
    assert pts
    assert all(road.distance(Point(x, y)) >= PALM_KERB for x, y in pts)

def test_palms_do_not_pile_up_where_centerlines_meet():
    """Mittellinien sind an Kreuzungen getrennt (Task 4). Ein Start bei Position 0 setzt
    auf zwei gegenüberliegenden Ästen zwei Palmen in genau denselben Punkt."""
    sand = Polygon([(-80, 20), (80, 20), (80, 40), (-80, 40)])
    arms = [LineString([(0, 0), (60, 0)]), LineString([(0, 0), (-60, 0)])]
    pts = palm_points([sand], [], arms, [], [], [], seed=1)
    assert pts
    assert len(set(pts)) == len(pts)

def test_no_palm_stands_in_the_water():
    """Der Versatz nach außen schiebt Palmen an Küstenstraßen über die Uferlinie.
    Am echten Mosaik standen so 218 von 2.433 Palmen im Wasser."""
    land = Polygon([(-100, -100), (100, -100), (100, 0), (-100, 0)])   # Ufer bei y = 0
    road = Polygon([(-100, -12), (100, -12), (100, 0), (-100, 0)])
    line = LineString([(-100, -6), (100, -6)])
    sand = Polygon([(-100, 0), (100, 0), (100, 10), (-100, 10)])
    pts = palm_points([sand], [], [line], [], [road], [land], seed=1)
    assert pts
    assert all(land.contains(Point(x, y)) for x, y in pts)

def test_park_scatter_is_deterministic_and_inside_the_park():
    """Ohne Sperrflächen verstreut ein Park reproduzierbare Punkte."""
    park = Polygon([(0, 0), (50, 0), (50, 50), (0, 50)])   # 2500 m² -> 10 Punkte
    pts_a = palm_points([], [park], [], [], [], [], seed=7)
    pts_b = palm_points([], [park], [], [], [], [], seed=7)
    assert pts_a
    assert all(park.contains(Point(x, y)) for x, y in pts_a)
    assert pts_a == pts_b
  • Step 10: Palmenpunkte ergänzen

An leonida/heights.py anfügen:

PALM_SPACING = 12.0        # Meter entlang der Mittellinie
PALM_BEACH_RANGE = 40.0    # Meter Abstand zur nächsten Sandfläche
PALM_CLEARANCE = 6.0       # Meter Abstand zu Gebäuden
PALM_KERB = 1.5            # Meter Abstand zum Fahrbahnrand
# Versatzkandidaten von innen nach außen; genommen wird der erste, der Fahrbahn und
# Gebäude freihält. Ein fester Wert geht nicht: die Fahrbahn ist an den Mittellinien-
# punkten p50 5,4 m, p90 11,7 m, p95 14,0 m halbbreit (am echten Mosaik gemessen).
PALM_VERGE = (6.0, 8.0, 10.0, 12.0, 14.0, 16.0)


def _verge_point(line, p, side: str, roads, buildings, land):
    """Erster Versatz von innen nach außen, der Fahrbahn und Gebäude freihält und
    auf Land bleibt (sonst schiebt der Versatz Küstenpalmen ins Wasser)."""
    for dist in PALM_VERGE:
        off = line.parallel_offset(dist, side)
        if off.is_empty:
            continue
        q = off.interpolate(off.project(p))
        if roads is not None and q.distance(roads) < PALM_KERB:
            continue
        if buildings is not None and q.distance(buildings) < PALM_CLEARANCE:
            continue
        if land is not None and not land.contains(q):
            continue
        return [(round(q.x, 2), round(q.y, 2))]
    return []


def palm_points(sand_polys, park_polys, road_lines, building_polys, road_polys,
                land_polys, seed: int):
    """Palmen entlang strandnaher Mittellinien und verstreut in Parks."""
    from shapely.ops import unary_union
    sand = unary_union(sand_polys) if sand_polys else None
    roads = unary_union(list(road_polys)) if road_polys else None
    buildings = unary_union(list(building_polys)) if building_polys else None
    land = unary_union(land_polys) if land_polys else None
    blocked = unary_union([g for g in (roads, buildings) if g is not None]) \
        if (roads is not None or buildings is not None) else None
    points = []
    for line in road_lines:
        if sand is None or line.distance(sand) > PALM_BEACH_RANGE:
            continue
        # Start bei halber Teilung: die Mittellinien sind an Kreuzungen getrennt, ein
        # Start bei 0 setzt je Ast eine Palme in denselben Punkt.
        pos = min(PALM_SPACING / 2, line.length / 2)
        while pos <= line.length:
            p = line.interpolate(pos)
            for side in ("left", "right"):
                points.extend(_verge_point(line, p, side, roads, buildings, land))
            pos += PALM_SPACING
    for park in park_polys:
        minx, miny, maxx, maxy = park.bounds
        n = int(park.area // 250)
        for k in range(n):
            u = _seeded_unit(seed, f"park:{minx:.0f}:{k}:x")
            v = _seeded_unit(seed, f"park:{miny:.0f}:{k}:y")
            q = Point(minx + u * (maxx - minx), miny + v * (maxy - miny))
            if park.contains(q) and (blocked is None or q.distance(blocked) > PALM_CLEARANCE):
                points.append((round(q.x, 2), round(q.y, 2)))
    return list(dict.fromkeys(points))
  • Step 11: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest -v Expected: PASS, alle Tests

  • Step 12: Commit
git add tools/extract/leonida/buildings.py tools/extract/leonida/heights.py tools/extract/tests
git commit -m "feat(extract): Gebäudepolygone, Höhen aus Wahrzeichen und Vierteln, Palmenpunkte

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 6: Chunk-Ausgabe, Manifest, Debug-Bilder

Files:

  • Create: tools/extract/leonida/emit.py, tools/extract/run.py, tools/extract/tests/test_emit.py
  • Test: tools/extract/tests/test_emit.py

Interfaces:

  • Consumes: alles aus Task 3 bis 5
  • Produces: public/data/vice-a/manifest.json und public/data/vice-a/chunk_{i}_{j}.json. Schema eines Chunks:
{
  "chunk": [3, 7],
  "origin": [-250, 750],
  "land": [[[x, z], ...]],
  "surfaces": [{"poly": [[x, z], ...], "kind": "sand"}],
  "roads": [{"poly": [[x, z], ...], "bridge": false}],
  "centerlines": [[[x, z], ...]],
  "buildings": [{"poly": [[x, z], ...], "height": 24.5, "source": "district", "speculative": false}],
  "palms": [[x, z], ...]
}

Alle Koordinaten sind three.js-Koordinaten relativ zur Chunk-Ecke, in Metern, auf zwei Stellen gerundet.

  • Step 1: Den fehlschlagenden Test schreiben

tools/extract/tests/test_emit.py:

import json
from shapely.geometry import Polygon
from leonida.emit import chunk_payload, split_by_chunk

BIG = Polygon([(-1000, 2500), (-700, 2500), (-700, 2200), (-1000, 2200)])   # Chunk (0,0) und Nachbarn

def test_polygon_is_clipped_into_its_chunks():
    parts = split_by_chunk(BIG)
    assert set(parts) >= {(0, 0), (1, 0), (0, 1), (1, 1)}
    assert abs(sum(p.area for p in parts.values()) - BIG.area) < 1.0

def test_coordinates_are_chunk_local_three_js():
    payload = chunk_payload(0, 0, land=[BIG], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[])
    ring = payload["land"][0]
    assert payload["origin"] == [-1000, -2500]          # three-z = -Y
    assert min(x for x, _ in ring) >= -0.01
    assert max(x for x, _ in ring) <= 250.01

def test_payload_matches_schema():
    payload = chunk_payload(2, 3, land=[], surfaces=[], roads=[], centerlines=[], buildings=[], palms=[])
    assert set(payload) == {"chunk", "origin", "land", "surfaces", "roads", "centerlines", "buildings", "palms"}
    assert payload["chunk"] == [2, 3]
  • Step 2: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_emit.py -v Expected: FAIL, ModuleNotFoundError: No module named 'leonida.emit'

  • Step 3: leonida/emit.py schreiben
import json
from pathlib import Path

from shapely.geometry import Polygon, box

from .coords import CHUNK_SIZE, SLICE

N = (SLICE["x1"] - SLICE["x0"]) // CHUNK_SIZE   # 12


def chunk_box(i: int, j: int) -> Polygon:
    x0 = SLICE["x0"] + i * CHUNK_SIZE
    y1 = SLICE["y1"] - j * CHUNK_SIZE
    return box(x0, y1 - CHUNK_SIZE, x0 + CHUNK_SIZE, y1)


def split_by_chunk(poly: Polygon) -> dict[tuple[int, int], Polygon]:
    out = {}
    for j in range(N):
        for i in range(N):
            part = poly.intersection(chunk_box(i, j))
            if not part.is_empty and part.area > 0.5:
                out[(i, j)] = part
    return out


def _ring(poly: Polygon, i: int, j: int) -> list[list[float]]:
    ox = SLICE["x0"] + i * CHUNK_SIZE
    oy = SLICE["y1"] - j * CHUNK_SIZE
    # Welt (X, Y) -> three (x, z) -> relativ zur Chunk-Ecke
    return [[round(x - ox, 2), round(-y - (-oy), 2)] for x, y in poly.exterior.coords]


def chunk_payload(i, j, *, land, surfaces, roads, centerlines, buildings, palms) -> dict:
    ox = SLICE["x0"] + i * CHUNK_SIZE
    oy = SLICE["y1"] - j * CHUNK_SIZE
    return {
        "chunk": [i, j],
        "origin": [ox, -oy],
        "land": [_ring(p, i, j) for p in land],
        "surfaces": [{"poly": _ring(p, i, j), "kind": k} for p, k in surfaces],
        "roads": [{"poly": _ring(p, i, j), "bridge": b} for p, b in roads],
        "centerlines": [[[round(x - ox, 2), round(-y + oy, 2)] for x, y in ln.coords] for ln in centerlines],
        "buildings": [{"poly": _ring(p, i, j), "height": h["height"],
                       "source": h["source"], "speculative": s} for p, h, s in buildings],
        "palms": [[round(x - ox, 2), round(-y + oy, 2)] for x, y in palms],
    }


def chunk_box_of(i: int, j: int) -> Polygon:
    return chunk_box(i, j)


def split_by_chunk_all(polys, i: int, j: int):
    """Alle Anteile der übergebenen Polygone, die in Chunk (i, j) liegen."""
    cell = chunk_box(i, j)
    for poly in polys:
        part = poly.intersection(cell)
        if not part.is_empty and part.area > 0.5:
            if part.geom_type == "Polygon":
                yield part
            else:
                yield from (g for g in part.geoms if g.geom_type == "Polygon" and g.area > 0.5)


def in_chunk(point, i: int, j: int) -> bool:
    x0 = SLICE["x0"] + i * CHUNK_SIZE
    y1 = SLICE["y1"] - j * CHUNK_SIZE
    return x0 <= point[0] < x0 + CHUNK_SIZE and y1 - CHUNK_SIZE <= point[1] < y1


def write_debug_png(out_dir: Path, mosaic, payloads: list[dict]) -> None:
    """Ein Bild je Chunk: die extrahierten Vektoren über der Karte, zur Sichtprüfung."""
    from PIL import Image, ImageDraw
    out_dir.mkdir(parents=True, exist_ok=True)
    for payload in payloads:
        i, j = payload["chunk"]
        x0, y0 = i * CHUNK_SIZE, j * CHUNK_SIZE
        crop = Image.fromarray(mosaic[y0:y0 + CHUNK_SIZE, x0:x0 + CHUNK_SIZE]).convert("RGB")
        draw = ImageDraw.Draw(crop)
        for ring in payload["land"]:
            draw.line([tuple(pt) for pt in ring] + [tuple(ring[0])], fill=(0, 255, 255), width=1)
        for road in payload["roads"]:
            draw.line([tuple(pt) for pt in road["poly"]], fill=(255, 0, 255), width=1)
        for b in payload["buildings"]:
            if len(b["poly"]) >= 3:
                draw.polygon([tuple(pt) for pt in b["poly"]], outline=(255, 220, 0))
        for x, z in payload["palms"]:
            draw.ellipse([x - 2, z - 2, x + 2, z + 2], outline=(0, 200, 0))
        crop.save(out_dir / f"chunk_{i}_{j}.png")


def write_chunks(out_dir: Path, payloads: list[dict], meta: dict) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    for payload in payloads:
        i, j = payload["chunk"]
        (out_dir / f"chunk_{i}_{j}.json").write_text(json.dumps(payload, separators=(",", ":")))
    (out_dir / "manifest.json").write_text(json.dumps(meta, indent=1))
  • Step 4: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_emit.py -v Expected: PASS, 3 Tests

  • Step 5: run.py als Ablaufsteuerung schreiben
"""Erzeugt aus den Kacheln die Chunk-Dateien. Aufruf: uv run python run.py"""
import json
from pathlib import Path

from leonida.buildings import building_polygons
from leonida.classify import CLASSES, class_shares, classify
from leonida.emit import (chunk_box_of, chunk_payload, in_chunk, split_by_chunk_all,
                          write_chunks, write_debug_png)
from leonida.heights import assign_heights, palm_points
from leonida.tiles import COMMIT, download_tiles, load_mosaic
from leonida.vectors import centerlines, land_polygons, mask_polygons, road_polygons

SEED = 20260912
OUT = Path(__file__).resolve().parents[2] / "public" / "data" / "vice-a"
TOLERANCE = {"wasser": (0.30, 0.36), "strasse": (0.10, 0.14), "gebaeude": (0.09, 0.13)}


def main() -> None:
    cache = Path("cache")
    download_tiles(cache)
    mosaic = load_mosaic(cache)
    labels, unknown_share = classify(mosaic, report_unknown=True)
    if unknown_share > 0.05:
        raise SystemExit(f"{unknown_share:.1%} der Pixel unbekannt. Palette kalibrieren, nicht die Grenze anheben.")
    shares = class_shares(labels)
    for name, (lo, hi) in TOLERANCE.items():
        if not lo <= shares[name] <= hi:
            raise SystemExit(f"Klassenanteil {name}={shares[name]:.3f} außerhalb {lo}..{hi}. Palette prüfen.")

    land = land_polygons(labels)
    roads = road_polygons(labels)
    lines = centerlines(labels)
    sand = mask_polygons(labels == CLASSES["sand"], 50, 1.0)
    parks = mask_polygons(labels == CLASSES["gruen"], 50, 1.0)
    urban = mask_polygons(labels == CLASSES["stadt"], 80, 1.5)
    surfaces = [(p, "sand") for p in sand] + [(p, "park") for p in parks] + [(p, "urban") for p in urban]

    road_shapes = [p for p, _ in roads]
    raw = building_polygons(labels)
    buildings = [(p, s) for p, s in raw
                 if not any(p.intersection(r).area > 0.5 * p.area for r in road_shapes)]

    landmarks = json.loads(Path("data/skyline.json").read_text())["landmarks"]
    districts = json.loads(Path("data/districts.json").read_text())
    heights = assign_heights([p for p, _ in buildings], landmarks, districts, SEED)
    palms = palm_points(sand, parks, lines, [p for p, _ in buildings], road_shapes,
                        land, SEED)

    payloads = []
    for j in range(12):
        for i in range(12):
            payloads.append(chunk_payload(
                i, j,
                land=list(split_by_chunk_all(land, i, j)),
                surfaces=[(part, kind) for (p, kind) in surfaces for part in split_by_chunk_all([p], i, j)],
                roads=[(part, b) for (p, b) in roads for part in split_by_chunk_all([p], i, j)],
                centerlines=[ln for ln in lines if ln.intersects(chunk_box_of(i, j))],
                buildings=[(p, h, s) for (p, s), h in zip(buildings, heights)
                           if p.intersects(chunk_box_of(i, j))],
                palms=[pt for pt in palms if in_chunk(pt, i, j)],
            ))
    write_debug_png(Path("debug"), mosaic, payloads)
    write_chunks(OUT, payloads, {
        "slice": "vice-a", "source_commit": COMMIT, "seed": SEED,
        "chunk_size": 250, "chunks": [p["chunk"] for p in payloads],
        "class_shares": shares,
        "attribution": "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. Wahrzeichen: rolux/gtamaplib-vc (Unlicense).",
    })


if __name__ == "__main__":
    main()

Die Hilfsfunktionen split_by_chunk_all, chunk_box_of und in_chunk sind dünne Hüllen um split_by_chunk bzw. chunk_box aus emit.py; beim Umsetzen dort ergänzen, damit run.py keine eigene Geometrie-Logik enthält.

Die beiden Datendateien liegen bereits unter tools/extract/data/ im Repository und werden in diesem Schritt nicht heruntergeladen — sie sind einmalig erzeugt und mit committet worden, weil an ihnen die Lizenzlage hängt:

data/skyline.json enthält die 54 Wahrzeichen, die in den Ausschnitt fallen, aus game/landmarks/landmarks.json von rolux/gtamaplib-vc bei Commit 98b858fe6cbb3ea0bffa94aa53d86c1d6b0d5a02. Das Repository steht unter der Unlicense (Public Domain; GitHub erkennt die Datei nicht automatisch, der Text ist wörtlich die Unlicense). Übernommen sind nur name, id, xyz und height — die Texturen des Quell-Repos sind Spiel-Screenshots und bleiben ausgeschlossen. height ist dort identisch mit xyz[2], also die Höhe der angepeilten Spitze über Null; genau das liest assign_heights. Die Namen sind echte Miami-Bauten, weil gtamaplib die Karte gegen reale Referenzen trianguliert.

data/districts.json ist selbst erstellt, nicht extrahiert: in gtadb gibt es keine Viertelgrenzen. Die beiden Kerne sind aus den Wahrzeichen abgeleitet — Downtown als Mittelpunkt der 11 westlichen Wahrzeichen ab 150 m Höhe, Ocean Beach als Mittelpunkt der 27 Wahrzeichen auf der Nehrung. Dazu fünf Mittelpunkte Suburbs auf dem Raster x ∈ {−750, 250, 1250}, y ∈ {−250, 750, 1750}, wobei Rasterpunkte näher als 900 m an einem Kern entfallen. Suburbs steht absichtlich nicht in DISTRICT_RANGES und fällt damit auf default zurück — ohne diese Punkte wäre jedes Gebäude des Festlands dem Downtown-Kern am nächsten und die Vorstadt bekäme 20-60 m.

Gemessen an den 2.352 echten Grundrissen: 68 Gebäude bekommen eine Wahrzeichenhöhe (31-248 m, Median 114,5), 279 fallen auf Downtown (20-59 m, Median 29,6), 543 auf Ocean Beach (8-25 m, Median 12,5), 1.462 auf Suburbs (6-15 m, Median 8,4). Gesamtprofil p50 9,4 m, p90 27,2 m.

  • Step 6: Durchlauf und Sichtprüfung

Run: cd tools/extract && uv run python run.py && ls ../../public/data/vice-a | head Expected: 144 Chunk-Dateien und ein Manifest, zusammen unter 10 MB.

  • Step 7: Commit
git add tools/extract public/data/vice-a
git commit -m "feat(extract): Chunk-Ausgabe, Manifest und Ablaufsteuerung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 6b: Kartenbeschriftung aus der Klassifikation entfernen

Warum diese Aufgabe existiert: Die Sichtprüfung der Gesamtansicht nach Task 6 hat gezeigt, dass die Beschriftungen der Originalkarte zu Geometrie werden. "VICE CITY (MIAMI)" steht als rund 1.600 m breiter Schriftzug aus Straßenfläche mitten in der Bucht, die roten Ortsnamen (STARFISH ISLAND, CATALAN BAY, PRAWN ISLAND) werden zu Brücken und spekulativen Gebäuden. Task 7 extrudiert genau diese Daten zu Meshes; ohne diese Aufgabe stehen die Buchstaben als Mauern in der Welt. Die 195 auffällig langen, dünnen "Gebäude" aus der Messung zu Task 5 (eines 586 m lang bei nur 1.576 m² Fläche, eines 384 m lang bei 8 m Breite) sind dieselbe Ursache.

Files:

  • Modify: tools/extract/leonida/classify.py
  • Test: tools/extract/tests/test_classify.py

Interfaces:

  • Consumes: CLASSES, classify(rgb) aus Task 3
  • Produces: drop_map_labels(labels: np.ndarray) -> np.ndarray — gibt ein neues Label-Bild zurück, in dem als Beschriftung erkannte Bereiche auf CLASSES["wasser"] stehen. run.py ruft es unmittelbar nach classify auf.

Was schon ausprobiert und widerlegt ist. Diese drei Wege sind gemessen und führen nicht zum Ziel; probier sie nicht noch einmal:

  1. Buntheit. Die als strasse klassifizierten Schriftpixel über Wasser haben Medianfarbe (47, 79, 102), Buntheit p50 49 gegen p50 0 bei echten Straßen. Eine Schwelle von 30 entfernt aber nur 74 % der verdächtigen Pixel und dabei 5,8 % echter Straßenpixel.
  2. Farbton. Die Palette trennt sich sauber (Wasser 201°, Gras 92°, Sand 53°, Stadt und Straße und Gebäude neutral), aber die Regel erfasst nur 5.551 von 64.125 Nicht-Wasser- Pixeln im Schriftzug: die hellen Buchstabenkörper sind farblich identisch mit echten Stadtpixeln. Farbe allein kann das nicht lösen.
  3. Zusammenhang nur der Straßenmaske. Scheitert daran, dass die hellen Teile der Buchstaben als stadt klassifiziert werden und damit selbst "Land" um sich herum erzeugen.

Der Weg, der trägt. Auf Regionenebene über alle Nicht-Wasser-Klassen gemeinsam. Gemessen am echten Mosaik: ~wasser zerfällt in 52 Komponenten. Die Hauptlandmasse hat 5.852.263 px; die Buchstaben bilden eigene Komponenten von 4.876 bis 14.760 px, und ihr Anteil natürlicher Merkmale (gruen, sand, land) ist 0,0 %. Echte Inseln haben Grün oder Sand, Buchstaben nicht. Die Regel "nicht die Hauptlandmasse, höchstens 2 % natürliche Merkmale" entfernt 106.800 px (1,77 % aller Nicht-Wasser-Pixel) und darin 63.199 von 64.125 Pixeln des Schriftzugs, also 98,6 %. Das Ergebnis ist zwischen den Größenschwellen 20.000 und 150.000 px identisch — die Trennung ist also nicht auf einen Schwellenwert gefittet.

  • Step 1: Den fehlschlagenden Test schreiben

An tools/extract/tests/test_classify.py anfügen:

from leonida.classify import drop_map_labels

def test_letter_shaped_island_becomes_water():
    """Ein Buchstabe im Wasser ist Beschriftung, keine Stadt: keine natuerlichen Merkmale."""
    labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
    labels[40:60, 40:120] = CLASSES["stadt"]          # Querbalken
    labels[40:120, 40:60] = CLASSES["strasse"]        # Laengsbalken -> zusammen ein "L"
    out = drop_map_labels(labels)
    assert (out == CLASSES["wasser"]).all()

def test_real_island_survives():
    """Eine Insel mit Gruen und Sand bleibt, auch wenn sie klein und vom Festland getrennt ist."""
    labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
    labels[40:120, 40:120] = CLASSES["stadt"]
    labels[50:70, 50:70] = CLASSES["gruen"]
    labels[100:115, 45:115] = CLASSES["sand"]
    out = drop_map_labels(labels)
    assert (out[40:120, 40:120] != CLASSES["wasser"]).all()

def test_mainland_is_never_dropped():
    """Die groesste Nicht-Wasser-Komponente bleibt immer, auch voellig ohne Gruen und Sand."""
    labels = np.full((200, 200), CLASSES["wasser"], dtype=np.uint8)
    labels[10:190, 10:100] = CLASSES["stadt"]
    out = drop_map_labels(labels)
    assert (out[10:190, 10:100] == CLASSES["stadt"]).all()
  • Step 2: Test laufen lassen, er muss scheitern

Run: cd tools/extract && uv run pytest tests/test_classify.py -v Expected: FAIL, ImportError: cannot import name 'drop_map_labels'

  • Step 3: drop_map_labels in leonida/classify.py ergänzen
import numpy as np
from scipy import ndimage as ndi

NATURAL_CLASSES = ("gruen", "sand", "land")
LABEL_MAX_NATURAL = 0.02     # Anteil natuerlicher Merkmale, ab dem eine Flaeche als echt gilt


def drop_map_labels(labels: np.ndarray) -> np.ndarray:
    """Beschriftungen der Originalkarte auf Wasser zuruecksetzen.

    Die Schriftzuege der Karte werden pixelweise als stadt/strasse/gebaeude klassifiziert und
    sind farblich von echten Flaechen nicht zu unterscheiden. Trennbar sind sie erst als
    Region: ein Schriftzug im Wasser bildet eine eigene, vom Festland getrennte Nicht-Wasser-
    Komponente ohne jedes natuerliche Merkmal, waehrend eine echte Insel Gruen oder Sand
    traegt. Die groesste Komponente ist immer das Festland und bleibt unangetastet.
    """
    solid = labels != CLASSES["wasser"]
    if not solid.any():
        return labels
    components, count = ndi.label(solid, structure=np.ones((3, 3)))
    sizes = ndi.sum(solid, components, range(1, count + 1))
    mainland = int(np.argmax(sizes)) + 1
    natural = np.zeros(labels.shape, dtype=bool)
    for name in NATURAL_CLASSES:
        natural |= labels == CLASSES[name]
    shares = ndi.mean(natural.astype(np.float32), components, range(1, count + 1))
    drop = np.zeros(count + 1, dtype=bool)
    for index in range(1, count + 1):
        drop[index] = index != mainland and shares[index - 1] <= LABEL_MAX_NATURAL
    out = labels.copy()
    out[drop[components]] = CLASSES["wasser"]
    return out
  • Step 4: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_classify.py -v Expected: PASS

  • Step 5: In run.py einhängen

In main() direkt nach labels = classify(mosaic) einfügen:

    labels = drop_map_labels(labels)

Der Import kommt zu der bestehenden Zeile aus leonida.classify dazu. Die Prüfung der Klassenanteile steht danach und misst damit die bereinigten Anteile — das ist gewollt.

  • Step 6: Durchlauf und Sichtprüfung

Run: cd tools/extract && uv run python run.py && uv run python overview.py Expected: 144 Chunks, und in debug/overview.png steht kein Schriftzug mehr in der Bucht.

Die Sichtprüfung ist hier die eigentliche Abnahme, nicht die Unit-Tests. Sieh dir das Bild an und beschreib, was du siehst: ob "VICE CITY (MIAMI)" verschwunden ist, ob die roten Ortsnamen weg sind, und vor allem, ob dabei etwas Echtes mitverschwunden ist — kleine Inseln, Molen, Hafenanlagen. Eine vollständig gepflasterte kleine Insel ohne Grün wäre der Fall, in dem die Regel zu viel nimmt. Wenn du so etwas findest, melde es mit Position und Größe, statt die Schwelle zu verschieben.

Beschriftungen, die auf dem Festland liegen (etwa "BOHEMIA (DESIGN DISTRICT)"), gehören zur Hauptlandmasse und bleiben bestehen. Das ist bewusst so: sie sind klein und stehen zwischen echten Gebäuden, statt als 1.600-m-Schriftzug im Wasser. Melde im Bericht, wie störend sie im Bild noch wirken.

  • Step 7: Commit
git add tools/extract/leonida/classify.py tools/extract/tests/test_classify.py \
        tools/extract/run.py public/data/vice-a
git commit -m "fix(extract): Kartenbeschriftung nicht mehr als Geometrie ausgeben

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 7: Geometrie-Baustein in JavaScript

Files:

  • Create: src/world/geometry.js, tests/geometry.test.js
  • Test: tests/geometry.test.js

Interfaces:

  • Consumes: Chunk-JSON aus Task 6
  • Produces: buildChunkGeometry(chunk) -> {ground, sand, park, urban, roads, bridges, buildings} (je eine BufferGeometry oder null), extrudePolygons(polygons, {base, top, uvScale}) -> BufferGeometry, flatPolygons(polygons, {y, uvScale}) -> BufferGeometry — je Fläche eine Liste von Ringen, Außenring zuerst

Reines JavaScript, nur three als Abhängigkeit, kein Zugriff auf document oder window — der Baustein muss in Node laufen.

  • Step 1: Den fehlschlagenden Test schreiben

tests/geometry.test.js:

import { describe, expect, it } from "vitest";
import { Box3, BufferGeometry } from "three";
import { buildChunkGeometry, extrudePolygons, flatPolygons } from "../src/world/geometry.js";

const SQUARE = [[0, 0], [10, 0], [10, 10], [0, 10]];
const PLOT = [SQUARE];                       // Fläche ohne Loch: nur der Außenring
const COURTYARD = [[[0, 0], [30, 0], [30, 30], [0, 30]],
                   [[10, 10], [20, 10], [20, 20], [10, 20]]];   // Block mit Innenhof

describe("geometry", () => {
    it("extrudiert ein Quadrat zu Wänden und Dach", () => {
        const geo = extrudePolygons([PLOT], { base: 0, top: 20, uvScale: 4 });
        // 4 Wände à 2 Dreiecke + Dach aus 2 Dreiecken
        expect(geo.getIndex().count / 3).toBe(10);
        const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
        expect(box.max.y).toBeCloseTo(20);
        expect(box.min.y).toBeCloseTo(0);
    });

    it("richtet Dachflächen nach oben aus", () => {
        const geo = extrudePolygons([PLOT], { base: 0, top: 5, uvScale: 4 });
        geo.computeVertexNormals();
        const n = geo.getAttribute("normal");
        const up = [...Array(n.count).keys()].filter((i) => n.getY(i) > 0.9);
        expect(up.length).toBeGreaterThan(0);
    });

    it("lässt den Innenhof eines Blocks offen", () => {
        // Außenring 30x30, Loch 10x10: 8 Wände à 2 Dreiecke, das Dach ist ein Ring aus
        // 8 Dreiecken. Ohne Lochbehandlung wäre der Hof zugedeckt und das Dach hätte 2.
        const geo = extrudePolygons([COURTYARD], { base: 0, top: 10, uvScale: 4 });
        expect(geo.getIndex().count / 3).toBe(24);
    });

    it("legt flache Flächen auf die gewünschte Höhe", () => {
        const geo = flatPolygons([PLOT], { y: 0.03, uvScale: 8 });
        const box = new Box3().setFromBufferAttribute(geo.getAttribute("position"));
        expect(box.min.y).toBeCloseTo(0.03);
        expect(box.max.y).toBeCloseTo(0.03);
    });

    it("baut alle Teile eines Chunks", () => {
        const chunk = {
            chunk: [0, 0], origin: [0, 0],
            land: [PLOT],
            surfaces: [{ poly: PLOT, kind: "sand" }, { poly: PLOT, kind: "park" }],
            roads: [{ poly: PLOT, bridge: false }, { poly: PLOT, bridge: true }],
            centerlines: [], palms: [],
            buildings: [{ poly: PLOT, height: 12, source: "district", speculative: false }],
        };
        const out = buildChunkGeometry(chunk);
        for (const key of ["ground", "sand", "park", "roads", "bridges", "buildings"]) {
            expect(out[key]).toBeInstanceOf(BufferGeometry);
        }
        expect(out.urban).toBeNull();
    });

    it("gibt Gebäuden eine Farbe je Haus", () => {
        const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [],
            buildings: [{ poly: PLOT, height: 9, source: "district", speculative: false }] };
        expect(buildChunkGeometry(chunk).buildings.getAttribute("color")).toBeTruthy();
    });

    it("hält alle Koordinaten im Chunk", () => {
        const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] };
        const box = new Box3().setFromBufferAttribute(buildChunkGeometry(chunk).ground.getAttribute("position"));
        expect(box.min.x).toBeGreaterThanOrEqual(-0.01);
        expect(box.max.x).toBeLessThanOrEqual(250.01);
    });
});
  • Step 2: Test laufen lassen, er muss scheitern

Run: npm test Expected: FAIL, Failed to resolve import "../src/world/geometry.js"

  • Step 3: src/world/geometry.js schreiben
import { BufferGeometry, Float32BufferAttribute, ShapeUtils } from "three";

const GROUND_THICKNESS = 1.0;
const SURFACE_OFFSET = 0.02;    // Sand, Park und Stadtfläche liegen knapp über dem Boden
const ROAD_OFFSET = 0.03;       // Straßen wiederum knapp über den Flächen
const BRIDGE_THICKNESS = 0.6;
export const SURFACE_KINDS = ["sand", "park", "urban"];

/** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */
function buildingTint(poly) {
    const [x, z] = poly[0][0];
    const h = Math.abs(Math.sin(x * 12.9898 + z * 78.233) * 43758.5453) % 1;
    return [0.82 + 0.18 * h, 0.80 + 0.20 * ((h * 3) % 1), 0.78 + 0.22 * ((h * 7) % 1)];
}

const signedArea = (ring) => ring.reduce((sum, [x0, z0], i) => {
    const [x1, z1] = ring[(i + 1) % ring.length];
    return sum + x0 * z1 - x1 * z0;
}, 0) / 2;

/** Ringe im Uhrzeigersinn, damit Wände nach außen zeigen. */
const clockwise = (ring) => (signedArea(ring) > 0 ? [...ring].reverse() : ring);

function toGeometry(pos, idx, uv, color) {
    const geo = new BufferGeometry();
    geo.setAttribute("position", new Float32BufferAttribute(pos, 3));
    geo.setAttribute("uv", new Float32BufferAttribute(uv, 2));
    if (color) geo.setAttribute("color", new Float32BufferAttribute(color, 3));
    geo.setIndex(idx);
    geo.computeVertexNormals();
    return geo;
}

export function flatPolygons(polygons, { y = 0, uvScale = 8 } = {}) {
    const pos = [], idx = [], uv = [];
    for (const raw of polygons) {
        const outer = clockwise(raw[0]);
        const holes = raw.slice(1).map((hole) => clockwise(hole).reverse());
        const base = pos.length / 3;
        for (const ring of [outer, ...holes]) {
            for (const [x, z] of ring) {
                pos.push(x, y, z);
                uv.push(x / uvScale, z / uvScale);
            }
        }
        const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z }));
        for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) {
            idx.push(base + tri[0], base + tri[2], base + tri[1]);   // nach oben gedreht
        }
    }
    return pos.length ? toGeometry(pos, idx, uv) : null;
}

export function extrudePolygons(polygons, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
    const pos = [], idx = [], uv = [], color = [];
    for (const raw of polygons) {
        // Jede Fläche ist eine Liste von Ringen: Außenring zuerst, danach die Löcher.
        // triangulateShape erwartet die Löcher gegenläufig zum Außenring.
        const outer = clockwise(raw[0]);
        const holes = raw.slice(1).map((hole) => clockwise(hole).reverse());
        for (const ring of [outer, ...holes]) {
            const n = ring.length;
            for (let i = 0; i < n; i++) {
                const [x0, z0] = ring[i];
                const [x1, z1] = ring[(i + 1) % n];
                const len = Math.hypot(x1 - x0, z1 - z0);
                const b = pos.length / 3;
                pos.push(x0, base, z0, x1, base, z1, x1, top, z1, x0, top, z0);
                uv.push(0, 0, len / uvScale, 0, len / uvScale, (top - base) / uvScale, 0, (top - base) / uvScale);
                if (tint) for (let k = 0; k < 4; k++) color.push(...tint);
                idx.push(b, b + 1, b + 2, b, b + 2, b + 3);
            }
        }
        // Deckfläche: erst alle Punkte in derselben Reihenfolge, die triangulateShape indiziert.
        const b = pos.length / 3;
        for (const ring of [outer, ...holes]) {
            for (const [x, z] of ring) {
                pos.push(x, top, z);
                uv.push(x / uvScale, z / uvScale);
                if (tint) color.push(...tint);
            }
        }
        const toPoints = (ring) => ring.map(([x, z]) => ({ x, y: z }));
        for (const tri of ShapeUtils.triangulateShape(toPoints(outer), holes.map(toPoints))) {
            idx.push(b + tri[0], b + tri[2], b + tri[1]);
        }
    }
    return pos.length ? toGeometry(pos, idx, uv, tint ? color : null) : null;
}

export function buildChunkGeometry(chunk) {
    const roads = chunk.roads.filter((r) => !r.bridge).map((r) => r.poly);
    const bridges = chunk.roads.filter((r) => r.bridge).map((r) => r.poly);
    const out = {
        ground: extrudePolygons(chunk.land, { base: -GROUND_THICKNESS, top: 0, uvScale: 8 }),
        roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }),
        bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: 0, uvScale: 8 }),
        buildings: buildBuildings(chunk.buildings),
    };
    for (const kind of SURFACE_KINDS) {
        const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly);
        out[kind] = flatPolygons(rings, { y: SURFACE_OFFSET, uvScale: 8 });
    }
    return out;
}

function buildBuildings(buildings) {
    const parts = buildings.map((b) =>
        extrudePolygons([b.poly], { base: 0, top: b.height, uvScale: 4, tint: buildingTint(b.poly) }));
    return parts.filter(Boolean).reduce((acc, geo) => (acc ? mergeInto(acc, geo) : geo), null);
}

function mergeInto(a, b) {
    const offset = a.getAttribute("position").count;
    const pos = [...a.getAttribute("position").array, ...b.getAttribute("position").array];
    const uv = [...a.getAttribute("uv").array, ...b.getAttribute("uv").array];
    const ca = a.getAttribute("color"), cb = b.getAttribute("color");
    const color = ca && cb ? [...ca.array, ...cb.array] : null;
    const idx = [...a.getIndex().array, ...[...b.getIndex().array].map((i) => i + offset)];
    return toGeometry(pos, idx, uv, color);
}
  • Step 4: Test laufen lassen, er muss bestehen

Run: npm test Expected: PASS, 5 Tests

  • Step 5: Alle 144 Chunks in Node durchrechnen

tools/checkChunks.mjs:

import { readdirSync, readFileSync } from "node:fs";
import { buildChunkGeometry } from "../src/world/geometry.js";

const dir = "public/data/vice-a";
let tris = 0;
for (const file of readdirSync(dir).filter((f) => f.startsWith("chunk_"))) {
    const chunk = JSON.parse(readFileSync(`${dir}/${file}`, "utf8"));
    for (const geo of Object.values(buildChunkGeometry(chunk))) {
        if (!geo) continue;
        const pos = geo.getAttribute("position").array;
        if (pos.some((v) => !Number.isFinite(v))) throw new Error(`NaN in ${file}`);
        tris += geo.getIndex().count / 3;
    }
}
console.log("Dreiecke gesamt:", tris);
if (tris > 1_500_000) throw new Error("Budget überschritten");

Run: node tools/checkChunks.mjs Expected: eine Zahl deutlich unter 1.500.000, kein Fehler.

  • Step 6: Commit
git add src/world/geometry.js tests/geometry.test.js tools/checkChunks.mjs
git commit -m "feat(world): Geometrie-Baustein für Land, Straßen, Brücken und Gebäude

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 8: Streaming, Kollision, Respawn

Files:

  • Create: src/world/streaming.js, tests/streaming.test.js
  • Modify: src/main.js
  • Test: tests/streaming.test.js

Interfaces:

  • Consumes: buildChunkGeometry aus Task 7, chunkForWorld, chunkOrigin aus Task 1

  • Produces: class ChunkStreamer { constructor({ scene, player, materials, baseUrl }); update(playerPosition); isReadyAt(position) -> boolean }, wantedChunks(position, {load, unload, active}) -> {add: [], remove: []}

  • Step 1: Den fehlschlagenden Test schreiben

tests/streaming.test.js:

import { describe, expect, it } from "vitest";
import { wantedChunks } from "../src/world/streaming.js";

describe("wantedChunks", () => {
    it("lädt die Umgebung des Spielers", () => {
        const { add } = wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() });
        expect(add.length).toBeGreaterThan(4);
        expect(add.length).toBeLessThan(30);
    });

    it("entlädt erst jenseits des größeren Radius", () => {
        const active = new Set(["5_5"]);
        const near = wantedChunks({ x: 1375, z: -1375 }, { load: 400, unload: 600, active });
        expect(near.remove).not.toContain("5_5");
        const far = wantedChunks({ x: -900, z: -2400 }, { load: 400, unload: 600, active });
        expect(far.remove).toContain("5_5");
    });

    it("meldet nichts doppelt an", () => {
        const active = new Set(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }).add);
        expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0);
    });
});
  • Step 2: Test laufen lassen, er muss scheitern

Run: npm test Expected: FAIL, Failed to resolve import "../src/world/streaming.js"

  • Step 3: src/world/streaming.js schreiben
import { Group, Mesh } from "three";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
import { buildChunkGeometry } from "./geometry.js";

const key = (i, j) => `${i}_${j}`;
const DECALS = new Set(["roads", "sand", "park", "urban"]);

/** Mittelpunkt eines Chunks in three-Koordinaten. */
function chunkCenter(i, j) {
    return { x: SLICE.x0 + (i + 0.5) * CHUNK_SIZE, z: -(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) };
}

export function wantedChunks(position, { load, unload, active }) {
    const add = [], remove = [];
    for (let j = 0; j < CHUNKS_PER_SIDE; j++) {
        for (let i = 0; i < CHUNKS_PER_SIDE; i++) {
            const c = chunkCenter(i, j);
            const d = Math.hypot(c.x - position.x, c.z - position.z);
            const k = key(i, j);
            if (d <= load && !active.has(k)) add.push(k);
            if (d > unload && active.has(k)) remove.push(k);
        }
    }
    add.sort((a, b) => dist(a, position) - dist(b, position));
    return { add, remove };
}

function dist(k, position) {
    const [i, j] = k.split("_").map(Number);
    const c = chunkCenter(i, j);
    return Math.hypot(c.x - position.x, c.z - position.z);
}

export class ChunkStreamer {
    constructor({ scene, player, materials, props, baseUrl = "./data/vice-a" }) {
        Object.assign(this, { scene, player, materials, props, baseUrl });
        this.active = new Map();      // key -> { group, handle }
        this.pending = new Set();
        this.queue = [];
    }

    update(position) {
        const { add, remove } = wantedChunks(position, {
            load: 400, unload: 600, active: new Set([...this.active.keys(), ...this.pending]),
        });
        for (const k of add) if (!this.queue.includes(k)) this.queue.push(k);
        // Höchstens ein Aufbau je Frame.
        const next = this.queue.shift();
        if (next) void this.#load(next);
        // Höchstens vier Abmeldungen je Frame.
        for (const k of remove.slice(0, 4)) this.#unload(k);
    }

    async #load(k) {
        if (this.active.has(k) || this.pending.has(k)) return;
        this.pending.add(k);
        try {
            const chunk = await this.#fetchChunk(k);
            const [i, j] = k.split("_").map(Number);
            const group = new Group();
            group.position.set(chunk.origin[0], 0, chunk.origin[1]);
            const geos = buildChunkGeometry(chunk);
            const collide = new Group();
            for (const [name, geo] of Object.entries(geos)) {
                if (!geo) continue;
                const mesh = new Mesh(geo, this.materials[name]);
                mesh.castShadow = name === "buildings";
                mesh.receiveShadow = true;
                // Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.
                (DECALS.has(name) ? group : collide).add(mesh);
            }
            group.add(collide);
            this.scene.add(group);
            group.updateMatrixWorld(true);
            const handle = this.player.addCollider({
                motion: "static", useWorker: true, shape: { kind: "mesh", source: collide },
            });
            this.active.set(k, { group, handle, chunk });
        } catch (err) {
            console.warn(`Chunk ${k} konnte nicht geladen werden:`, err);
        } finally {
            this.pending.delete(k);
        }
    }

    async #fetchChunk(k) {
        const [i, j] = k.split("_").map(Number);
        for (let attempt = 0; attempt < 3; attempt++) {
            try {
                const res = await fetch(`${this.baseUrl}/chunk_${i}_${j}.json`);
                if (res.ok) return await res.json();
            } catch { /* nächster Versuch */ }
        }
        throw new Error("dreimal fehlgeschlagen");
    }

    #unload(k) {
        const entry = this.active.get(k);
        if (!entry) return;
        this.player.removeCollider(entry.handle);
        this.scene.remove(entry.group);
        entry.group.traverse((o) => o.geometry?.dispose());
        this.active.delete(k);
    }

    /** Ist der Collider unter dieser Position einsatzbereit? */
    isReadyAt(position) {
        const i = Math.floor((position.x - SLICE.x0) / CHUNK_SIZE);
        const j = Math.floor((SLICE.y1 + position.z) / CHUNK_SIZE);
        const entry = this.active.get(key(i, j));
        return Boolean(entry && this.player.collisionWorld.get(entry.handle.id)?.ready);
    }
}
  • Step 4: Test laufen lassen, er muss bestehen

Run: npm test Expected: PASS, alle Tests

  • Step 5: In src/main.js einhängen, mit Startschweben und Auffangnetz
const SPAWN = { x: 1699, y: 3, z: -743 };
const streamer = new ChunkStreamer({ scene, player, materials });
player.setSkipCapsuleCollision(true);
let settled = false;
const startedAt = performance.now();
let lastSafe = { ...SPAWN };

function tick(delta) {
    streamer.update(player.playerCapsule.position);
    if (!settled && (streamer.isReadyAt(player.playerCapsule.position) || performance.now() - startedAt > 15000)) {
        settled = true;
        player.setSkipCapsuleCollision(false);
        player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
    }
    const p = player.playerCapsule.position;
    if (p.y < -30) player.reset(new Vector3(lastSafe.x, lastSafe.y, lastSafe.z));
    else if (p.y > -1) lastSafe = { x: p.x, y: p.y + 1, z: p.z };
}
  • Step 6: Im Browser prüfen

Run: npm run dev Expected: Die Figur landet auf Vice Beach, Chunks erscheinen beim Laufen, die Konsole bleibt leer. Über das Panel aus Task 10 die Chunk-Grenzen einblenden und prüfen, dass kein Loch entsteht.

  • Step 7: Commit
git add src/world/streaming.js tests/streaming.test.js src/main.js
git commit -m "feat(world): Chunk-Streaming mit Collider-Verwaltung und Auffangnetz

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 9: Materialien, Wasser, Himmel

Files:

  • Create: src/world/materials.js, tools/assets/textures.mjs, tests/materials.test.js
  • Modify: src/main.js
  • Test: tests/materials.test.js

Interfaces:

  • Consumes: nichts aus früheren Tasks
  • Produces: MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"], createMaterials() -> Promise<{...MATERIAL_KEYS, water, seafloor}>, waterNormalData(size) -> Uint8Array, TEXTURES (dict Materialname → Poly-Haven-Kennung)

Die Schlüssel müssen exakt denen entsprechen, die buildChunkGeometry aus Task 7 liefert. ChunkStreamer greift mit genau diesen Namen zu.

  • Step 1: Texturen holen

tools/assets/textures.mjs:

// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/.
import { mkdir, writeFile } from "node:fs/promises";

const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" };
const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark"];
const MAPS = ["Diffuse", "nor_gl", "arm"];

await mkdir("public/textures", { recursive: true });
for (const slug of SLUGS) {
    const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json();
    for (const map of MAPS) {
        const entry = files[map]?.["1k"]?.jpg;
        if (!entry) { console.warn(`${slug}: ${map} fehlt`); continue; }
        const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer());
        await writeFile(`public/textures/${slug}_${map}.jpg`, buf);
        console.log(`${slug}_${map}.jpg ${(buf.length / 1e6).toFixed(1)} MB`);
    }
}

Run: node tools/assets/textures.mjs Expected: 21 Dateien, zusammen rund 18 MB.

  • Step 2: Den fehlschlagenden Test schreiben

tests/materials.test.js:

import { describe, expect, it } from "vitest";
import { MATERIAL_KEYS, TEXTURES, waterNormalData } from "../src/world/materials.js";
import { buildChunkGeometry } from "../src/world/geometry.js";

describe("materials", () => {
    it("deckt genau die Geometrie-Teile ab", () => {
        const chunk = { chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], buildings: [], palms: [] };
        expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort());
    });

    it("nennt für jedes Material eine Textur", () => {
        for (const key of MATERIAL_KEYS) expect(TEXTURES[key]).toBeTruthy();
    });

    it("deckt Strand und Park mit eigenen Materialien ab", () => {
        expect(MATERIAL_KEYS).toContain("sand");
        expect(MATERIAL_KEYS).toContain("park");
    });

    it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
        const data = waterNormalData(32);
        expect(data.length).toBe(32 * 32 * 4);
        // Normalen zeigen im Mittel nach oben: Blaukanal nahe 255.
        const blue = [...data].filter((_, i) => i % 4 === 2);
        expect(blue.reduce((a, b) => a + b, 0) / blue.length).toBeGreaterThan(200);
    });
});
  • Step 3: Test laufen lassen, er muss scheitern

Run: npm test Expected: FAIL, Failed to resolve import "../src/world/materials.js"

  • Step 4: src/world/materials.js schreiben
import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader } from "three";

export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"];
export const TEXTURES = {
    ground: "grass_path_2",            // unbebautes Land
    sand: "sand_01",                   // Strand
    park: "leafy_grass",               // Grünflächen
    urban: "concrete_pavement_03",     // versiegelte Stadtfläche
    roads: "asphalt_02",
    bridges: "concrete_pavement_03",
    buildings: "white_stucco",
};
const FALLBACK_COLORS = {
    ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8,
    roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc,
};

/** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */
export function waterNormalData(size = 128) {
    const data = new Uint8Array(size * size * 4);
    for (let y = 0; y < size; y++) {
        for (let x = 0; x < size; x++) {
            const i = (y * size + x) * 4;
            const u = (x / size) * Math.PI * 2;
            const v = (y / size) * Math.PI * 2;
            const dx = Math.sin(u * 3) * 0.5 + Math.sin(v * 2 + 1.3) * 0.3;
            const dz = Math.cos(v * 3) * 0.5 + Math.cos(u * 2 + 0.7) * 0.3;
            data[i] = 128 + dx * 40;
            data[i + 1] = 128 + dz * 40;
            data[i + 2] = 245;
            data[i + 3] = 255;
        }
    }
    return data;
}

async function loadSet(loader, slug, fallback) {
    let map, normalMap, arm;
    try {
        [map, normalMap, arm] = await Promise.all(
            ["Diffuse", "nor_gl", "arm"].map((m) => loader.loadAsync(`./textures/${slug}_${m}.jpg`)),
        );
    } catch (err) {
        console.warn(`Textur ${slug} fehlt, einfarbiger Ersatz:`, err);
        return new MeshStandardMaterial({ color: fallback, roughness: 0.9 });
    }
    for (const t of [map, normalMap, arm]) {
        t.wrapS = t.wrapT = RepeatWrapping;
    }
    map.colorSpace = SRGBColorSpace;
    // arm packt Ambient Occlusion, Rauheit und Metallgrad in R, G und B.
    return new MeshStandardMaterial({ map, normalMap, aoMap: arm, roughnessMap: arm, metalnessMap: arm, metalness: 0 });
}

export async function createMaterials() {
    const loader = new TextureLoader();
    const entries = await Promise.all(
        MATERIAL_KEYS.map(async (key) => [key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key])]),
    );
    const materials = Object.fromEntries(entries);
    materials.buildings.vertexColors = true;   // pastellige Tönung je Gebäude

    const normal = new DataTexture(waterNormalData(128), 128, 128, RGBAFormat);
    normal.wrapS = normal.wrapT = RepeatWrapping;
    normal.repeat.set(60, 60);
    normal.needsUpdate = true;
    materials.water = new MeshStandardMaterial({
        color: 0x2c67a4, normalMap: normal, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.92,
    });
    materials.seafloor = new MeshStandardMaterial({ color: 0x1d4468, roughness: 1 });
    return materials;
}
  • Step 5: Test laufen lassen, er muss bestehen

Run: npm test Expected: PASS, 3 Tests

  • Step 6: Himmel, Nebel, Schatten und Wasserfläche in src/main.js
import { BoxGeometry, Fog, Mesh, PlaneGeometry } from "three";
import { Sky } from "three/examples/jsm/objects/Sky.js";

const materials = await createMaterials();

const sky = new Sky();
sky.scale.setScalar(45000);
scene.add(sky);
sky.material.uniforms.turbidity.value = 8;
sky.material.uniforms.rayleigh.value = 1.6;
sky.material.uniforms.sunPosition.value.copy(sun.position.clone().normalize());
scene.fog = new Fog(0xbcd7ea, 500, 2200);

const water = new Mesh(new PlaneGeometry(4000, 4000), materials.water);
water.rotation.x = -Math.PI / 2;
water.position.set(500, -0.6, -1000);
scene.add(water);

// Meeresboden: man watet hinein, statt endlos zu fallen. Einmalig, nicht je Chunk.
const seafloor = new Mesh(new BoxGeometry(4000, 1, 4000), materials.seafloor);
seafloor.position.set(500, -3.0, -1000);      // Oberkante bei y = -2,5
scene.add(seafloor);
seafloor.updateMatrixWorld(true);
player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } });

// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen.
function updateShadow(target) {
    sun.position.set(target.x + 120, 240, target.z + 80);
    sun.target.position.copy(target);
    sun.target.updateMatrixWorld();
    sun.shadow.camera.left = -150; sun.shadow.camera.right = 150;
    sun.shadow.camera.top = 150; sun.shadow.camera.bottom = -150;
    sun.shadow.camera.far = 600;
    sun.shadow.camera.updateProjectionMatrix();
}

updateShadow(player.playerCapsule.position) und materials.water.normalMap.offset.x += delta * 0.01 in die Schleife hängen.

  • Step 7: Commit
git add src/world/materials.js tools/assets/textures.mjs tests/materials.test.js src/main.js public/textures
git commit -m "feat(world): Poly-Haven-Materialien, Wasser, Himmel und mitlaufende Schatten

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 10: Palmen und Requisiten

Files:

  • Create: src/world/props.js, tools/assets/props.mjs, tools/assets/palm.sh, tests/props.test.js
  • Modify: src/world/streaming.js
  • Test: tests/props.test.js

Interfaces:

  • Consumes: chunk.palms, chunk.centerlines aus Task 6

  • Produces: lampPoints(centerlines, spacing) -> [{x, z, angle}], palmTransforms(points, seed) -> [{x, z, scale, rotation}], class PropLayer { async load(); build(chunk) -> {visual: Group, colliders: Mesh[]} }

  • Step 1: Palme aufbereiten

tools/assets/palm.sh:

#!/usr/bin/env bash
# Nobiax-Palme (CC0) holen und die TGA-Texturen nach PNG wandeln.
set -e
mkdir -p public/models/palm && cd public/models/palm
curl -sL -o palm.7z "https://opengameart.org/sites/default/files/palm_tree_v2.7z"
tar -xf palm.7z
sips -s format png diffus.tga --out diffuse.png > /dev/null
sips -s format png normal.tga --out normal.png > /dev/null
rm -f palm.7z diffus.tga normal.tga specular.tga palm_tree.FBX preview.jpg
echo "Palme bereit: $(ls)"

Run: bash tools/assets/palm.sh Expected: palm_tree.obj, diffuse.png, normal.png, readme.txt.

  • Step 2: Requisiten holen und vereinfachen

tools/assets/props.mjs lädt die fünf Poly-Haven-Modelle in 1k (gleiches Muster wie textures.mjs, aber über files[slug].gltf["1k"].gltf samt include-Dateien) nach public/models/props/raw/ und ruft danach je Modell:

npx --yes @gltf-transform/cli@4.5.0 optimize \
  public/models/props/raw/<slug>/<slug>_1k.gltf public/models/props/<slug>.glb \
  --texture-size 512 --simplify-error 0.01 --compress meshopt

Run: node tools/assets/props.mjs Expected: fünf .glb unter je 1 MB. street_lamp_02 sollte von 20.338 auf unter 4.000 Dreiecke fallen, fire_hydrant von 86.316 auf unter 5.000.

  • Step 3: Den fehlschlagenden Test schreiben

tests/props.test.js:

import { describe, expect, it } from "vitest";
import { lampPoints, palmTransforms } from "../src/world/props.js";

describe("props", () => {
    it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
        const pts = lampPoints([[[0, 0], [100, 0]]], 35);
        expect(pts.length).toBe(3);                       // 0, 35, 70 Meter
        expect(pts[1].x).toBeCloseTo(35);
    });

    it("ignoriert zu kurze Linien", () => {
        expect(lampPoints([[[0, 0], [10, 0]]], 35)).toHaveLength(1);
    });

    it("skaliert Palmen reproduzierbar zwischen 10 und 18 Metern", () => {
        const a = palmTransforms([[5, 5], [9, 9]], 7);
        const b = palmTransforms([[5, 5], [9, 9]], 7);
        expect(a).toEqual(b);
        for (const t of a) {
            expect(t.height).toBeGreaterThanOrEqual(10);
            expect(t.height).toBeLessThanOrEqual(18);
        }
    });
});
  • Step 4: Test laufen lassen, er muss scheitern

Run: npm test Expected: FAIL, Failed to resolve import "../src/world/props.js"

  • Step 5: src/world/props.js schreiben
import { CylinderGeometry, Group, InstancedMesh, Matrix4, Mesh, MeshStandardMaterial,
         Object3D, RepeatWrapping, SRGBColorSpace, TextureLoader } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";

const PALM_MODEL_HEIGHT = 439.74;     // Einheiten im OBJ, gemessen
const TRUNK_RADIUS = 0.22;

/** Kleiner, wiederholbarer Zufall aus einem Schlüssel. */
function hashUnit(seed, key) {
    let h = 2166136261 ^ seed;
    for (const ch of key) { h = Math.imul(h ^ ch.charCodeAt(0), 16777619); }
    return ((h >>> 0) % 100000) / 100000;
}

export function lampPoints(lines, spacing = 35) {
    const out = [];
    for (const line of lines) {
        let travelled = 0, next = 0;
        for (let i = 0; i < line.length - 1; i++) {
            const [x0, z0] = line[i], [x1, z1] = line[i + 1];
            const len = Math.hypot(x1 - x0, z1 - z0);
            while (next <= travelled + len) {
                const t = (next - travelled) / (len || 1);
                out.push({ x: x0 + (x1 - x0) * t, z: z0 + (z1 - z0) * t, angle: Math.atan2(z1 - z0, x1 - x0) });
                next += spacing;
            }
            travelled += len;
        }
    }
    return out;
}

export function palmTransforms(points, seed = 1) {
    return points.map(([x, z]) => {
        const key = `${x.toFixed(1)},${z.toFixed(1)}`;
        return {
            x, z,
            height: 10 + hashUnit(seed, key) * 8,
            rotation: hashUnit(seed, `${key}:r`) * Math.PI * 2,
        };
    });
}

export class PropLayer {
    async load() {
        const texLoader = new TextureLoader();
        const [obj, diffuse, normal] = await Promise.all([
            new OBJLoader().loadAsync("./models/palm/palm_tree.obj"),
            texLoader.loadAsync("./models/palm/diffuse.png"),
            texLoader.loadAsync("./models/palm/normal.png"),
        ]);
        diffuse.colorSpace = SRGBColorSpace;
        for (const t of [diffuse, normal]) t.wrapS = t.wrapT = RepeatWrapping;
        this.palmGeometry = null;
        obj.traverse((o) => { if (o.isMesh && !this.palmGeometry) this.palmGeometry = o.geometry; });
        this.palmMaterial = new MeshStandardMaterial({
            map: diffuse, normalMap: normal, alphaTest: 0.5, side: 2, roughness: 0.9,
        });
        const gltfLoader = new GLTFLoader();
        this.lamp = (await gltfLoader.loadAsync("./models/props/street_lamp_02.glb")).scene;
        this.trunkGeometry = new CylinderGeometry(TRUNK_RADIUS, TRUNK_RADIUS * 1.2, 6, 6);
    }

    /** Sichtbare Instanzen plus einfache Stammzylinder für den Chunk-Collider. */
    build(chunk) {
        const visual = new Group();
        const colliders = [];
        const palms = palmTransforms(chunk.palms ?? [], 20260912);
        if (palms.length) {
            const mesh = new InstancedMesh(this.palmGeometry, this.palmMaterial, palms.length);
            mesh.castShadow = true;
            const dummy = new Object3D();
            palms.forEach((p, i) => {
                const s = p.height / PALM_MODEL_HEIGHT;
                dummy.position.set(p.x, 0, p.z);
                dummy.rotation.set(0, p.rotation, 0);
                dummy.scale.setScalar(s);
                dummy.updateMatrix();
                mesh.setMatrixAt(i, dummy.matrix);
                const trunk = new Mesh(this.trunkGeometry);
                trunk.position.set(p.x, 3, p.z);
                colliders.push(trunk);
            });
            mesh.userData.excludeFromCollider = true;
            visual.add(mesh);
        }
        return { visual, colliders };
    }
}
  • Step 6: Test laufen lassen, er muss bestehen

Run: npm test Expected: PASS, 3 Tests

  • Step 7: In ChunkStreamer einhängen

In #load, direkt vor this.player.addCollider(...):

const props = this.props.build(chunk);
group.add(props.visual);
for (const trunk of props.colliders) collide.add(trunk);   // wandern in dieselbe BVH

Der PropLayer kommt als props in den ChunkStreamer-Konstruktor (siehe Task 8) und wird einmalig vor dem ersten Frame mit await props.load() bereitgestellt.

  • Step 8: Im Browser prüfen

Expected: Palmen stehen an der Promenade und in Parks, sie lassen sich nicht durchlaufen, die Bildrate bleibt über 55.

  • Step 9: Commit
git add src/world/props.js src/world/streaming.js tools/assets tests/props.test.js public/models
git commit -m "feat(world): Palmen und Requisiten als Instanzen mit Stammkollision

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 11: Figuren, Autos, Panel und Namensnennung

Files:

  • Create: src/characters/index.js, src/ui/panel.js, src/ui/hud.js, tests/characters.test.js
  • Modify: src/main.js
  • Test: tests/characters.test.js

Interfaces:

  • Consumes: player aus Task 1, Manifest aus Task 6
  • Produces: retargetClips(clips, sourceRest, targetRest) -> AnimationClip[], createCharacterModule(player) -> {switchCharacter, switchHair, applyHeight}, teleportTargets(landmarks) -> [{name, x, z}], mountHud(root, attribution)

Der Figurenteil wird aus three-player-controller/example/showcase.js übernommen: die Funktionen collectRestPose, trackVaries, retargetClips, clipsForModel, measureTorsoRadiusRatio, applyCharacter, applyHair samt der Konstanten CHARACTERS, HAIRSTYLES, PLAYER_MODEL_BASE. Die Assets liegen dort unter example/public/glb/characters/ und werden nach public/models/characters/ kopiert.

  • Step 1: Den fehlschlagenden Test schreiben

tests/characters.test.js:

import { describe, expect, it } from "vitest";
import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
import { retargetClips, teleportTargets } from "../src/characters/index.js";

const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]);

describe("retargetClips", () => {
    it("wirft konstante Positionsspuren weg", () => {
        const clip = new AnimationClip("a", 1, [
            new VectorKeyframeTrack("arm.position", [0, 1], [0, 1, 0, 0, 1, 0]),
            new QuaternionKeyframeTrack("arm.quaternion", [0], [0, 0, 0, 1]),
        ]);
        const out = retargetClips([clip], rest(new Quaternion()), rest(new Quaternion()));
        expect(out[0].tracks.map((t) => t.name)).toEqual(["arm.quaternion"]);
    });

    it("überträgt Rotationen relativ zur Ruhepose", () => {
        const src = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.3);
        const dst = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.9);
        const clip = new AnimationClip("a", 1, [
            new QuaternionKeyframeTrack("arm.quaternion", [0], [src.x, src.y, src.z, src.w]),
        ]);
        const out = retargetClips([clip], rest(src), rest(dst));
        const v = out[0].tracks[0].values;
        expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(dst)).toBeLessThan(1e-6);
    });
});

describe("teleportTargets", () => {
    it("sortiert Wahrzeichen nach Höhe", () => {
        const targets = teleportTargets([
            { name: "klein", xyz: [0, 0, 0], height: 30 },
            { name: "groß", xyz: [10, 20, 0], height: 200 },
        ]);
        expect(targets[0].name).toBe("groß");
        expect(targets[0].z).toBe(-20);
    });
});
  • Step 2: Test laufen lassen, er muss scheitern

Run: npm test Expected: FAIL, Failed to resolve import "../src/characters/index.js"

  • Step 3: Modul übernehmen und Test erfüllen

Die genannten Funktionen unverändert aus showcase.js kopieren, als benannte Exporte bereitstellen und teleportTargets ergänzen:

export function teleportTargets(landmarks) {
    return [...landmarks]
        .sort((a, b) => b.height - a.height)
        .map((l) => ({ name: l.name, x: l.xyz[0], z: -l.xyz[1], height: l.height }));
}
  • Step 4: Test laufen lassen, er muss bestehen

Run: npm test Expected: PASS, alle Tests

  • Step 5: Autos setzen

Vier SUVs an Punkten der Mittellinien nahe dem Start, Konfiguration aus spawnShowcaseVehicles in showcase.js. Die Chunks dieser vier Punkte werden in ChunkStreamer festgehalten (this.pinned = new Set([...]), in #unload überspringen), weil ein Fahrzeug ohne Boden ins Nichts fällt.

  • Step 6: Panel und HUD

src/ui/panel.js baut mit lil-gui: Figur, Frisur, Höhe, Hitbox anzeigen, Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel), Sprung zu Wahrzeichen (Auswahl aus teleportTargets, setzt player.reset(...)).

src/ui/hud.js hängt die Namensnennung dauerhaft unten links ein:

export function mountHud(root, attribution) {
    const el = document.createElement("div");
    el.className = "attribution";
    el.textContent = attribution;
    root.appendChild(el);
    return el;
}

Der Text kommt aus manifest.json und wird um die Modellquellen ergänzt: SUV von Boris731 (CC BY), Poly Haven, Nobiax und Quaternius (CC0).

  • Step 7: Commit
git add src/characters src/ui tests/characters.test.js src/main.js public/models/characters
git commit -m "feat: Figurenauswahl, Autos, Debug-Panel und Namensnennung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"

Task 12: Abnahme

Files:

  • Create: docs/abnahme-vice-a.md

  • Test: alle vorhandenen Suites

  • Step 1: Alle Tests laufen lassen

Run: npm test && cd tools/extract && uv run pytest -v && cd ../.. && node tools/checkChunks.mjs Expected: alles grün, Dreieckszahl unter dem Budget.

  • Step 2: Budgets messen

Im Panel die Anzeige für Draw Calls (renderer.info.render.calls) und Dreiecke einblenden, dann auf Vice Beach, in Downtown und auf einer Dammstraße je 30 Sekunden messen. Expected: mindestens 55 FPS, unter 150 Draw Calls.

  • Step 3: Abnahmeliste durchspielen und protokollieren

In docs/abnahme-vice-a.md je Punkt Ergebnis und Screenshot festhalten:

  1. Start auf der Promenade, Figur steht auf dem Boden
  2. Mit dem SUV über eine Dammstraße auf das Festland fahren
  3. Ins Wasser laufen, wieder herauskommen
  4. Einen Wolkenkratzer in Downtown erreichen, Höhe stimmt mit den Daten überein
  5. Den Ausschnitt einmal von Nord nach Süd durchqueren, ohne Ruckler und ohne Loch im Boden
  6. Figur und Frisur wechseln, Hitbox einblenden
  7. Unter die Karte fallen lassen, Respawn greift
  • Step 4: Commit
git add docs/abnahme-vice-a.md
git commit -m "docs: Abnahmeprotokoll für den Vice-City-Ausschnitt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"