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>
70 KiB
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 }.atist 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ürsearsia_lucida*undgrass_medium_*; 0,25 fürmodular_electricity_poles_piecesundsecurity_camera_01;error0,01. Keininstance(), keinpalette(). - Ergebnis
public/models/places/hood-yard.glbkleiner 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/functions4.5.0,sharp0.35.4,meshoptimizer1.2.0. - Nicht anfassen:
src/world/streaming.js,src/world/props.js,public/data/, die Minimap. - Gearbeitet wird im Arbeitsbaum
.claude/worktrees/hood-yardauf Branchleonida/hood-yard. Gestaget wird immer mit Pfaden, nie mitgit 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 mitnpm 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 ExtensionPropertymitgetBumpFactor(): number,setBumpFactor(factor: number): this,getBumpTexture(): Texture | null,getBumpTextureInfo(): TextureInfo | null,setBumpTexture(texture: Texture | null): this;export class EXTMaterialsBump extends ExtensionmitcreateBump(): 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,EXTMaterialsBumpaus 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>schreibtpublic/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 }aussrc/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,chunkForWorldaussrc/world/coords.js;lampPoints,vergeLampPoints,pointInPolygonaussrc/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,loadModelundPlaceLayeram Ende) - Test:
tests/places.test.js(Importe erweitern,describe("PlaceLayer")anhängen)
Interfaces:
-
Consumes: alles aus Task 3.
-
Produces:
export class PlaceLayermitconstructor({ scene: Scene, player: { addCollider(desc), removeCollider(handle) }, places = PLACES, load = (url) => Promise<Object3D> })undupdate(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 demChunkStreamer, Aufruf intick()) - Vorübergehend, nie committen: ein Eintrag in der
.claude/launch.json, die das Vorschau-Werkzeug liest, und bei verborgenem Browser-Bereich eine Kopieindex-debug.htmlim Arbeitsbaum
Interfaces:
-
Consumes:
PlaceLayer(Task 6),playerPosition(player)aussrc/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_requestsmiturlPattern: "hood-yard.glb": Status 200, rund 16 MB. -
read_console_messagesmitonlyErrors: true: höchstens die bekannte Pointer-Lock-Meldung des Browser-Bereichs. KeinOrt 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.pngaus 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. Mitturn(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), mitturn(180 - heading())nach Süden drehen,await walk(3000). Auf dem Weg liegengrass_backund 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:
- Task 4, Step 3 wiederholen.
npx vitest run tests/placesAsset.test.js tests/placesPipeline.test.jsausführen.- 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.