diff --git a/src/main.js b/src/main.js index 09911e7..5545e86 100644 --- a/src/main.js +++ b/src/main.js @@ -54,25 +54,42 @@ 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). +// streaming.js). setSkipCapsuleCollision(true) schaltet nur das horizontale Herausdrücken +// ab; applyGravity läuft im Controller unabhängig davon weiter (CharacterMovement.ts), die +// Kapsel würde also binnen Sekunden frei fallen, wenn man sie nur "schweben" ließe. Solange +// der Chunk unter dem Spawn nicht bereit ist, hält tick() sie deshalb aktiv auf Spawnhöhe +// fest (Ruling 53) statt sie fallen und vom Auffangnetz auffangen zu lassen — eine feste +// Zeitschranke, die danach die Kollision ohne vorhandenen Boden einschaltet, würde in eine +// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt +// es nur noch eine einmalige Warnung, kein Verhaltenswechsel. const streamer = new ChunkStreamer({ scene, player, materials }); player.setSkipCapsuleCollision(true); let settled = false; +let warnedSlowSpawn = 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)); + if (!settled) { + if (streamer.isReadyAt(player.playerCapsule.position)) { + settled = true; + player.setSkipCapsuleCollision(false); + player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); + } else { + player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); + if (!warnedSlowSpawn && performance.now() - startedAt > 15000) { + warnedSlowSpawn = true; + console.warn(`Chunk am Spawn (${SPAWN.x}, ${SPAWN.z}) ist nach 15s immer noch nicht bereit.`); + } + } + return; } const p = player.playerCapsule.position; + // Ruling 52: an ein echtes Bodensignal binden statt an die Höhe — sonst würde auch der + // Scheitelpunkt eines Sprungs, ein Dach oder ein Moment im freien Fall als "sicher" gelten. 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 }; + else if (player.getIsOnGround()) lastSafe = { x: p.x, y: p.y + 1, z: p.z }; } const timer = new Timer(); diff --git a/src/world/streaming.js b/src/world/streaming.js index d1d2398..38d4b71 100644 --- a/src/world/streaming.js +++ b/src/world/streaming.js @@ -72,9 +72,15 @@ export class ChunkStreamer { group.add(collide); this.scene.add(group); group.updateMatrixWorld(true); - const handle = this.player.addCollider({ - motion: "static", useWorker: true, shape: { kind: "mesh", source: collide }, - }); + // 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); @@ -97,7 +103,8 @@ export class ChunkStreamer { #unload(k) { const entry = this.active.get(k); if (!entry) return; - this.player.removeCollider(entry.handle); + // Ruling 51: collider-lose Chunks (offenes Wasser) haben kein handle abzumelden. + if (entry.handle) this.player.removeCollider(entry.handle); this.scene.remove(entry.group); entry.group.traverse((o) => o.geometry?.dispose()); this.active.delete(k); @@ -108,6 +115,9 @@ export class ChunkStreamer { 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); + 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); } } diff --git a/tests/streaming.test.js b/tests/streaming.test.js index e49b464..cc6e945 100644 --- a/tests/streaming.test.js +++ b/tests/streaming.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { wantedChunks } from "../src/world/streaming.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js"; describe("wantedChunks", () => { it("lädt die Umgebung des Spielers", () => { @@ -27,3 +27,63 @@ describe("wantedChunks", () => { expect(wantedChunks({ x: 0, z: -1000 }, { load: 400, unload: 600, active }).add).toHaveLength(0); }); }); + +describe("ChunkStreamer", () => { + // Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json + // (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51). + const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], roads: [], buildings: [] }; + // Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE). + const emptyChunkCenter = { x: 125, z: -875 }; + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeStreamer(chunkData) { + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData }))); + const player = { + addCollider: vi.fn(() => ({ id: 42 })), + removeCollider: vi.fn(), + // ready:false hier ist bewusst: ein echter (nicht-null) Handle wäre damit NIE bereit, + // sodass "isReadyAt sofort true" nur bestehen kann, wenn Ruling 51 tatsächlich greift + // und gar kein Handle erst registriert wird — sonst wäre der Test kein Regressionstest. + collisionWorld: { get: vi.fn(() => ({ ready: false })) }, + }; + const scene = { add: vi.fn(), remove: vi.fn() }; + return { streamer: new ChunkStreamer({ scene, player, materials: {} }), player, scene }; + } + + // #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um + // fetch() -> res.json() -> Geometrieaufbau vollständig abzuwarten (siehe #fetchChunk/#load). + const flush = () => new Promise((r) => setTimeout(r, 0)).then(() => new Promise((r) => setTimeout(r, 0))); + + it("registriert für einen Chunk ganz ohne Kollisionsgeometrie keinen Collider (Ruling 51)", async () => { + const { streamer, player } = makeStreamer(emptyChunk); + streamer.update(emptyChunkCenter); // Zentrum -> garantiert der nächstliegende, zuerst geladene Chunk. + await flush(); + + expect(player.addCollider).not.toHaveBeenCalled(); + expect(streamer.active.get("4_6")).toMatchObject({ handle: null }); + }); + + it("meldet einen collider-losen Chunk sofort als bereit", async () => { + const { streamer } = makeStreamer(emptyChunk); + streamer.update(emptyChunkCenter); + await flush(); + + expect(streamer.isReadyAt(emptyChunkCenter)).toBe(true); + }); + + it("entlädt einen collider-losen Chunk, ohne removeCollider aufzurufen", async () => { + const { streamer, player } = makeStreamer(emptyChunk); + streamer.update(emptyChunkCenter); + await flush(); + expect(streamer.active.has("4_6")).toBe(true); + + // Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update(). + streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z }); + + expect(player.removeCollider).not.toHaveBeenCalled(); + expect(streamer.active.has("4_6")).toBe(false); + }); +});