feat(world): Orte-Liste mit Lage, Radien und Kollisionsmaterialien

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Haiku 4.5 committed 2026-09-14 10:59:02 +02:00
1 parent 6c291c4106
commit 62dbbe41d2
2 files changed
+207

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from "three";
import { PLACES, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
wantedPlaces } from "../src/world/places.js";
describe("placeTransform", () => {
// Bruch: Welt-y nicht negiert (der Hof landete 2 km weiter südlich im Meer) oder lift nicht in y.
it("rechnet Welt-x/-y in three um und hebt um lift", () => {
const transform = placeTransform({ at: [1489, 1067.5], rotation: 0, lift: -0.28 });
expect(transform.position).toEqual({ x: 1489, y: -0.28, z: -1067.5 });
expect(transform.rotationY).toBe(0);
});
// Bruch: Grad nicht ins Bogenmaß umgerechnet oder falsche Drehrichtung. "Gegen den Uhrzeigersinn,
// Norden oben" heißt in three: +x (Osten) wird zu −z (Norden).
it("dreht mit positivem Winkel Osten nach Norden", () => {
const root = new Group();
root.rotation.y = placeTransform({ at: [0, 0], rotation: 90, lift: 0 }).rotationY;
root.updateMatrixWorld();
const east = new Vector3(1, 0, 0).applyMatrix4(root.matrixWorld);
expect(east.x).toBeCloseTo(0);
expect(east.z).toBeCloseTo(-1);
});
});
describe("wantedPlaces", () => {
const place = { id: "hof", at: [1000, 1000], rotation: 0, lift: 0 };
const south = (distance) => ({ x: 1000, z: -1000 + distance });
const radii = { show: 250, hide: 450 };
it("setzt innerhalb des show-Radius ein", () => {
expect(wantedPlaces(south(240), [place], { ...radii, active: new Set() }).add).toEqual(["hof"]);
expect(wantedPlaces(south(260), [place], { ...radii, active: new Set() }).add).toEqual([]);
});
// Bruch: ein Radius für Ein- und Ausblenden — an der Grenze flackerte der Hof mit jedem Schritt.
it("nimmt erst jenseits des hide-Radius heraus", () => {
const active = new Set(["hof"]);
expect(wantedPlaces(south(400), [place], { ...radii, active }).remove).toEqual([]);
expect(wantedPlaces(south(460), [place], { ...radii, active }).remove).toEqual(["hof"]);
});
it("meldet einen aktiven Ort nicht doppelt an", () => {
expect(wantedPlaces(south(10), [place], { ...radii, active: new Set(["hof"]) }).add).toEqual([]);
});
});
describe("isSolidMaterial", () => {
// Bruch: Gras oder Blätter im Collider (man bliebe im Gras hängen), oder ein fester Teil fehlt
// (man liefe durch Container, Schuppen oder Zaun).
it("lässt feste Teile kollidieren, Gras, Blätter und Kleinteile nicht", () => {
for (const name of ["gravel", "cont_a_side", "ironTeal", "chainlink_wire", "barrel_03", "quiver_tree_01_trunk"]) {
expect(isSolidMaterial(name)).toBe(true);
}
for (const name of ["grass_medium_01", "quiver_tree_01_leaf", "searsia_lucida_leaves", "trashbag", undefined]) {
expect(isSolidMaterial(name)).toBe(false);
}
});
it("kennt jedes Material der Liste", () => {
expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true);
});
});
/** Ein Mesh je Materialname unter einer Wurzel, wie GLTFLoader es nach join() liefert. */
function fakeModel(names = ["cont_a_side", "grass_medium_01"]) {
const root = new Group();
for (const name of names) root.add(new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name })));
return root;
}
describe("prepareModel", () => {
// Bruch: Lage nicht gesetzt, Schatten aus, oder feste Teile fälschlich aus der Kollision genommen.
it("setzt Lage und Schatten und nimmt nur nicht feste Teile aus der Kollision", () => {
const root = fakeModel();
const [container, grass] = root.children;
expect(prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 })).toBe(true);
expect(root.position.toArray()).toEqual([1489, -0.28, -1067.5]);
expect(container.userData.excludeFromCollider).toBeUndefined();
expect(grass.userData.excludeFromCollider).toBe(true);
for (const mesh of [container, grass]) {
expect(mesh.castShadow).toBe(true);
expect(mesh.receiveShadow).toBe(true);
}
});
it("meldet, wenn kein Teil fest ist", () => {
expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false);
});
});
describe("PLACES", () => {
// Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds
// prüften nichts.
it("hat für jeden Ort id, url, at, rotation, lift und bounds", () => {
for (const entry of PLACES) {
expect(entry).toEqual({
id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/),
at: [expect.any(Number), expect.any(Number)], rotation: expect.any(Number), lift: expect.any(Number),
bounds: { minX: expect.any(Number), maxX: expect.any(Number), minZ: expect.any(Number), maxZ: expect.any(Number) },
});
}
});
});