fix(world): leere Chunks, Bodensignal und Fall-Auffang-Schleife (Fix-Runde 1)

Ruling 51: #load() rief addCollider() auch für Chunks ohne jede Kollisions-
geometrie auf (offenes Wasser: 4_6, 4_10, 5_6, 5_10 — an den Rohdaten
nachgezählt). Das lieferte nur eine Fremdwarnung aus ColliderRegistry und
ein handle mit id:-1, das nie ready wird. addCollider läuft jetzt nur noch,
wenn die collide-Gruppe Kinder hat; #unload und isReadyAt behandeln einen
Eintrag ohne handle korrekt (kein removeCollider nötig, sofort "bereit").

Ruling 52: lastSafe hielt sich an der Höhe fest (p.y > -1), nicht am
tatsächlichen Bodenkontakt — der Scheitel eines Sprungs oder ein Dach
zählten als "sicher". Prüfung im Submodul ergab: playerIsOnGround (public,
via setOnGround() nur aus snapToGround() auf true gesetzt) mit passendem
Getter getIsOnGround(). lastSafe aktualisiert sich jetzt nur noch bei
echtem Bodenkontakt.

Ruling 53: setSkipCapsuleCollision(true) schaltet nur die horizontale
Kollision ab, applyGravity läuft unabhängig weiter — die "Schwebephase"
war freier Fall. Die alte 15s-Zeitschranke schaltete die Kollision ohne
vorhandenen Boden scharf, was bei dauerhaft fehlendem Chunk in eine
endlose Fall-Auffang-Schleife lief. Die Figur wird jetzt aktiv auf
Spawnhöhe gehalten, bis ihr Chunk bereit ist; nach 15s nur noch eine
einmalige Warnung, kein Verhaltenswechsel. Per künstlicher 8s-Verzögerung
gemessen: Höhe blieb über den gesamten (durch Worker-Pool-Stau auf über
14s verlängerten) Wartezeitraum innerhalb 0,004 Einheiten der Spawnhöhe.

Neue Tests in tests/streaming.test.js decken Ruling 51 ab (ChunkStreamer
mit gemocktem fetch/player/scene); Ruling 52/53 sind Physik-Verhalten des
Submoduls und über den Browser verifiziert (Bericht: task-8-report.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-12 21:18:23 +02:00
1 parent f7041dab91
commit 289bbbba1d
3 files changed
+98 -11

No files matched your search

+22 -5
View File
@@ -54,25 +54,42 @@ await player.init({
}); });
// Chunk-Collider treffen erst nach und nach ein (Fetch + asynchroner BVH-Bau, siehe // 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 — // streaming.js). setSkipCapsuleCollision(true) schaltet nur das horizontale Herausdrücken
// bleibt die Kapselkollision aus, damit die Figur nicht an halb geladenen Gebäuden hängen // ab; applyGravity läuft im Controller unabhängig davon weiter (CharacterMovement.ts), die
// bleibt; das Auffangnetz unten fängt jeden tieferen Durchfall ab (auch während dieser Zeit). // 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 }); const streamer = new ChunkStreamer({ scene, player, materials });
player.setSkipCapsuleCollision(true); player.setSkipCapsuleCollision(true);
let settled = false; let settled = false;
let warnedSlowSpawn = false;
const startedAt = performance.now(); const startedAt = performance.now();
let lastSafe = { ...SPAWN }; let lastSafe = { ...SPAWN };
function tick(delta) { function tick(delta) {
streamer.update(player.playerCapsule.position); streamer.update(player.playerCapsule.position);
if (!settled && (streamer.isReadyAt(player.playerCapsule.position) || performance.now() - startedAt > 15000)) { if (!settled) {
if (streamer.isReadyAt(player.playerCapsule.position)) {
settled = true; settled = true;
player.setSkipCapsuleCollision(false); player.setSkipCapsuleCollision(false);
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); 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; 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)); 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(); const timer = new Timer();
+14 -4
View File
@@ -72,9 +72,15 @@ export class ChunkStreamer {
group.add(collide); group.add(collide);
this.scene.add(group); this.scene.add(group);
group.updateMatrixWorld(true); group.updateMatrixWorld(true);
const handle = this.player.addCollider({ // 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 }, motion: "static", useWorker: true, shape: { kind: "mesh", source: collide },
}); })
: null;
this.active.set(k, { group, handle, chunk }); this.active.set(k, { group, handle, chunk });
} catch (err) { } catch (err) {
console.warn(`Chunk ${k} konnte nicht geladen werden:`, err); console.warn(`Chunk ${k} konnte nicht geladen werden:`, err);
@@ -97,7 +103,8 @@ export class ChunkStreamer {
#unload(k) { #unload(k) {
const entry = this.active.get(k); const entry = this.active.get(k);
if (!entry) return; 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); this.scene.remove(entry.group);
entry.group.traverse((o) => o.geometry?.dispose()); entry.group.traverse((o) => o.geometry?.dispose());
this.active.delete(k); this.active.delete(k);
@@ -108,6 +115,9 @@ export class ChunkStreamer {
const i = Math.floor((position.x - SLICE.x0) / CHUNK_SIZE); const i = Math.floor((position.x - SLICE.x0) / CHUNK_SIZE);
const j = Math.floor((SLICE.y1 + position.z) / CHUNK_SIZE); const j = Math.floor((SLICE.y1 + position.z) / CHUNK_SIZE);
const entry = this.active.get(key(i, j)); 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);
} }
} }
+62 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { wantedChunks } from "../src/world/streaming.js"; import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js";
describe("wantedChunks", () => { describe("wantedChunks", () => {
it("lädt die Umgebung des Spielers", () => { 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); 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);
});
});