Files
gta6/docs/superpowers/plans/2026-09-12-leonida-vice-slice.md
T
knsoandClaude Opus 5 d40d96a800 docs: classify meldet auf Wunsch den Anteil unbekannter Pixel
Aufgabe 6 braucht den Anteil für den Abbruch über 5 Prozent; die Signatur in Aufgabe 3
kannte ihn bisher nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:07:30 +02:00

87 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":     (44, 103, 164),
    "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 = 14.0     # darüber gilt ein Pixel als unbekannt (Beschriftung, Icon, Kante)


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: Wasser rund 32 %, Stadt rund 31 %, Gebäude rund 9 %, Straße rund 8 %. Weichen die Werte um mehr als die Toleranz aus Task 8 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 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 binary_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
        (holes if not _is_outer(mask, contour - 1) else outers).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 _is_outer(mask: np.ndarray, contour: np.ndarray) -> bool:
    """Innen liegende Konturen umschließen Löcher."""
    r, c = contour.mean(axis=0)
    r = int(np.clip(r, 0, mask.shape[0] - 1))
    c = int(np.clip(c, 0, mask.shape[1] - 1))
    return bool(mask[r, c])


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 binary_closing(mask, disk(3))


def road_polygons(labels: np.ndarray) -> list[tuple[Polygon, bool]]:
    water = labels == CLASSES["wasser"]
    polys = mask_polygons(road_mask(labels), min_area=20, simplify=0.8)
    out = []
    for poly in polys:
        r0, c0, r1, c1 = _bbox_px(poly)
        window = water[r0:r1 + 1, c0:c1 + 1]
        out.append((poly, bool(window.mean() > 0.3)))
    return out


def _bbox_px(poly: Polygon) -> tuple[int, int, int, int]:
    minx, miny, maxx, maxy = poly.bounds
    return (int(SLICE["y1"] - maxy), int(minx - SLICE["x0"]),
            int(SLICE["y1"] - miny), int(maxx - SLICE["x0"]))


def centerlines(labels: np.ndarray) -> list[LineString]:
    """Skelett der Straßenflächen, nur zum Platzieren von Laternen und Palmen."""
    skeleton = skeletonize(road_mask(labels))
    lines = []
    for contour in find_contours(skeleton.astype(float), 0.5):
        pts = _to_world(contour)
        if len(pts) >= 2:
            line = LineString(pts).simplify(1.5)
            if line.length >= 15:
                lines.append(line)
    return lines
  • Step 4: Test laufen lassen, er muss bestehen

Run: cd tools/extract && uv run pytest tests/test_vectors.py -v Expected: PASS, 4 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[float], height_sources(...) -> list[str], palm_points(labels, lines, 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
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

def test_l_shape_is_kept_not_boxed():
    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
  • 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
    simple = poly.simplify(1.0)
    return simple if simple.area >= MIN_AREA else None


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: 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 und Straßen im Park


def palm_points(sand_polys, park_polys, road_lines, building_polys, road_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
    blocked = unary_union(list(building_polys) + list(road_polys)) if (building_polys or road_polys) else None
    points = []
    for line in road_lines:
        if sand is None or line.distance(sand) > PALM_BEACH_RANGE:
            continue
        steps = max(1, int(line.length // PALM_SPACING))
        for k in range(steps + 1):
            p = line.interpolate(k * PALM_SPACING)
            for side in (-1, 1):
                off = line.parallel_offset(4.0 * side, "left" if side > 0 else "right")
                if off.is_empty:
                    continue
                q = off.interpolate(off.project(p))
                points.append((round(q.x, 2), round(q.y, 2)))
    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 points
  • Step 10: 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.29, 0.35), "strasse": (0.06, 0.10), "gebaeude": (0.07, 0.11)}


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, 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": "GTA VI Map Tiles & Landmarks (CC BY 4.0) — https://map.gtadb.org, YANIS v15",
    })


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 Datei data/skyline.json ist die aus rolux/gtamaplib-vc geholte game/landmarks/landmarks.json, data/districts.json enthält Name und Mittelpunkt je Viertel aus den gtadb-Landmarks. Beide werden in diesem Schritt einmalig heruntergeladen und mit committet.

  • 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 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(rings, {base, top, uvScale}) -> BufferGeometry, flatPolygons(rings, {y, uvScale}) -> BufferGeometry

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]];

describe("geometry", () => {
    it("extrudiert ein Quadrat zu Wänden und Dach", () => {
        const geo = extrudePolygons([SQUARE], { 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([SQUARE], { 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("legt flache Flächen auf die gewünschte Höhe", () => {
        const geo = flatPolygons([SQUARE], { 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: [SQUARE],
            surfaces: [{ poly: SQUARE, kind: "sand" }, { poly: SQUARE, kind: "park" }],
            roads: [{ poly: SQUARE, bridge: false }, { poly: SQUARE, bridge: true }],
            centerlines: [], palms: [],
            buildings: [{ poly: SQUARE, 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: SQUARE, 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: [SQUARE], 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(ring) {
    const [x, z] = ring[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(rings, { y = 0, uvScale = 8 } = {}) {
    const pos = [], idx = [], uv = [];
    for (const raw of rings) {
        const ring = clockwise(raw);
        const base = pos.length / 3;
        for (const [x, z] of ring) {
            pos.push(x, y, z);
            uv.push(x / uvScale, z / uvScale);
        }
        for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
            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(rings, { base = 0, top = 10, uvScale = 4, tint = null } = {}) {
    const pos = [], idx = [], uv = [], color = [];
    for (const raw of rings) {
        const ring = clockwise(raw);
        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);
        }
        const b = pos.length / 3;
        for (const [x, z] of ring) {
            pos.push(x, top, z);
            uv.push(x / uvScale, z / uvScale);
            if (tint) color.push(...tint);
        }
        for (const tri of ShapeUtils.triangulateShape(ring.map(([x, z]) => ({ x, y: z })), [])) {
            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>"