From f7041dab9175cf48deea65677db26db2b43b581f Mon Sep 17 00:00:00 2001 From: KNSO Date: Sat, 12 Sep 2026 20:18:54 +0200 Subject: [PATCH] feat(world): Chunk-Streaming mit Collider-Verwaltung und Auffangnetz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main.js | 54 +++++++++++++++---- src/world/streaming.js | 113 ++++++++++++++++++++++++++++++++++++++++ tests/streaming.test.js | 29 +++++++++++ 3 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 src/world/streaming.js create mode 100644 tests/streaming.test.js diff --git a/src/main.js b/src/main.js index 57126ae..09911e7 100644 --- a/src/main.js +++ b/src/main.js @@ -1,8 +1,9 @@ -import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, Mesh, MeshStandardMaterial, - PerspectiveCamera, PlaneGeometry, Scene, Timer, Vector3, WebGLRenderer } from "three"; +import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, MeshStandardMaterial, + PerspectiveCamera, Scene, Timer, Vector3, WebGLRenderer } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { playerController } from "three-player-controller"; +import { ChunkStreamer } from "./world/streaming.js"; const container = document.querySelector("#container"); const scene = new Scene(); @@ -22,17 +23,28 @@ sun.position.set(60, 120, 40); sun.castShadow = true; scene.add(sun); -const ground = new Mesh(new PlaneGeometry(400, 400), new MeshStandardMaterial({ color: 0x9aa88a })); -ground.rotation.x = -Math.PI / 2; -ground.receiveShadow = true; -scene.add(ground); +// Ein Material je Schicht aus buildChunkGeometry() (Task 7). buildings trägt die dort erzeugten +// Vertexfarben (Pastellton pro Gebäude) und bleibt deshalb weiß grundiert; die übrigen Schichten +// sind reine Auflagen/Landmassen ohne Vertexfarben und bekommen je einen flachen Grundton. +const materials = { + ground: new MeshStandardMaterial({ color: 0x6d7a52, roughness: 1 }), + roads: new MeshStandardMaterial({ color: 0x3a3a3d, roughness: 0.9 }), + bridges: new MeshStandardMaterial({ color: 0x8a8a8a, roughness: 0.8 }), + buildings: new MeshStandardMaterial({ vertexColors: true, roughness: 0.85 }), + sand: new MeshStandardMaterial({ color: 0xd9c48c, roughness: 1 }), + park: new MeshStandardMaterial({ color: 0x4f7a3d, roughness: 1 }), + urban: new MeshStandardMaterial({ color: 0xa8a8a0, roughness: 0.9 }), +}; + +// Vice Beach; Task 8, Schritt 5. Auch initPos, damit der Streamer von Anfang an den richtigen +// Chunk anfragt statt erst nach dem späteren reset() umzuschwenken. +const SPAWN = { x: 1699, y: 3, z: -743 }; const gltf = await new GLTFLoader().loadAsync("./models/ual.glb"); const player = new playerController(); await player.init({ scene, camera, controls, - colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }], - initPos: new Vector3(0, 2, 0), + initPos: new Vector3(SPAWN.x, SPAWN.y, SPAWN.z), playerModelConfig: { model: gltf.scene, animations: gltf.animations, scale: 0.005, idleAnim: "Idle_Loop", walkAnim: "Walk_Loop", runAnim: "Sprint_Loop", @@ -41,11 +53,35 @@ await player.init({ }, }); +// Chunk-Collider treffen erst nach und nach ein (Fetch + asynchroner BVH-Bau, siehe +// streaming.js). Bis der Chunk unter dem Spawn bereit ist — oder spätestens nach 15s — +// bleibt die Kapselkollision aus, damit die Figur nicht an halb geladenen Gebäuden hängen +// bleibt; das Auffangnetz unten fängt jeden tieferen Durchfall ab (auch während dieser Zeit). +const streamer = new ChunkStreamer({ scene, player, materials }); +player.setSkipCapsuleCollision(true); +let settled = false; +const startedAt = performance.now(); +let lastSafe = { ...SPAWN }; + +function tick(delta) { + streamer.update(player.playerCapsule.position); + if (!settled && (streamer.isReadyAt(player.playerCapsule.position) || performance.now() - startedAt > 15000)) { + settled = true; + player.setSkipCapsuleCollision(false); + player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); + } + const p = player.playerCapsule.position; + if (p.y < -30) player.reset(new Vector3(lastSafe.x, lastSafe.y, lastSafe.z)); + else if (p.y > -1) lastSafe = { x: p.x, y: p.y + 1, z: p.z }; +} + const timer = new Timer(); timer.connect(document); renderer.setAnimationLoop((t) => { timer.update(t); - player.update(Math.min(timer.getDelta(), 1 / 40)); + const delta = Math.min(timer.getDelta(), 1 / 40); + player.update(delta); + tick(delta); renderer.render(scene, camera); }); addEventListener("resize", () => { diff --git a/src/world/streaming.js b/src/world/streaming.js new file mode 100644 index 0000000..d1d2398 --- /dev/null +++ b/src/world/streaming.js @@ -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); + } +} diff --git a/tests/streaming.test.js b/tests/streaming.test.js new file mode 100644 index 0000000..e49b464 --- /dev/null +++ b/tests/streaming.test.js @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { wantedChunks } from "../src/world/streaming.js"; + +describe("wantedChunks", () => { + it("lädt die Umgebung des Spielers", () => { + const { add } = wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }); + expect(add.length).toBeGreaterThan(4); + expect(add.length).toBeLessThan(30); + }); + + it("entlädt erst jenseits des größeren Radius", () => { + // Chunk 5_5 hat sein Zentrum bei (375, -1125) (SLICE.x0 + 5.5*CHUNK_SIZE, ...). + // "near" liegt hier bewusst 500 Einheiten entfernt: außerhalb des Laderadius (400), + // aber innerhalb des Entladeradius (600) — genau die Hysterese, die der Testname + // behauptet. Der ursprüngliche Brief-Wert {x:1375, z:-1375} traf exakt das Zentrum + // von Chunk 9_4 (Distanz zu 5_5: ~1030, weit jenseits von 600) und ließ die eigene + // Behauptung "not.toContain" fehlschlagen — siehe Task-8-Report für den RED-Beleg. + const active = new Set(["5_5"]); + const near = wantedChunks({ x: 875, z: -1125 }, { load: 400, unload: 600, active }); + expect(near.remove).not.toContain("5_5"); + const far = wantedChunks({ x: -900, z: -2400 }, { load: 400, unload: 600, active }); + expect(far.remove).toContain("5_5"); + }); + + it("meldet nichts doppelt an", () => { + const active = new Set(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active: new Set() }).add); + expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0); + }); +});