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); + }); +});