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

+99
View File
@@ -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;
}
+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) },
});
}
});
});