220 lines
9.7 KiB
JavaScript
220 lines
9.7 KiB
JavaScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
||
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three";
|
||
import { PLACES, PlaceLayer, 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) },
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("PlaceLayer", () => {
|
||
afterEach(() => vi.restoreAllMocks());
|
||
|
||
const place = { id: "hof", url: "./hof.glb", at: [1489, 1067.5], rotation: 0, lift: -0.28 };
|
||
// Spieler distance Meter südlich der Ortsmitte (three: +z ist Süden).
|
||
const south = (distance) => ({ x: 1489, y: 0, z: -1067.5 + distance });
|
||
|
||
function makeLayer(load) {
|
||
const scene = new Scene();
|
||
const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() };
|
||
const layer = new PlaceLayer({ scene, player, places: [place], load });
|
||
return { scene, player, layer };
|
||
}
|
||
|
||
// Bruch: Einsetzen schon beim Vorladen (der Hof stünde über ungeladenem Boden) oder erst nach
|
||
// einem zweiten Laden.
|
||
it("lädt ab 600 m vor und setzt erst ab 250 m ein", async () => {
|
||
const model = fakeModel();
|
||
const load = vi.fn(async () => model);
|
||
const { scene, player, layer } = makeLayer(load);
|
||
|
||
layer.update(south(500));
|
||
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
|
||
expect(load).toHaveBeenCalledWith("./hof.glb");
|
||
expect(scene.children).not.toContain(model);
|
||
expect(player.addCollider).not.toHaveBeenCalled();
|
||
|
||
layer.update(south(240));
|
||
expect(scene.children).toContain(model);
|
||
expect(player.addCollider).toHaveBeenCalledWith({
|
||
motion: "static", useWorker: true, shape: { kind: "mesh", source: model },
|
||
});
|
||
});
|
||
|
||
// Bruch: Collider bleibt nach dem Herausnehmen stehen (unsichtbare Wand) oder Flackern an der Grenze.
|
||
it("nimmt den Ort erst jenseits von 450 m heraus und meldet den Collider ab", async () => {
|
||
const model = fakeModel();
|
||
const { scene, player, layer } = makeLayer(vi.fn(async () => model));
|
||
layer.update(south(100));
|
||
await vi.waitFor(() => expect(scene.children).toContain(model));
|
||
|
||
layer.update(south(400));
|
||
expect(scene.children).toContain(model);
|
||
expect(player.removeCollider).not.toHaveBeenCalled();
|
||
|
||
layer.update(south(460));
|
||
expect(scene.children).not.toContain(model);
|
||
expect(player.removeCollider).toHaveBeenCalledWith({ id: 7 });
|
||
});
|
||
|
||
it("setzt nach der Rückkehr ohne neues Laden wieder ein", async () => {
|
||
const model = fakeModel();
|
||
const load = vi.fn(async () => model);
|
||
const { scene, player, layer } = makeLayer(load);
|
||
layer.update(south(100));
|
||
await vi.waitFor(() => expect(scene.children).toContain(model));
|
||
|
||
layer.update(south(700));
|
||
layer.update(south(100));
|
||
|
||
expect(scene.children).toContain(model);
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
expect(player.addCollider).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
// Bruch: ein spät fertiges Laden setzt den Ort ein, obwohl der Spieler längst weitergefahren ist.
|
||
it("lässt den Ort draußen, wenn der Spieler beim Ende des Ladens schon fort ist", async () => {
|
||
const model = fakeModel();
|
||
let finish;
|
||
const { scene, player, layer } = makeLayer(vi.fn(() => new Promise((resolve) => { finish = resolve; })));
|
||
|
||
layer.update(south(100));
|
||
layer.update(south(500));
|
||
finish(model);
|
||
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
|
||
expect(scene.children).not.toContain(model);
|
||
expect(player.addCollider).not.toHaveBeenCalled();
|
||
|
||
layer.update(south(100));
|
||
expect(scene.children).toContain(model);
|
||
});
|
||
|
||
it("meldet ohne feste Teile keinen Collider an", async () => {
|
||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
const model = fakeModel(["grass_medium_01"]);
|
||
const { scene, player, layer } = makeLayer(vi.fn(async () => model));
|
||
|
||
layer.update(south(100));
|
||
await vi.waitFor(() => expect(scene.children).toContain(model));
|
||
layer.update(south(460));
|
||
|
||
expect(player.addCollider).not.toHaveBeenCalled();
|
||
expect(player.removeCollider).not.toHaveBeenCalled();
|
||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"));
|
||
});
|
||
|
||
// Bruch: ein fehlender Ort bricht das Spiel ab oder wird in jedem Frame neu angefragt.
|
||
it("warnt bei einem Ladefehler, wirft nicht und versucht es nicht erneut", async () => {
|
||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
const load = vi.fn(async () => { throw new Error("404"); });
|
||
const { scene, layer } = makeLayer(load);
|
||
|
||
expect(() => layer.update(south(100))).not.toThrow();
|
||
await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error)));
|
||
layer.update(south(100));
|
||
|
||
expect(load).toHaveBeenCalledTimes(1);
|
||
expect(scene.children).toHaveLength(0);
|
||
});
|
||
});
|