feat(world): PlaceLayer lädt vor, setzt ein und nimmt heraus
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
16eb651308
commit
ef2001542a
2 files changed
+191
-3
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user