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

2309 lines
87 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**
```bash
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`:
```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`:
```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`:
```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**
```js
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`:
```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**
```bash
# 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`:
```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`:
```python
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**
```python
"""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):
```python
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**
```python
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:
```bash
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**
```bash
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`:
```python
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**
```python
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:
```bash
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**
```bash
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`:
```python
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**
```python
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**
```bash
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`:
```python
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**
```python
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`:
```python
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**
```python
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:
```python
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**
```bash
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:
```json
{
"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`:
```python
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**
```python
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**
```python
"""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**
```bash
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`:
```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**
```js
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`:
```js
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**
```bash
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`:
```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**
```js
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**
```js
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**
```bash
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`:
```js
// 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`:
```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**
```js
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`**
```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**
```bash
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`:
```bash
#!/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:
```bash
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`:
```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**
```js
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(...)`:
```js
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**
```bash
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`:
```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:
```js
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:
```js
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**
```bash
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**
```bash
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>"
```