import { Group, Mesh } from "three"; import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js"; import { buildChunkGeometry } from "./geometry.js"; const key = (i, j) => `${i}_${j}`; const DECALS = new Set(["roads", "sand", "park", "urban"]); /** 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); } export class ChunkStreamer { constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) { Object.assign(this, { scene, player, materials, props, baseUrl }); 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(chunk); 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); } }