feat(world): Orte-Liste mit Lage, Radien und Kollisionsmaterialien
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6c291c4106
commit
62dbbe41d2
2 files changed
+207
No files matched your search
@@ -0,0 +1,99 @@
|
|||||||
|
// Orte: fest platzierte Modelle aus dem Editor three-d-stage, getrennt vom Chunk-Streaming
|
||||||
|
// (docs/superpowers/specs/2026-09-14-hood-yard-design.md). Aufbereitet werden sie mit
|
||||||
|
// tools/assets/places.mjs; hier stehen Lage, Laden und Kollision.
|
||||||
|
import { worldToThree } from "./coords.js";
|
||||||
|
|
||||||
|
// Chunks laden bei 400 m und bauen bei 600 m ab, gemessen zu ihrem Mittelpunkt (ChunkStreamer.update).
|
||||||
|
// Der fernste Chunk-Mittelpunkt unter dem Hood Yard liegt 148 m von dessen Mitte entfernt: erschiene
|
||||||
|
// der Hof schon bei 400 m, stünde er vom Startpunkt aus über dem Meer. Mit 250 m und 450 m ist sein
|
||||||
|
// Boden geladen, solange er in der Szene ist (tests/placesSite.test.js prüft das für jeden Ort).
|
||||||
|
// Heruntergeladen wird früher, damit das Einsetzen nicht auf die Datei wartet.
|
||||||
|
export const PLACE_PRELOAD_RADIUS = 600;
|
||||||
|
export const PLACE_SHOW_RADIUS = 250;
|
||||||
|
export const PLACE_HIDE_RADIUS = 450;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* at: Welt-x und Welt-y in Metern, wie auf der Minimap — nie three-z. rotation: Grad gegen den
|
||||||
|
* Uhrzeigersinn, von oben mit Norden oben (wie add_building in corrections.toml). lift: Meter auf die
|
||||||
|
* Modellwurzel. bounds: Außenmaße in Modellkoordinaten (x, z); das Spiel braucht sie nicht, die Tests
|
||||||
|
* prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander.
|
||||||
|
*/
|
||||||
|
export const PLACES = [
|
||||||
|
{
|
||||||
|
id: "hood-yard",
|
||||||
|
url: "./models/places/hood-yard.glb",
|
||||||
|
// Übergang Container -> Schuppen an derselben Stelle wie im Satellitenbild des Nutzers.
|
||||||
|
at: [1489, 1067.5],
|
||||||
|
rotation: 0,
|
||||||
|
// Die 34 cm dicke Kiesplatte liegt mit ihrer Unterseite auf 0; so ragt sie noch 6 cm heraus.
|
||||||
|
lift: -0.28,
|
||||||
|
bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Kleinteile — läuft man durch. */
|
||||||
|
export const SOLID_MATERIALS = [
|
||||||
|
// Boden
|
||||||
|
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
|
||||||
|
// Container
|
||||||
|
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame",
|
||||||
|
// Schuppen und Tore
|
||||||
|
"ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB",
|
||||||
|
"rollershutter_window_01", "rollershutter_window_01_graffiti",
|
||||||
|
// Zaun
|
||||||
|
"chainlink_post", "chainlink_wire",
|
||||||
|
// Masten, Fass, Kiste
|
||||||
|
"modular_electricity_poles", "barrel_03", "plastic_container",
|
||||||
|
// Bäume
|
||||||
|
"quiver_tree_01_trunk", "quiver_tree_02",
|
||||||
|
];
|
||||||
|
const SOLID = new Set(SOLID_MATERIALS);
|
||||||
|
|
||||||
|
export function isSolidMaterial(name) {
|
||||||
|
return SOLID.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lage eines Orts in three-Koordinaten. Positives rotationY dreht +x nach −z, also nach Norden. */
|
||||||
|
export function placeTransform(place) {
|
||||||
|
const { x, z } = worldToThree(place.at[0], place.at[1]);
|
||||||
|
return { position: { x, y: place.lift, z }, rotationY: (place.rotation * Math.PI) / 180 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abstand in der Ebene zwischen einer three-Position und der Mitte eines Orts. */
|
||||||
|
export function placeDistance(position, place) {
|
||||||
|
const { x, z } = worldToThree(place.at[0], place.at[1]);
|
||||||
|
return Math.hypot(x - position.x, z - position.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie wantedChunks: add innerhalb von show, remove erst jenseits von hide. */
|
||||||
|
export function wantedPlaces(position, places, { show, hide, active }) {
|
||||||
|
const add = [];
|
||||||
|
const remove = [];
|
||||||
|
for (const place of places) {
|
||||||
|
const distance = placeDistance(position, place);
|
||||||
|
if (distance <= show && !active.has(place.id)) add.push(place.id);
|
||||||
|
if (distance > hide && active.has(place.id)) remove.push(place.id);
|
||||||
|
}
|
||||||
|
return { add, remove };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Lage und Schatten und nimmt nicht feste Meshes aus der Kollision (colliderBuild.ts im
|
||||||
|
* three-player-controller überspringt userData.excludeFromCollider). Gibt zurück, ob ein Teil fest ist.
|
||||||
|
*/
|
||||||
|
export function prepareModel(root, place) {
|
||||||
|
const { position, rotationY } = placeTransform(place);
|
||||||
|
root.position.set(position.x, position.y, position.z);
|
||||||
|
root.rotation.set(0, rotationY, 0);
|
||||||
|
let solid = false;
|
||||||
|
root.traverse((object) => {
|
||||||
|
if (!object.isMesh) return;
|
||||||
|
object.castShadow = true;
|
||||||
|
object.receiveShadow = true;
|
||||||
|
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
||||||
|
if (materials.some((material) => isSolidMaterial(material?.name))) solid = true;
|
||||||
|
else object.userData.excludeFromCollider = true;
|
||||||
|
});
|
||||||
|
root.updateMatrixWorld(true);
|
||||||
|
return solid;
|
||||||
|
}
|
||||||
@@ -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) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user