Files
gta6/src/world/places.js
T

199 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
}