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>
87 KiB
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, Dateiname5,{zeile},{spalte}.jpg. - Kartenquelle ist ausschließlich
github.com/rolux/gtadb.orgbeim Commit4093e8c874e480d47c38b37fe7a404f8b0c5275e. Niemalsmap.stateofleonida.netabfragen. - three exakt auf
0.184.0pinnen. Invite.config.jssindresolve.dedupe: ['three','three-mesh-bvh']undoptimizeDeps.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(durchgit 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}aussrc/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.jsschreiben
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)auscoords.py;download_tiles(cache: Path) -> None,load_mosaic(cache: Path) -> np.ndarray(Form(3000, 3000, 3), uint8, bereits auf den Ausschnitt beschnitten) austiles.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.pyschreiben
"""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.pyschreiben
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_mosaicaus 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.pyschreiben
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,classifyaus 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.pyschreiben
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,centerlinesaus Task 4,CLASSESaus 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.pyschreiben
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.pyschreiben
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.jsonundpublic/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.pyschreiben
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.pyals 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 eineBufferGeometryodernull),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.jsschreiben
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:
buildChunkGeometryaus Task 7,chunkForWorld,chunkOriginaus 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.jsschreiben
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.jseinhä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.jsschreiben
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.centerlinesaus 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.jsschreiben
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
ChunkStreamereinhä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:
playeraus 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:
- Start auf der Promenade, Figur steht auf dem Boden
- Mit dem SUV über eine Dammstraße auf das Festland fahren
- Ins Wasser laufen, wieder herauskommen
- Einen Wolkenkratzer in Downtown erreichen, Höhe stimmt mit den Daten überein
- Den Ausschnitt einmal von Nord nach Süd durchqueren, ohne Ruckler und ohne Loch im Boden
- Figur und Frisur wechseln, Hitbox einblenden
- 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>"