tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
240 lines
11 KiB
JavaScript
240 lines
11 KiB
JavaScript
// 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 angefordert, 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. replaces
|
||
* (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim
|
||
* Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js).
|
||
*/
|
||
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 },
|
||
},
|
||
{
|
||
id: "tribuene",
|
||
url: "./models/places/tribuene.glb",
|
||
// Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers);
|
||
// längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten.
|
||
at: [1409.9, 1051.1],
|
||
// Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate.
|
||
rotation: 45,
|
||
// Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts.
|
||
lift: 0,
|
||
bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 },
|
||
// Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6).
|
||
replaces: [[1403.25, 1057.75]],
|
||
},
|
||
];
|
||
|
||
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, 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",
|
||
// Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden,
|
||
// Glas der Pressekabine, Anschlusskästen am Boden
|
||
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01",
|
||
];
|
||
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, warmUp = null }) {
|
||
Object.assign(this, { scene, player, places, load, warmUp });
|
||
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(async (root) => {
|
||
const solid = prepareModel(root, place);
|
||
if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`);
|
||
// Vorwärmen (Texturupload, Shader-Übersetzung) noch hier im Vorladen: schlägt es fehl,
|
||
// wird der Ort trotzdem eingesetzt, nur ohne den Vorteil (siehe main.js, warmUpPlace).
|
||
if (this.warmUp) {
|
||
try {
|
||
await this.warmUp(root);
|
||
} catch (err) {
|
||
console.warn(`Ort ${id} konnte nicht vorgewärmt werden:`, err);
|
||
}
|
||
}
|
||
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) {
|
||
if (this.failed.has(id)) return;
|
||
const model = this.models.get(id);
|
||
if (!model || this.active.has(id)) return;
|
||
// Collider vor der Szene bauen: prepareModel hat schon updateMatrixWorld(true) auf der Wurzel
|
||
// aufgerufen, die Quelle hat also eine gültige Weltmatrix, obwohl sie noch nicht in der Szene
|
||
// hängt. Wirft addCollider (z. B. toter Worker), bleibt die Szene unverändert statt eine
|
||
// Wurzel ohne active-Eintrag (und damit nie wieder entfernbar) stehen zu lassen — und aus
|
||
// update(), das #show synchron aufruft, entkäme sonst eine Ausnahme in die Spielschleife.
|
||
let handle;
|
||
try {
|
||
// Ohne feste Teile keinen Collider: eine leere Quelle liefert im Controller nur ein Handle,
|
||
// das nie fertig wird (vgl. Ruling 51 in streaming.js).
|
||
handle = model.solid
|
||
? this.player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: model.root } })
|
||
: null;
|
||
} catch (err) {
|
||
this.failed.add(id);
|
||
console.warn(`Ort ${id} konnte nicht eingesetzt werden:`, err);
|
||
return;
|
||
}
|
||
this.scene.add(model.root);
|
||
model.root.updateMatrixWorld(true);
|
||
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);
|
||
}
|
||
}
|