diff --git a/public/textures/concrete_pavement_Diffuse.jpg b/public/textures/concrete_pavement_Diffuse.jpg new file mode 100644 index 0000000..5b8eb60 Binary files /dev/null and b/public/textures/concrete_pavement_Diffuse.jpg differ diff --git a/public/textures/concrete_pavement_arm.jpg b/public/textures/concrete_pavement_arm.jpg new file mode 100644 index 0000000..9e1a37c Binary files /dev/null and b/public/textures/concrete_pavement_arm.jpg differ diff --git a/public/textures/concrete_pavement_nor_gl.jpg b/public/textures/concrete_pavement_nor_gl.jpg new file mode 100644 index 0000000..13ab5f9 Binary files /dev/null and b/public/textures/concrete_pavement_nor_gl.jpg differ diff --git a/src/world/geometry.js b/src/world/geometry.js index be2a71d..dc787af 100644 --- a/src/world/geometry.js +++ b/src/world/geometry.js @@ -2,7 +2,7 @@ import { BufferGeometry, Float32BufferAttribute, ShapeUtils, Vector2 } from "thr const GROUND_THICKNESS = 1.0; const SURFACE_OFFSET = 0.02; // Sand, Park und Stadtfläche liegen knapp über dem Boden -const ROAD_OFFSET = 0.03; // Straßen wiederum knapp über den Flächen +const ROAD_OFFSET = 0.03; // Straßen und Parkwege wiederum knapp über den Flächen const BRIDGE_THICKNESS = 0.6; // Brückendeck lag bisher exakt bei y=0, identisch mit der Bodenoberkante (top weiter unten) — // garantiertes Z-Fighting überall dort, wo eine Brücke an Land endet, unabhängig von Kamera- @@ -12,7 +12,7 @@ const BRIDGE_THICKNESS = 0.6; // klein genug, dass die Brücke weiterhin flach und ohne spürbare Stufe befahrbar bleibt — wie // die von SURFACE_OFFSET bereits unangetastet gebliebene Kollision auf den Bodenflächen. const BRIDGE_OFFSET = SURFACE_OFFSET; -export const SURFACE_KINDS = ["sand", "park", "urban"]; +export const SURFACE_KINDS = ["sand", "park", "urban", "field", "court", "pool", "parking"]; /** Pastellton je Gebäude, reproduzierbar aus seiner Lage. */ function buildingTint(poly) { @@ -132,6 +132,9 @@ export function buildChunkGeometry(chunk) { roads: flatPolygons(roads, { y: ROAD_OFFSET, uvScale: 8 }), bridges: extrudePolygons(bridges, { base: -BRIDGE_THICKNESS, top: BRIDGE_OFFSET, uvScale: 8 }), buildings: buildBuildings(chunk.buildings), + // Parkwege (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): flach wie Straßen, Textur in + // echter Größe — Poly Haven misst concrete_pavement mit 1,8 × 1,8 m. + paths: flatPolygons((chunk.paths ?? []).map((path) => path.poly), { y: ROAD_OFFSET, uvScale: 1.8 }), }; for (const kind of SURFACE_KINDS) { const rings = (chunk.surfaces ?? []).filter((s) => s.kind === kind).map((s) => s.poly); diff --git a/src/world/materials.js b/src/world/materials.js index 3b9b979..a3a665f 100644 --- a/src/world/materials.js +++ b/src/world/materials.js @@ -1,12 +1,18 @@ import { DataTexture, MeshStandardMaterial, RepeatWrapping, RGBAFormat, SRGBColorSpace, TextureLoader, Vector2 } from "three"; -export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "roads", "bridges", "buildings"]; +export const MATERIAL_KEYS = ["ground", "sand", "park", "urban", "field", "court", "pool", "parking", "paths", + "roads", "bridges", "buildings"]; export const TEXTURES = { ground: "grass_path_2", // unbebautes Land sand: "sand_01", // Strand park: "leafy_grass", // Grünflächen urban: "concrete_pavement_03", // versiegelte Stadtfläche + // Flamingo Park (docs/superpowers/specs/2026-09-15-flamingo-park-design.md): Felder im Gras des Parks, der + // Parkplatz im Asphalt der Straßen. Beide lädt createMaterials nicht neu, sondern klont park und roads. + field: "leafy_grass", + parking: "asphalt_02", + paths: "concrete_pavement", // helle Betonplatten, auch für die Gehwege aus Teil 2 gedacht roads: "asphalt_02", // Ruling 56: eine Brücke ist in den Daten dasselbe Straßenstück, nur über Wasser // (geometry.js trennt sie einzig über roads.filter(r => r.bridge)) — dieselbe @@ -14,8 +20,10 @@ export const TEXTURES = { bridges: "asphalt_02", buildings: "white_stucco", }; +// Plätze und Pool sind einfarbig: Sandplätze, Hartplätze und Poolwasser hat Poly Haven nicht. +export const SURFACE_COLORS = { court: 0x6f8c79, pool: 0x3f9fd8 }; const FALLBACK_COLORS = { - ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8, + ground: 0xa8b878, sand: 0xe8d888, park: 0x8bc34a, urban: 0xd8d8d8, paths: 0xd8d2c4, roads: 0x505050, bridges: 0xb0b0b0, buildings: 0xe8e4dc, }; // Ruling 55: asphalt_02 misst roh heller (Ø 89.5) als concrete_pavement_03 (Ø 65.9) — @@ -24,6 +32,11 @@ const FALLBACK_COLORS = { // also wird hier die hellere Straße (und die jetzt identisch texturierte Brücke) // abgetönt statt die Stadtfläche zu verändern. const ROAD_TINT = 0x999999; +// Felder heben sich vom Rasen ab. material.color kann nur abdunkeln, also Rot und Blau stärker als Grün; +// abgestimmt wird im Browser. +const FIELD_TINT = 0xc8f0b0; +// Klone statt zweiter Ladevorgang: dieselben Texturen, eigene Tönung und eigener polygonOffset. +const CLONES = { field: { from: "park", tint: FIELD_TINT }, parking: { from: "roads" } }; /** Kachelbare Wellen-Normalmap, weil Poly Haven keine Wassertextur hat. */ export function waterNormalData(size = 128) { @@ -102,7 +115,9 @@ async function loadSet(loader, slug, fallback, { tint, maxAnisotropy } = {}) { // Fläche), units den senkrechten Blick von oben (Steigung ~ 0) — der Spieler sieht im Spiel // beides, je nach Kamerawinkel (Vogelperspektive in fly mode wie im Befund, aber auch normale // Bodenperspektive). -export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "roads"]; +// Flamingo Park: Felder, Plätze, Pool und Parkplatz liegen über dem Rasen, die Wege über allen Flächen +// und unter den Straßen, die sie an der Parkkante berühren. +export const POLYGON_OFFSET_ORDER = ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"]; export const POLYGON_OFFSETS = Object.fromEntries( POLYGON_OFFSET_ORDER.map((key, i) => [ @@ -111,7 +126,7 @@ export const POLYGON_OFFSETS = Object.fromEntries( ]), ); -/** Trägt POLYGON_OFFSETS in die passenden Materialien ein. Nur die fünf benannten Schichten — +/** Trägt POLYGON_OFFSETS in die passenden Materialien ein. Nur die benannten Schichten — * Brücken (eigene, geometrische Lösung, s. geometry.js BRIDGE-Kommentar), Gebäude, Wasser und * Meeresboden sind eigene Materialobjekte ohne Coplanar-Risiko und bleiben unberührt. */ export function applyPolygonOffsets(materials) { @@ -124,11 +139,19 @@ export function applyPolygonOffsets(materials) { export async function createMaterials(maxAnisotropy) { const loader = new TextureLoader(); const tints = { roads: ROAD_TINT, bridges: ROAD_TINT }; + const textured = MATERIAL_KEYS.filter((key) => !CLONES[key] && !SURFACE_COLORS[key]); const entries = await Promise.all( - MATERIAL_KEYS.map(async (key) => + textured.map(async (key) => [key, await loadSet(loader, TEXTURES[key], FALLBACK_COLORS[key], { tint: tints[key], maxAnisotropy })]), ); const materials = Object.fromEntries(entries); + for (const [key, { from, tint }] of Object.entries(CLONES)) { + materials[key] = materials[from].clone(); + if (tint) materials[key].color.set(tint); + } + for (const [key, color] of Object.entries(SURFACE_COLORS)) { + materials[key] = new MeshStandardMaterial({ color, roughness: key === "pool" ? 0.25 : 0.9, metalness: 0 }); + } materials.buildings.vertexColors = true; // pastellige Tönung je Gebäude applyPolygonOffsets(materials); diff --git a/src/world/streaming.js b/src/world/streaming.js index 3a80662..68fe675 100644 --- a/src/world/streaming.js +++ b/src/world/streaming.js @@ -4,7 +4,7 @@ import { buildChunkGeometry } from "./geometry.js"; import { pointInPolygon } from "./props.js"; const key = (i, j) => `${i}_${j}`; -const DECALS = new Set(["roads", "sand", "park", "urban"]); +const DECALS = new Set(["roads", "sand", "park", "urban", "field", "court", "pool", "parking", "paths"]); /** Mittelpunkt eines Chunks in three-Koordinaten. */ function chunkCenter(i, j) { diff --git a/tests/geometry.test.js b/tests/geometry.test.js index 7516416..541e326 100644 --- a/tests/geometry.test.js +++ b/tests/geometry.test.js @@ -101,6 +101,27 @@ describe("geometry", () => { expect(out.urban).toBeNull(); }); + it("baut Parkwege flach auf Straßenhöhe und die neuen Flächenarten wie Beläge", () => { + const chunk = { + chunk: [0, 0], origin: [0, 0], land: [], roads: [], centerlines: [], palms: [], buildings: [], + surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: PLOT, kind })), + paths: [{ poly: COURTYARD }], + }; + const out = buildChunkGeometry(chunk); + for (const kind of ["field", "court", "pool", "parking"]) { + const box = new Box3().setFromBufferAttribute(out[kind].getAttribute("position")); + expect(box.max.y, kind).toBeCloseTo(0.02); + } + const paths = new Box3().setFromBufferAttribute(out.paths.getAttribute("position")); + expect(paths.min.y).toBeCloseTo(0.03); + expect(footprintArea(out.paths)).toBeCloseTo(800, 6); // Weg um einen Hof: das Loch bleibt offen + }); + + it("liefert ohne Parkwege null statt einer leeren Geometrie", () => { + const chunk = { chunk: [0, 0], origin: [0, 0], land: [PLOT], surfaces: [], roads: [], centerlines: [], buildings: [], palms: [] }; + expect(buildChunkGeometry(chunk).paths).toBeNull(); + }); + it("gibt Gebäuden eine Farbe je Haus", () => { const chunk = { chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], roads: [], centerlines: [], palms: [], buildings: [{ poly: PLOT, height: 9, source: "district", speculative: false }] }; diff --git a/tests/materials.test.js b/tests/materials.test.js index 4b4e8db..9634b9f 100644 --- a/tests/materials.test.js +++ b/tests/materials.test.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { applyPolygonOffsets, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, TEXTURES, +import { applyPolygonOffsets, MATERIAL_KEYS, POLYGON_OFFSETS, POLYGON_OFFSET_ORDER, SURFACE_COLORS, TEXTURES, waterNormalData } from "../src/world/materials.js"; import { buildChunkGeometry } from "../src/world/geometry.js"; @@ -9,8 +9,15 @@ describe("materials", () => { expect(Object.keys(buildChunkGeometry(chunk)).sort()).toEqual([...MATERIAL_KEYS].sort()); }); - it("nennt für jedes Material eine Textur", () => { - for (const key of MATERIAL_KEYS) expect(TEXTURES[key]).toBeTruthy(); + it("nennt für jedes Material genau eine Textur oder eine Farbe", () => { + for (const key of MATERIAL_KEYS) expect(Boolean(TEXTURES[key]) !== Boolean(SURFACE_COLORS[key]), key).toBe(true); + }); + + it("gibt Feldern das Gras des Parks, dem Parkplatz den Asphalt der Straßen und Wegen Betonplatten", () => { + expect(TEXTURES.field).toBe(TEXTURES.park); + expect(TEXTURES.parking).toBe(TEXTURES.roads); + expect(TEXTURES.paths).toBe("concrete_pavement"); + expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"]); }); it("deckt Strand und Park mit eigenen Materialien ab", () => { @@ -40,8 +47,8 @@ describe("materials", () => { // Datenstruktur, applyPolygonOffsets() eine reine Funktion auf Platzhalterobjekten — beide // brauchen weder createMaterials() noch echtes Texturladen. describe("Tiefenpuffer-Versatz je Schicht (gegen Flackern zwischen Boden/Stadt/Park/Strand/Straße)", () => { - it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Straße", () => { - expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "roads"]); + it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Feld < Platz < Pool < Parkplatz < Weg < Straße", () => { + expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"]); }); it("aktiviert polygonOffset für jede der fünf Schichten", () => { @@ -72,7 +79,8 @@ describe("Tiefenpuffer-Versatz je Schicht (gegen Flackern zwischen Boden/Stadt/P it("trägt den Versatz nur in Boden/Stadtfläche/Park/Strand/Straße ein — Brücken, Gebäude, " + "Wasser und Meeresboden bleiben unberührt (eigene Materialobjekte, s. Auftrag)", () => { const fake = Object.fromEntries( - ["ground", "urban", "park", "sand", "roads", "bridges", "buildings", "water", "seafloor"] + ["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads", + "bridges", "buildings", "water", "seafloor"] .map((k) => [k, {}]), ); applyPolygonOffsets(fake); diff --git a/tests/streaming.test.js b/tests/streaming.test.js index ecde1ea..55cdc81 100644 --- a/tests/streaming.test.js +++ b/tests/streaming.test.js @@ -221,6 +221,24 @@ describe("ChunkStreamer", () => { expect(source.children).toHaveLength(1); expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude }); + + // Bruch: eine neue Schicht fehlt in DECALS — Weg oder Feld landete in der Kollision und die Kapsel stolperte + // über 2–3 cm hohe Kanten, oder die Schicht fehlte ganz in der Szene. + it("legt Parkwege und die neuen Flächenarten als Auflagen ohne Kollision in die Szene", async () => { + const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]]; + const chunk = { + chunk: [4, 6], origin: [0, -1000], land: [square], roads: [], buildings: [], + surfaces: ["field", "court", "pool", "parking"].map((kind) => ({ poly: square, kind })), + paths: [{ poly: square }], + }; + const { streamer, player, scene } = makeStreamer(chunk); + streamer.update(emptyChunkCenter); + await flush(); + + expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(1); // nur die Landplatte + const group = scene.add.mock.calls[0][0]; + expect(group.children.filter((child) => child.isMesh)).toHaveLength(5); + }); }); // Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine diff --git a/tools/assets/textures.mjs b/tools/assets/textures.mjs index 198d1a7..f6ecf12 100644 --- a/tools/assets/textures.mjs +++ b/tools/assets/textures.mjs @@ -1,18 +1,24 @@ -// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/. +// Lädt die benötigten Poly-Haven-Texturen in 1k nach public/textures/. Vorhandene Dateien bleiben +// unangetastet, damit ein neuer Eintrag die übrigen Texturen nicht still ändert. +import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; const UA = { "User-Agent": "leonida-assets/0.1 (local hobby project)" }; -const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark"]; +const SLUGS = ["asphalt_02", "concrete_pavement_03", "white_stucco", "sand_01", "leafy_grass", "grass_path_2", "palm_bark", + "concrete_pavement"]; const MAPS = ["Diffuse", "nor_gl", "arm"]; await mkdir("public/textures", { recursive: true }); for (const slug of SLUGS) { + if (MAPS.every((map) => existsSync(`public/textures/${slug}_${map}.jpg`))) continue; const files = await (await fetch(`https://api.polyhaven.com/files/${slug}`, { headers: UA })).json(); for (const map of MAPS) { + const target = `public/textures/${slug}_${map}.jpg`; + if (existsSync(target)) continue; const entry = files[map]?.["1k"]?.jpg; if (!entry) { console.warn(`${slug}: ${map} fehlt`); continue; } const buf = Buffer.from(await (await fetch(entry.url, { headers: UA })).arrayBuffer()); - await writeFile(`public/textures/${slug}_${map}.jpg`, buf); + await writeFile(target, buf); console.log(`${slug}_${map}.jpg ${(buf.length / 1e6).toFixed(1)} MB`); } }