feat(world): Parkwege, Felder, Plätze, Pool und Parkplatz als eigene Schichten
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e675e62a16
commit
b736afb7ff
10 files changed
+96
-17
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 904 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 784 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -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);
|
||||
|
||||
+28
-5
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }] };
|
||||
|
||||
+14
-6
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user