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:
knsoandClaude Sonnet 5 committed 2026-09-14 11:26:34 +02:00
1 parent 16eb651308
commit ef2001542a
2 files changed
+191 -3

No files matched your search

+77
View File
@@ -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);
}
}