163 lines
7.7 KiB
JavaScript
163 lines
7.7 KiB
JavaScript
import { Group, Mesh } from "three";
|
|
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
|
|
import { buildChunkGeometry } from "./geometry.js";
|
|
import { pointInPolygon } from "./props.js";
|
|
|
|
const key = (i, j) => `${i}_${j}`;
|
|
const DECALS = new Set(["roads", "sand", "park", "urban", "field", "court", "pool", "parking", "paths"]);
|
|
|
|
/** Mittelpunkt eines Chunks in three-Koordinaten. */
|
|
function chunkCenter(i, j) {
|
|
return { x: SLICE.x0 + (i + 0.5) * CHUNK_SIZE, z: -(SLICE.y1 - (j + 0.5) * CHUNK_SIZE) };
|
|
}
|
|
|
|
export function wantedChunks(position, { load, unload, active }) {
|
|
const add = [], remove = [];
|
|
for (let j = 0; j < CHUNKS_PER_SIDE; j++) {
|
|
for (let i = 0; i < CHUNKS_PER_SIDE; i++) {
|
|
const c = chunkCenter(i, j);
|
|
const d = Math.hypot(c.x - position.x, c.z - position.z);
|
|
const k = key(i, j);
|
|
if (d <= load && !active.has(k)) add.push(k);
|
|
if (d > unload && active.has(k)) remove.push(k);
|
|
}
|
|
}
|
|
add.sort((a, b) => dist(a, position) - dist(b, position));
|
|
return { add, remove };
|
|
}
|
|
|
|
function dist(k, position) {
|
|
const [i, j] = k.split("_").map(Number);
|
|
const c = chunkCenter(i, j);
|
|
return Math.hypot(c.x - position.x, c.z - position.z);
|
|
}
|
|
|
|
/**
|
|
* Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js):
|
|
* jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und
|
|
* Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt
|
|
* derselbe Chunk zurück.
|
|
*
|
|
* Ein Gebäude an einer Chunkgrenze schreibt die Extraktion ungeschnitten in jeden berührten Chunk. Deshalb
|
|
* wird hier jeder Punkt gegen jeden Chunk geprüft, statt ihn vorab einem Chunk zuzuordnen — sonst bliebe
|
|
* die Kopie im Nachbarchunk stehen.
|
|
*/
|
|
export function withoutReplacedBuildings(chunk, points) {
|
|
if (points.length === 0) return chunk;
|
|
const [ox, oz] = chunk.origin;
|
|
const buildings = chunk.buildings.filter((building) =>
|
|
!points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly)));
|
|
return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings };
|
|
}
|
|
|
|
export class ChunkStreamer {
|
|
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) {
|
|
Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings });
|
|
this.active = new Map(); // key -> { group, handle }
|
|
this.pending = new Set();
|
|
this.queue = [];
|
|
// Chunks, die #unload nie abbaut (Task 11, Schritt 5): die vier Fahrzeug-Spawnpunkte
|
|
// liegen darauf, und ein Fahrzeug ohne Boden fällt ins Nichts, sobald der Spieler
|
|
// sich weit genug entfernt und der normale Radius-Abbau eingreift.
|
|
this.pinned = new Set(pinned);
|
|
}
|
|
|
|
update(position) {
|
|
const { add, remove } = wantedChunks(position, {
|
|
load: 400, unload: 600, active: new Set([...this.active.keys(), ...this.pending]),
|
|
});
|
|
for (const k of add) if (!this.queue.includes(k)) this.queue.push(k);
|
|
// Höchstens ein Aufbau je Frame.
|
|
const next = this.queue.shift();
|
|
if (next) void this.#load(next);
|
|
// Höchstens vier Abmeldungen je Frame.
|
|
for (const k of remove.slice(0, 4)) this.#unload(k);
|
|
}
|
|
|
|
async #load(k) {
|
|
if (this.active.has(k) || this.pending.has(k)) return;
|
|
this.pending.add(k);
|
|
try {
|
|
const chunk = await this.#fetchChunk(k);
|
|
const [i, j] = k.split("_").map(Number);
|
|
const group = new Group();
|
|
group.position.set(chunk.origin[0], 0, chunk.origin[1]);
|
|
const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings));
|
|
const collide = new Group();
|
|
for (const [name, geo] of Object.entries(geos)) {
|
|
if (!geo) continue;
|
|
const mesh = new Mesh(geo, this.materials[name]);
|
|
mesh.castShadow = name === "buildings";
|
|
mesh.receiveShadow = true;
|
|
// Straßen und Bodenflächen sind reine Auflagen; Kollision trägt die Landplatte.
|
|
(DECALS.has(name) ? group : collide).add(mesh);
|
|
}
|
|
// this.props ist seit Task 8 ein optionales Konstruktorfeld (siehe dortiger
|
|
// Kommentar); die bestehenden Streaming-Tests bauen einen ChunkStreamer ohne
|
|
// props auf und dürfen dabei nicht auf einmal an einem unbedingten Aufruf
|
|
// scheitern — daher die Wache, statt props stillschweigend zur Pflicht zu machen.
|
|
if (this.props) {
|
|
const props = this.props.build(chunk);
|
|
group.add(props.visual);
|
|
for (const trunk of props.colliders) collide.add(trunk); // wandern in dieselbe BVH
|
|
}
|
|
group.add(collide);
|
|
this.scene.add(group);
|
|
group.updateMatrixWorld(true);
|
|
// Ruling 51: ein Chunk ganz ohne Kollisionsgeometrie (offenes Wasser, z.B. 4_6/4_10/
|
|
// 5_6/5_10 — per public/data/vice-a nachgezählt) braucht keinen Collider. addCollider
|
|
// auf einer leeren Gruppe liefert nur eine Fremdwarnung und ein handle mit id:-1, das
|
|
// nie ready wird — der Chunk bleibt trotzdem in active, nur ohne Collider-Handle.
|
|
const handle = collide.children.length > 0
|
|
? this.player.addCollider({
|
|
motion: "static", useWorker: true, shape: { kind: "mesh", source: collide },
|
|
})
|
|
: null;
|
|
this.active.set(k, { group, handle, chunk });
|
|
} catch (err) {
|
|
console.warn(`Chunk ${k} konnte nicht geladen werden:`, err);
|
|
} finally {
|
|
this.pending.delete(k);
|
|
}
|
|
}
|
|
|
|
async #fetchChunk(k) {
|
|
const [i, j] = k.split("_").map(Number);
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
try {
|
|
const res = await fetch(`${this.baseUrl}/chunk_${i}_${j}.json`);
|
|
if (res.ok) return await res.json();
|
|
} catch { /* nächster Versuch */ }
|
|
}
|
|
throw new Error("dreimal fehlgeschlagen");
|
|
}
|
|
|
|
#unload(k) {
|
|
if (this.pinned.has(k)) return;
|
|
const entry = this.active.get(k);
|
|
if (!entry) return;
|
|
// Ruling 51: collider-lose Chunks (offenes Wasser) haben kein handle abzumelden.
|
|
if (entry.handle) this.player.removeCollider(entry.handle);
|
|
this.scene.remove(entry.group);
|
|
// Ruling 62: this.palmGeometry, das Palmenmaterial und die Laternenteile entstehen
|
|
// einmal in PropLayer.load() und werden von jedem Chunk benutzt (props.visual landet
|
|
// hier in derselben Gruppe). Ohne die Wache würde jeder Chunk-Abbau ihre GPU-Puffer
|
|
// freigeben, während Nachbarchunks sie noch anzeigen — kein Absturz (dispose() lässt
|
|
// die CPU-Daten stehen, der nächste Zugriff lädt still neu hoch), aber unnötige Arbeit
|
|
// bei jedem Abbau. mesh.userData.sharedGeometry markiert das, analog excludeFromCollider.
|
|
entry.group.traverse((o) => { if (o.geometry && !o.userData.sharedGeometry) o.geometry.dispose(); });
|
|
this.active.delete(k);
|
|
}
|
|
|
|
/** Ist der Collider unter dieser Position einsatzbereit? */
|
|
isReadyAt(position) {
|
|
const i = Math.floor((position.x - SLICE.x0) / CHUNK_SIZE);
|
|
const j = Math.floor((SLICE.y1 + position.z) / CHUNK_SIZE);
|
|
const entry = this.active.get(key(i, j));
|
|
if (!entry) return false;
|
|
// Ruling 51: kein Collider nötig (leerer Chunk) -> nichts, worauf noch zu warten wäre.
|
|
if (!entry.handle) return true;
|
|
return Boolean(this.player.collisionWorld.get(entry.handle.id)?.ready);
|
|
}
|
|
}
|