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

70 KiB
Raw Blame History

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)
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)
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

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:

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:

// 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
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:

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:

// 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
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:

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:

// 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
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:

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
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
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:

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
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:

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:

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:

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:

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
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";:

import { PlaceLayer } from "./world/places.js";

Direkt nach der Zeile const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });:

// 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));:

    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.

{
    "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):

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:

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:

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:

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:

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:
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:

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.

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.