feat(world): Chunk-Streaming mit Collider-Verwaltung und Auffangnetz

wantedChunks() liefert Lade-/Entladelisten mit Hysterese (Laderadius 400,
Entladeradius 600) um die Spielerposition; ChunkStreamer holt Chunks per
fetch, baut sie über buildChunkGeometry() (Task 7) zu Meshes, registriert
die Kollisionsschicht (ground/bridges/buildings) beim Player-Controller
mit asynchronem BVH-Bau und meldet über isReadyAt() wann ein Collider
tatsächlich einsatzbereit ist. Straßen/Sand/Park/Stadtfläche bleiben reine
Auflagen ohne eigene Kollision.

main.js schwebt die Figur kollisionsfrei am Spawn (Vice Beach), bis ihr
Chunk bereit ist oder 15s vergangen sind, und fängt jeden tieferen
Durchfall über ein Auffangnetz ab, das zur letzten sicheren Höhe
zurücksetzt. Der alte Platzhalter-Boden (400x400-Ebene samt Collider)
entfällt zugunsten der echten gestreamten Welt.

Der zweite Testfall aus dem Task-Brief hatte einen Datenfehler: die
"near"-Position {x:1375,z:-1375} traf exakt das Zentrum von Chunk 9_4
(Distanz zu Chunk 5_5 ca. 1030, weit jenseits des Entladeradius 600) und
ließ die eigene Behauptung "not.toContain" fehlschlagen, sobald
streaming.js existierte (RED belegt im Task-8-Report). Position auf
{x:875,z:-1125} korrigiert, exakt 500 Einheiten von Chunk 5_5 entfernt —
außerhalb von 400, innerhalb von 600 — und damit die Hysterese, die der
Testname behauptet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-12 20:18:54 +02:00
1 parent 962bfef778
commit f7041dab91
3 files changed
+187 -9

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
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" }) {
Object.assign(this, { scene, player, materials, props, baseUrl });
this.active = new Map(); // key -> { group, handle }
this.pending = new Set();
this.queue = [];
}
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);
}
group.add(collide);
this.scene.add(group);
group.updateMatrixWorld(true);
const handle = this.player.addCollider({
motion: "static", useWorker: true, shape: { kind: "mesh", source: collide },
});
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) {
const entry = this.active.get(k);
if (!entry) return;
this.player.removeCollider(entry.handle);
this.scene.remove(entry.group);
entry.group.traverse((o) => 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));
return Boolean(entry && this.player.collisionWorld.get(entry.handle.id)?.ready);
}
}