Files
gta6/docs/superpowers/plans/2026-09-14-hood-yard.md
T
knsoandClaude Opus 5 3321a029fa docs: Umsetzungsplan für den Hood Yard, Spec am Prototyp geschärft
Prototyp am echten Modell: 653,6 MB -> 16,2 MB, 2,9 Mio. -> 658 000 Dreiecke,
Bump-Maps erhalten. Radien geändert: Der Hof erscheint erst ab 250 m und
verschwindet ab 450 m, sonst stünde er vom Startpunkt aus über ungeladenem
Boden. Vereinfachung je Material statt eines gemeinsamen Anteils.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 10:24:46 +02:00

1549 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hood Yard — 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:** Den im Editor three-d-stage gebauten Hood Yard 1:1 an die Stelle der kleinen Gebäude zwischen Baseball- und Fußballfeld im Park J6/K6 setzen, verkleinert von 653 MB auf rund 16 MB, mit Schatten und Kollision.
**Architecture:** Ein Node-Skript (`tools/assets/places.mjs`) verkleinert die Quelldatei mit `@gltf-transform` und erhält über eine eigene Erweiterung (`tools/assets/bumpExtension.mjs`) das Wellblech der Container. Eine neue Orte-Schicht (`src/world/places.js`) lädt das Ergebnis ab 600 m vor, setzt es ab 250 m in die Szene und meldet einen statischen Collider an, ab 450 m nimmt sie es wieder heraus. `main.js` ruft sie in `tick()` neben dem Chunk-Streaming auf.
**Tech Stack:** three.js 0.184, Vite 7, vitest 3, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0.
**Spec:** `docs/superpowers/specs/2026-09-14-hood-yard-design.md`
## Global Constraints
- Lage: `at: [1489, 1067.5]`, `rotation: 0`, `lift: -0.28`, `bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 }`. `at` ist Welt-x und Welt-y, nie three-z.
- Radien: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`.
- Texturen: WebP, 1024 px für die Nahflächen aus der Spec, 512 px für alle anderen, Qualität 80, Normal-Maps 92.
- Vereinfachung: 0,15 für `quiver_tree_01_leaf`, `quiver_tree_01_trunk`, `quiver_tree_02`; 0,5 für `searsia_lucida*` und `grass_medium_*`; 0,25 für `modular_electricity_poles_pieces` und `security_camera_01`; `error` 0,01. Kein `instance()`, kein `palette()`.
- Ergebnis `public/models/places/hood-yard.glb` kleiner als 30 MB. Die Quelle `~/Downloads/hood-yard.glb` (653 MB) kommt nie ins Repo.
- Versionen exakt gepinnt: `@gltf-transform/core`, `@gltf-transform/extensions`, `@gltf-transform/functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0.
- Nicht anfassen: `src/world/streaming.js`, `src/world/props.js`, `public/data/`, die Minimap.
- Gearbeitet wird im Arbeitsbaum `.claude/worktrees/hood-yard` auf Branch `leonida/hood-yard`. Gestaget wird immer mit Pfaden, nie mit `git add -A`.
- Kommentare und Testnamen auf Deutsch, im Stil der vorhandenen Dateien (Kommentar „Bruch:“ über Tests, die eine Fehlerart abfangen).
- Tests: `npx vitest run <datei>`, alle mit `npm test`. Ausgangsstand: 12 Testdateien, 132 Tests.
## Vorbereitung: Arbeitsbaum (einmal vor Task 1)
Im Hauptordner liegen nicht committete Änderungen einer anderen Sitzung (`tools/extract`, drei Specs, Controller) und unversionierte Kopien aus dem Controller-Repo. Darunter ist ein `vite.config.ts`, das vitest dem `vite.config.js` vorzieht und das dort „No test files found“ auslöst. Der Arbeitsbaum hat beides nicht.
- [ ] **Schritt 1: Arbeitsbaum anlegen** (mit superpowers:using-git-worktrees, aus dem Hauptordner)
```bash
git worktree add .claude/worktrees/hood-yard -b leonida/hood-yard
cp -Rc node_modules .claude/worktrees/hood-yard/node_modules
cp three-player-controller/package.json .claude/worktrees/hood-yard/three-player-controller/
cp -R three-player-controller/dist .claude/worktrees/hood-yard/three-player-controller/dist
```
`cp -c` klont auf APFS ohne Kopierzeit. Das Submodul `three-player-controller` ist im Arbeitsbaum nicht ausgecheckt. `dist/` und `package.json` reichen dem Spiel, denn `node_modules/three-player-controller` zeigt relativ auf `../three-player-controller`. Git ignoriert Dateien in einem nicht ausgecheckten Submodul, das ist geprüft.
- [ ] **Schritt 2: Ausgangsstand prüfen** (alle weiteren Befehle im Arbeitsbaum)
```bash
cd .claude/worktrees/hood-yard
git status --short
npm test
```
Erwartet: `git status` gibt nichts aus. `npm test` meldet `Test Files 12 passed (12)` und `Tests 132 passed (132)`.
---
### Task 1: `EXT_materials_bump` für gltf-transform
**Files:**
- Modify: `package.json`, `package-lock.json` (Entwicklungsabhängigkeiten)
- Create: `tools/assets/bumpExtension.mjs`
- Test: `tests/bumpExtension.test.js`
**Interfaces:**
- Produces: `export const EXT_MATERIALS_BUMP = "EXT_materials_bump"`; `export class Bump extends ExtensionProperty` mit `getBumpFactor(): number`, `setBumpFactor(factor: number): this`, `getBumpTexture(): Texture | null`, `getBumpTextureInfo(): TextureInfo | null`, `setBumpTexture(texture: Texture | null): this`; `export class EXTMaterialsBump extends Extension` mit `createBump(): Bump`.
- [ ] **Step 1: Abhängigkeiten installieren**
```bash
npm install --save-dev --save-exact @gltf-transform/core@4.5.0 @gltf-transform/extensions@4.5.0 @gltf-transform/functions@4.5.0 sharp@0.35.4 meshoptimizer@1.2.0
```
Erwartet: In `package.json` stehen unter `devDependencies` fünf neue Einträge ohne `^`. `npm test` meldet weiter 12 Dateien und 132 Tests.
- [ ] **Step 2: Write the failing test**
`tests/bumpExtension.test.js`:
```js
import { describe, expect, it } from "vitest";
import { Document, NodeIO } from "@gltf-transform/core";
import { KHRTextureTransform } from "@gltf-transform/extensions";
import { dedup, prune } from "@gltf-transform/functions";
import sharp from "sharp";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
/** 8 × 8 px mit wechselnden Werten — prune() ersetzt einfarbige Texturen sonst durch Faktoren. */
function pattern() {
const data = Buffer.alloc(8 * 8 * 3);
for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;
return sharp(data, { raw: { width: 8, height: 8, channels: 3 } }).png().toBuffer();
}
/** Ein Dreieck mit dem Material cont_a_side, Bump-Stärke 2,2 und Wellenteilung [3.2, 1]. */
async function bumpDocument() {
const document = new Document();
document.createBuffer();
const bumpExtension = document.createExtension(EXTMaterialsBump);
const transformExtension = document.createExtension(KHRTextureTransform);
const texture = document.createTexture("wellblech").setImage(await pattern()).setMimeType("image/png");
const bump = bumpExtension.createBump().setBumpFactor(2.2).setBumpTexture(texture);
bump.getBumpTextureInfo().setExtension("KHR_texture_transform", transformExtension.createTransform().setScale([3.2, 1]));
const material = document.createMaterial("cont_a_side").setExtension(EXT_MATERIALS_BUMP, bump);
const positions = document.createAccessor().setType("VEC3").setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const primitive = document.createPrimitive().setMaterial(material).setAttribute("POSITION", positions);
document.createScene().addChild(document.createNode().setMesh(document.createMesh().addPrimitive(primitive)));
return document;
}
/** JSON-Teil einer .glb: 12 Byte Kopf, dann Länge und Typ des ersten Blocks, dann das JSON. */
function glbJson(glb) {
const view = new DataView(glb.buffer, glb.byteOffset, glb.byteLength);
return JSON.parse(new TextDecoder().decode(glb.subarray(20, 20 + view.getUint32(12, true))));
}
describe("EXT_materials_bump", () => {
const io = () => new NodeIO().registerExtensions([EXTMaterialsBump, KHRTextureTransform]);
// Bruch: @gltf-transform verwirft die Erweiterung (Wellblech flach), prune entfernt die Textur als
// unbenutzt, oder die Wellenteilung an der bumpTexture geht verloren (Wellen 3,2-mal zu breit).
it("übersteht dedup, prune, Schreiben und Einlesen mit Stärke, Textur und Teilung", async () => {
const document = await bumpDocument();
await document.transform(dedup(), prune());
const read = await io().readBinary(await io().writeBinary(document));
const bump = read.getRoot().listMaterials()[0].getExtension(EXT_MATERIALS_BUMP);
expect(bump.getBumpFactor()).toBe(2.2);
expect(bump.getBumpTexture().getSize()).toEqual([8, 8]);
expect(bump.getBumpTextureInfo().getExtension("KHR_texture_transform").getScale()).toEqual([3.2, 1]);
});
// Bruch: Feldnamen oder Aufbau weichen von der Spezifikation ab — three.js (GLTFLoader) liest
// genau materials[i].extensions.EXT_materials_bump.bumpFactor und .bumpTexture.index.
it("schreibt das JSON so, wie three.js es liest", async () => {
const json = glbJson(await io().writeBinary(await bumpDocument()));
expect(json.extensionsUsed).toContain(EXT_MATERIALS_BUMP);
const def = json.materials[0].extensions[EXT_MATERIALS_BUMP];
expect(def.bumpFactor).toBe(2.2);
expect(json.textures[def.bumpTexture.index]).toBeDefined();
expect(def.bumpTexture.extensions.KHR_texture_transform.scale).toEqual([3.2, 1]);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run tests/bumpExtension.test.js`
Expected: FAIL, `Failed to load url ../tools/assets/bumpExtension.mjs` (Datei fehlt)
- [ ] **Step 4: Write minimal implementation**
`tools/assets/bumpExtension.mjs`:
```js
// EXT_materials_bump für @gltf-transform (docs/superpowers/specs/2026-09-14-hood-yard-design.md).
// @gltf-transform 4.5.0 kennt die Erweiterung nicht und verwirft sie beim Einlesen. Das Wellblech der
// Container aus three-d-stage steckt aber allein darin, die Container selbst sind flache Kästen.
// Aufgebaut wie KHR_materials_transmission in @gltf-transform/extensions: bumpTexture ist eine echte
// Texturreferenz, damit dedup, prune und compressTexture sie sehen. Ihre TextureInfo läuft über
// setTextureInfo/createTextureInfoDef, so kommt KHR_texture_transform (die Wellenteilung, etwa
// scale [3.2, 1]) mit. three.js liest die Erweiterung selbst (GLTFLoader: bumpFactor -> bumpScale,
// bumpTexture -> bumpMap).
import { Extension, ExtensionProperty, PropertyType, TextureChannel, TextureInfo } from "@gltf-transform/core";
export const EXT_MATERIALS_BUMP = "EXT_materials_bump";
export class Bump extends ExtensionProperty {
static EXTENSION_NAME = EXT_MATERIALS_BUMP;
init() {
this.extensionName = EXT_MATERIALS_BUMP;
this.propertyType = "Bump";
this.parentTypes = [PropertyType.MATERIAL];
}
getDefaults() {
return Object.assign(super.getDefaults(), {
bumpFactor: 1,
bumpTexture: null,
bumpTextureInfo: new TextureInfo(this.graph, "bumpTextureInfo"),
});
}
getBumpFactor() {
return this.get("bumpFactor");
}
setBumpFactor(factor) {
return this.set("bumpFactor", factor);
}
getBumpTexture() {
return this.getRef("bumpTexture");
}
getBumpTextureInfo() {
return this.getRef("bumpTexture") ? this.getRef("bumpTextureInfo") : null;
}
setBumpTexture(texture) {
return this.setRef("bumpTexture", texture, { channels: TextureChannel.R });
}
}
export class EXTMaterialsBump extends Extension {
static EXTENSION_NAME = EXT_MATERIALS_BUMP;
extensionName = EXT_MATERIALS_BUMP;
prereadTypes = [PropertyType.MESH];
prewriteTypes = [PropertyType.MESH];
createBump() {
return new Bump(this.document.getGraph());
}
read() {
return this;
}
write() {
return this;
}
preread(context) {
const materialDefs = context.jsonDoc.json.materials ?? [];
const textureDefs = context.jsonDoc.json.textures ?? [];
materialDefs.forEach((materialDef, index) => {
const def = materialDef.extensions?.[EXT_MATERIALS_BUMP];
if (!def) return;
const bump = this.createBump();
context.materials[index].setExtension(EXT_MATERIALS_BUMP, bump);
if (def.extras) bump.setExtras(def.extras);
if (def.bumpFactor !== undefined) bump.setBumpFactor(def.bumpFactor);
if (def.bumpTexture !== undefined) {
bump.setBumpTexture(context.textures[textureDefs[def.bumpTexture.index].source]);
context.setTextureInfo(bump.getBumpTextureInfo(), def.bumpTexture);
}
});
return this;
}
prewrite(context) {
for (const material of this.document.getRoot().listMaterials()) {
const bump = material.getExtension(EXT_MATERIALS_BUMP);
if (!bump) continue;
const materialDef = context.jsonDoc.json.materials[context.materialIndexMap.get(material)];
const def = context.createPropertyDef(bump);
def.bumpFactor = bump.getBumpFactor();
const texture = bump.getBumpTexture();
if (texture) def.bumpTexture = context.createTextureInfoDef(texture, bump.getBumpTextureInfo());
materialDef.extensions = materialDef.extensions ?? {};
materialDef.extensions[EXT_MATERIALS_BUMP] = def;
}
return this;
}
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run tests/bumpExtension.test.js`
Expected: PASS, 2 Tests
- [ ] **Step 6: Commit**
```bash
git add package.json package-lock.json tools/assets/bumpExtension.mjs tests/bumpExtension.test.js
git commit -m "$(cat <<'EOF'
feat(assets): EXT_materials_bump für gltf-transform
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Aufbereitungs-Skript `tools/assets/places.mjs`
**Files:**
- Create: `tools/assets/places.mjs`
- Test: `tests/placesPipeline.test.js`
**Interfaces:**
- Consumes: `EXT_MATERIALS_BUMP`, `EXTMaterialsBump` aus Task 1.
- Produces: `export const NEAR_TEXTURE_SIZE = 1024`; `export const FAR_TEXTURE_SIZE = 512`; `export const NEAR_MATERIALS: Set<string>`; `export const SIMPLIFY_RATIOS: Map<string, number>`; `export async function optimizePlace(document: Document): Promise<Document>`; `export function describePlace(document: Document): { materials, primitives, triangles, textures: Record<string, number>, gpuMB, bounds: { min: number[], max: number[] } }`. Aufruf: `node tools/assets/places.mjs <quelle.glb> <id>` schreibt `public/models/places/<id>.glb`.
- [ ] **Step 1: Write the failing test**
`tests/placesPipeline.test.js`:
```js
import { beforeAll, describe, expect, it } from "vitest";
import { Document } from "@gltf-transform/core";
import sharp from "sharp";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace } from "../tools/assets/places.mjs";
/** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */
function stripes(seed) {
const size = 1100;
const data = Buffer.alloc(size * size * 3);
for (let i = 0; i < data.length; i++) data[i] = (Math.floor(i / 3) % size) * seed & 0xff;
return sharp(data, { raw: { width: size, height: size, channels: 3 } }).png().toBuffer();
}
/** n × n Quadrate in der Ebene (2·n² Dreiecke), indiziert. */
function grid(document, n) {
const positions = [];
const indices = [];
for (let z = 0; z <= n; z++) for (let x = 0; x <= n; x++) positions.push(x, 0, z);
for (let z = 0; z < n; z++) {
for (let x = 0; x < n; x++) {
const a = z * (n + 1) + x, b = a + 1, c = a + n + 1, d = c + 1;
indices.push(a, c, b, b, c, d);
}
}
return document.createPrimitive()
.setAttribute("POSITION", document.createAccessor().setType("VEC3").setArray(new Float32Array(positions)))
.setIndices(document.createAccessor().setType("SCALAR").setArray(new Uint32Array(indices)));
}
/** Ein Container (Nahfläche mit Bump) und ein Grasstück (Pflanze mit Normal-Map), je ein Knoten. */
async function sampleDocument() {
const document = new Document();
document.createBuffer();
const bumpExtension = document.createExtension(EXTMaterialsBump);
const [near, nearBump, far, farNormal] = await Promise.all([1, 3, 5, 7].map(stripes));
const texture = (name, image) => document.createTexture(name).setImage(image).setMimeType("image/png");
const container = document.createMaterial("cont_a_side").setBaseColorTexture(texture("near", near));
container.setExtension(EXT_MATERIALS_BUMP,
bumpExtension.createBump().setBumpFactor(2.2).setBumpTexture(texture("nearBump", nearBump)));
const grass = document.createMaterial("grass_medium_01")
.setBaseColorTexture(texture("far", far))
.setNormalTexture(texture("farNormal", farNormal));
const scene = document.createScene();
scene.addChild(document.createNode("container").setMesh(document.createMesh().addPrimitive(grid(document, 1).setMaterial(container))));
scene.addChild(document.createNode("grass").setMesh(document.createMesh().addPrimitive(grid(document, 40).setMaterial(grass))));
return { document, container, grass };
}
const trianglesOf = (document, name) => document.getRoot().listMeshes()
.flatMap((mesh) => mesh.listPrimitives())
.filter((primitive) => primitive.getMaterial().getName() === name)
.reduce((sum, primitive) => sum + primitive.getIndices().getCount() / 3, 0);
describe("optimizePlace", () => {
let sample;
beforeAll(async () => {
sample = await sampleDocument();
await optimizePlace(sample.document);
}, 60000);
// Bruch: Nahflächen so unscharf wie Pflanzen, Größen vertauscht, oder die Bump-Map zählt nicht
// zu ihrem Material (sie hängt über die Erweiterung dran, nicht direkt am Material).
it("verkleinert Texturen je nach Material auf 1024 oder 512 px und wandelt sie in WebP", () => {
const { document, container, grass } = sample;
const bumpTexture = container.getExtension(EXT_MATERIALS_BUMP).getBumpTexture();
expect(container.getBaseColorTexture().getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]);
expect(bumpTexture.getSize()).toEqual([NEAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE]);
expect(grass.getBaseColorTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]);
expect(grass.getNormalTexture().getSize()).toEqual([FAR_TEXTURE_SIZE, FAR_TEXTURE_SIZE]);
for (const texture of document.getRoot().listTextures()) expect(texture.getMimeType()).toBe("image/webp");
});
// Bruch: die Vereinfachung trifft Container und Schuppen (Löcher in Wänden) oder lässt Pflanzen
// unberührt (die Köcherbäume blieben bei 2,2 Mio. Dreiecken).
it("vereinfacht nur Pflanzen und Kleinteile und behält die Materialnamen", () => {
const { document } = sample;
expect(trianglesOf(document, "cont_a_side")).toBe(2);
expect(trianglesOf(document, "grass_medium_01")).toBeGreaterThan(0);
expect(trianglesOf(document, "grass_medium_01")).toBeLessThanOrEqual(3200 * 0.5);
});
// Bruch: das Wellblech geht verloren, die Geometrie bleibt unkomprimiert, oder es entstehen
// GPU-Instanzen, deren Matrizen die Kollision nicht liest.
it("erhält die Bump-Map, komprimiert mit meshopt und legt keine Instanzen an", () => {
const { document, container } = sample;
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(container.getExtension(EXT_MATERIALS_BUMP).getBumpFactor()).toBe(2.2);
expect(used).toEqual(expect.arrayContaining([EXT_MATERIALS_BUMP, "EXT_meshopt_compression", "EXT_texture_webp"]));
expect(used).not.toContain("EXT_mesh_gpu_instancing");
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/placesPipeline.test.js`
Expected: FAIL, `Failed to load url ../tools/assets/places.mjs`
- [ ] **Step 3: Write minimal implementation**
`tools/assets/places.mjs`:
```js
// Bereitet ein Modell aus dem Editor three-d-stage für die Orte-Schicht auf (src/world/places.js,
// docs/superpowers/specs/2026-09-14-hood-yard-design.md):
//
// node tools/assets/places.mjs <quelle.glb> <id> -> public/models/places/<id>.glb
//
// Die Quelle bleibt außerhalb des Repos (hood-yard.glb: 653 MB, 105 PNG in 2048 px, rund 2,9 Mio.
// gezeichnete Dreiecke). Anders als tools/assets/props.mjs nicht über die gltf-transform-CLI: die
// verwirft EXT_materials_bump, und darin steckt das Wellblech der Container (bumpExtension.mjs).
import { mkdir, stat } from "node:fs/promises";
import { dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { ExtensionProperty, NodeIO, PropertyType } from "@gltf-transform/core";
import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions";
import { compressTexture, dedup, flatten, getBounds, join, listTextureSlots, meshopt, prune,
simplifyPrimitive, weld } from "@gltf-transform/functions";
import { MeshoptEncoder, MeshoptSimplifier } from "meshoptimizer";
import sharp from "sharp";
import { EXTMaterialsBump } from "./bumpExtension.mjs";
export const NEAR_TEXTURE_SIZE = 1024;
export const FAR_TEXTURE_SIZE = 512;
const QUALITY = 80;
// Verlustbehaftetes WebP erzeugt in Normal-Maps sonst Blockmuster, die im Streiflicht auffallen.
const NORMAL_QUALITY = 92;
/** Flächen, an denen der Spieler direkt vorbeiläuft: ihre Texturen in 1024 px, alle anderen in 512 px. */
export const NEAR_MATERIALS = new Set([
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass",
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame", "contHardware",
"ironPlain", "ironTeal", "ironBlue", "osb", "osbB", "rollershutter_window_01", "rollershutter_window_01_graffiti",
"chainlink_post", "chainlink_wire",
]);
/**
* Anteil der Dreiecke, der je Material bleibt; Container, Schuppen, Zaun und Boden 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.
*/
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],
]);
// Anteil am Mesh-Radius. Nach join umspannt ein Pflanzenmaterial den ganzen Hof (~17 m Radius), die
// Grenze liegt dann bei ~17 cm und greift kaum — die Anteile oben steuern (am Modell gemessen: 0,002
// und 0,01 ergeben dieselben Dreieckszahlen).
const SIMPLIFY_ERROR = 0.01;
/** Materialien, die eine Textur benutzen: direkt oder über eine Erweiterung wie EXT_materials_bump. */
function materialsUsing(texture) {
const materials = new Set();
for (const parent of texture.listParents()) {
if (parent.propertyType === PropertyType.MATERIAL) materials.add(parent);
if (!(parent instanceof ExtensionProperty)) continue;
for (const owner of parent.listParents()) {
if (owner.propertyType === PropertyType.MATERIAL) materials.add(owner);
}
}
return [...materials];
}
/** Verkleinert ein eingelesenes Dokument an Ort und Stelle, in der Reihenfolge der Spec. */
export async function optimizePlace(document) {
await Promise.all([MeshoptEncoder.ready, MeshoptSimplifier.ready]);
// Kein instance(): die Kollision des Controllers liest nur mesh.geometry mit matrixWorld.
// Kein palette(): es fasst texturlose Materialien unter neuem Namen zusammen, die Kollision
// wählt aber nach Materialnamen (src/world/places.js, SOLID_MATERIALS).
await document.transform(dedup(), flatten(), join(), weld());
for (const mesh of document.getRoot().listMeshes()) {
for (const primitive of mesh.listPrimitives()) {
const ratio = SIMPLIFY_RATIOS.get(primitive.getMaterial()?.getName());
if (ratio === undefined) continue;
simplifyPrimitive(primitive, { simplifier: MeshoptSimplifier, ratio, error: SIMPLIFY_ERROR });
}
}
await document.transform(prune());
// createExtension meldet das Bildformat nicht an, das tut sonst erst NodeIO.registerExtensions.
// Ohne register() liefert texture.getSize() für WebP null (Bericht, Tests).
EXTTextureWebP.register();
document.createExtension(EXTTextureWebP).setRequired(true);
for (const texture of document.getRoot().listTextures()) {
const near = materialsUsing(texture).some((material) => NEAR_MATERIALS.has(material.getName()));
const size = near ? NEAR_TEXTURE_SIZE : FAR_TEXTURE_SIZE;
const quality = listTextureSlots(texture).includes("normalTexture") ? NORMAL_QUALITY : QUALITY;
await compressTexture(texture, { encoder: sharp, targetFormat: "webp", resize: [size, size], quality });
}
await document.transform(meshopt({ encoder: MeshoptEncoder, level: "medium" }));
return document;
}
/** Kennzahlen für den Bericht. Dreiecke und Primitive je Knoten gezählt, also wie gezeichnet. */
export function describePlace(document) {
const root = document.getRoot();
let triangles = 0;
let primitives = 0;
for (const node of root.listNodes()) {
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
primitives++;
triangles += (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION").getCount()) / 3;
}
}
let gpuBytes = 0;
const sizes = {};
for (const texture of root.listTextures()) {
const [width, height] = texture.getSize() ?? [0, 0];
gpuBytes += (width * height * 4 * 4) / 3; // RGBA mit Mipmaps
const key = `${width}x${height} ${texture.getMimeType()}`;
sizes[key] = (sizes[key] ?? 0) + 1;
}
const { min, max } = getBounds(root.listScenes()[0]);
const round = (values) => values.map((value) => Number(value.toFixed(2)));
return {
materials: root.listMaterials().length, primitives, triangles, textures: sizes,
gpuMB: Math.round(gpuBytes / 1e6), bounds: { min: round(min), max: round(max) },
};
}
async function main() {
const [source, id] = process.argv.slice(2);
if (!source || !id) {
console.error("Aufruf: node tools/assets/places.mjs <quelle.glb> <id>");
process.exit(1);
}
const target = `public/models/places/${id}.glb`;
const io = new NodeIO()
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
.registerDependencies({ "meshopt.encoder": MeshoptEncoder });
const megabytes = async (path) => `${((await stat(path)).size / 1e6).toFixed(1)} MB`;
const document = await io.read(source);
console.log("vorher", await megabytes(source), describePlace(document));
await optimizePlace(document);
await mkdir(dirname(target), { recursive: true });
await io.write(target, document);
console.log("nachher", await megabytes(target), describePlace(document));
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/placesPipeline.test.js`
Expected: PASS, 3 Tests. `prune: Removed types...`-Zeilen im stdout sind normal.
- [ ] **Step 5: Commit**
```bash
git add tools/assets/places.mjs tests/placesPipeline.test.js
git commit -m "$(cat <<'EOF'
feat(assets): Aufbereitung für Orte mit Texturgrößen je Material und Vereinfachung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: Orte-Liste und reine Helfer
**Files:**
- Create: `src/world/places.js`
- Test: `tests/places.test.js`
**Interfaces:**
- Consumes: `worldToThree(X, Y) -> { x, z }` aus `src/world/coords.js`.
- Produces: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`; `PLACES: Array<{ id, url, at: [X, Y], rotation, lift, bounds: { minX, maxX, minZ, maxZ } }>`; `SOLID_MATERIALS: string[]`; `isSolidMaterial(name): boolean`; `placeTransform(place) -> { position: { x, y, z }, rotationY }`; `placeDistance(position: { x, z }, place): number`; `wantedPlaces(position, places, { show, hide, active: Set<string> }) -> { add: string[], remove: string[] }`; `prepareModel(root: Object3D, place): boolean`.
- [ ] **Step 1: Write the failing test**
`tests/places.test.js`:
```js
import { describe, expect, it } from "vitest";
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from "three";
import { PLACES, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
wantedPlaces } from "../src/world/places.js";
describe("placeTransform", () => {
// Bruch: Welt-y nicht negiert (der Hof landete 2 km weiter südlich im Meer) oder lift nicht in y.
it("rechnet Welt-x/-y in three um und hebt um lift", () => {
const transform = placeTransform({ at: [1489, 1067.5], rotation: 0, lift: -0.28 });
expect(transform.position).toEqual({ x: 1489, y: -0.28, z: -1067.5 });
expect(transform.rotationY).toBe(0);
});
// Bruch: Grad nicht ins Bogenmaß umgerechnet oder falsche Drehrichtung. "Gegen den Uhrzeigersinn,
// Norden oben" heißt in three: +x (Osten) wird zu −z (Norden).
it("dreht mit positivem Winkel Osten nach Norden", () => {
const root = new Group();
root.rotation.y = placeTransform({ at: [0, 0], rotation: 90, lift: 0 }).rotationY;
root.updateMatrixWorld();
const east = new Vector3(1, 0, 0).applyMatrix4(root.matrixWorld);
expect(east.x).toBeCloseTo(0);
expect(east.z).toBeCloseTo(-1);
});
});
describe("wantedPlaces", () => {
const place = { id: "hof", at: [1000, 1000], rotation: 0, lift: 0 };
const south = (distance) => ({ x: 1000, z: -1000 + distance });
const radii = { show: 250, hide: 450 };
it("setzt innerhalb des show-Radius ein", () => {
expect(wantedPlaces(south(240), [place], { ...radii, active: new Set() }).add).toEqual(["hof"]);
expect(wantedPlaces(south(260), [place], { ...radii, active: new Set() }).add).toEqual([]);
});
// Bruch: ein Radius für Ein- und Ausblenden — an der Grenze flackerte der Hof mit jedem Schritt.
it("nimmt erst jenseits des hide-Radius heraus", () => {
const active = new Set(["hof"]);
expect(wantedPlaces(south(400), [place], { ...radii, active }).remove).toEqual([]);
expect(wantedPlaces(south(460), [place], { ...radii, active }).remove).toEqual(["hof"]);
});
it("meldet einen aktiven Ort nicht doppelt an", () => {
expect(wantedPlaces(south(10), [place], { ...radii, active: new Set(["hof"]) }).add).toEqual([]);
});
});
describe("isSolidMaterial", () => {
// Bruch: Gras oder Blätter im Collider (man bliebe im Gras hängen), oder ein fester Teil fehlt
// (man liefe durch Container, Schuppen oder Zaun).
it("lässt feste Teile kollidieren, Gras, Blätter und Kleinteile nicht", () => {
for (const name of ["gravel", "cont_a_side", "ironTeal", "chainlink_wire", "barrel_03", "quiver_tree_01_trunk"]) {
expect(isSolidMaterial(name)).toBe(true);
}
for (const name of ["grass_medium_01", "quiver_tree_01_leaf", "searsia_lucida_leaves", "trashbag", undefined]) {
expect(isSolidMaterial(name)).toBe(false);
}
});
it("kennt jedes Material der Liste", () => {
expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true);
});
});
/** Ein Mesh je Materialname unter einer Wurzel, wie GLTFLoader es nach join() liefert. */
function fakeModel(names = ["cont_a_side", "grass_medium_01"]) {
const root = new Group();
for (const name of names) root.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name })));
return root;
}
describe("prepareModel", () => {
// Bruch: Lage nicht gesetzt, Schatten aus, oder feste Teile fälschlich aus der Kollision genommen.
it("setzt Lage und Schatten und nimmt nur nicht feste Teile aus der Kollision", () => {
const root = fakeModel();
const [container, grass] = root.children;
expect(prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 })).toBe(true);
expect(root.position.toArray()).toEqual([1489, -0.28, -1067.5]);
expect(container.userData.excludeFromCollider).toBeUndefined();
expect(grass.userData.excludeFromCollider).toBe(true);
for (const mesh of [container, grass]) {
expect(mesh.castShadow).toBe(true);
expect(mesh.receiveShadow).toBe(true);
}
});
it("meldet, wenn kein Teil fest ist", () => {
expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false);
});
});
describe("PLACES", () => {
// Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds
// prüften nichts.
it("hat für jeden Ort id, url, at, rotation, lift und bounds", () => {
for (const 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) },
});
}
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/places.test.js`
Expected: FAIL, `Failed to load url ../src/world/places.js`
- [ ] **Step 3: Write minimal implementation**
`src/world/places.js`:
```js
// Orte: fest platzierte Modelle aus dem Editor three-d-stage, getrennt vom Chunk-Streaming
// (docs/superpowers/specs/2026-09-14-hood-yard-design.md). Aufbereitet werden sie mit
// tools/assets/places.mjs; hier stehen Lage, Laden und Kollision.
import { worldToThree } from "./coords.js";
// Chunks laden bei 400 m und bauen bei 600 m ab, gemessen zu ihrem Mittelpunkt (ChunkStreamer.update).
// Der fernste Chunk-Mittelpunkt unter dem Hood Yard liegt 148 m von dessen Mitte entfernt: erschiene
// der Hof schon bei 400 m, stünde er vom Startpunkt aus über dem Meer. Mit 250 m und 450 m ist sein
// Boden geladen, solange er in der Szene ist (tests/placesSite.test.js prüft das für jeden Ort).
// Heruntergeladen wird früher, damit das Einsetzen nicht auf die Datei wartet.
export const PLACE_PRELOAD_RADIUS = 600;
export const PLACE_SHOW_RADIUS = 250;
export const PLACE_HIDE_RADIUS = 450;
/**
* 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.
*/
export const PLACES = [
{
id: "hood-yard",
url: "./models/places/hood-yard.glb",
// Übergang Container -> Schuppen an derselben Stelle wie im Satellitenbild des Nutzers.
at: [1489, 1067.5],
rotation: 0,
// Die 34 cm dicke Kiesplatte liegt mit ihrer Unterseite auf 0; so ragt sie noch 6 cm heraus.
lift: -0.28,
bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 },
},
];
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Kleinteile — läuft man durch. */
export const SOLID_MATERIALS = [
// Boden
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
// Container
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame",
// Schuppen und Tore
"ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB",
"rollershutter_window_01", "rollershutter_window_01_graffiti",
// Zaun
"chainlink_post", "chainlink_wire",
// Masten, Fass, Kiste
"modular_electricity_poles", "barrel_03", "plastic_container",
// Bäume
"quiver_tree_01_trunk", "quiver_tree_02",
];
const SOLID = new Set(SOLID_MATERIALS);
export function isSolidMaterial(name) {
return SOLID.has(name);
}
/** Lage eines Orts in three-Koordinaten. Positives rotationY dreht +x nach −z, also nach Norden. */
export function placeTransform(place) {
const { x, z } = worldToThree(place.at[0], place.at[1]);
return { position: { x, y: place.lift, z }, rotationY: (place.rotation * Math.PI) / 180 };
}
/** Abstand in der Ebene zwischen einer three-Position und der Mitte eines Orts. */
export function placeDistance(position, place) {
const { x, z } = worldToThree(place.at[0], place.at[1]);
return Math.hypot(x - position.x, z - position.z);
}
/** Wie wantedChunks: add innerhalb von show, remove erst jenseits von hide. */
export function wantedPlaces(position, places, { show, hide, active }) {
const add = [];
const remove = [];
for (const place of places) {
const distance = placeDistance(position, place);
if (distance <= show && !active.has(place.id)) add.push(place.id);
if (distance > hide && active.has(place.id)) remove.push(place.id);
}
return { add, remove };
}
/**
* Setzt Lage und Schatten und nimmt nicht feste Meshes aus der Kollision (colliderBuild.ts im
* three-player-controller überspringt userData.excludeFromCollider). Gibt zurück, ob ein Teil fest ist.
*/
export function prepareModel(root, place) {
const { position, rotationY } = placeTransform(place);
root.position.set(position.x, position.y, position.z);
root.rotation.set(0, rotationY, 0);
let solid = false;
root.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = true;
object.receiveShadow = true;
const materials = Array.isArray(object.material) ? object.material : [object.material];
if (materials.some((material) => isSolidMaterial(material?.name))) solid = true;
else object.userData.excludeFromCollider = true;
});
root.updateMatrixWorld(true);
return solid;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/places.test.js`
Expected: PASS, 10 Tests
- [ ] **Step 5: Commit**
```bash
git add src/world/places.js tests/places.test.js
git commit -m "$(cat <<'EOF'
feat(world): Orte-Liste mit Lage, Radien und Kollisionsmaterialien
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: Hood Yard aufbereiten und die Datei prüfen
**Files:**
- Create: `public/models/places/hood-yard.glb` (erzeugt, versioniert)
- Test: `tests/placesAsset.test.js`
**Interfaces:**
- Consumes: `PLACES`, `SOLID_MATERIALS` (Task 3); `EXT_MATERIALS_BUMP`, `EXTMaterialsBump` (Task 1); CLI aus Task 2.
- [ ] **Step 1: Write the failing test**
`tests/placesAsset.test.js`:
```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";
const place = PLACES.find((entry) => entry.id === "hood-yard");
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
const CONTAINERS = ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"];
describe("hood-yard.glb", () => {
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 hatte 653 MB.
it("ist kleiner als 30 MB", () => {
expect(statSync(file).size).toBeLessThan(30e6);
});
// Bruch: das Wellblech ist weg, die Geometrie unkomprimiert, oder GPU-Instanzen, deren Matrizen die
// Kollision des Controllers nicht liest.
it("nutzt meshopt und WebP, behält EXT_materials_bump und hat keine Instanzen", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp", EXT_MATERIALS_BUMP]));
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);
}
});
it("gibt allen sechs Container-Materialien ihre Bump-Textur", () => {
const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material]));
for (const name of CONTAINERS) {
expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy();
}
});
// Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Hof wäre dort ohne Warnung
// durchlässig, weil die Kollision nach Namen auswählt.
it("enthält jedes feste Material der Kollisionsliste", () => {
const names = document.getRoot().listMaterials().map((material) => material.getName());
expect(SOLID_MATERIALS.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);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/placesAsset.test.js`
Expected: FAIL im `beforeAll` mit `ENOENT` (die Datei gibt es noch nicht)
- [ ] **Step 3: Datei erzeugen**
```bash
node tools/assets/places.mjs ~/Downloads/hood-yard.glb hood-yard 2>&1 | grep -v "^prune\|^quantize"
```
Erwartet, etwa 10 s Laufzeit (so am Prototyp gemessen):
```
vorher 653.6 MB { materials: 44, primitives: 542, triangles: 2899070, textures: { '2048x2048 image/png': 105 }, gpuMB: 2349, bounds: { min: [ -17.15, -0, -13.4 ], max: [ 17, 8.94, 8.6 ] } }
nachher 16.2 MB { materials: 43, primitives: 43, triangles: 657653, textures: { '1024x1024 image/webp': 32, '512x512 image/webp': 37 }, gpuMB: 231, bounds: { min: [ -17.15, -0, -13.4 ], max: [ 17, 8.94, 8.6 ] } }
```
Weichen `bounds` um mehr als 5 cm von `PLACES` ab, gilt `PLACES.bounds` als falsch. Dann den Grund klären, bevor die Werte in `PLACES` angepasst werden: Vereinfachung an den Hintergrundbäumen? `lot_gravel` bestimmt x ±17 und z −13,4/8,6.
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/placesAsset.test.js`
Expected: PASS, 6 Tests
- [ ] **Step 5: Commit**
```bash
git add public/models/places/hood-yard.glb tests/placesAsset.test.js
git commit -m "$(cat <<'EOF'
feat(assets): Hood Yard aufbereitet (653,6 MB -> 16,2 MB)
2,9 Mio. -> 658 000 Dreiecke, 542 -> 43 Primitive, 105 PNG in 2048 px ->
32 WebP in 1024 px und 37 in 512 px. EXT_materials_bump der Container erhalten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 5: Lage der Orte gegen die Karte prüfen
**Files:**
- Test: `tests/placesSite.test.js`
**Interfaces:**
- Consumes: `PLACES`, `PLACE_SHOW_RADIUS`, `PLACE_HIDE_RADIUS`, `placeTransform` (Task 3); `CHUNKS_PER_SIDE`, `CHUNK_SIZE`, `SLICE`, `chunkForWorld` aus `src/world/coords.js`; `lampPoints`, `vergeLampPoints`, `pointInPolygon` aus `src/world/props.js`.
Ein Wächtertest über vorhandene Daten: Er muss mit der richtigen Lage sofort bestehen. Rot wird er über eine absichtlich falsche Lage gezeigt (Schritt 3).
- [ ] **Step 1: Test schreiben**
`tests/placesSite.test.js`:
```js
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js";
import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js";
import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt.
const CHUNK_LOAD_RADIUS = 400;
const CHUNK_UNLOAD_RADIUS = 600;
const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche
const cache = new Map();
function chunk(i, j) {
const key = `${i}_${j}`;
if (!cache.has(key)) {
cache.set(key, JSON.parse(readFileSync(new URL(`../public/data/vice-a/chunk_${key}.json`, import.meta.url), "utf8")));
}
return cache.get(key);
}
/** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */
function modelToWorld(place, x, z) {
const { position, rotationY } = placeTransform(place);
const cos = Math.cos(rotationY), sin = Math.sin(rotationY);
return { X: position.x + x * cos + z * sin, Y: -(position.z - x * sin + z * cos) };
}
/** Welt (X, Y) -> Modellpunkt (x, z), die Umkehrung von modelToWorld. */
function worldToModel(place, X, Y) {
const { position, rotationY } = placeTransform(place);
const cos = Math.cos(rotationY), sin = Math.sin(rotationY);
const dx = X - position.x, dz = -Y - position.z;
return { x: dx * cos - dz * sin, z: dx * sin + dz * cos };
}
function insideBounds(place, X, Y) {
const { x, z } = worldToModel(place, X, Y);
const { minX, maxX, minZ, maxZ } = place.bounds;
return x >= minX && x <= maxX && z >= minZ && z <= maxZ;
}
describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", (_id, place) => {
const points = [];
for (let x = place.bounds.minX; x <= place.bounds.maxX + 1e-9; x += STEP) {
for (let z = place.bounds.minZ; z <= place.bounds.maxZ + 1e-9; z += STEP) points.push(modelToWorld(place, x, z));
}
const keys = [...new Set(points.map(({ X, Y }) => {
const { i, j } = chunkForWorld(X, Y);
return `${i}_${j}`;
}))];
const under = keys.map((key) => key.split("_").map(Number));
// 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.
it("liegt ganz auf Land, auf keiner Straße und in keinem Gebäude", () => {
const bad = points.filter(({ X, Y }) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const u = X - c.origin[0], v = -Y - c.origin[1];
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly));
const inBuilding = c.buildings.some((building) => pointInPolygon(u, v, building.poly));
return !onLand || onRoad || inBuilding;
});
expect(bad.slice(0, 5)).toEqual([]);
});
// Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten;
// Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks.
it("enthält keine Palme und keine Laterne", () => {
const nearby = new Set();
for (const [i, j] of under) {
for (let di = -1; di <= 1; di++) {
for (let dj = -1; dj <= 1; dj++) {
if (i + di >= 0 && i + di < CHUNKS_PER_SIDE && j + dj >= 0 && j + dj < CHUNKS_PER_SIDE) nearby.add(`${i + di}_${j + dj}`);
}
}
}
const props = [...nearby].flatMap((key) => {
const c = chunk(...key.split("_").map(Number));
const toWorld = (u, v) => ({ X: c.origin[0] + u, Y: -(c.origin[1] + v) });
const palms = (c.palms ?? []).map(([u, v]) => toWorld(u, v));
const lamps = vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? []).map((p) => toWorld(p.x, p.z));
return [...palms, ...lamps];
});
expect(props.filter(({ X, Y }) => insideBounds(place, X, Y))).toEqual([]);
});
// Bruch: der Ort erscheint, bevor sein Boden geladen ist, und steht dann über dem Meer — oder der
// Boden wird abgebaut, während der Ort noch in der Szene ist.
it("ist nur in der Szene, solange die Chunks unter ihm geladen sind", () => {
const { position } = placeTransform(place);
const farthest = Math.max(...under.map(([i, j]) => Math.hypot(
SLICE.x0 + (i + 0.5) * CHUNK_SIZE - position.x,
-(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) - position.z,
)));
expect(farthest + PLACE_SHOW_RADIUS).toBeLessThanOrEqual(CHUNK_LOAD_RADIUS);
expect(farthest + PLACE_HIDE_RADIUS).toBeLessThanOrEqual(CHUNK_UNLOAD_RADIUS);
});
});
```
- [ ] **Step 2: Run test to verify it passes**
Run: `npx vitest run tests/placesSite.test.js`
Expected: PASS, 3 Tests
- [ ] **Step 3: Rot zeigen und zurücksetzen**
In `src/world/places.js` vorübergehend `at: [1489, 1067.5]` durch `at: [1489, 1072.5]` ersetzen (5 m nach Norden) und `npx vitest run tests/placesSite.test.js` starten. Erwartet: FAIL in „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ (Rand auf der Straße) und in „enthält keine Palme und keine Laterne“ (Laterne bei 1478,1 | 1081,6).
Danach `at` auf `[1489, 1067.5]` zurücksetzen und `PLACE_SHOW_RADIUS = 250` vorübergehend auf `400` stellen. Erwartet: FAIL nur in „ist nur in der Szene, solange die Chunks unter ihm geladen sind“.
Zurücksetzen, dann `git diff src/world/places.js`. Erwartet: keine Ausgabe. Der Test ist wieder grün.
- [ ] **Step 4: Commit**
```bash
git add tests/placesSite.test.js
git commit -m "$(cat <<'EOF'
test(world): Lage der Orte gegen Straßen, Gebäude, Palmen, Laternen und Chunk-Radien
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 6: `PlaceLayer` lädt, setzt ein und nimmt heraus
**Files:**
- Modify: `src/world/places.js` (Importe oben, `loadModel` und `PlaceLayer` am Ende)
- Test: `tests/places.test.js` (Importe erweitern, `describe("PlaceLayer")` anhängen)
**Interfaces:**
- Consumes: alles aus Task 3.
- Produces: `export class PlaceLayer` mit `constructor({ scene: Scene, player: { addCollider(desc), removeCollider(handle) }, places = PLACES, load = (url) => Promise<Object3D> })` und `update(position: { x, y, z }): void`. Öffentliche Felder für Tests: `models: Map<id, { root, solid }>`, `wanted: Set<id>`, `active: Map<id, { root, handle }>`.
- [ ] **Step 1: Write the failing test**
In `tests/places.test.js` die ersten beiden Importzeilen ersetzen:
```js
import { afterEach, describe, expect, it, vi } from "vitest";
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three";
import { PLACES, PlaceLayer, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
wantedPlaces } from "../src/world/places.js";
```
Und am Ende der Datei anhängen:
```js
describe("PlaceLayer", () => {
afterEach(() => vi.restoreAllMocks());
const place = { id: "hof", url: "./hof.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 };
// Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden).
const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance });
function makeLayer(load) {
const scene = new Scene();
const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() };
const layer = new PlaceLayer({ scene, player, places: [place], load });
return { scene, player, layer };
}
// Bruch: Einsetzen schon beim Vorladen (der Hof stünde über ungeladenem Boden) oder erst nach
// einem zweiten Laden.
it("lädt ab 600 m vor und setzt erst ab 250 m ein", async () => {
const model = fakeModel();
const load = vi.fn(async () => model);
const { scene, player, layer } = makeLayer(load);
layer.update(south(500));
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
expect(load).toHaveBeenCalledWith("./hof.glb");
expect(scene.children).not.toContain(model);
expect(player.addCollider).not.toHaveBeenCalled();
layer.update(south(240));
expect(scene.children).toContain(model);
expect(player.addCollider).toHaveBeenCalledWith({
motion: "static", useWorker: true, shape: { kind: "mesh", source: model },
});
});
// Bruch: Collider bleibt nach dem Herausnehmen stehen (unsichtbare Wand) oder Flackern an der Grenze.
it("nimmt den Ort erst jenseits von 450 m heraus und meldet den Collider ab", async () => {
const model = fakeModel();
const { scene, player, layer } = makeLayer(vi.fn(async () => model));
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
layer.update(south(400));
expect(scene.children).toContain(model);
expect(player.removeCollider).not.toHaveBeenCalled();
layer.update(south(460));
expect(scene.children).not.toContain(model);
expect(player.removeCollider).toHaveBeenCalledWith({ id: 7 });
});
it("setzt nach der Rückkehr ohne neues Laden wieder ein", async () => {
const model = fakeModel();
const load = vi.fn(async () => model);
const { scene, player, layer } = makeLayer(load);
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
layer.update(south(700));
layer.update(south(100));
expect(scene.children).toContain(model);
expect(load).toHaveBeenCalledTimes(1);
expect(player.addCollider).toHaveBeenCalledTimes(2);
});
// Bruch: ein spät fertiges Laden setzt den Ort ein, obwohl der Spieler längst weitergefahren ist.
it("lässt den Ort draußen, wenn der Spieler beim Ende des Ladens schon fort ist", async () => {
const model = fakeModel();
let finish;
const { scene, player, layer } = makeLayer(vi.fn(() => new Promise((resolve) => { finish = resolve; })));
layer.update(south(100));
layer.update(south(500));
finish(model);
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
expect(scene.children).not.toContain(model);
expect(player.addCollider).not.toHaveBeenCalled();
layer.update(south(100));
expect(scene.children).toContain(model);
});
it("meldet ohne feste Teile keinen Collider an", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const model = fakeModel(["grass_medium_01"]);
const { scene, player, layer } = makeLayer(vi.fn(async () => model));
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
layer.update(south(460));
expect(player.addCollider).not.toHaveBeenCalled();
expect(player.removeCollider).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"));
});
// Bruch: ein fehlender Ort bricht das Spiel ab oder wird in jedem Frame neu angefragt.
it("warnt bei einem Ladefehler, wirft nicht und versucht es nicht erneut", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const load = vi.fn(async () => { throw new Error("404"); });
const { scene, layer } = makeLayer(load);
expect(() => layer.update(south(100))).not.toThrow();
await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error)));
layer.update(south(100));
expect(load).toHaveBeenCalledTimes(1);
expect(scene.children).toHaveLength(0);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run tests/places.test.js`
Expected: FAIL in den sechs `PlaceLayer`-Tests mit `PlaceLayer is not a constructor`, die zehn aus Task 3 bestehen weiter
- [ ] **Step 3: Write minimal implementation**
In `src/world/places.js` die Importzeile `import { worldToThree } from "./coords.js";` ersetzen durch:
```js
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
import { worldToThree } from "./coords.js";
```
Und am Ende der Datei anhängen:
```js
function loadModel(url) {
const loader = new GLTFLoader();
// tools/assets/places.mjs komprimiert mit EXT_meshopt_compression, wie die Straßenlaterne (props.js).
loader.setMeshoptDecoder(MeshoptDecoder);
return loader.loadAsync(url).then((gltf) => gltf.scene);
}
export class PlaceLayer {
constructor({ scene, player, places = PLACES, load = loadModel }) {
Object.assign(this, { scene, player, places, load });
this.models = new Map(); // id -> { root, solid }; bleibt nach dem ersten Laden erhalten
this.pending = new Set();
this.failed = new Set();
this.wanted = new Set(); // innerhalb von show gewesen, noch nicht jenseits von hide
this.active = new Map(); // id -> { root, handle }
}
update(position) {
for (const place of this.places) {
if (placeDistance(position, place) <= PLACE_PRELOAD_RADIUS) this.#preload(place);
}
const { add, remove } = wantedPlaces(position, this.places, {
show: PLACE_SHOW_RADIUS, hide: PLACE_HIDE_RADIUS, active: this.wanted,
});
for (const id of add) {
this.wanted.add(id);
this.#show(id);
}
for (const id of remove) {
this.wanted.delete(id);
this.#hide(id);
}
}
#preload(place) {
const { id } = place;
if (this.models.has(id) || this.pending.has(id) || this.failed.has(id)) return;
this.pending.add(id);
this.load(place.url)
.then((root) => {
const solid = prepareModel(root, place);
if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`);
this.models.set(id, { root, solid });
// Nur einsetzen, wenn der Spieler noch da ist; sonst beim nächsten Herankommen.
if (this.wanted.has(id)) this.#show(id);
})
.catch((err) => {
this.failed.add(id);
console.warn(`Ort ${id} konnte nicht geladen werden:`, err);
})
.finally(() => this.pending.delete(id));
}
#show(id) {
const model = this.models.get(id);
if (!model || this.active.has(id)) return;
this.scene.add(model.root);
model.root.updateMatrixWorld(true);
// Ohne feste Teile keinen Collider: eine leere Quelle liefert im Controller nur ein Handle,
// das nie fertig wird (vgl. Ruling 51 in streaming.js).
const handle = model.solid
? this.player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: model.root } })
: null;
this.active.set(id, { root: model.root, handle });
}
#hide(id) {
const entry = this.active.get(id);
if (!entry) return;
this.scene.remove(entry.root);
if (entry.handle) this.player.removeCollider(entry.handle);
this.active.delete(id);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run tests/places.test.js`
Expected: PASS, 16 Tests. Danach `npm test`, erwartet 17 Testdateien und 162 Tests, alle grün.
- [ ] **Step 5: Commit**
```bash
git add src/world/places.js tests/places.test.js
git commit -m "$(cat <<'EOF'
feat(world): PlaceLayer lädt vor, setzt ein und nimmt heraus
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 7: Einbau ins Spiel und Prüfung im Browser
**Files:**
- Modify: `src/main.js` (Import, Anlegen nach dem `ChunkStreamer`, Aufruf in `tick()`)
- Vorübergehend, nie committen: ein Eintrag in der `.claude/launch.json`, die das Vorschau-Werkzeug liest, und bei verborgenem Browser-Bereich eine Kopie `index-debug.html` im Arbeitsbaum
**Interfaces:**
- Consumes: `PlaceLayer` (Task 6), `playerPosition(player)` aus `src/player.js`.
- [ ] **Step 1: Einbauen**
In `src/main.js` unter `import { PropLayer } from "./world/props.js";`:
```js
import { PlaceLayer } from "./world/places.js";
```
Direkt nach der Zeile `const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`:
```js
// Fest platzierte Modelle aus three-d-stage (src/world/places.js). Eigene Radien, damit ein Ort nur in
// der Szene ist, solange die Chunks unter ihm geladen sind.
const places = new PlaceLayer({ scene, player });
```
In `tick()` direkt nach `streamer.update(playerPosition(player));`:
```js
places.update(playerPosition(player));
```
- [ ] **Step 2: Alle Tests**
Run: `npm test`
Expected: 17 Testdateien, 162 Tests, alle grün.
- [ ] **Step 3: Dev-Server des Arbeitsbaums starten**
In der `.claude/launch.json`, die das Vorschau-Werkzeug liest, vorübergehend eintragen. Auf 5173 läuft oft schon der Server des Hauptordners.
```json
{
"name": "hood-yard",
"runtimeExecutable": "bash",
"runtimeArgs": ["-lc", "cd .claude/worktrees/hood-yard && npx vite --port 5181 --strictPort"],
"port": 5181,
"url": "http://localhost:5181"
}
```
Dann `preview_start` mit `{ name: "hood-yard" }`. Die Kniffe für einen verborgenen Browser-Bereich stehen in `/Users/Kenso/.claude/projects/-Users-Kenso-Developer-gta6/memory/browser-verification-hidden-pane.md`: Worker-Takt statt `requestAnimationFrame`, `Scene.prototype.onBeforeRender`, vorgetäuschter Pointer-Lock, zwei Screenshots nacheinander.
- [ ] **Step 4: Laden prüfen**
- `read_network_requests` mit `urlPattern: "hood-yard.glb"`: Status 200, rund 16 MB.
- `read_console_messages` mit `onlyErrors: true`: höchstens die bekannte Pointer-Lock-Meldung des Browser-Bereichs. Kein `Ort hood-yard konnte nicht geladen werden`, keine GLTFLoader-Fehler.
- [ ] **Step 5: Zum Hof springen**
`M` drücken (`computer` mit `action: "key"`, `text: "m"`). Mit `javascript_tool` die Klickstelle für Welt (1489 | 1040) berechnen, das ist das Baseballfeld 27,5 m südlich der Hofmitte. Die Karte zeigt nach dem Öffnen das ganze Kartenbild (`fitView` in `src/ui/map.js`):
```js
const dpr = Math.min(devicePixelRatio, 2);
const w = Math.round(innerWidth * dpr), h = Math.round(innerHeight * dpr);
const scale = Math.min(w / 3000, h / 3000);
const offsetX = (w - 3000 * scale) / 2, offsetY = (h - 3000 * scale) / 2;
const px = 1489 + 1000, py = 2500 - 1040;
[(px * scale + offsetX) / dpr, (py * scale + offsetY) / dpr]
```
Dorthin klicken. Danach Szene, Kamera und Renderer greifbar machen:
```js
const url = performance.getEntriesByType("resource").map((entry) => entry.name).find((name) => name.includes("/node_modules/.vite/deps/three.js"));
const THREE = await import(url);
window.__probe = {};
THREE.Scene.prototype.onBeforeRender = function (renderer, scene, camera) { Object.assign(window.__probe, { renderer, scene, camera }); };
```
Warten, bis `window.__probe.scene.getObjectByName("capsule").position` bei x ≈ 1489, z ≈ −1040 steht. Warten, bis `window.__probe.scene.children` eine Gruppe mit Position (1489, −0.28, −1067.5) enthält.
- [ ] **Step 6: Ansehen und Bilder festhalten**
Kamera übersteuern, ohne den Spieler zu bewegen. Die Schattenstufen müssen denselben Blick bekommen, sonst fehlen die Schatten im Bild. Einmal ausführen:
```js
const deps = performance.getEntriesByType("resource").map((entry) => entry.name);
const THREE = await import(deps.find((name) => name.includes("/node_modules/.vite/deps/three.js")));
const { CSM } = await import(deps.find((name) => name.includes("three_examples_jsm_csm_CSM")));
const eye = new THREE.Vector3(), target = new THREE.Vector3(), up = new THREE.Vector3(), matrix = new THREE.Matrix4();
window.__view = null; // { position: [x, y, z], target: [x, y, z], up?: [x, y, z] } in three-Koordinaten
function applyView(camera) {
if (!window.__view) return;
eye.fromArray(window.__view.position);
target.fromArray(window.__view.target);
up.fromArray(window.__view.up ?? [0, 1, 0]);
matrix.lookAt(eye, target, up).setPosition(eye);
camera.matrixWorld.copy(matrix);
camera.matrixWorldInverse.copy(matrix).invert();
}
const csmUpdate = CSM.prototype.update;
CSM.prototype.update = function (...args) { applyView(this.camera); return csmUpdate.apply(this, args); };
THREE.Scene.prototype.onBeforeRender = function (renderer, scene, camera) {
Object.assign(window.__probe, { renderer, scene, camera });
applyView(camera);
};
```
Drei Blicke:
| Blick | `position` | `target` | `up` |
|---|---|---|---|
| Vom Baseballfeld | [1489, 1.7, -1040] | [1489, 2, -1067.5] | – |
| Von der Straße | [1489, 1.7, -1085] | [1489, 1.5, -1067.5] | – |
| Draufsicht | [1489, 60, -1067.5] | [1489, 0, -1067.5] | [0, 0, -1] |
Je Blick `window.__view` setzen, ein Bild lang warten (verborgener Bereich: Worker-Takt), dann im selben Aufruf:
```js
const { renderer, scene, camera } = window.__probe;
renderer.render(scene, camera);
renderer.domElement.toDataURL("image/jpeg", 0.9);
```
Die Datenadresse mit Python aus der gespeicherten Werkzeugausgabe dekodieren und als `hof-baseballfeld.jpg`, `hof-strasse.jpg` und `hof-draufsicht.jpg` in den Scratchpad legen. Am Ende `window.__view = null`.
Prüfen:
- Container haben sichtbares Wellblech.
- Kiesfläche ohne Kante und ohne Flackern.
- Schatten von Containern und Bäumen sind da.
- Die Köcherbäume sind nicht löchrig.
- Die Draufsicht passt zum Vergleichsbild `hof-lage.png` aus dem Gespräch: Container im Westen, Schuppen östlich daneben, J6/K6-Grenze 0,65 m östlich des letzten Schuppens.
- [ ] **Step 7: Kollision prüfen**
Der Controller liest Tasten über `keydown`/`keyup` auf `window` (`e.code`). Mausbewegungen liest er nur, wenn `document.pointerLockElement` sein Canvas ist (`InputSystem.ts`, `onMouseMove`):
Jeder `javascript_tool`-Aufruf hat eigene Variablen, deshalb holt der Ausschnitt `THREE` neu:
```js
const deps = performance.getEntriesByType("resource").map((entry) => entry.name);
const THREE = await import(deps.find((name) => name.includes("/node_modules/.vite/deps/three.js")));
const { renderer, scene, camera } = window.__probe;
Object.defineProperty(document, "pointerLockElement", { configurable: true, get: () => renderer.domElement });
const capsule = scene.getObjectByName("capsule");
const heading = () => { const d = camera.getWorldDirection(new THREE.Vector3()); return Math.atan2(d.x, -d.z) * 180 / Math.PI; }; // 0° = Norden, 90° = Osten
const turn = (degrees) => window.dispatchEvent(new MouseEvent("mousemove", { movementX: degrees / 8.6 * 300, movementY: 0 }));
const walk = (ms) => new Promise((resolve) => {
window.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyW" }));
setTimeout(() => { window.dispatchEvent(new KeyboardEvent("keyup", { code: "KeyW" })); resolve(capsule.position.toArray()); }, ms);
});
Object.assign(window, { capsule, heading, turn, walk }); // für die folgenden Aufrufe
```
- **Container von Westen:** `capsule.position.set(1473, 1, -1067.5)` steht auf dem Kies westlich von Container A, auf dem Weg liegt kein Bordstein. Die Westwand von Container A liegt bei x 1478,83. Mit `turn(90 - heading())` nach Osten drehen, `heading()` prüfen (±5°, sonst noch einmal). `await walk(3000)`: Die Kapsel bleibt vor x ≈ 1478,8 stehen und kommt nicht bis 1480.
- **Zaun von Norden, durch Gras und Büsche:** `capsule.position.set(1485, 1, -1079)`, mit `turn(180 - heading())` nach Süden drehen, `await walk(3000)`. Auf dem Weg liegen `grass_back` und Hintergrundbüsche, die durchlässig sind. Das Zaunbrett liegt bei z −1071,4. Erwartet: Die Kapsel kommt bis kurz vor z −1071,4, bleibt dort stehen und bleibt vorher nirgends hängen.
- **Kollisionsmarken am echten Modell:**
```js
const hof = window.__probe.scene.children.find((object) => object.position.x === 1489 && object.position.z === -1067.5);
const solid = new Set();
hof.traverse((object) => { if (object.isMesh && !object.userData.excludeFromCollider) solid.add(object.material.name); });
[...solid].sort()
```
Erwartet: nur Namen aus `SOLID_MATERIALS`, keiner mit `grass_medium`, `leaf`, `searsia` oder `trashbag`.
Die Drehung in `turn` ist nur ungefähr, deshalb nach jedem Drehen `heading()` prüfen. Ohne vorgetäuschten Pointer-Lock geht es nicht, der Browser-Bereich verweigert ihn.
- [ ] **Step 8: Kennzahlen festhalten**
In der Baseballfeld-Ansicht `renderer.info.render.calls`, `renderer.info.render.triangles` und `renderer.info.memory` notieren, einmal mit und einmal ohne Hof. Für „ohne Hof“ vorübergehend `scene.remove` der Hof-Gruppe und danach wieder `scene.add`. Ladezeit aus `performance.getEntriesByName(...)` für `hood-yard.glb` notieren. Ist der Bereich sichtbar, die Bildrate über 2 s mit `requestAnimationFrame`-Zeitstempeln messen, sonst als nicht messbar vermerken.
- [ ] **Step 9: Nachstellen, falls nötig**
Wirken Köcherbäume oder Gras zu grob, `SIMPLIFY_RATIOS` in `tools/assets/places.mjs` anheben (zum Beispiel 0,15 → 0,25). Danach:
1. Task 4, Step 3 wiederholen.
2. `npx vitest run tests/placesAsset.test.js tests/placesPipeline.test.js` ausführen.
3. Die Blicke aus Step 6 neu aufnehmen.
Jede Änderung zusammen mit der neuen `.glb` committen:
```bash
git add tools/assets/places.mjs public/models/places/hood-yard.glb
git commit -m "$(cat <<'EOF'
fix(assets): Vereinfachung der Köcherbäume nach Augenschein
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
Wirkt der Hof unter dem Spiellicht deutlich heller oder dunkler als im Editor, nichts ändern. Stattdessen die Bilder dem Nutzer zeigen und fragen (Spec, Fehlerfälle).
- [ ] **Step 10: Aufräumen und committen**
Den Eintrag `hood-yard` aus der `.claude/launch.json` entfernen und `index-debug.html` löschen, falls angelegt. Erwartet: `git status --short` zeigt nur `M src/main.js`.
```bash
git add src/main.js
git commit -m "$(cat <<'EOF'
feat(world): Hood Yard im Spiel
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 11: Dem Nutzer zeigen**
Die drei Bilder mit `SendUserFile` schicken, zusammen mit den Kennzahlen aus Step 8 und dem Ergebnis der Kollisionsprüfung. Um Abnahme bitten. Danach geht es mit superpowers:finishing-a-development-branch weiter, der Branch soll zurück nach `leonida/vice-slice`.