From 62dbbe41d2ecaab46294722a697677ddcdca2d1f Mon Sep 17 00:00:00 2001 From: KNSO Date: Mon, 14 Sep 2026 10:59:02 +0200 Subject: [PATCH] feat(world): Orte-Liste mit Lage, Radien und Kollisionsmaterialien Co-Authored-By: Claude Haiku 4.5 --- src/world/places.js | 99 +++++++++++++++++++++++++++++++++++++++ tests/places.test.js | 108 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/world/places.js create mode 100644 tests/places.test.js diff --git a/src/world/places.js b/src/world/places.js new file mode 100644 index 0000000..3239a9a --- /dev/null +++ b/src/world/places.js @@ -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; +} diff --git a/tests/places.test.js b/tests/places.test.js new file mode 100644 index 0000000..80f6e2b --- /dev/null +++ b/tests/places.test.js @@ -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) }, + }); + } + }); +});