feat(world): Materialien, Kollision und Laternen für die neuen Straßen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ca4f7161af
commit
25248b3929
8 files changed
+205
-110
No files matched your search
+41
-10
@@ -15,9 +15,9 @@ describe("materials", () => {
|
||||
|
||||
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.parking).toBe(TEXTURES.carriageway);
|
||||
expect(TEXTURES.paths).toBe("concrete_pavement");
|
||||
expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "pool"]);
|
||||
expect(Object.keys(SURFACE_COLORS).sort()).toEqual(["court", "markings", "pool"]);
|
||||
});
|
||||
|
||||
it("deckt Strand und Park mit eigenen Materialien ab", () => {
|
||||
@@ -26,9 +26,8 @@ describe("materials", () => {
|
||||
});
|
||||
|
||||
it("gibt Brücken dieselbe Textur wie Straßen, damit an der Übergangsnaht kein Farbsprung entsteht (Ruling 56)", () => {
|
||||
// Brücken sind in den Daten dasselbe Straßenstück, nur über Wasser
|
||||
// (geometry.js trennt sie einzig über roads.filter(r => r.bridge)).
|
||||
expect(TEXTURES.bridges).toBe(TEXTURES.roads);
|
||||
// Brücken sind in den Daten dasselbe Straßenstück, nur über Wasser.
|
||||
expect(TEXTURES.bridges).toBe(TEXTURES.carriageway);
|
||||
});
|
||||
|
||||
it("erzeugt eine Wellen-Normalmap mit neutraler Grundfarbe", () => {
|
||||
@@ -47,8 +46,9 @@ 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 < Feld < Platz < Pool < Parkplatz < Weg < Straße", () => {
|
||||
expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "paths", "roads"]);
|
||||
it("ordnet die Schichten Boden < Stadtfläche < Park < Strand < Feld < Platz < Pool < Parkplatz < Restfläche < Gehweg < Weg", () => {
|
||||
expect(POLYGON_OFFSET_ORDER).toEqual(["ground", "urban", "park", "sand", "field", "court", "pool",
|
||||
"parking", "lots", "sidewalk", "paths"]);
|
||||
});
|
||||
|
||||
it("aktiviert polygonOffset für jede der fünf Schichten", () => {
|
||||
@@ -79,16 +79,47 @@ 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", "field", "court", "pool", "parking", "paths", "roads",
|
||||
"bridges", "buildings", "water", "seafloor"]
|
||||
["ground", "urban", "park", "sand", "field", "court", "pool", "parking", "lots", "sidewalk",
|
||||
"paths", "markings", "carriageway", "curb", "bridges", "buildings", "water", "seafloor"]
|
||||
.map((k) => [k, {}]),
|
||||
);
|
||||
applyPolygonOffsets(fake);
|
||||
for (const key of POLYGON_OFFSET_ORDER) {
|
||||
expect(fake[key]).toMatchObject(POLYGON_OFFSETS[key]);
|
||||
}
|
||||
for (const key of ["bridges", "buildings", "water", "seafloor"]) {
|
||||
for (const key of ["carriageway", "curb", "bridges", "buildings", "water", "seafloor"]) {
|
||||
expect(fake[key]).toEqual({});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Schichten aus Teil 2", () => {
|
||||
it("hat für jede neue Schicht ein Material", () => {
|
||||
for (const key of ["carriageway", "curb", "sidewalk", "markings", "lots"]) {
|
||||
expect(MATERIAL_KEYS).toContain(key);
|
||||
}
|
||||
expect(MATERIAL_KEYS).not.toContain("roads");
|
||||
});
|
||||
|
||||
it("nimmt Bordstein und Gehweg aus dem Kit", () => {
|
||||
expect(TEXTURES.curb).toBe("curb");
|
||||
expect(TEXTURES.sidewalk).toBe("sidewalk");
|
||||
expect(TEXTURES.carriageway).toBe("asphalt_02");
|
||||
expect(TEXTURES.lots).toBe("asphalt_02");
|
||||
});
|
||||
|
||||
it("gibt der Markierung eine Farbe statt einer Textur", () => {
|
||||
expect(SURFACE_COLORS.markings).toBeDefined();
|
||||
expect(TEXTURES.markings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gibt der Markierung einen eigenen kleinen Versatz statt der obersten Stufe der Beläge", () => {
|
||||
// Ihr einziger Nachbar ist die Fahrbahn 1 cm darunter, und die hat keinen Versatz. Die oberste
|
||||
// Stufe zöge sie beim streifenden Blick am weitesten nach vorn, ohne dass es etwas nützte.
|
||||
expect(POLYGON_OFFSET_ORDER).not.toContain("markings");
|
||||
expect(POLYGON_OFFSETS.markings).toEqual({ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 });
|
||||
// Fahrbahn und Bordstein liegen 15 cm tiefer und sind echte Geometrie — kein Versatz nötig.
|
||||
expect(POLYGON_OFFSETS.carriageway).toBeUndefined();
|
||||
expect(POLYGON_OFFSETS.curb).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js";
|
||||
import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js";
|
||||
import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
|
||||
import { chunkLampPoints, pointInPolygon } from "../src/world/props.js";
|
||||
import { withoutReplacedBuildings } from "../src/world/streaming.js";
|
||||
|
||||
// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt.
|
||||
@@ -72,7 +72,9 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
|
||||
const c = chunk(i, j);
|
||||
const u = X - c.origin[0], v = -Y - c.origin[1];
|
||||
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
|
||||
const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly));
|
||||
// Straße ist seit Teil 2 alles, was früher der Korridor war: Fahrbahn, Gehweg und Restflächen.
|
||||
const onRoad = [...(c.carriageway ?? []), ...(c.sidewalk ?? []), ...(c.lots ?? [])]
|
||||
.some((road) => pointInPolygon(u, v, road.poly));
|
||||
const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));
|
||||
return !onLand || onRoad || inBuilding;
|
||||
});
|
||||
@@ -106,7 +108,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
|
||||
});
|
||||
|
||||
// Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten;
|
||||
// Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks.
|
||||
// Laternen stehen bis 12 m neben ihrer Straßenkante (LAMP_VERGE), deshalb zählen auch die Nachbarchunks.
|
||||
it("enthält keine Palme und keine Laterne", () => {
|
||||
const nearby = new Set();
|
||||
for (const [i, j] of under) {
|
||||
@@ -120,7 +122,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
|
||||
const c = chunk(...key.split("_").map(Number));
|
||||
const toWorld = (u, v) => ({ X: c.origin[0] + u, Y: -(c.origin[1] + v) });
|
||||
const palms = (c.palms ?? []).map(([u, v]) => toWorld(u, v));
|
||||
const lamps = vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? []).map((p) => toWorld(p.x, p.z));
|
||||
const lamps = chunkLampPoints(c).map((p) => toWorld(p.x, p.z));
|
||||
return [...palms, ...lamps];
|
||||
});
|
||||
expect(props.filter(({ X, Y }) => insideBounds(place, X, Y))).toEqual([]);
|
||||
|
||||
+48
-33
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { STREET_LAMP_HEIGHT, lampFit, lampPoints, palmTransforms, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
|
||||
import { STREET_LAMP_HEIGHT, chunkLampPoints, lampFit, lampPoints, palmTransforms, pointInPolygon,
|
||||
sidewalkLampPoints } from "../src/world/props.js";
|
||||
|
||||
describe("props", () => {
|
||||
it("setzt Laternen im gewünschten Abstand entlang der Linie", () => {
|
||||
@@ -66,43 +67,57 @@ describe("pointInPolygon", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("vergeLampPoints", () => {
|
||||
it("versetzt eine Laterne von der Fahrbahn zum ersten freien Abstand, senkrecht zur Linie", () => {
|
||||
// angle 0 -> Blickrichtung (1, 0), Normale (0, 1). Straße 10 m breit (Halbbreite 5 m):
|
||||
// der erste Versatz aus LAMP_VERGE (6 m) reicht bereits über den Fahrbahnrand hinaus.
|
||||
const points = [{ x: 0, z: 0, angle: 0 }];
|
||||
const roads = [{ poly: [[[-50, -5], [50, -5], [50, 5], [-50, 5]]], bridge: false }];
|
||||
const out = vergeLampPoints(points, roads);
|
||||
describe("Laternen auf dem Gehweg", () => {
|
||||
// Straße entlang der x-Achse, angle 0: Die Normale ist (0, 1). Der Gehweg liegt nur auf der Seite
|
||||
// z > 0, 3,5 bis 6 m neben der Mittellinie — wie bei einer Wohnstraße mit 7 m Fahrbahn.
|
||||
const gehweg = [{ poly: [[[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]]] }];
|
||||
|
||||
it("stellt die Laterne auf den Gehweg, so dicht an die Bordsteinkante wie möglich", () => {
|
||||
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gehweg);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].x).toBeCloseTo(0);
|
||||
expect(out[0].z).toBeCloseTo(6);
|
||||
expect(pointInPolygon(out[0].x, out[0].z, roads[0].poly)).toBe(false);
|
||||
expect(out[0].z).toBeCloseTo(4); // der erste Abstand aus LAMP_VERGE, der auf dem Gehweg liegt
|
||||
});
|
||||
|
||||
it("lässt eine Laterne entfallen, wenn keiner der sechs Abstände die Fahrbahn verlässt", () => {
|
||||
// Halbbreite 20 m > größter Versatz (16 m) -> jede Probe bleibt auf der Fahrbahn.
|
||||
const points = [{ x: 0, z: 0, angle: 0 }];
|
||||
const roads = [{ poly: [[[-50, -20], [50, -20], [50, 20], [-50, 20]]], bridge: false }];
|
||||
expect(vergeLampPoints(points, roads)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wechselt die Versatzseite von Punkt zu Punkt ab", () => {
|
||||
// Ohne Straßen gewinnt sofort der erste (kleinste) Versatz bei jedem Punkt.
|
||||
const points = [{ x: 0, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
|
||||
const out = vergeLampPoints(points, []);
|
||||
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);
|
||||
it("sucht auch auf der anderen Straßenseite", () => {
|
||||
const gegenueber = [{ poly: [[[-20, -6], [20, -6], [20, -3.5], [-20, -3.5]]] }];
|
||||
const out = sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], gegenueber);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].z).toBeCloseTo(6);
|
||||
expect(out[0].z).toBeCloseTo(-4);
|
||||
});
|
||||
|
||||
it("lässt eine Laterne weg, für die es keinen Gehweg gibt", () => {
|
||||
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("hängt nicht an der Listenposition", () => {
|
||||
// Die alte Fassung wählte die Seite nach dem Index: Der zweite Punkt hätte auf der Seite ohne
|
||||
// Gehweg gesucht und wäre entfallen.
|
||||
const punkte = [{ x: -10, z: 0, angle: 0 }, { x: 10, z: 0, angle: 0 }];
|
||||
const vorwaerts = sidewalkLampPoints(punkte, gehweg);
|
||||
const rueckwaerts = sidewalkLampPoints([...punkte].reverse(), gehweg);
|
||||
expect(vorwaerts).toHaveLength(2);
|
||||
const xs = (out) => out.map((p) => p.x).sort((a, b) => a - b);
|
||||
expect(xs(vorwaerts)).toEqual(xs(rueckwaerts));
|
||||
});
|
||||
|
||||
it("findet den Gehweg auch, wenn sein Ring geschlossen ist (so liegen die Chunk-Dateien vor)", () => {
|
||||
const ring = [[-20, 3.5], [20, 3.5], [20, 6], [-20, 6]];
|
||||
expect(sidewalkLampPoints([{ x: 0, z: 0, angle: 0 }], [{ poly: [[...ring, ring[0]]] }])).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("setzt aus einem Chunk im Format von Teil 2 Laternen auf den Gehweg", () => {
|
||||
// Wohnstraße entlang z = 50, Gehweg auf beiden Seiten; lampPoints() setzt auf 100 m drei Punkte.
|
||||
const chunk = {
|
||||
chunk: [0, 0], origin: [0, 0], land: [], surfaces: [], buildings: [], palms: [],
|
||||
carriageway: [], curb: [], markings: [], lots: [], bridges: [],
|
||||
edges: [{ line: [[0, 50], [100, 50]], klasse: "wohnstrasse", breite: 12, fahrbahn: 7 }],
|
||||
sidewalk: [{ poly: [[[0, 53.5], [100, 53.5], [100, 56], [0, 56]]] },
|
||||
{ poly: [[[0, 44], [100, 44], [100, 46.5], [0, 46.5]]] }],
|
||||
};
|
||||
const out = chunkLampPoints(chunk);
|
||||
expect(out).toHaveLength(3);
|
||||
for (const p of out) expect(chunk.sidewalk.some((s) => pointInPolygon(p.x, p.z, s.poly))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -239,6 +239,27 @@ describe("ChunkStreamer", () => {
|
||||
const group = scene.add.mock.calls[0][0];
|
||||
expect(group.children.filter((child) => child.isMesh)).toHaveLength(5);
|
||||
});
|
||||
|
||||
// Teil 2: Fahrbahn und Bordstein sind Kollision — man fährt in der Mulde, und der Bordstein ist die
|
||||
// Stufe zum Gehweg. Gehweg, Markierung und Restflächen bleiben Auflagen.
|
||||
it("legt Fahrbahn und Bordstein in die Kollision, Gehweg, Markierung und Restfläche als Auflagen in die Szene", async () => {
|
||||
const square = [[[0, 0], [20, 0], [20, 20], [0, 20]]];
|
||||
const chunk = {
|
||||
chunk: [4, 6], origin: [0, -1000], land: [square], surfaces: [], buildings: [],
|
||||
carriageway: [{ poly: [[[0, 20], [20, 20], [20, 30], [0, 30]]], klasse: "wohnstrasse" }],
|
||||
curb: [[[0, 20], [20, 20]]],
|
||||
sidewalk: [{ poly: [[[0, 17], [20, 17], [20, 20], [0, 20]]] }],
|
||||
markings: [{ poly: [[[2, 25], [5, 25], [5, 25.1], [2, 25.1]]], art: "gelb_gestrichelt" }],
|
||||
lots: [{ poly: [[[0, 0], [5, 0], [5, 5], [0, 5]]] }],
|
||||
};
|
||||
const { streamer, player, scene } = makeStreamer(chunk);
|
||||
streamer.update(emptyChunkCenter);
|
||||
await flush();
|
||||
|
||||
expect(player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(3); // Landplatte, Fahrbahn, Bordstein
|
||||
const group = scene.add.mock.calls[0][0];
|
||||
expect(group.children.filter((child) => child.isMesh)).toHaveLength(3); // Gehweg, Markierung, Restfläche
|
||||
});
|
||||
});
|
||||
|
||||
// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine
|
||||
|
||||
Reference in new issue
Block a user