From ef2001542adc8a033f2a251c0aadea7315243646 Mon Sep 17 00:00:00 2001 From: KNSO Date: Mon, 14 Sep 2026 11:26:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(world):=20PlaceLayer=20l=C3=A4dt=20vor,=20?= =?UTF-8?q?setzt=20ein=20und=20nimmt=20heraus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 --- src/world/places.js | 77 ++++++++++++++++++++++++++++ tests/places.test.js | 117 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/world/places.js b/src/world/places.js index 3239a9a..33dd517 100644 --- a/src/world/places.js +++ b/src/world/places.js @@ -1,6 +1,8 @@ // 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 { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; +import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js"; import { worldToThree } from "./coords.js"; // Chunks laden bei 400 m und bauen bei 600 m ab, gemessen zu ihrem Mittelpunkt (ChunkStreamer.update). @@ -97,3 +99,78 @@ export function prepareModel(root, place) { root.updateMatrixWorld(true); return solid; } + +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); + } +} diff --git a/tests/places.test.js b/tests/places.test.js index 80f6e2b..3d4f094 100644 --- a/tests/places.test.js +++ b/tests/places.test.js @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from "three"; -import { PLACES, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel, +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"; describe("placeTransform", () => { @@ -106,3 +106,114 @@ describe("PLACES", () => { } }); }); + +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); + }); +});