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