diff --git a/src/main.js b/src/main.js index 1db8b64..50c41bf 100644 --- a/src/main.js +++ b/src/main.js @@ -8,7 +8,7 @@ import { createMaterials } from "./world/materials.js"; import { createSky } from "./world/sky.js"; import { createSunShadows, prepareLitMaterials } from "./world/shadows.js"; import { PropLayer } from "./world/props.js"; -import { PlaceLayer } from "./world/places.js"; +import { PLACES, PlaceLayer } from "./world/places.js"; import { chunkForWorld, threeToWorld } from "./world/coords.js"; import { createCharacterModule } from "./characters/index.js"; import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js"; @@ -236,7 +236,12 @@ await props.load(); // 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, props, pinned: VEHICLE_PINNED_CHUNKS }); +// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer +// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten. +const streamer = new ChunkStreamer({ + scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS, + replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []), +}); // warmUp fürs Vorladen eines Orts (PlaceLayer unten): lädt seine Texturen schon hier auf die GPU und // übersetzt seine CSM-Shader, statt das im ersten Bild nach dem Einsetzen zu tun — dort kostete genau // das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md, diff --git a/src/world/streaming.js b/src/world/streaming.js index 6a7ae1b..5476d0d 100644 --- a/src/world/streaming.js +++ b/src/world/streaming.js @@ -1,6 +1,7 @@ import { Group, Mesh } from "three"; import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js"; import { buildChunkGeometry } from "./geometry.js"; +import { pointInPolygon } from "./props.js"; const key = (i, j) => `${i}_${j}`; const DECALS = new Set(["roads", "sand", "park", "urban"]); @@ -31,9 +32,23 @@ function dist(k, position) { return Math.hypot(c.x - position.x, c.z - position.z); } +/** + * Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js): + * jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und + * Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt + * derselbe Chunk zurück. + */ +export function withoutReplacedBuildings(chunk, points) { + if (points.length === 0) return chunk; + const [ox, oz] = chunk.origin; + const buildings = chunk.buildings.filter((building) => + !points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly))); + return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings }; +} + export class ChunkStreamer { - constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) { - Object.assign(this, { scene, player, materials, props, baseUrl }); + constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) { + Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings }); this.active = new Map(); // key -> { group, handle } this.pending = new Set(); this.queue = []; @@ -63,7 +78,7 @@ export class ChunkStreamer { 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 geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings)); const collide = new Group(); for (const [name, geo] of Object.entries(geos)) { if (!geo) continue; diff --git a/tests/streaming.test.js b/tests/streaming.test.js index f226a5c..439785e 100644 --- a/tests/streaming.test.js +++ b/tests/streaming.test.js @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { BufferGeometry, Group, Mesh } from "three"; -import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js"; +import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js"; describe("wantedChunks", () => { it("lädt die Umgebung des Spielers", () => { @@ -29,6 +29,33 @@ describe("wantedChunks", () => { }); }); +describe("withoutReplacedBuildings", () => { + // Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250. + const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]]; + const chunk = { + chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [], + buildings: [ + { poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060 + { poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150 + ], + }; + + // Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten + // stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert. + it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => { + const result = withoutReplacedBuildings(chunk, [[1405, 1055]]); + + expect(result.buildings).toEqual([chunk.buildings[1]]); + expect(result.land).toBe(chunk.land); + expect(chunk.buildings).toHaveLength(2); + }); + + it("gibt ohne Treffer den Chunk selbst zurück", () => { + expect(withoutReplacedBuildings(chunk, [])).toBe(chunk); + expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk); + }); +}); + // 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). @@ -36,7 +63,7 @@ const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [], // 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, pinned) { +function makeStreamer(chunkData, props, pinned, replacedBuildings) { vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData }))); const player = { addCollider: vi.fn(() => ({ id: 42 })), @@ -47,7 +74,7 @@ function makeStreamer(chunkData, props, pinned) { collisionWorld: { get: vi.fn(() => ({ ready: false })) }, }; const scene = { add: vi.fn(), remove: vi.fn() }; - return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned }), player, scene }; + return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene }; } // #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um @@ -152,6 +179,31 @@ describe("ChunkStreamer", () => { expect(streamer.active.has("4_6")).toBe(true); expect(player.removeCollider).not.toHaveBeenCalled(); }); + + // Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt + // Kollision mitten in dem Ort, der ihn ersetzt. + it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => { + const chunk = { + chunk: [4, 6], origin: [0, -1000], + land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]], + surfaces: [], roads: [], + buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }], + }; + // Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe. + const plain = makeStreamer(chunk); + plain.streamer.update(emptyChunkCenter); + await flush(); + expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2); + + // Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985. + const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]); + streamer.update(emptyChunkCenter); + await flush(); + + const source = player.addCollider.mock.calls[0][0].shape.source; + expect(source.children).toHaveLength(1); + expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude + }); }); // Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine