// 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 { BufferAttribute } from "three"; 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). // 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 }; } // tools/assets/places.mjs komprimiert mit meshopt() (KHR_mesh_quantization): Positionen fester Meshes // liegen als normalisierte Ganzzahlen vor, erst über die Mesh-Transform auf echte Maße skaliert. // colliderBuild.ts im three-player-controller klont die Geometrie aber und rechnet matrixWorld per // applyMatrix4 in die Kopie hinein — dabei würden normalisierte Werte beim Zurückschreiben auf ±1 // geklemmt. Deshalb hier vorher in echte, nicht normalisierte Float32-Koordinaten entquantisieren. function dequantizePosition(geometry) { const position = geometry.attributes.position; if (!position.normalized && position.array instanceof Float32Array) return; const values = new Float32Array(position.count * 3); for (let i = 0; i < position.count; i++) { values[i * 3] = position.getX(i); values[i * 3 + 1] = position.getY(i); values[i * 3 + 2] = position.getZ(i); } geometry.setAttribute("position", new BufferAttribute(values, 3)); } /** * 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; dequantizePosition(object.geometry); } else { object.userData.excludeFromCollider = true; } }); 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); } }