main #1

Merged
knso merged 10 commits from main into leonida/vice-slice 2026-09-15 19:35:33 +00:00
Showing only changes of commit f7506ac556 - Show all commits
@@ -0,0 +1,718 @@
# Tribüne im Park J6 — 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:** Die Tribüne aus `tribuene.glb` ersetzt 1:1 den Gebäudekasten in J6 (Flamingo Park Baseball Stadium): an der Stelle des alten Gebäudes, mit Schatten und Kollision, und den alten Kasten gibt es im Spiel nicht mehr.
**Architecture:** Die Orte-Schicht aus dem Hood Yard bleibt, wie sie ist. Neu ist das Feld `replaces` im `PLACES`-Eintrag, eine Liste von Weltpunkten. `ChunkStreamer` bekommt diese Punkte aus `main.js` und lässt über die reine Funktion `withoutReplacedBuildings` jeden Grundriss weg, in dem ein Punkt liegt. Die Tribüne selbst wird mit `tools/assets/places.mjs` aufbereitet und über die globalen Materiallisten eingeordnet. Der Asset-Test prüft danach jeden Ort einzeln.
**Tech Stack:** three.js 0.184, Vite 7, vitest 3.2, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0.
**Spec:** `docs/superpowers/specs/2026-09-14-tribuene-design.md`
## Global Constraints
- **Lage:** `at: [1409.9, 1051.1]`, `rotation: 45`, `lift: 0`, `bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }`, `replaces: [[1403.25, 1057.75]]`. `at` und `replaces` sind Welt-x und Welt-y, nie three-z.
- **Radien** bleiben: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`.
- **Materialrollen:**
- fest: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`
- Nahflächen: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale`
- Vereinfachung 0,25: `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01`
- **Quelle:** `/Users/Kenso/Downloads/tribuene.glb` (6,3 MB) kommt nie ins Repo. Die Kopie `tribuene.glb` im Hauptordner bleibt unberührt.
- **Aufbereitung** nur mit `node tools/assets/places.mjs`, nie mit der gltf-transform-CLI.
- **Nicht anfassen:** `public/data/`, `tools/extract/`, die Minimap, `src/world/geometry.js`, `src/world/props.js`, die Radien.
- **Arbeitsbaum** `/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene`, Branch `leonida/tribuene`, alle Befehle von dort aus.
- Gestaget wird immer mit Pfaden, nie mit `git add -A`.
- Die Isolationswache verweigert Befehle mit Variablen als Befehl oder mit Skriptpfaden, die Leerzeichen enthalten. Deshalb Pfade ausschreiben und einfache Einzelbefehle nutzen.
- **Stil:** Kommentare und Testnamen auf Deutsch, wie in den vorhandenen Dateien. Über Tests, die eine Fehlerart abfangen, steht ein Kommentar „Bruch:“.
- **Commit-Nachrichten** enden mit einer Leerzeile und `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
- **Tests:** einzeln mit `npx vitest run <datei>`, alle mit `npm test`. Ausgangsstand: `Test Files 17 passed (17)`, `Tests 170 passed (170)`.
## Vorbereitung (erledigt)
Der Arbeitsbaum steht auf `6a05626` plus dem Spec-Commit `4a1b337`. `node_modules` sowie `three-player-controller/package.json` und `dist/` sind hineinkopiert, der Ausgangsstand ist grün.
- [ ] **Prüfen:** `git status --short` gibt nichts aus, `npm test` meldet 17 Dateien und 170 Tests grün.
---
### Task 1: Ersetzte Gebäude beim Chunk-Aufbau weglassen
**Files:**
- Modify: `src/world/streaming.js` (Import, neue Funktion, Konstruktor, `#load`)
- Modify: `src/main.js:11` (Import) und `src/main.js:239` (Konstruktion des `ChunkStreamer`)
- Test: `tests/streaming.test.js`
**Interfaces:**
- Consumes: `pointInPolygon(x, z, rings)` aus `src/world/props.js`, `PLACES` aus `src/world/places.js` (bisher ohne `replaces`)
- Produces:
- `withoutReplacedBuildings(chunk, points)` in `src/world/streaming.js`. `points` ist `Array<[x, y]>` in Weltkoordinaten. Die Funktion gibt `chunk` selbst zurück, wenn nichts entfällt, sonst `{ ...chunk, buildings }` mit gefilterter Liste.
- `new ChunkStreamer({ ..., replacedBuildings = [] })`
- `main.js` übergibt `PLACES.flatMap((place) => place.replaces ?? [])`.
- [ ] **Schritt 1: Failing Tests schreiben**
In `tests/streaming.test.js` den Import ändern:
```js
import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js";
```
`makeStreamer` bekommt einen vierten Parameter (die übrigen Aufrufe bleiben gültig, `undefined` greift auf die Vorgabe zurück):
```js
function makeStreamer(chunkData, props, pinned, replacedBuildings) {
```
und in derselben Funktion die Rückgabe:
```js
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene };
```
Direkt nach `describe("wantedChunks", …)` (vor dem Kommentar „Gemeinsame Vorrichtung …“) einfügen:
```js
describe("withoutReplacedBuildings", () => {
// Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250.
const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]];
const chunk = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [
{ poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060
{ poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150
],
};
// Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten
// stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert.
it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => {
const result = withoutReplacedBuildings(chunk, [[1405, 1055]]);
expect(result.buildings).toEqual([chunk.buildings[1]]);
expect(result.land).toBe(chunk.land);
expect(chunk.buildings).toHaveLength(2);
});
it("gibt ohne Treffer den Chunk selbst zurück", () => {
expect(withoutReplacedBuildings(chunk, [])).toBe(chunk);
expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk);
});
});
```
Im `describe("ChunkStreamer", …)` als letzten Test (nach „entlädt einen angehefteten Chunk nicht …“) einfügen:
```js
// Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt
// Kollision mitten in dem Ort, der ihn ersetzt.
it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => {
const chunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [],
buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }],
};
// Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe.
const plain = makeStreamer(chunk);
plain.streamer.update(emptyChunkCenter);
await flush();
expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2);
// Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985.
const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]);
streamer.update(emptyChunkCenter);
await flush();
const source = player.addCollider.mock.calls[0][0].shape.source;
expect(source.children).toHaveLength(1);
expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude
});
```
(Gebäudegeometrie trägt als einzige ein `color`-Attribut, siehe `buildBuildings` in `src/world/geometry.js`.)
- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen**
Run: `npx vitest run tests/streaming.test.js`
Erwartet: Die zwei `withoutReplacedBuildings`-Tests schlagen fehl (`withoutReplacedBuildings is not a function`). „baut ein Gebäude, das ein Ort ersetzt, …“ scheitert an `expected [ …(2) ] to have a length of 1`.
- [ ] **Schritt 3: Umsetzen**
In `src/world/streaming.js` die Importe ergänzen:
```js
import { Group, Mesh } from "three";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
import { buildChunkGeometry } from "./geometry.js";
import { pointInPolygon } from "./props.js";
```
Nach `function dist(k, position) { … }` einfügen:
```js
/**
* Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js):
* jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und
* Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt
* derselbe Chunk zurück.
*/
export function withoutReplacedBuildings(chunk, points) {
if (points.length === 0) return chunk;
const [ox, oz] = chunk.origin;
const buildings = chunk.buildings.filter((building) =>
!points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly)));
return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings };
}
```
Konstruktor:
```js
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) {
Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings });
```
In `#load` die Zeile `const geos = buildChunkGeometry(chunk);` ersetzen durch:
```js
const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings));
```
`this.props.build(chunk)` bleibt beim ungefilterten Chunk, Palmen und Laternen kennen keine Gebäude.
In `src/main.js` Zeile 11:
```js
import { PLACES, PlaceLayer } from "./world/places.js";
```
und Zeile 239 (`const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`) ersetzen durch:
```js
// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer
// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten.
const streamer = new ChunkStreamer({
scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS,
replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []),
});
```
- [ ] **Schritt 4: Tests laufen lassen, sie müssen bestehen**
Run: `npx vitest run tests/streaming.test.js`
Erwartet: alle Tests grün.
Run: `npm test`
Erwartet: `Test Files 17 passed (17)`, `Tests 173 passed (173)`.
- [ ] **Schritt 5: Commit**
```bash
git add src/world/streaming.js src/main.js tests/streaming.test.js
git commit -m "feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen" -m "withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes." -m "Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 2: Materialrollen der Tribüne und Asset-Test je Ort
**Files:**
- Modify: `src/world/places.js:37-52` (`SOLID_MATERIALS` und Kommentar darüber)
- Modify: `tools/assets/places.mjs:26-45` (`NEAR_MATERIALS`, `SIMPLIFY_RATIOS` und Kommentar)
- Test: `tests/places.test.js` (`describe("isSolidMaterial")`), `tests/placesPipeline.test.js` (neuer Block), `tests/placesAsset.test.js` (neu geschrieben)
**Interfaces:**
- Consumes: nichts aus Task 1
- Produces:
- `SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` enthalten die Tribünen-Namen.
- `tests/placesAsset.test.js` prüft jede Datei aus `PLACES` gegen die Tabellen `SOLID_BY_PLACE` und `BUMP_BY_PLACE`. Task 3 fügt `tribuene` zu `PLACES` hinzu, die Zeile in `SOLID_BY_PLACE` steht dann schon.
- [ ] **Schritt 1: Failing Tests schreiben**
In `tests/places.test.js` im `describe("isSolidMaterial", …)` nach dem ersten `it` einfügen:
```js
// Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man
// bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm).
it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => {
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung",
"utility_box_01"]) {
expect(isSolidMaterial(name)).toBe(true);
}
for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp",
"industrial_wall_lamp_glass", "power_box_01"]) {
expect(isSolidMaterial(name)).toBe(false);
}
});
```
In `tests/placesPipeline.test.js` den Import ändern:
```js
import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget }
from "../tools/assets/places.mjs";
```
und am Dateiende anhängen:
```js
describe("Materiallisten der Tribüne", () => {
// Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt
// davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px.
it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => {
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true);
for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false);
});
// Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper,
// Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten).
it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => {
for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp",
"industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) {
expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25);
}
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung",
"Sitzschale"]) {
expect(SIMPLIFY_RATIOS.has(name)).toBe(false);
}
});
});
```
- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen**
Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js`
Erwartet: 3 fehlgeschlagene Tests, nämlich der neue `isSolidMaterial`-Test (`Sichtbeton`: expected false to be true) und beide Tests in „Materiallisten der Tribüne“.
- [ ] **Schritt 3: Listen ergänzen**
In `src/world/places.js` den Kommentar über `SOLID_MATERIALS` ändern und am Listenende (nach der Zeile mit den Bäumen) eine Gruppe anhängen:
```js
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */
export const SOLID_MATERIALS = [
```
```js
// Bäume
"quiver_tree_01_trunk", "quiver_tree_02",
// Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden,
// Glas der Pressekabine, Anschlusskästen am Boden
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01",
];
```
In `tools/assets/places.mjs` in `NEAR_MATERIALS` nach `"chainlink_post", "chainlink_wire",` anhängen:
```js
// Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale",
```
Den Kommentar über `SIMPLIFY_RATIOS` und die Liste so ändern:
```js
/**
* Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit
* Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume
* haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000,
* quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben
* Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298.
*/
export const SIMPLIFY_RATIOS = new Map([
["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15],
["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5],
["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5],
["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25],
["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25],
["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25],
]);
```
- [ ] **Schritt 4: Tests laufen lassen**
Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js`
Erwartet: grün.
Run: `npx vitest run tests/placesAsset.test.js`
Erwartet: 1 Fehlschlag in „enthält jedes feste Material der Kollisionsliste“ mit den acht Tribünen-Namen. Das ist der bekannte Fallstrick: Der Test gleicht die ganze globale Liste mit der Datei des Hood Yard ab.
- [ ] **Schritt 5: Asset-Test auf alle Orte umbauen**
`tests/placesAsset.test.js` vollständig ersetzen durch:
```js
import { statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { PLACES, SOLID_MATERIALS } from "../src/world/places.js";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle
// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile.
const SOLID_BY_PLACE = {
"hood-yard": [
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame",
"ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB",
"rollershutter_window_01", "rollershutter_window_01_graffiti",
"chainlink_post", "chainlink_wire",
"modular_electricity_poles", "barrel_03", "plastic_container",
"quiver_tree_01_trunk", "quiver_tree_02",
],
tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"],
};
// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps.
const BUMP_BY_PLACE = {
"hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"],
};
// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft.
it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => {
expect(Object.values(SOLID_BY_PLACE).flat().sort()).toEqual([...SOLID_MATERIALS].sort());
});
describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => {
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
const bump = BUMP_BY_PLACE[id] ?? [];
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
document = await io.read(file);
}, 30000);
// Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB.
it("ist kleiner als 30 MB", () => {
expect(statSync(file).size).toBeLessThan(30e6);
});
// Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest.
it("nutzt meshopt und WebP und hat keine Instanzen", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"]));
expect(used).not.toContain("EXT_mesh_gpu_instancing");
});
it("hat nur WebP-Texturen bis 1024 px", () => {
for (const texture of document.getRoot().listTextures()) {
expect(texture.getMimeType()).toBe("image/webp");
expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(1024);
}
});
// Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der
// Container wäre flach.
it.runIf(bump.length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material]));
expect(used).toContain(EXT_MATERIALS_BUMP);
for (const name of bump) {
expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy();
}
});
// Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung
// durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE.
it("enthält jedes feste Material seines Orts", () => {
const names = document.getRoot().listMaterials().map((material) => material.getName());
expect(SOLID_BY_PLACE[id]).toBeDefined();
expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]);
});
// Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche.
it("passt auf 5 cm zu den Außenmaßen in PLACES", () => {
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
const { minX, maxX, minZ, maxZ } = place.bounds;
expect(Math.abs(min[0] - minX)).toBeLessThan(0.05);
expect(Math.abs(max[0] - maxX)).toBeLessThan(0.05);
expect(Math.abs(min[2] - minZ)).toBeLessThan(0.05);
expect(Math.abs(max[2] - maxZ)).toBeLessThan(0.05);
});
});
```
- [ ] **Schritt 6: Alle Tests laufen lassen**
Run: `npm test`
Erwartet: `Test Files 17 passed (17)`, `Tests 177 passed (177)`. Gegenüber dem Ausgangsstand kommen hinzu:
- 3 Tests aus Task 1
- 1 Test aus `places.test.js`
- 2 Tests aus `placesPipeline.test.js`
- der neue Verteilungstest im Asset-Test
- [ ] **Schritt 7: Commit**
```bash
git add src/world/places.js tools/assets/places.mjs tests/places.test.js tests/placesPipeline.test.js tests/placesAsset.test.js
git commit -m "feat(world): Materialrollen der Tribüne, Asset-Test je Ort" -m "Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard." -m "Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 3: Tribüne aufbereiten und eintragen
**Files:**
- Create: `public/models/places/tribuene.glb` (Ergebnis von `places.mjs`)
- Modify: `src/world/places.js` (Kommentar über `PLACES`, neuer Eintrag)
- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (Abschnitt „Budget“)
- Test: `tests/places.test.js` (`describe("PLACES")`), `tests/placesSite.test.js`
**Interfaces:**
- Consumes:
- `withoutReplacedBuildings(chunk, points)` aus Task 1
- die Materiallisten und `SOLID_BY_PLACE.tribuene` aus Task 2
- `node tools/assets/places.mjs <quelle.glb> <id>`
- Produces: `PLACES` mit dem Eintrag `tribuene`, den `main.js` (Task 1) und `PlaceLayer` verwenden, und `public/models/places/tribuene.glb`.
- [ ] **Schritt 1: Aufbereiten**
Run: `node tools/assets/places.mjs /Users/Kenso/Downloads/tribuene.glb tribuene`
Erwartet (am Prototyp gemessen, Laufzeit unter 5 s):
- `vorher 6.3 MB`, darin `triangles: 117916`, `gpuMB: 53`
- `nachher 1.7 MB`, darin:
- `materials: 14`, `primitives: 14`, `triangles: 69557`
- `textures: { '512x512 image/webp': 23, '256x256 image/webp': 12, '768x768 image/webp': 4 }`
- `gpuMB: 49`, `bounds: { min: [ -20.8, 0, -14.82 ], max: [ 20.8, 12.57, 0.8 ] }`
Weicht eine Zahl deutlich ab, anhalten und melden.
- [ ] **Schritt 2: Eintrag in `PLACES`**
In `src/world/places.js` den Kommentar über `PLACES` ergänzen:
```js
/**
* at: Welt-x und Welt-y in Metern, wie auf der Minimap — nie three-z. rotation: Grad gegen den
* Uhrzeigersinn, von oben mit Norden oben (wie add_building in corrections.toml). lift: Meter auf die
* Modellwurzel. bounds: Außenmaße in Modellkoordinaten (x, z); das Spiel braucht sie nicht, die Tests
* prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. replaces
* (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim
* Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js).
*/
```
und nach dem Eintrag `hood-yard` anfügen:
```js
{
id: "tribuene",
url: "./models/places/tribuene.glb",
// Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers);
// längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten.
at: [1409.9, 1051.1],
// Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate.
rotation: 45,
// Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts.
lift: 0,
bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 },
// Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6).
replaces: [[1403.25, 1057.75]],
},
```
- [ ] **Schritt 3: Tests laufen lassen, die vorhandenen Datentests müssen anschlagen**
Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js`
Erwartet:
- `tribuene.glb` im Asset-Test: 5 Tests grün, 1 übersprungen (Bump).
- Rot wird „hat für jeden Ort id, url, at, rotation, lift und bounds“ in `places.test.js`, weil `replaces` ein unerwartetes Feld ist.
- Rot wird „Ort tribuene auf der Karte > liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“. Die Liste zeigt Punkte im alten Gebäude.
- Alles andere bleibt grün.
- [ ] **Schritt 4: Form-Test lässt `replaces` zu**
In `tests/places.test.js` den Test im `describe("PLACES", …)` ersetzen durch:
```js
// Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds
// prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg.
it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => {
for (const { replaces = [], ...entry } of PLACES) {
expect(entry).toEqual({
id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/),
at: [expect.any(Number), expect.any(Number)], rotation: expect.any(Number), lift: expect.any(Number),
bounds: { minX: expect.any(Number), maxX: expect.any(Number), minZ: expect.any(Number), maxZ: expect.any(Number) },
});
for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]);
}
});
```
- [ ] **Schritt 5: Lage-Test kennt ersetzte Gebäude**
In `tests/placesSite.test.js`:
Import ergänzen (nach der Zeile mit `props.js`):
```js
import { withoutReplacedBuildings } from "../src/world/streaming.js";
```
Nach der Funktion `chunk(i, j)` einfügen:
```js
// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in
// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht.
const REPLACED = PLACES.flatMap((place) => place.replaces ?? []);
const standing = new Map();
function standingBuildings(i, j) {
const key = `${i}_${j}`;
if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings);
return standing.get(key);
}
```
Im Test „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ den Kommentar und die Gebäudezeile ändern:
```js
// Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben
// in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht.
```
```js
const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));
```
Direkt nach diesem Test einfügen:
```js
// Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt. Der
// Punkt träfe dann nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse oder einen außerhalb.
it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => {
const bad = place.replaces.filter(([X, Y]) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly));
return hits.length !== 1 || !insideBounds(place, X, Y);
});
expect(bad).toEqual([]);
});
```
- [ ] **Schritt 6: Tests laufen lassen, sie müssen bestehen**
Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js`
Erwartet: alles grün. Übersprungen sind nur „gibt den Bump-Materialien ihre Bump-Textur“ bei `tribuene.glb` und „ersetzt mit jedem replaces-Punkt …“ beim Hood Yard.
- [ ] **Schritt 7: Gegenprobe, der neue Datentest muss anschlagen**
In `src/world/places.js` den Punkt vorübergehend auf `replaces: [[1403.25, 1087.75]]` setzen (30 m nördlich, auf der Straße).
Run: `npx vitest run tests/placesSite.test.js`
Erwartet für `tribuene`: rot sind „liegt ganz auf Land, … in keinem Gebäude“ (der Kasten zählt wieder) und „ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich“ (`[[1403.25, 1087.75]]`).
Danach zurück auf `replaces: [[1403.25, 1057.75]]`. Run: `npx vitest run tests/placesSite.test.js`, erwartet grün.
- [ ] **Schritt 8: Budget in der Spec**
In `docs/superpowers/specs/2026-09-14-tribuene-design.md` den Abschnitt „## Budget“ vollständig ersetzen, mit den Werten aus dem Bericht von Schritt 1:
```markdown
## Budget
- **Gemessen an der Aufbereitung (2026-09-14):**
- 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls)
- 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px
- geschätzt 49 MB Grafikspeicher, Außenmaße unverändert
- Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298.
- **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB.
```
Weicht der Bericht vom Prototyp ab, gelten die Werte des Berichts.
- [ ] **Schritt 9: Alle Tests laufen lassen**
Run: `npm test`
Erwartet: `Test Files 17 passed (17)`, `Tests 186 passed | 2 skipped (188)`. Gegenüber Task 2 kommen hinzu:
- 6 Asset-Tests der Tribüne, davon 5 grün und 1 übersprungen
- 4 Lage-Tests der Tribüne, alle grün
- beim Hood Yard der neue Lage-Test, übersprungen
- [ ] **Schritt 10: Commit**
```bash
git add public/models/places/tribuene.glb src/world/places.js tests/places.test.js tests/placesSite.test.js docs/superpowers/specs/2026-09-14-tribuene-design.md
git commit -m "feat(world): Tribüne im Park J6 statt des alten Gebäudekastens" -m "tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft." -m "Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 4: Prüfung im Browser (Controller, nicht an Subagenten)
Das übernimmt die Sitzung selbst. Sie braucht den Browser-Bereich, einen Eintrag in `.claude/launch.json` des Hauptordners (vorher den Nutzer fragen) und Rückfragen an den Nutzer. Grundlage ist `.claude/skills/placing-editor-models/browser-checks.md`, die Abschnitte werden unten mit §N zitiert.
**Files:**
- Create, nie committen, am Ende löschen: `index-debug.html` (§2)
- Modify, am Ende zurücknehmen: `/Users/Kenso/Developer/gta6/.claude/launch.json` (§1, Eintrag `place-check`, Port 5181, `cd /Users/Kenso/Developer/gta6/.claude/worktrees/tribuene && npx vite --port 5181 --strictPort`)
- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (neuer Punkt „Gemessen im Spiel“ unter „Fehlerfälle und Risiken“)
**Werte** (Welt → three: z = −y):
| Zweck | Welt (x \| y) | three |
|---|---|---|
| Wurzel der Tribüne | (1409,9 \| 1051,1) | `position` (1409.9, 0, −1051.1), `rotation.y` = π/4 |
| Sprungziel auf dem Spielfeld | (1420,5 \| 1040,5) | — |
| Draufsicht | Mitte (1404,94 \| 1056,06) | `position [1404.94, 60, -1056.06]`, `target [1404.94, 0, -1056.06]`, `up [0, 0, -1]` |
| Blick vom Spielfeld | (1427,58 \| 1033,42) | `position [1427.58, 1.7, -1033.42]`, `target [1404.94, 5, -1056.06]` |
| Blick von hinten | (1388,69 \| 1072,31) | `position [1388.69, 1.7, -1072.31]`, `target [1404.94, 5, -1056.06]` |
| Streifen des alten Kastens hinter der Tribüne | (1397,88 \| 1063,12) | Strahl von (1397.88, 60, −1063.12) nach unten |
| Kapsel hinter der Rückwand | (1389,39 \| 1054,64) | `capsule.position.set(1389.39, 1, -1054.64)`, Blick 135° |
| Kapsel vor dem Tunnel (x 6–8) | (1402,83 \| 1068,07) | `capsule.position.set(1402.83, 1, -1068.07)`, Blick 135° |
| Kapsel auf der Sitzstufe y 6,0 | (1394,34 \| 1049,69) | `capsule.position.set(1394.34, 7, -1049.69)`, Blick 45° (längs der Reihe) |
- [ ] **Schritt 1: Server und Debug-Seite**
- Den Nutzer fragen, ob der Eintrag `place-check` in `.claude/launch.json` des Hauptordners gesetzt werden darf.
- Dann `ExitWorktree(keep)`, Eintrag setzen, `preview_start({ name: "place-check" })`, `EnterWorktree({ path: "/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene" })`.
- `index-debug.html` nach §2 anlegen, `resize_window` auf 1280 × 800 setzen, `http://localhost:5181/index-debug.html` öffnen und den Hook nach §3 einrichten.
- [ ] **Schritt 2: Vorwärmen am Startpunkt**
- Warten, bis `renderer.info.memory.textures` 5 s lang gleich bleibt.
- Die verschiedenen Texturen zählen, die über `scene.traverse` an Materialien hängen.
- Erwartet: Die GPU-Zahl liegt um mindestens 108 höher (69 Hood Yard + 39 Tribüne), weil beide Orte vorgeladen, aber noch nicht in der Szene sind.
- [ ] **Schritt 3: Sprung**
- Minimap nach §4 öffnen und einen Screenshot machen.
- Bei (1420,5 \| 1040,5) klicken und warten, bis die Wurzel der Tribüne in `scene.children` hängt.
- Die Konsole mit `read_console_messages({ onlyErrors: true })` lesen: keine Fehler.
- [ ] **Schritt 4: Alter Kasten weg**
- Mit `THREE.Raycaster` von (1397.88, 60, −1063.12) senkrecht nach unten gegen `scene.children` schießen.
- Erwartet: Der erste Treffer mit `object.isMesh` liegt unter y 1. Die Oberkante des alten Kastens läge bei 18,6 m.
- Denselben Strahl an der alten Mitte (1403.25, 60, −1057.75) schießen: Der erste Treffer liegt höchstens bei 12,6 m, also auf dem Dach der Tribüne.
- [ ] **Schritt 5: Kollisionsmarken und Quantisierung (§5)**
- Die Meshes ohne `userData.excludeFromCollider` haben genau die Materialnamen `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`.
- Alle haben `Float32Array`-Positionen.
- [ ] **Schritt 6: Blicke (§6)**
- Draufsicht, Blick vom Spielfeld und Blick von hinten aufnehmen und mit `decode-view.py` speichern.
- Die Bilder per `SendUserFile` an den Nutzer schicken.
- Achten auf: zerrissene Kabel oder Leuchten, Schatten des Glases, Helligkeit.
- [ ] **Schritt 7: Kollision (§7)**, jede Sekunde die Lage notieren, danach `keyup`:
- **Rückwand:** Kapsel hinter die Rückwand setzen, Blick 135°, 15 s `KeyW`. Erwartet: Sie steht bei y 0,75 und bleibt wenige cm vor der Wand stehen, also Modell-z knapp unter −14,8.
- **Tunnel:** Kapsel vor den Tunnel setzen, Blick 135°, 25 s `KeyW`. Festhalten, ob sie über die 25-cm-Kante hineinkommt und die Treppe hinaufsteigt. Nur feststellen, nichts ändern.
- **Sitzstufe:** Kapsel auf die Sitzstufe setzen, Blick 45°, 15 s `KeyW`. Erwartet: Sie steht bei etwa y 6,75 und läuft durch die Sitzschalen hindurch, ohne Halt bis zum nächsten Geländer.
- [ ] **Schritt 8: Kosten (§8)**
- Den Median aus 60 Bildern mit `gl.finish` messen: Blick vom Spielfeld, einmal mit und einmal ohne die Wurzel der Tribüne in der Szene.
- Dasselbe in der Draufsicht.
- Erwartet: deutlich unter dem Hood Yard (+0,5 ms).
- [ ] **Schritt 9: Aufräumen**
- `window.__view = null`, `index-debug.html` löschen, `preview_stop`.
- `ExitWorktree(keep)`, den Eintrag `place-check` aus `.claude/launch.json` entfernen, `EnterWorktree(path)`.
- `git status --short` zeigt nur die Spec.
- [ ] **Schritt 10: Messwerte in die Spec und Commit**
- Unter „Fehlerfälle und Risiken“ einen Punkt **Gemessen im Spiel (2026-09-14)** anlegen:
- Kosten mit und ohne Tribüne
- Vorwärmen (Differenz des Texturzählers)
- Ergebnis der drei Kollisionsläufe
- Auffälligkeiten aus den Blicken
- Commit mit `git add docs/superpowers/specs/2026-09-14-tribuene-design.md`, Nachricht `docs: Tribünen-Spec mit Messwerten aus dem Spiel` plus Co-Authored-By-Zeile.