diff --git a/src/world/props.js b/src/world/props.js index 42101dc..12edc76 100644 --- a/src/world/props.js +++ b/src/world/props.js @@ -168,10 +168,17 @@ export class PropLayer { const trunk = new Mesh(this.trunkGeometry); trunk.visible = false; trunk.position.set(p.x, 3, p.z); + // sharedGeometry: this.trunkGeometry ist wie this.palmGeometry ein einziges, + // von PropLayer.load() gehaltenes Objekt für alle Chunks (Ruling 62). + trunk.userData.sharedGeometry = true; colliders.push(trunk); }); mesh.instanceMatrix.needsUpdate = true; mesh.userData.excludeFromCollider = true; + // sharedGeometry: this.palmGeometry entsteht einmal in load() und wird von jedem + // Chunk instanziert — #unload() in streaming.js darf es nicht mit entsorgen, + // solange andere Chunks noch darauf zeigen (Ruling 62, Fix-Runde 2). + mesh.userData.sharedGeometry = true; visual.add(mesh); } this.#buildLamps(chunk, visual); @@ -197,6 +204,9 @@ export class PropLayer { const mesh = new InstancedMesh(part.geometry, part.material, points.length); mesh.castShadow = true; mesh.userData.excludeFromCollider = true; + // sharedGeometry: part.geometry gehört this.lampParts, einmalig aus load() + // (Ruling 62, Fix-Runde 2 — dieselbe Begründung wie bei den Palmen oben). + mesh.userData.sharedGeometry = true; points.forEach((p, i) => { dummy.position.set(p.x, 0, p.z); dummy.rotation.set(0, p.angle, 0); diff --git a/src/world/streaming.js b/src/world/streaming.js index 7b248c7..d1eeea5 100644 --- a/src/world/streaming.js +++ b/src/world/streaming.js @@ -115,7 +115,13 @@ export class ChunkStreamer { // 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()); + // Ruling 62: this.palmGeometry, das Palmenmaterial und die Laternenteile entstehen + // einmal in PropLayer.load() und werden von jedem Chunk benutzt (props.visual landet + // hier in derselben Gruppe). Ohne die Wache würde jeder Chunk-Abbau ihre GPU-Puffer + // freigeben, während Nachbarchunks sie noch anzeigen — kein Absturz (dispose() lässt + // die CPU-Daten stehen, der nächste Zugriff lädt still neu hoch), aber unnötige Arbeit + // bei jedem Abbau. mesh.userData.sharedGeometry markiert das, analog excludeFromCollider. + entry.group.traverse((o) => { if (o.geometry && !o.userData.sharedGeometry) o.geometry.dispose(); }); this.active.delete(k); } diff --git a/tests/props.test.js b/tests/props.test.js index 26499d1..89499d8 100644 --- a/tests/props.test.js +++ b/tests/props.test.js @@ -52,6 +52,18 @@ describe("pointInPolygon", () => { it("Punkt im Loch (im Außenring, aber auch im Loch)", () => { expect(pointInPolygon(10, 10, [outer, hole])).toBe(false); }); + + // Fix-Runde 2, Befund B / Ruling 63: bis hierher benutzte jede Vorrichtung offene Ringe; + // die echten Chunk-Daten schließen ihre Ringe ausnahmslos (letzter Punkt = erster, siehe + // Kommentar oben und in props.js) — dieser Fall stand bisher in keinem Test. Ein künftiger + // Umbau, der das Schließen kaputt macht, ginge sonst durch alle Tests. + it("behandelt geschlossene Ringe genauso wie offene (Laufzeitdaten schließen ihre Ringe)", () => { + const closedOuter = [...outer, outer[0]]; + const closedHole = [...hole, hole[0]]; + expect(pointInPolygon(2, 2, [closedOuter, closedHole])).toBe(true); + expect(pointInPolygon(25, 25, [closedOuter, closedHole])).toBe(false); + expect(pointInPolygon(10, 10, [closedOuter, closedHole])).toBe(false); + }); }); describe("vergeLampPoints", () => { @@ -81,4 +93,15 @@ describe("vergeLampPoints", () => { expect(out[0].z).toBeCloseTo(6); expect(out[1].z).toBeCloseTo(-6); }); + + // Fix-Runde 2, Befund B / Ruling 63: siehe Kommentar bei pointInPolygon — derselbe + // ungeprüfte Fall (offene statt geschlossene Ringe) steckte auch hier in jeder Vorrichtung. + it("versetzt eine Laterne auch dann, wenn der Straßenring geschlossen ist", () => { + const points = [{ x: 0, z: 0, angle: 0 }]; + const ring = [[-50, -5], [50, -5], [50, 5], [-50, 5]]; + const roads = [{ poly: [[...ring, ring[0]]], bridge: false }]; + const out = vergeLampPoints(points, roads); + expect(out).toHaveLength(1); + expect(out[0].z).toBeCloseTo(6); + }); }); diff --git a/tests/streaming.test.js b/tests/streaming.test.js index cc6e945..8abb642 100644 --- a/tests/streaming.test.js +++ b/tests/streaming.test.js @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { BufferGeometry, Group, Mesh } from "three"; import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js"; describe("wantedChunks", () => { @@ -28,35 +29,36 @@ describe("wantedChunks", () => { }); }); -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 }; +// Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer). +// 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 }; +function makeStreamer(chunkData, props) { + 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: {}, props }), 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))); + +describe("ChunkStreamer", () => { 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. @@ -86,4 +88,90 @@ describe("ChunkStreamer", () => { expect(player.removeCollider).not.toHaveBeenCalled(); expect(streamer.active.has("4_6")).toBe(false); }); + + // Fix-Runde 2, Befund A / Ruling 62: this.palmGeometry, das Palmenmaterial und die + // Laternenteile entstehen einmal in PropLayer.load() und werden von jedem Chunk benutzt; + // #unload lief zuvor über die ganze Chunk-Gruppe und entsorgte sie mit, während + // Nachbarchunks sie noch anzeigten. mesh.userData.sharedGeometry markiert, was verschont + // bleibt — chunk-eigene Geometrie (hier: die Landfläche aus buildChunkGeometry) muss + // weiterhin entsorgt werden. + it("entsorgt beim Abmelden chunk-eigene Geometrie, aber keine als geteilt markierte (Ruling 62)", async () => { + const landChunk = { + chunk: [4, 6], origin: [0, -1000], + land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], + surfaces: [], roads: [], buildings: [], + }; + const sharedGeometry = new BufferGeometry(); + const sharedDispose = vi.spyOn(sharedGeometry, "dispose"); + const sharedMesh = new Mesh(sharedGeometry); + sharedMesh.userData.sharedGeometry = true; + const props = { build: () => { const visual = new Group(); visual.add(sharedMesh); return { visual, colliders: [] }; } }; + + const { streamer } = makeStreamer(landChunk, props); + streamer.update(emptyChunkCenter); + await flush(); + + // Chunk-eigene Geometrie (aus buildChunkGeometry, nicht als geteilt markiert) abgreifen + // und ihr dispose bespitzeln, bevor das Abmelden läuft. + const entry = streamer.active.get("4_6"); + let ownGeometry; + entry.group.traverse((o) => { + if (o.isMesh && o !== sharedMesh && o.geometry && !ownGeometry) ownGeometry = o.geometry; + }); + expect(ownGeometry).toBeTruthy(); // sonst testet dieser Test nichts + const ownDispose = vi.spyOn(ownGeometry, "dispose"); + + // Weit weg -> jenseits des Entladeradius (600) -> #unload("4_6") im selben update(). + streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z }); + + expect(ownDispose).toHaveBeenCalledTimes(1); + expect(sharedDispose).not.toHaveBeenCalled(); + }); +}); + +// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine +// automatische Abdeckung — nur von Hand belegt (Entfernen -> drei Tests fallen), siehe +// Task-10-Bericht. Ein Platzhalter-PropLayer im Konstruktor deckt das jetzt ab. +describe("ChunkStreamer mit PropLayer", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("ruft props.build() je geladenem Chunk einmal auf und hängt sein visual in die Szene", async () => { + const propsVisual = new Group(); + const props = { build: vi.fn(() => ({ visual: propsVisual, colliders: [] })) }; + const { streamer, scene } = makeStreamer(emptyChunk, props); + + streamer.update(emptyChunkCenter); + await flush(); + + expect(props.build).toHaveBeenCalledTimes(1); + expect(props.build).toHaveBeenCalledWith(emptyChunk); + expect(scene.add).toHaveBeenCalledTimes(1); + const sceneGroup = scene.add.mock.calls[0][0]; + expect(sceneGroup.children).toContain(propsVisual); + }); + + it("hängt colliders von props.build() in dieselbe BVH-Gruppe wie die Chunk-Kollision", async () => { + const trunk = new Mesh(); + const props = { build: vi.fn(() => ({ visual: new Group(), colliders: [trunk] })) }; + const { streamer, player } = makeStreamer(emptyChunk, props); + + streamer.update(emptyChunkCenter); + await flush(); + + // emptyChunk hat sonst keine Kollisionsgeometrie (Ruling 51) — der Stamm allein + // macht den Chunk-Collider nicht-leer, addCollider muss also aufgerufen werden. + expect(player.addCollider).toHaveBeenCalledTimes(1); + const source = player.addCollider.mock.calls[0][0].shape.source; + expect(source.children).toContain(trunk); + }); + + it("läuft ohne props weiterhin fehlerfrei (props ist seit Task 8 optional)", async () => { + const { streamer } = makeStreamer(emptyChunk /* kein props */); + streamer.update(emptyChunkCenter); + await flush(); + + expect(streamer.active.get("4_6")).toMatchObject({ handle: null }); + }); });