Compare commits

...
20 Commits
Author SHA1 Message Date
knsoandClaude Opus 5 fb38652e7f chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur
lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json.
Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser
Stand als normaler Ordner im Repo, ohne Verweis auf GitHub.

dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher
lokal gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:27:03 +02:00
knsoandClaude Opus 5 780e246fe5 Merge branch 'leonida/tribuene'
Tribüne im Park J6 statt des alten Gebäudekastens, mit Materialrollen,
ersetzten Grundrissen beim Chunk-Aufbau und Asset-Test je Ort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:26:58 +02:00
knso a55dca5fb6 start 2026-09-15 21:05:18 +02:00
knsoandClaude Sonnet 5 71011e27e2 docs(skills): Projekt-Skill kennt replaces und den Asset-Test je Ort
Behebt Befund 1 aus dem Abschluss-Review: der Skill war seit den
Tribüne-Tasks veraltet - er kannte weder replaces noch die
Materialzeilen je Ort in SOLID_BY_PLACE/BUMP_BY_PLACE. Ersetzt statt
angehängt, an den drei Stellen, wo man danach sucht: Überblick
(Ein-neuer-Ort-Liste), Schritt 2 (Stelle holen) und Schritt 8 (Tests),
dazu zwei Fallstricke-Zeilen für die roten Ausgangszustände.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 03:57:37 +02:00
knsoandClaude Sonnet 5 237a4ed389 test(world): ersetzte Grundrisse und feste Materialien je Ort strenger prüfen
Behebt Befunde 2-6 aus dem Abschluss-Review: der replaces-Test prüft
jetzt zusätzlich jede Ecke des getroffenen Grundrisses gegen die
Außenmaße plus REPLACED_OVERHANG (5 m), der Verteilungstest vergleicht
als Mengen und bekommt eine Gegenrichtung je Datei, BUMP_BY_PLACE
erzwingt eine Zeile je Ort statt sie mit "?? []" zu verdecken, ein
Gebäude an einer Chunkgrenze bekommt einen Test über zwei Chunks samt
JSDoc-Hinweis, und der Rückwand-Messwert in der Spec nennt Kapselmitte,
Wandfläche und Radius einzeln.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 03:57:31 +02:00
knsoandClaude Opus 5 14fd9cefde docs: Tribünen-Spec mit Messwerten aus dem Spiel
Browserprüfung: alter Kasten weg (Strahl trifft Boden bzw. Tribünendach), acht feste Materialien als Float32 im Collider, Rückwand stoppt die Kapsel am Sockel, Tunnel samt Treppe begehbar, Sitze durchlässig, +0,1–0,3 ms je Bild, vorgewärmt (48 ms bis zum Einsetzen, größte Lücke 56 ms).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:57:00 +02:00
knsoandClaude Sonnet 5 190fe9c13c feat(world): Tribüne im Park J6 statt des alten Gebäudekastens
tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 20:36:29 +02:00
knsoandClaude Sonnet 5 ae0f6106bb feat(world): Materialrollen der Tribüne, Asset-Test je Ort
Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 20:24:09 +02:00
knsoandClaude Opus 5 e33c1817ec feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen
withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:13:47 +02:00
knsoandClaude Opus 5 f7506ac556 docs: Umsetzungsplan für die Tribüne im Park J6
Vier Tasks: ersetzte Gebäude beim Chunk-Aufbau weglassen, Materialrollen und Asset-Test je Ort, Tribüne aufbereiten und eintragen (am Prototyp gemessen: 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB), Prüfung im Browser mit vorberechneten Blick- und Kapselpunkten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:08:18 +02:00
knsoandClaude Opus 5 4a1b337609 docs: Entwurf für die Tribüne im Park J6
Die Tribüne aus tribuene.glb ersetzt 1:1 den Gebäudekasten in J6:
längs mittig auf dem alten Gebäude, 45° gedreht, Front 1 m vor dessen
Feldseite (Vergleichsbild im Gespräch bestätigt), lift 0 ohne
Bodenplatte. Neu gegenüber dem Hood Yard ist replaces im Orte-Eintrag:
der ChunkStreamer lässt den Grundriss beim Aufbau weg. Dazu
Materialrollen, Budget und der Umbau des Asset-Tests auf alle Orte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:55:12 +02:00
knsoandClaude Opus 5 6a056262a8 chore(skills): Projekt-Skill zum Platzieren von three-d-stage-Modellen
Ablauf wie beim Hood Yard mit den gefundenen Fallstricken (Dreiecke je Knoten,
Bodenplatte +6 cm, globale Materiallisten, Asset-Test je Ort, launch.json nur im
Hauptordner, gl.finish-Messung, Vorwärmen prüfen) und Werkzeugen: Modell vermessen,
Kartendaten eines Weltfensters, Vergleichsbild Satellit/Spiel, Screenshot-Dekodierung,
erprobte Browser-Ausschnitte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:22:20 +02:00
knsoandClaude Sonnet 5 7e4af1d0bc docs: Hood-Yard-Spec mit Messwerten und Vorwärmen
Wortlaut: die Radien garantieren nur, dass die Chunks unter einem Ort
angefordert sind, solange er in der Szene ist — nicht, dass ihr Boden
schon gebaut ist (ChunkStreamer baut höchstens einen Chunk je Bild).
"geladen" -> "angefordert" im Kommentar über den Radien
(src/world/places.js), über der PlaceLayer-Konstruktion (src/main.js),
im Testnamen (tests/placesSite.test.js) und in der Spec, dort mit
einem Satz zu den paar Bildern nach einem Kartensprung.

Spec zusätzlich: warmUp in den Bullets "Vorbereiten"/"Einsetzen",
robustes Einsetzen (Warnung, kein erneuter Versuch) im Bullet zum
Ladefehler, und ein neuer Messwerte-Bullet in "Fehlerfälle und
Risiken" (+0,4–0,6 ms je Bild, ~130 ms einmaliger Texturupload ohne
warmUp).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:43:23 +02:00
knsoandClaude Sonnet 5 fc9e818c79 test(world): entquantisierte Positionen auch für interleaved Attribute
GLTFLoader liefert quantisierte Positionen in echt als
InterleavedBufferAttribute (gemeinsamer Puffer mit Normale/UV), nicht
als eigene BufferAttribute wie im bisherigen Test. dequantizePosition
braucht dafür keine Änderung: es liest nur über das gemeinsame
Attribut-Interface (normalized, getX/getY/getZ, count), das three.js
für beide Klassen implementiert. Der Test läuft grün und schreibt das
fest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:41:46 +02:00
knsoandClaude Sonnet 5 23a0623898 fix(assets): Zielpfad der Aufbereitung unabhängig vom Arbeitsverzeichnis
places.mjs schrieb bisher nach public/models/places/<id>.glb relativ
zu process.cwd() und nahm jede id ungeprüft an. Die neue, exportierte
placeTarget(id) löst den Pfad stattdessen von import.meta.url ab (egal
von wo das Skript aufgerufen wird) und lehnt IDs außerhalb von
[a-z0-9-]+ ab, bevor daraus ein Pfad wird, der aus public/models/
places/ herausführen könnte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:41:18 +02:00
knsoandClaude Sonnet 5 ed5254f88e fix(world): Einsetzen eines Orts gegen Collider-Fehler absichern
#show baute bisher scene.add, bevor player.addCollider lief: wirft
addCollider (z. B. toter Worker), blieb die Wurzel ohne active-Eintrag
in der Szene stehen — sichtbar, aber nie wieder entfernbar — und die
Ausnahme entkam synchron aus update() in die Spielschleife.

Der Collider-Handle wird jetzt vor scene.add in einem try/catch
gebaut; schlägt er fehl, bleibt die Szene unverändert, der Ort landet
in this.failed (kein erneuter Versuch) und #show überspringt ihn beim
nächsten Aufruf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:40:28 +02:00
knsoandClaude Sonnet 5 1c24fbf71e feat(world): Orte beim Vorladen vorwärmen
PlaceLayer nimmt jetzt ein optionales warmUp(root) entgegen und ruft es
nach prepareModel, aber vor dem Einsetzen auf. main.js nutzt das, um
Texturen hochzuladen und CSM-Shader zu übersetzen, solange der Ort noch
nicht in der Szene ist — gemessen kostete das sonst rund 130 ms auf
einen Schlag im ersten Bild, in dem der Hof erscheint. Schlägt das
Vorwärmen fehl, wird nur gewarnt; der Ort wird trotzdem eingesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:39:50 +02:00
knsoandClaude Sonnet 5 522101c026 fix(world): quantisierte Positionen vor dem Kollisionsaufbau in Float32 wandeln
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:42:25 +02:00
knsoandClaude Haiku 4.5 9f6570b11d feat(world): Hood Yard im Spiel
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-14 11:35:17 +02:00
knsoandClaude Sonnet 5 ef2001542a feat(world): PlaceLayer lädt vor, setzt ein und nimmt heraus
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 11:26:34 +02:00
182 changed files with 44835 additions and 82 deletions

No files matched your search

@@ -0,0 +1,99 @@
---
name: placing-editor-models
description: Use when a model built in the three-d-stage editor (GLB export, often sent with a .mtl) should be placed 1:1 at a real-world spot in the Leonida world — "setz das an die Stelle aus dem Satellitenbild", a yard, kiosk or building next to a park or street in a Planquadrat — or when an entry in src/world/places.js has to move, look different or collide differently.
---
# Editor-Modelle in Leonida platzieren
## Überblick
Der Ablauf bleibt der des Hood Yard: Brainstorming mit der Lagefrage am Vergleichsbild → kurze Spec → Plan → Umsetzung mit Subagenten und Reviews → Browserprüfung → Gesamt-Review → Abschluss. Vorlage: `docs/superpowers/specs/2026-09-14-hood-yard-design.md` und `docs/superpowers/plans/2026-09-14-hood-yard.md`.
Neu gebaut werden muss nichts. Orte-Schicht (`src/world/places.js`), Aufbereitung (`tools/assets/places.mjs`), Bump-Erweiterung, Tests und das Vorwärmen in `main.js` gibt es schon. Ein neuer Ort besteht aus:
- einem `PLACES`-Eintrag, optional mit `replaces` (Weltpunkte), wenn an der Stelle schon ein Gebäudekasten aus den Chunk-Daten steht,
- einer aufbereiteten `.glb`,
- je einer Zeile in `SOLID_BY_PLACE` und `BUMP_BY_PLACE` (`tests/placesAsset.test.js`).
Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spiel.
**Pflicht-Skills in dieser Reihenfolge:** superpowers:brainstorming, superpowers:writing-plans, superpowers:subagent-driven-development, superpowers:finishing-a-development-branch.
## Schon entschieden, nicht neu fragen
| Punkt | Festlegung |
|---|---|
| Maßstab | 1:1, three-d-stage baut in Metern; `PLACES` hat kein `scale` |
| Lage | `at` = Welt-x/-y (Minimap), `rotation` in Grad gegen den Uhrzeigersinn, Norden oben |
| Höhe | Mit Bodenplatte (flaches Teil über die ganze Grundfläche): `lift = 0,06 − t`, t = Höhe, auf der die übrigen Gruppen stehen (Hood Yard: 0,34, obwohl `ground` wegen Bordsteinen bis 0,5 reicht → −0,28). Bei 0 cm flackert der Boden, ein Podest wird zur Stolperkante. Ohne Bodenplatte: `lift = 0` |
| Radien | Vorladen 600 m, einsetzen 250 m, herausnehmen 450 m; `tests/placesSite.test.js` prüft den Chunk-Abstand |
| Aufbereitung | nur `tools/assets/places.mjs`: die gltf-transform-CLI verwirft `EXT_materials_bump`. Ergebnis < 30 MB, die Quelle kommt nie ins Repo |
| Kollision | über Materialnamen (`SOLID_MATERIALS`); Pflanzen und Kleinteile durchlässig, Stämme fest |
| Arbeitsweise | eigener Arbeitsbaum, wenn im Hauptordner fremde Änderungen liegen |
## Ablauf
1. **Modell vermessen**, die `.mtl` ignorieren:
```bash
node .claude/skills/placing-editor-models/scripts/measure-model.mjs ~/Downloads/<name>.glb <scratch>/model.json
```
- Dreiecke zählt das Skript je Knoten; die Summe über die Meshes unterschätzt bei three-d-stage stark.
- Außerdem zeigt es Bump-Materialien, Außenmaße und Baugruppen.
- **Bodenplatte suchen:** eine flache Gruppe, die x und z der Außenmaße abdeckt. Gibt es keine, ist `lift = 0`.
- **t bestimmen:** t ist das kleinste y-Minimum der übrigen Gruppen, also die Höhe, auf der sie stehen. Nicht das y-Maximum der Plattengruppe, darin stecken oft Bordsteine und Wege. Dann gilt `lift = 0,06 − t`.
- Die Wurzel-Verschiebung ist nur ein Hinweis, nicht die Plattendicke.
2. **Stelle holen:** Planquadrat → Weltbox (`parseChunkName`, `chunkOrigin` in `src/world/coords.js`), dann ein Fenster von etwa 150 × 50 m:
```bash
node .claude/skills/placing-editor-models/scripts/site-data.mjs <x0> <x1> <y0> <y1> <scratch>/site.json
```
Straßenränder, Flächen und Laternen daraus ablesen.
- **Ersetzt der Ort ein Gebäude:** Steht in `site.json` unter `buildings` ein Grundriss an der Stelle, den der Ort ersetzen soll, dessen Mitte als `replaces: [[x, y]]` in den `PLACES`-Eintrag. Der `ChunkStreamer` lässt ihn dann weg (`withoutReplacedBuildings`). Den Ort nicht um den Kasten herum verschieben, nicht `corrections.toml` vorschlagen. Vorlage: `docs/superpowers/specs/2026-09-14-tribuene-design.md`.
3. **Satellitenbild einhängen:**
- **Anker:** ein Punkt, der im Bild und in den Chunk-Daten eindeutig ist (Feldecke, Kreuzung), möglichst nah am Ziel. Echte Stadt und Spielkarte weichen voneinander ab.
- **Maßstab:** Meter pro Pixel aus Autos (~4,5 m) oder Feldmarkierungen schätzen, mit ±15 % Unsicherheit rechnen.
4. **Vergleichsbild** rendern und per `SendUserFile` schicken:
```bash
tools/extract/.venv/bin/python .claude/skills/placing-editor-models/scripts/render-site.py --site site.json --model model.json --at X Y --satellite <bild> --anchor-px PX PY --anchor-world AX AY --mpp 0.15 --out lage.png
```
- Dann genau **eine** Frage mit `AskUserQuestion`: „wie im Bild“ oder die zwei naheliegenden Verschiebungen.
- Ist die echte Anlage länger oder kürzer als das Modell, die Ausrichtung (Mitte, Anfang, Übergang zwischen Bauteilen) ausdrücklich zur Wahl stellen.
5. **Materialrollen festlegen.** `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` (`places.mjs`) sowie `SOLID_MATERIALS` (`places.js`) sind global und nach Namen geordnet.
- Neue Namen gegen die Listen abgleichen. Hat derselbe Name eine andere Rolle (etwa `grass` als begehbare Fläche statt als Halme), dem Nutzer vorlegen und nicht still teilen.
- Anteile beim Vereinfachen, nach gezeichneten Dreiecken: Bäume mit mehr als 300 000 → 0,15; Gras und Büsche → 0,5; Kleinteile → 0,25; Gebäude, Boden und Zäune nie.
6. **Spec und Plan** kurz halten, nur was neu ist:
- **Spec:** Befund (Tabelle aus Schritt 1), Lage-Tabelle mit Abständen zu Straße, Feldern und Laternen, Materialrollen, Budget.
- **Plan-Tasks:**
- Materiallisten
- Aufbereitung und Asset-Test
- `PLACES`-Eintrag und Lage-Test
- Browserprüfung
7. **Aufbereiten:** `node tools/assets/places.mjs <quelle.glb> <id>`. Aus dem Bericht die `bounds` in `PLACES` übernehmen; `gpuMB` und die Dreieckszahl mit dem Nutzer abgleichen, wenn sie deutlich über dem Hood Yard liegen (231 MB, 658 000).
8. **Tests:**
- `tests/placesSite.test.js` prüft jeden `PLACES`-Eintrag von selbst, bei `replaces` auch, ob der ersetzte Grundriss noch an der Stelle liegt.
- `tests/placesAsset.test.js` läuft über `describe.each(PLACES)`. Für den neuen Ort je eine Zeile ergänzen: `SOLID_BY_PLACE` (feste Materialien seiner Datei) und `BUMP_BY_PLACE` (Materialien mit Bump, sonst `[]`).
9. **Browserprüfung** nach `browser-checks.md`:
- Lage prüfen.
- Kollision: Kapsel steht auf der Platte, eine Wand stoppt sie, Pflanzen sind durchlässig.
- Drei Blicke als Bilder an den Nutzer.
- Kosten mit `gl.finish` messen, mit und ohne Ort.
- Vorwärmen: Texturzähler am Startpunkt ablesen.
10. **Gesamt-Review, Abnahme durch den Nutzer, Branch abschließen.**
## Fallstricke
| Symptom | Ursache | Gegenmittel |
|---|---|---|
| Dreieckszahl viel kleiner als gezeichnet | three-d-stage benutzt Meshes mehrfach | je Knoten zählen (`measure-model.mjs`) |
| Wellblech, Relief oder Rillen flach | gltf-transform-CLI verwirft `EXT_materials_bump` | nur `places.mjs` |
| Figur läuft durch Wände, steht bei y 0,75 statt auf der Platte | meshopt-Positionen sind quantisiert, der Collider-Bau klemmt sie auf ±1 | Orte immer über `prepareModel`/`PlaceLayer` einsetzen; im Browser prüfen, dass feste Meshes Float32 haben |
| Boden flackert im Ort | Plattenoberkante auf y 0 | `lift` für +6 cm |
| Laterne oder Palme steht im Ort | Requisiten entstehen je Chunk, Laternen bis 16 m neben der Mittellinie, auch aus Nachbarchunks | Site-Test, Lage verschieben |
| Ort schwebt nach einem Minimap-Sprung kurz über dem Meer | der Streamer baut einen Chunk pro Frame | hingenommen (Ruling Hood Yard) |
| Asset-Test rot, „verteilt SOLID_MATERIALS …“ oder eine fehlende Zeile | Ort ohne Zeile in `SOLID_BY_PLACE`/`BUMP_BY_PLACE` | Zeile für den Ort ergänzen (Schritt 8) |
| Site-Test „in keinem Gebäude“ rot | der Ort soll ein vorhandenes Gebäude ersetzen | `replaces` mit der Mitte des Grundrisses setzen (Schritt 2), statt den Ort zu verschieben |
| `preview_start` findet den Server nicht | liest nur `.claude/launch.json` des Hauptordners | Nutzer fragen, Arbeitsbaum kurz verlassen, Eintrag setzen, zurück, am Ende entfernen |
| Befehle im Arbeitsbaum verweigert | Isolationswache mag keine Variablen als Befehl oder Skriptpfade mit Leerzeichen | Pfade ausschreiben; statt `review-package` drei Befehle: `git log --oneline A..B >`, `git diff --stat A..B >>`, `git diff -U10 A..B >>` in eine Datei |
| Draw-Call- und Dreieckszahlen zu optimistisch | `renderer.info` wird nach den Schattenstufen zurückgesetzt | `gl.finish`-Messung (`browser-checks.md` §8) |
| Ruckler beim ersten Erscheinen | Vorwärmen lief nicht | am Startpunkt muss `renderer.info.memory.textures` die Texturen des Orts schon enthalten |
| Seite friert nach einem Browser-Hook ein | Ausnahme im `onBeforeRender`-Hook beendet die Zeichenschleife | `window.__probe ??= {}`, nach Vite-Reload neu einrichten |
Werkzeuge: `scripts/` (Vermessung, Kartendaten, Vergleichsbild, Screenshot-Dekodierung); Browser-Ausschnitte: `browser-checks.md`.
@@ -0,0 +1,123 @@
# Browserprüfung eines Orts (verborgener Browser-Bereich)
Alle Ausschnitte sind am Hood Yard erprobt (2026-09-14). `javascript_tool` bricht nach 45 s ab: lange Abläufe auf mehrere Aufrufe verteilen. Jeder Aufruf hat eigene Variablen — was später gebraucht wird, an `window` hängen.
## 1. Dev-Server
- Das Vorschau-Werkzeug liest Server nur aus `.claude/launch.json` des **Hauptordners**. Auf 5173 läuft oft schon der Hauptordner.
- Arbeitet die Sitzung in einem Arbeitsbaum, darf sie diese Datei nicht ändern: Nutzer fragen, dann `ExitWorktree(keep)` → Eintrag setzen → `preview_start` → `EnterWorktree(path)`. Am Ende wieder raus und den Eintrag entfernen.
```json
{ "name": "place-check", "runtimeExecutable": "bash",
"runtimeArgs": ["-lc", "cd /Users/Kenso/Developer/gta6/.claude/worktrees/<name> && npx vite --port 5181 --strictPort"],
"port": 5181, "url": "http://localhost:5181" }
```
- Arbeitsbaum vorbereiten: `cp -Rc node_modules <baum>/node_modules`, dazu `three-player-controller/package.json` und `dist/` in `<baum>/three-player-controller/` kopieren. Git ignoriert Dateien im nicht ausgecheckten Submodul.
## 2. Debug-Seite (nie committen, danach löschen)
Der verborgene Bereich meldet oft 0 × 0 px, `requestAnimationFrame` steht, und three's `Timer` liefert delta 0, solange `document.hidden` gilt. Vor jedem Zug `resize_window` auf 1280 × 800 setzen (die App setzt es am Zugende zurück). `index-debug.html` im Root: Kopie von `index.html` mit diesem Skript im `<head>` vor dem Modul-Skript:
```html
<script>
(() => {
Object.defineProperty(document, "hidden", { configurable: true, get: () => false });
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => "visible" });
const queue = new Map(); let nextId = 1;
window.requestAnimationFrame = (cb) => { const id = nextId++; queue.set(id, cb); return id; };
window.cancelAnimationFrame = (id) => { queue.delete(id); };
const worker = new Worker(URL.createObjectURL(new Blob(["setInterval(() => postMessage(0), 33);"], { type: "text/javascript" })));
worker.onmessage = () => { const cbs = [...queue.values()]; queue.clear(); const now = performance.now(); for (const cb of cbs) cb(now); };
})();
</script>
```
## 3. Szene, Kamera, Renderer greifbar machen
Wirft ein Hook, stirbt die Zeichenschleife endgültig. Vite lädt die Seite bei Abhängigkeits-Rebuilds neu (`performance.now()` klein), dann ist `window.__probe` weg — deshalb `??=`.
```js
const deps = performance.getEntriesByType("resource").map((e) => e.name);
const THREE = await import(deps.find((n) => n.includes("/node_modules/.vite/deps/three.js")));
window.__frames = [];
THREE.Scene.prototype.onBeforeRender = function (renderer, scene, camera) {
Object.assign((window.__probe ??= {}), { renderer, scene, camera });
window.__frames.push(performance.now());
if (window.__frames.length > 4000) window.__frames.splice(0, 2000);
if (window.__applyView) window.__applyView(camera);
};
```
## 4. Hinspringen über die Minimap
`M` per `keydown`/`keyup` (`code: "KeyM"`) auf `window`. Das Kartenbild bleibt im verborgenen Bereich bei „Karte lädt …“, bis ein **Screenshot** gemacht wurde. Danach klicken, mit `X`, `Y` = Welt-Ziel neben dem Ort (nicht auf ein Gebäude):
```js
const map = [...document.querySelectorAll("canvas")].find((c) => c !== window.__probe.renderer.domElement);
const rect = map.getBoundingClientRect(), dpr = map.width / rect.width;
const scale = Math.min(map.width / 3000, map.height / 3000);
const ox = (map.width - 3000 * scale) / 2, oy = (map.height - 3000 * scale) / 2;
const opts = { clientX: rect.left + ((X + 1000) * scale + ox) / dpr, clientY: rect.top + ((2500 - Y) * scale + oy) / dpr, button: 0, bubbles: true };
map.dispatchEvent(new MouseEvent("mousedown", opts)); map.dispatchEvent(new MouseEvent("mouseup", opts));
```
Die Wurzel des Orts findet man über ihre Lage: `scene.children.find((o) => Math.abs(o.position.x - at[0]) < 0.01 && Math.abs(o.position.z + at[1]) < 0.01)`.
## 5. Zustand prüfen
- **Kollisionsmarken:** Mit `root.traverse` die Meshes ohne `userData.excludeFromCollider` sammeln. Ihre Materialnamen müssen genau die festen Teile sein.
- **Quantisierung:** Alle festen Meshes haben `geometry.attributes.position.array instanceof Float32Array`, dafür sorgt `prepareModel`. Fehlt das, landet der Collider bei ±1 m um den Weltursprung.
- **Vorwärmen:** Vor dem Sprung ist der Ort noch nicht in der Szene. `renderer.info.memory.textures` liegt am Startpunkt trotzdem schon um die Texturzahl des Orts höher.
## 6. Blicke aufnehmen
Kamera übersteuern, auch in einem Wrapper um `CSM.prototype.update`, sonst fehlen die Schatten im Bild:
```js
const { CSM } = await import(deps.find((n) => n.includes("three_examples_jsm_csm_CSM")));
const eye = new THREE.Vector3(), target = new THREE.Vector3(), up = new THREE.Vector3(), m = new THREE.Matrix4();
window.__applyView = (camera) => {
if (!window.__view) return;
eye.fromArray(window.__view.position); target.fromArray(window.__view.target); up.fromArray(window.__view.up ?? [0, 1, 0]);
m.lookAt(eye, target, up).setPosition(eye);
camera.matrixWorld.copy(m); camera.matrixWorldInverse.copy(m).invert();
};
if (!CSM.prototype.__wrapped) {
const update = CSM.prototype.update;
CSM.prototype.update = function (...a) { window.__applyView(this.camera); return update.apply(this, a); };
CSM.prototype.__wrapped = true;
}
```
- **Blicke:** Augenhöhe von vorn und von hinten (y 1,7), dazu eine Draufsicht (`position [x, 60, z]`, `up [0, 0, -1]`, Norden oben). Pro Blick `window.__view` setzen und ~1 s warten.
- **Aufnehmen:** `renderer.render(scene, camera)`, auf ein 2D-Canvas mit 1280 × 800 zeichnen, `toDataURL("image/jpeg", 0.85)` zurückgeben.
- **Speichern:** Das Ergebnis ist zu groß und landet unter `tool-results/`. `scripts/decode-view.py <tool-result.txt> <bild.jpg>` holt das Bild heraus.
- **Aufräumen:** `window.__view = null`.
## 7. Kollision ablaufen
```js
Object.defineProperty(document, "pointerLockElement", { configurable: true, get: () => renderer.domElement });
const capsule = scene.getObjectByName("capsule");
const heading = () => { const d = camera.getWorldDirection(new THREE.Vector3()); return Math.atan2(d.x, -d.z) * 180 / Math.PI; }; // 0 = Norden
const turn = (deg) => window.dispatchEvent(new MouseEvent("mousemove", { movementX: deg / 8.6 * 300, movementY: 0 }));
Object.assign(window, { capsule, heading, turn });
```
- **Start:** Kapsel mit `capsule.position.set(x, 1, z)` auf freien Boden des Orts setzen, mit Blick auf eine Wand; der Weg darf nicht über Bordsteine führen. Drehen, `heading()` prüfen, dann `keydown` `KeyW`. Jede Sekunde die Lage notieren, danach `keyup`.
- **Stand:** Auf blankem Chunk-Boden steht die Kapsel bei y 0,75, auf der Bodenplatte des Orts um deren Oberkante höher (Hood Yard: 0,81).
- **Wand:** Die Kapsel muss mit wenigen cm Abstand vor der Wand stehen bleiben. Gras und Blätter muss sie ohne Halt durchlaufen.
- **Tempo:** etwa 0,38 m/s bei 33-ms-Takt, also genug Zeit geben (15–25 s).
## 8. Kosten messen
`renderer.info.render` zählt die Schattenstufen nicht mit, es wird nach `shadowMap.render` zurückgesetzt. Stattdessen takten, einmal mit und einmal ohne die Wurzel des Orts in der Szene:
```js
const gl = renderer.getContext();
const measure = (n) => { const t = []; for (let i = 0; i < n; i++) { gl.finish(); const s = performance.now(); renderer.render(scene, camera); gl.finish(); t.push(performance.now() - s); } t.sort((a, b) => a - b); return t[n >> 1]; };
```
- **Median:** Beim Hood Yard kosteten Blick vom Rand und Blick im Ort je +0,5 ms.
- **Ruckler beim Einsetzen:** Nach einem Sprung die größte Lücke in `window.__frames` rund um den Moment suchen, in dem der Ort in der Szene erscheint. Mit Vorwärmen waren das 86 ms bei 33-ms-Takt, ohne kamen allein ~130 ms für den Texturupload dazu.
@@ -0,0 +1,16 @@
"""Holt ein JPEG aus einer gespeicherten javascript_tool-Ausgabe (tool-results/*.txt) und schreibt es als Datei.
python3 .claude/skills/placing-editor-models/scripts/decode-view.py <tool-result.txt> <bild.jpg>
"""
import base64
import json
import re
import sys
source, target = sys.argv[1], sys.argv[2]
text = "".join(item.get("text", "") for item in json.load(open(source)))
match = re.search(r"data:image/jpeg;base64,([A-Za-z0-9+/=]+)", text)
if not match:
sys.exit("keine JPEG-Datenadresse gefunden")
open(target, "wb").write(base64.b64decode(match.group(1)))
print("geschrieben", target)
@@ -0,0 +1,109 @@
// Vermisst eine .glb aus three-d-stage (Quelle oder aufbereitete Datei) für die Platzierung:
//
// node .claude/skills/placing-editor-models/scripts/measure-model.mjs <modell.glb> [bericht.json]
//
// Gibt eine kurze Übersicht aus und schreibt optional das volle JSON, das render-site.py für die
// Grundriss-Überlagerung liest. Dreiecke werden je Knoten gezählt: three-d-stage benutzt Meshes
// mehrfach, die Summe über die Meshes allein unterschätzt, was gezeichnet wird (Hood Yard: 0,75 statt
// 2,9 Mio.).
import { createHash } from "node:crypto";
import { open, stat, writeFile } from "node:fs/promises";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../../../../tools/assets/bumpExtension.mjs";
const [source, reportPath] = process.argv.slice(2);
if (!source) {
console.error("Aufruf: node measure-model.mjs <modell.glb> [bericht.json]");
process.exit(1);
}
/** asset.generator aus dem JSON-Block der .glb; NodeIO überschreibt ihn beim Einlesen. */
async function readGenerator(path) {
const file = await open(path, "r");
try {
const header = Buffer.alloc(20);
await file.read(header, 0, 20, 0);
const json = Buffer.alloc(header.readUInt32LE(12));
await file.read(json, 0, json.length, 20);
return JSON.parse(json.toString("utf8")).asset?.generator ?? "(unbekannt)";
} finally {
await file.close();
}
}
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
const document = await io.read(source);
const root = document.getRoot();
const round = (values) => values.map((value) => Number(value.toFixed(2)));
const materials = new Map();
for (const node of root.listNodes()) {
for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
const material = primitive.getMaterial();
const name = material?.getName() ?? "(ohne Material)";
const entry = materials.get(name) ?? {
name, drawnTriangles: 0, primitives: 0,
alphaMode: material?.getAlphaMode() ?? "OPAQUE",
bump: Boolean(material?.getExtension(EXT_MATERIALS_BUMP)),
};
entry.drawnTriangles += (primitive.getIndices()?.getCount() ?? primitive.getAttribute("POSITION").getCount()) / 3;
entry.primitives++;
materials.set(name, entry);
}
}
const textureSizes = {};
const hashes = new Set();
let gpuBytes = 0;
for (const texture of root.listTextures()) {
const [width, height] = texture.getSize() ?? [0, 0];
const key = `${width}x${height} ${texture.getMimeType()}`;
textureSizes[key] = (textureSizes[key] ?? 0) + 1;
hashes.add(createHash("md5").update(texture.getImage() ?? new Uint8Array()).digest("hex"));
gpuBytes += (width * height * 4 * 4) / 3;
}
// Baugruppen: die Kinder des obersten Knotens mit Unterbaum (bei three-d-stage z. B. ground, container_row,
// fence). Ihre Außenmaße ergeben den Grundriss für die Überlagerung auf Karte und Satellitenbild.
const scene = root.listScenes()[0];
const top = scene.listChildren();
const groupParents = top.length === 1 && top[0].listChildren().length > 0 ? top[0].listChildren() : top;
const groups = groupParents
.filter((node) => node.getMesh() || node.listChildren().length > 0)
.map((node) => {
const { min, max } = getBounds(node);
return { name: node.getName() || "(unbenannt)", min: round(min), max: round(max) };
});
const { min, max } = getBounds(scene);
const report = {
file: source,
megabytes: Number(((await stat(source)).size / 1e6).toFixed(1)),
generator: await readGenerator(source),
extensions: root.listExtensionsUsed().map((extension) => extension.extensionName),
rootTranslation: top.length === 1 ? round(top[0].getTranslation()) : null,
bounds: { min: round(min), max: round(max) },
textures: { count: root.listTextures().length, unique: hashes.size, sizes: textureSizes, gpuMB: Math.round(gpuBytes / 1e6) },
drawnTriangles: [...materials.values()].reduce((sum, entry) => sum + entry.drawnTriangles, 0),
materials: [...materials.values()].sort((a, b) => b.drawnTriangles - a.drawnTriangles),
groups,
};
console.log(`${report.file}: ${report.megabytes} MB, ${report.generator}`);
console.log(`Erweiterungen: ${report.extensions.join(", ")}`);
console.log(`Außenmaße: x ${report.bounds.min[0]} … ${report.bounds.max[0]}, y ${report.bounds.min[1]} … ${report.bounds.max[1]}, z ${report.bounds.min[2]} … ${report.bounds.max[2]}`);
console.log(`Wurzel-Verschiebung: ${JSON.stringify(report.rootTranslation)}`);
console.log(`Texturen: ${report.textures.count} (${report.textures.unique} verschieden), ${JSON.stringify(report.textures.sizes)}, ~${report.textures.gpuMB} MB Grafikspeicher`);
console.log(`Gezeichnete Dreiecke: ${report.drawnTriangles}`);
console.table(report.materials.map(({ name, drawnTriangles, primitives, alphaMode, bump }) => ({ name, drawnTriangles, primitives, alphaMode, bump })));
console.table(report.groups.map(({ name, min: lo, max: hi }) => ({ name, x: `${lo[0]} … ${hi[0]}`, y: `${lo[1]} … ${hi[1]}`, z: `${lo[2]} … ${hi[2]}` })));
if (reportPath) {
await writeFile(reportPath, JSON.stringify(report, null, 2));
console.log(`JSON: ${reportPath}`);
}
@@ -0,0 +1,165 @@
"""Vergleichsbild für die Platzierung: Spielkarte (und optional das Satellitenbild) mit Grundriss des Modells.
tools/extract/.venv/bin/python .claude/skills/placing-editor-models/scripts/render-site.py \\
--site site.json --model modell.json --at 1489 1067.5 [--rotation 0] \\
[--satellite bild.png --anchor-px 1000 213 --anchor-world 1525 1058 --mpp 0.15] \\
[--px-per-m 7] --out lage.png
site.json kommt aus site-data.mjs, modell.json aus measure-model.mjs. Das Satellitenbild wird mit Norden
oben angenommen: ein Punkt, der im Bild und in der Karte eindeutig ist (Anker), plus Meter je Bildpixel
legen es auf Weltmeter. Beide Felder zeigen denselben Weltausschnitt (box aus site.json), damit der
Nutzer die Lage des Grundrisses in echt und im Spiel direkt vergleichen kann.
"""
import argparse
import json
import math
from PIL import Image, ImageDraw, ImageFont
parser = argparse.ArgumentParser()
parser.add_argument("--site", required=True)
parser.add_argument("--model", required=True)
parser.add_argument("--at", nargs=2, type=float, required=True, metavar=("X", "Y"))
parser.add_argument("--rotation", type=float, default=0.0, help="Grad gegen den Uhrzeigersinn, Norden oben")
parser.add_argument("--satellite")
parser.add_argument("--anchor-px", nargs=2, type=float, metavar=("PX", "PY"))
parser.add_argument("--anchor-world", nargs=2, type=float, metavar=("X", "Y"))
parser.add_argument("--mpp", type=float, help="Meter je Pixel im Satellitenbild")
parser.add_argument("--px-per-m", type=float, default=7.0)
parser.add_argument("--out", required=True)
args = parser.parse_args()
site = json.load(open(args.site))
model = json.load(open(args.model))
box = site["box"]
PPM = args.px_per_m
W, H = round((box["x1"] - box["x0"]) * PPM), round((box["y1"] - box["y0"]) * PPM)
try:
font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial Bold.ttf", 16)
except OSError:
font = ImageFont.load_default()
def uv(X, Y):
return ((X - box["x0"]) * PPM, (box["y1"] - Y) * PPM)
def model_to_world(x, z):
# wie placeTransform + three rotation.y: +x wird bei positivem Winkel zu Norden
r = math.radians(args.rotation)
return args.at[0] + x * math.cos(r) + z * math.sin(r), args.at[1] - (-x * math.sin(r) + z * math.cos(r))
def rect_polygon(min_xyz, max_xyz):
corners = [(min_xyz[0], min_xyz[2]), (max_xyz[0], min_xyz[2]), (max_xyz[0], max_xyz[2]), (min_xyz[0], max_xyz[2])]
return [uv(*model_to_world(x, z)) for x, z in corners]
def area(group):
return (group["max"][0] - group["min"][0]) * (group["max"][2] - group["min"][2])
def draw_footprint(image, with_labels):
"""Große Gruppen (Boden, Gras, Büsche über den ganzen Hof) nur als Umriss, kompakte gefüllt und
beschriftet — sonst verdecken Flächengruppen die Gebäude, auf die es beim Vergleich ankommt."""
overlay = Image.new("RGBA", image.size, (0, 0, 0, 0))
d = ImageDraw.Draw(overlay)
palette = [(230, 140, 40), (70, 130, 220), (200, 80, 160), (220, 200, 60), (90, 200, 200), (150, 110, 230)]
total = area({"min": model["bounds"]["min"], "max": model["bounds"]["max"]}) or 1
groups = sorted(model["groups"], key=area, reverse=True)
compact = [g for g in groups if area(g) < 0.35 * total]
for group in groups:
if area(group) >= 0.35 * total:
d.polygon(rect_polygon(group["min"], group["max"]), outline=(255, 255, 255, 110))
for index, group in enumerate(compact):
color = palette[index % len(palette)]
alpha = 60 if area(group) >= 0.15 * total else 170 # breite Pflanzengruppen blasser
d.polygon(rect_polygon(group["min"], group["max"]), fill=color + (alpha,), outline=(0, 0, 0, 230))
d.polygon(rect_polygon(model["bounds"]["min"], model["bounds"]["max"]), outline=(255, 255, 255, 255), width=3)
if with_labels:
taken = []
for group in sorted(compact, key=area): # Gebäude vor Pflanzenflächen beschriften
if area(group) < 4: # Müllsäcke, Kameras usw. nicht beschriften
continue
cx = (group["min"][0] + group["max"][0]) / 2
cz = (group["min"][2] + group["max"][2]) / 2
u, v = uv(*model_to_world(cx, cz))
left, top, right, bottom = d.textbbox((u, v), group["name"], font=font)
if any(left < r and right > l and top < b and bottom > t for l, t, r, b in taken):
continue
taken.append((left, top, right, bottom))
d.rectangle([left - 2, top - 1, right + 2, bottom + 1], fill=(0, 0, 0, 150))
d.text((u, v), group["name"], font=font, fill=(255, 255, 255, 255))
return Image.alpha_composite(image.convert("RGBA"), overlay)
def game_panel():
image = Image.new("RGB", (W, H), (44, 90, 140))
d = ImageDraw.Draw(image)
colors = {"park": (90, 150, 70), "urban": (200, 200, 200), "sand": (230, 210, 140)}
for c in site["chunks"]:
for poly in c["land"]:
d.polygon([uv(*p) for p in poly[0]], fill=(170, 185, 130))
for c in site["chunks"]:
for s in c["surfaces"]:
d.polygon([uv(*p) for p in s["rings"][0]], fill=colors.get(s["kind"], (150, 150, 150)))
for c in site["chunks"]:
for r in c["roads"]:
d.polygon([uv(*p) for p in r["rings"][0]], fill=(80, 80, 85))
for hole in r["rings"][1:]:
d.polygon([uv(*p) for p in hole], fill=(170, 185, 130))
for c in site["chunks"]:
for b in c["buildings"]:
d.polygon([uv(*p) for p in b["rings"][0]], fill=(150, 110, 90), outline=(0, 0, 0))
for X, Y in site["palms"]:
u, v = uv(X, Y)
d.ellipse([u - 5, v - 5, u + 5, v + 5], fill=(20, 90, 20))
image = draw_footprint(image, with_labels=False)
d = ImageDraw.Draw(image)
for X, Y in site["lamps"]:
u, v = uv(X, Y)
d.ellipse([u - 6, v - 6, u + 6, v + 6], fill=(255, 220, 0, 255), outline=(0, 0, 0, 255))
# Chunk-Grenzen alle 250 m; Planquadrat-Namen oben links in jedem sichtbaren Teil eines Chunks
for X in range(-1000, 2001, 250):
if box["x0"] <= X <= box["x1"]:
u, _ = uv(X, 0)
d.line([(u, 0), (u, H)], fill=(255, 40, 40), width=2)
for Y in range(-500, 2501, 250):
if box["y0"] <= Y <= box["y1"]:
_, v = uv(0, Y)
d.line([(0, v), (W, v)], fill=(255, 40, 40), width=2)
names = {c["key"]: c["name"] for c in site["chunks"]}
for i in range(12):
for j in range(12):
left, right = max(box["x0"], -1000 + i * 250), min(box["x1"], -1000 + (i + 1) * 250)
north, south = min(box["y1"], 2500 - j * 250), max(box["y0"], 2500 - (j + 1) * 250)
if left < right and south < north and f"{i}_{j}" in names:
u, v = uv(left, north)
d.text((u + 6, v + 4), names[f"{i}_{j}"], font=font, fill=(255, 40, 40))
return image
def satellite_panel():
real = Image.open(args.satellite).convert("RGB")
k = 1 / PPM / args.mpp
cx = args.anchor_px[0] + (box["x0"] - args.anchor_world[0]) / args.mpp
cy = args.anchor_px[1] - (box["y1"] - args.anchor_world[1]) / args.mpp
panel = real.transform((W, H), Image.AFFINE, (k, 0, cx, 0, k, cy), resample=Image.BICUBIC, fillcolor=(30, 30, 30))
return draw_footprint(panel, with_labels=True)
panels = [("Spiel: Chunk-Daten, gelb Laternen, weiß Außenmaße", game_panel())]
if args.satellite:
if not (args.anchor_px and args.anchor_world and args.mpp):
parser.error("--satellite braucht --anchor-px, --anchor-world und --mpp")
panels.insert(0, (f"Echt: Satellitenbild, 1 px ≈ {args.mpp} m, Anker {args.anchor_world}", satellite_panel()))
M = 30
out = Image.new("RGB", (W, len(panels) * (H + M)), (18, 18, 18))
d = ImageDraw.Draw(out)
for index, (title, panel) in enumerate(panels):
top = index * (H + M)
d.text((8, top + 7), f"{title} · Mitte ({args.at[0]:g} | {args.at[1]:g}), Drehung {args.rotation:g}°", font=font, fill=(240, 240, 240))
out.paste(panel.convert("RGB"), (0, top + M))
out.save(args.out)
print(args.out, out.size)
@@ -0,0 +1,50 @@
// Holt die Kartendaten eines Weltausschnitts für die Platzierung, in Welt-x/-y (Meter, Norden = +y):
//
// node .claude/skills/placing-editor-models/scripts/site-data.mjs <x0> <x1> <y0> <y1> <ausgabe.json>
//
// Enthält Land, Flächen (park/sand/urban), Straßen, Gebäude, Palmen und die Laternen, wie PropLayer sie
// setzt (vergeLampPoints(lampPoints(...)) je Chunk — Laternen stehen bis 16 m neben ihrer Mittellinie,
// deshalb werden auch die Nachbarchunks des Ausschnitts gelesen). render-site.py zeichnet daraus das
// Vergleichsbild; die Grenzen für Straßenrand, Felder usw. lassen sich direkt aus dem JSON ablesen.
import { readFileSync, writeFileSync } from "node:fs";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkName } from "../../../../src/world/coords.js";
import { lampPoints, vergeLampPoints } from "../../../../src/world/props.js";
const [x0, x1, y0, y1] = process.argv.slice(2, 6).map(Number);
const out = process.argv[6];
if ([x0, x1, y0, y1].some(Number.isNaN) || !out) {
console.error("Aufruf: node site-data.mjs <x0> <x1> <y0> <y1> <ausgabe.json>");
process.exit(1);
}
const iOf = (X) => Math.floor((X - SLICE.x0) / CHUNK_SIZE);
const jOf = (Y) => Math.floor((SLICE.y1 - Y) / CHUNK_SIZE);
const clamp = (k) => Math.min(CHUNKS_PER_SIDE - 1, Math.max(0, k));
const chunkUrl = (i, j) => new URL(`../../../../public/data/vice-a/chunk_${i}_${j}.json`, import.meta.url);
const chunks = [];
const palms = [];
const lamps = [];
for (let i = clamp(iOf(x0) - 1); i <= clamp(iOf(x1) + 1); i++) {
for (let j = clamp(jOf(y1) - 1); j <= clamp(jOf(y0) + 1); j++) {
const c = JSON.parse(readFileSync(chunkUrl(i, j), "utf8"));
const [ox, oz] = c.origin;
const world = (u, v) => [Number((ox + u).toFixed(2)), Number((-(oz + v)).toFixed(2))];
const rings = (poly) => poly.map((ring) => ring.map(([u, v]) => world(u, v)));
chunks.push({
key: `${i}_${j}`, name: chunkName(i, j),
land: c.land.map(rings),
surfaces: (c.surfaces ?? []).map((s) => ({ kind: s.kind, rings: rings(s.poly) })),
roads: c.roads.map((r) => ({ bridge: r.bridge, rings: rings(r.poly) })),
buildings: c.buildings.map((b) => ({ height: b.height, rings: rings(b.poly) })),
});
for (const [u, v] of c.palms ?? []) palms.push(world(u, v));
for (const p of vergeLampPoints(lampPoints(c.centerlines ?? []), c.roads ?? [])) lamps.push(world(p.x, p.z));
}
}
const inside = ([X, Y]) => X >= x0 && X <= x1 && Y >= y0 && Y <= y1;
writeFileSync(out, JSON.stringify({ box: { x0, x1, y0, y1 }, chunks, palms, lamps }));
console.log(`${chunks.length} Chunks (${chunks.map((c) => c.name).join(", ")}), ` +
`${palms.filter(inside).length} Palmen und ${lamps.filter(inside).length} Laternen im Ausschnitt → ${out}`);
for (const lamp of lamps.filter(inside)) console.log(` Laterne bei (${lamp[0]} | ${lamp[1]})`);
-3
View File
@@ -1,3 +0,0 @@
[submodule "three-player-controller"]
path = three-player-controller
url = https://github.com/hh-hang/three-player-controller.git
@@ -0,0 +1,718 @@
# Tribüne im Park J6 — Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Die Tribüne aus `tribuene.glb` ersetzt 1:1 den Gebäudekasten in J6 (Flamingo Park Baseball Stadium): an der Stelle des alten Gebäudes, mit Schatten und Kollision, und den alten Kasten gibt es im Spiel nicht mehr.
**Architecture:** Die Orte-Schicht aus dem Hood Yard bleibt, wie sie ist. Neu ist das Feld `replaces` im `PLACES`-Eintrag, eine Liste von Weltpunkten. `ChunkStreamer` bekommt diese Punkte aus `main.js` und lässt über die reine Funktion `withoutReplacedBuildings` jeden Grundriss weg, in dem ein Punkt liegt. Die Tribüne selbst wird mit `tools/assets/places.mjs` aufbereitet und über die globalen Materiallisten eingeordnet. Der Asset-Test prüft danach jeden Ort einzeln.
**Tech Stack:** three.js 0.184, Vite 7, vitest 3.2, `@gltf-transform/core|extensions|functions` 4.5.0, `sharp` 0.35.4, `meshoptimizer` 1.2.0.
**Spec:** `docs/superpowers/specs/2026-09-14-tribuene-design.md`
## Global Constraints
- **Lage:** `at: [1409.9, 1051.1]`, `rotation: 45`, `lift: 0`, `bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 }`, `replaces: [[1403.25, 1057.75]]`. `at` und `replaces` sind Welt-x und Welt-y, nie three-z.
- **Radien** bleiben: `PLACE_PRELOAD_RADIUS = 600`, `PLACE_SHOW_RADIUS = 250`, `PLACE_HIDE_RADIUS = 450`.
- **Materialrollen:**
- fest: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`
- Nahflächen: `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale`
- Vereinfachung 0,25: `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01`
- **Quelle:** `/Users/Kenso/Downloads/tribuene.glb` (6,3 MB) kommt nie ins Repo. Die Kopie `tribuene.glb` im Hauptordner bleibt unberührt.
- **Aufbereitung** nur mit `node tools/assets/places.mjs`, nie mit der gltf-transform-CLI.
- **Nicht anfassen:** `public/data/`, `tools/extract/`, die Minimap, `src/world/geometry.js`, `src/world/props.js`, die Radien.
- **Arbeitsbaum** `/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene`, Branch `leonida/tribuene`, alle Befehle von dort aus.
- Gestaget wird immer mit Pfaden, nie mit `git add -A`.
- Die Isolationswache verweigert Befehle mit Variablen als Befehl oder mit Skriptpfaden, die Leerzeichen enthalten. Deshalb Pfade ausschreiben und einfache Einzelbefehle nutzen.
- **Stil:** Kommentare und Testnamen auf Deutsch, wie in den vorhandenen Dateien. Über Tests, die eine Fehlerart abfangen, steht ein Kommentar „Bruch:“.
- **Commit-Nachrichten** enden mit einer Leerzeile und `Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>`.
- **Tests:** einzeln mit `npx vitest run <datei>`, alle mit `npm test`. Ausgangsstand: `Test Files 17 passed (17)`, `Tests 170 passed (170)`.
## Vorbereitung (erledigt)
Der Arbeitsbaum steht auf `6a05626` plus dem Spec-Commit `4a1b337`. `node_modules` sowie `three-player-controller/package.json` und `dist/` sind hineinkopiert, der Ausgangsstand ist grün.
- [ ] **Prüfen:** `git status --short` gibt nichts aus, `npm test` meldet 17 Dateien und 170 Tests grün.
---
### Task 1: Ersetzte Gebäude beim Chunk-Aufbau weglassen
**Files:**
- Modify: `src/world/streaming.js` (Import, neue Funktion, Konstruktor, `#load`)
- Modify: `src/main.js:11` (Import) und `src/main.js:239` (Konstruktion des `ChunkStreamer`)
- Test: `tests/streaming.test.js`
**Interfaces:**
- Consumes: `pointInPolygon(x, z, rings)` aus `src/world/props.js`, `PLACES` aus `src/world/places.js` (bisher ohne `replaces`)
- Produces:
- `withoutReplacedBuildings(chunk, points)` in `src/world/streaming.js`. `points` ist `Array<[x, y]>` in Weltkoordinaten. Die Funktion gibt `chunk` selbst zurück, wenn nichts entfällt, sonst `{ ...chunk, buildings }` mit gefilterter Liste.
- `new ChunkStreamer({ ..., replacedBuildings = [] })`
- `main.js` übergibt `PLACES.flatMap((place) => place.replaces ?? [])`.
- [ ] **Schritt 1: Failing Tests schreiben**
In `tests/streaming.test.js` den Import ändern:
```js
import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js";
```
`makeStreamer` bekommt einen vierten Parameter (die übrigen Aufrufe bleiben gültig, `undefined` greift auf die Vorgabe zurück):
```js
function makeStreamer(chunkData, props, pinned, replacedBuildings) {
```
und in derselben Funktion die Rückgabe:
```js
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene };
```
Direkt nach `describe("wantedChunks", …)` (vor dem Kommentar „Gemeinsame Vorrichtung …“) einfügen:
```js
describe("withoutReplacedBuildings", () => {
// Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250.
const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]];
const chunk = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [
{ poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060
{ poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150
],
};
// Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten
// stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert.
it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => {
const result = withoutReplacedBuildings(chunk, [[1405, 1055]]);
expect(result.buildings).toEqual([chunk.buildings[1]]);
expect(result.land).toBe(chunk.land);
expect(chunk.buildings).toHaveLength(2);
});
it("gibt ohne Treffer den Chunk selbst zurück", () => {
expect(withoutReplacedBuildings(chunk, [])).toBe(chunk);
expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk);
});
});
```
Im `describe("ChunkStreamer", …)` als letzten Test (nach „entlädt einen angehefteten Chunk nicht …“) einfügen:
```js
// Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt
// Kollision mitten in dem Ort, der ihn ersetzt.
it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => {
const chunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [],
buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }],
};
// Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe.
const plain = makeStreamer(chunk);
plain.streamer.update(emptyChunkCenter);
await flush();
expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2);
// Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985.
const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]);
streamer.update(emptyChunkCenter);
await flush();
const source = player.addCollider.mock.calls[0][0].shape.source;
expect(source.children).toHaveLength(1);
expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude
});
```
(Gebäudegeometrie trägt als einzige ein `color`-Attribut, siehe `buildBuildings` in `src/world/geometry.js`.)
- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen**
Run: `npx vitest run tests/streaming.test.js`
Erwartet: Die zwei `withoutReplacedBuildings`-Tests schlagen fehl (`withoutReplacedBuildings is not a function`). „baut ein Gebäude, das ein Ort ersetzt, …“ scheitert an `expected [ …(2) ] to have a length of 1`.
- [ ] **Schritt 3: Umsetzen**
In `src/world/streaming.js` die Importe ergänzen:
```js
import { Group, Mesh } from "three";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
import { buildChunkGeometry } from "./geometry.js";
import { pointInPolygon } from "./props.js";
```
Nach `function dist(k, position) { … }` einfügen:
```js
/**
* Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js):
* jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und
* Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt
* derselbe Chunk zurück.
*/
export function withoutReplacedBuildings(chunk, points) {
if (points.length === 0) return chunk;
const [ox, oz] = chunk.origin;
const buildings = chunk.buildings.filter((building) =>
!points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly)));
return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings };
}
```
Konstruktor:
```js
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) {
Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings });
```
In `#load` die Zeile `const geos = buildChunkGeometry(chunk);` ersetzen durch:
```js
const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings));
```
`this.props.build(chunk)` bleibt beim ungefilterten Chunk, Palmen und Laternen kennen keine Gebäude.
In `src/main.js` Zeile 11:
```js
import { PLACES, PlaceLayer } from "./world/places.js";
```
und Zeile 239 (`const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });`) ersetzen durch:
```js
// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer
// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten.
const streamer = new ChunkStreamer({
scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS,
replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []),
});
```
- [ ] **Schritt 4: Tests laufen lassen, sie müssen bestehen**
Run: `npx vitest run tests/streaming.test.js`
Erwartet: alle Tests grün.
Run: `npm test`
Erwartet: `Test Files 17 passed (17)`, `Tests 173 passed (173)`.
- [ ] **Schritt 5: Commit**
```bash
git add src/world/streaming.js src/main.js tests/streaming.test.js
git commit -m "feat(world): Gebäude, die ein Ort ersetzt, beim Chunk-Aufbau weglassen" -m "withoutReplacedBuildings filtert die Grundrisse, in denen ein Weltpunkt aus PLACES[].replaces liegt; ChunkStreamer baut die Geometrie daraus, main.js übergibt die Punkte aller Orte. Damit entfallen Kasten, Schatten und Kollision des alten Gebäudes." -m "Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 2: Materialrollen der Tribüne und Asset-Test je Ort
**Files:**
- Modify: `src/world/places.js:37-52` (`SOLID_MATERIALS` und Kommentar darüber)
- Modify: `tools/assets/places.mjs:26-45` (`NEAR_MATERIALS`, `SIMPLIFY_RATIOS` und Kommentar)
- Test: `tests/places.test.js` (`describe("isSolidMaterial")`), `tests/placesPipeline.test.js` (neuer Block), `tests/placesAsset.test.js` (neu geschrieben)
**Interfaces:**
- Consumes: nichts aus Task 1
- Produces:
- `SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` enthalten die Tribünen-Namen.
- `tests/placesAsset.test.js` prüft jede Datei aus `PLACES` gegen die Tabellen `SOLID_BY_PLACE` und `BUMP_BY_PLACE`. Task 3 fügt `tribuene` zu `PLACES` hinzu, die Zeile in `SOLID_BY_PLACE` steht dann schon.
- [ ] **Schritt 1: Failing Tests schreiben**
In `tests/places.test.js` im `describe("isSolidMaterial", …)` nach dem ersten `it` einfügen:
```js
// Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man
// bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm).
it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => {
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung",
"utility_box_01"]) {
expect(isSolidMaterial(name)).toBe(true);
}
for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp",
"industrial_wall_lamp_glass", "power_box_01"]) {
expect(isSolidMaterial(name)).toBe(false);
}
});
```
In `tests/placesPipeline.test.js` den Import ändern:
```js
import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget }
from "../tools/assets/places.mjs";
```
und am Dateiende anhängen:
```js
describe("Materiallisten der Tribüne", () => {
// Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt
// davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px.
it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => {
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true);
for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false);
});
// Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper,
// Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten).
it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => {
for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp",
"industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) {
expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25);
}
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung",
"Sitzschale"]) {
expect(SIMPLIFY_RATIOS.has(name)).toBe(false);
}
});
});
```
- [ ] **Schritt 2: Tests laufen lassen, sie müssen fehlschlagen**
Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js`
Erwartet: 3 fehlgeschlagene Tests, nämlich der neue `isSolidMaterial`-Test (`Sichtbeton`: expected false to be true) und beide Tests in „Materiallisten der Tribüne“.
- [ ] **Schritt 3: Listen ergänzen**
In `src/world/places.js` den Kommentar über `SOLID_MATERIALS` ändern und am Listenende (nach der Zeile mit den Bäumen) eine Gruppe anhängen:
```js
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */
export const SOLID_MATERIALS = [
```
```js
// Bäume
"quiver_tree_01_trunk", "quiver_tree_02",
// Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden,
// Glas der Pressekabine, Anschlusskästen am Boden
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01",
];
```
In `tools/assets/places.mjs` in `NEAR_MATERIALS` nach `"chainlink_post", "chainlink_wire",` anhängen:
```js
// Tribüne: die Quelle hat höchstens 768 px, compressTexture vergrößert nicht
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale",
```
Den Kommentar über `SIMPLIFY_RATIOS` und die Liste so ändern:
```js
/**
* Anteil der Dreiecke, der je Material bleibt; Gebäude, Boden und Zäune (Container, Schuppen, Tribüne mit
* Sitzen) fehlen hier und bleiben unverändert. Gemessen am Hood Yard nach flatten/join: die vier Köcherbäume
* haben zusammen 2,2 Mio. Dreiecke (quiver_tree_01_leaf 1,13 Mio., quiver_tree_02 739 000,
* quiver_tree_01_trunk 370 000), Gras je 60 000–150 000, die Mast-Kleinteile 92 000. An der Tribüne haben
* Kabel, Kabeldosen, Wandleuchten und Kästen zusammen 64 298.
*/
export const SIMPLIFY_RATIOS = new Map([
["quiver_tree_01_leaf", 0.15], ["quiver_tree_01_trunk", 0.15], ["quiver_tree_02", 0.15],
["searsia_lucida", 0.5], ["searsia_lucida_leaves", 0.5], ["searsia_lucida_twigs", 0.5],
["grass_medium_01", 0.5], ["grass_medium_01_dry", 0.5], ["grass_medium_02", 0.5],
["modular_electricity_poles_pieces", 0.25], ["security_camera_01", 0.25],
["modular_electric_cables", 0.25], ["modular_electric_cables_boxes", 0.25], ["industrial_wall_lamp", 0.25],
["industrial_wall_lamp_glass", 0.25], ["power_box_01", 0.25], ["utility_box_01", 0.25],
]);
```
- [ ] **Schritt 4: Tests laufen lassen**
Run: `npx vitest run tests/places.test.js tests/placesPipeline.test.js`
Erwartet: grün.
Run: `npx vitest run tests/placesAsset.test.js`
Erwartet: 1 Fehlschlag in „enthält jedes feste Material der Kollisionsliste“ mit den acht Tribünen-Namen. Das ist der bekannte Fallstrick: Der Test gleicht die ganze globale Liste mit der Datei des Hood Yard ab.
- [ ] **Schritt 5: Asset-Test auf alle Orte umbauen**
`tests/placesAsset.test.js` vollständig ersetzen durch:
```js
import { statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { PLACES, SOLID_MATERIALS } from "../src/world/places.js";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle
// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile.
const SOLID_BY_PLACE = {
"hood-yard": [
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame",
"ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB",
"rollershutter_window_01", "rollershutter_window_01_graffiti",
"chainlink_post", "chainlink_wire",
"modular_electricity_poles", "barrel_03", "plastic_container",
"quiver_tree_01_trunk", "quiver_tree_02",
],
tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"],
};
// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps.
const BUMP_BY_PLACE = {
"hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"],
};
// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft.
it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => {
expect(Object.values(SOLID_BY_PLACE).flat().sort()).toEqual([...SOLID_MATERIALS].sort());
});
describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => {
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
const bump = BUMP_BY_PLACE[id] ?? [];
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions([...ALL_EXTENSIONS, EXTMaterialsBump])
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
document = await io.read(file);
}, 30000);
// Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB.
it("ist kleiner als 30 MB", () => {
expect(statSync(file).size).toBeLessThan(30e6);
});
// Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest.
it("nutzt meshopt und WebP und hat keine Instanzen", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"]));
expect(used).not.toContain("EXT_mesh_gpu_instancing");
});
it("hat nur WebP-Texturen bis 1024 px", () => {
for (const texture of document.getRoot().listTextures()) {
expect(texture.getMimeType()).toBe("image/webp");
expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(1024);
}
});
// Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der
// Container wäre flach.
it.runIf(bump.length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material]));
expect(used).toContain(EXT_MATERIALS_BUMP);
for (const name of bump) {
expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy();
}
});
// Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung
// durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE.
it("enthält jedes feste Material seines Orts", () => {
const names = document.getRoot().listMaterials().map((material) => material.getName());
expect(SOLID_BY_PLACE[id]).toBeDefined();
expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]);
});
// Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche.
it("passt auf 5 cm zu den Außenmaßen in PLACES", () => {
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
const { minX, maxX, minZ, maxZ } = place.bounds;
expect(Math.abs(min[0] - minX)).toBeLessThan(0.05);
expect(Math.abs(max[0] - maxX)).toBeLessThan(0.05);
expect(Math.abs(min[2] - minZ)).toBeLessThan(0.05);
expect(Math.abs(max[2] - maxZ)).toBeLessThan(0.05);
});
});
```
- [ ] **Schritt 6: Alle Tests laufen lassen**
Run: `npm test`
Erwartet: `Test Files 17 passed (17)`, `Tests 177 passed (177)`. Gegenüber dem Ausgangsstand kommen hinzu:
- 3 Tests aus Task 1
- 1 Test aus `places.test.js`
- 2 Tests aus `placesPipeline.test.js`
- der neue Verteilungstest im Asset-Test
- [ ] **Schritt 7: Commit**
```bash
git add src/world/places.js tools/assets/places.mjs tests/places.test.js tests/placesPipeline.test.js tests/placesAsset.test.js
git commit -m "feat(world): Materialrollen der Tribüne, Asset-Test je Ort" -m "Fest: Sichtbeton, Estrich, Verkleidung, Stahl, Trapezblech, Riffelblech, Verglasung, utility_box_01. Nahflächen: Sichtbeton, Estrich, Verkleidung, Stahl, Sitzschale. Kabel, Kabeldosen, Wandleuchten und Kästen auf 0,25. Der Asset-Test prüft jede Datei aus PLACES gegen ihre eigene Zeile fester Materialien statt gegen die ganze globale Liste, Bump nur beim Hood Yard." -m "Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 3: Tribüne aufbereiten und eintragen
**Files:**
- Create: `public/models/places/tribuene.glb` (Ergebnis von `places.mjs`)
- Modify: `src/world/places.js` (Kommentar über `PLACES`, neuer Eintrag)
- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (Abschnitt „Budget“)
- Test: `tests/places.test.js` (`describe("PLACES")`), `tests/placesSite.test.js`
**Interfaces:**
- Consumes:
- `withoutReplacedBuildings(chunk, points)` aus Task 1
- die Materiallisten und `SOLID_BY_PLACE.tribuene` aus Task 2
- `node tools/assets/places.mjs <quelle.glb> <id>`
- Produces: `PLACES` mit dem Eintrag `tribuene`, den `main.js` (Task 1) und `PlaceLayer` verwenden, und `public/models/places/tribuene.glb`.
- [ ] **Schritt 1: Aufbereiten**
Run: `node tools/assets/places.mjs /Users/Kenso/Downloads/tribuene.glb tribuene`
Erwartet (am Prototyp gemessen, Laufzeit unter 5 s):
- `vorher 6.3 MB`, darin `triangles: 117916`, `gpuMB: 53`
- `nachher 1.7 MB`, darin:
- `materials: 14`, `primitives: 14`, `triangles: 69557`
- `textures: { '512x512 image/webp': 23, '256x256 image/webp': 12, '768x768 image/webp': 4 }`
- `gpuMB: 49`, `bounds: { min: [ -20.8, 0, -14.82 ], max: [ 20.8, 12.57, 0.8 ] }`
Weicht eine Zahl deutlich ab, anhalten und melden.
- [ ] **Schritt 2: Eintrag in `PLACES`**
In `src/world/places.js` den Kommentar über `PLACES` ergänzen:
```js
/**
* 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. replaces
* (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim
* Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js).
*/
```
und nach dem Eintrag `hood-yard` anfügen:
```js
{
id: "tribuene",
url: "./models/places/tribuene.glb",
// Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers);
// längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten.
at: [1409.9, 1051.1],
// Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate.
rotation: 45,
// Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts.
lift: 0,
bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 },
// Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6).
replaces: [[1403.25, 1057.75]],
},
```
- [ ] **Schritt 3: Tests laufen lassen, die vorhandenen Datentests müssen anschlagen**
Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js`
Erwartet:
- `tribuene.glb` im Asset-Test: 5 Tests grün, 1 übersprungen (Bump).
- Rot wird „hat für jeden Ort id, url, at, rotation, lift und bounds“ in `places.test.js`, weil `replaces` ein unerwartetes Feld ist.
- Rot wird „Ort tribuene auf der Karte > liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“. Die Liste zeigt Punkte im alten Gebäude.
- Alles andere bleibt grün.
- [ ] **Schritt 4: Form-Test lässt `replaces` zu**
In `tests/places.test.js` den Test im `describe("PLACES", …)` ersetzen durch:
```js
// Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds
// prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg.
it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => {
for (const { replaces = [], ...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) },
});
for (const point of replaces) expect(point).toEqual([expect.any(Number), expect.any(Number)]);
}
});
```
- [ ] **Schritt 5: Lage-Test kennt ersetzte Gebäude**
In `tests/placesSite.test.js`:
Import ergänzen (nach der Zeile mit `props.js`):
```js
import { withoutReplacedBuildings } from "../src/world/streaming.js";
```
Nach der Funktion `chunk(i, j)` einfügen:
```js
// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in
// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht.
const REPLACED = PLACES.flatMap((place) => place.replaces ?? []);
const standing = new Map();
function standingBuildings(i, j) {
const key = `${i}_${j}`;
if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings);
return standing.get(key);
}
```
Im Test „liegt ganz auf Land, auf keiner Straße und in keinem Gebäude“ den Kommentar und die Gebäudezeile ändern:
```js
// Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben
// in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht.
```
```js
const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));
```
Direkt nach diesem Test einfügen:
```js
// Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt. Der
// Punkt träfe dann nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse oder einen außerhalb.
it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => {
const bad = place.replaces.filter(([X, Y]) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly));
return hits.length !== 1 || !insideBounds(place, X, Y);
});
expect(bad).toEqual([]);
});
```
- [ ] **Schritt 6: Tests laufen lassen, sie müssen bestehen**
Run: `npx vitest run tests/places.test.js tests/placesSite.test.js tests/placesAsset.test.js`
Erwartet: alles grün. Übersprungen sind nur „gibt den Bump-Materialien ihre Bump-Textur“ bei `tribuene.glb` und „ersetzt mit jedem replaces-Punkt …“ beim Hood Yard.
- [ ] **Schritt 7: Gegenprobe, der neue Datentest muss anschlagen**
In `src/world/places.js` den Punkt vorübergehend auf `replaces: [[1403.25, 1087.75]]` setzen (30 m nördlich, auf der Straße).
Run: `npx vitest run tests/placesSite.test.js`
Erwartet für `tribuene`: rot sind „liegt ganz auf Land, … in keinem Gebäude“ (der Kasten zählt wieder) und „ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich“ (`[[1403.25, 1087.75]]`).
Danach zurück auf `replaces: [[1403.25, 1057.75]]`. Run: `npx vitest run tests/placesSite.test.js`, erwartet grün.
- [ ] **Schritt 8: Budget in der Spec**
In `docs/superpowers/specs/2026-09-14-tribuene-design.md` den Abschnitt „## Budget“ vollständig ersetzen, mit den Werten aus dem Bericht von Schritt 1:
```markdown
## Budget
- **Gemessen an der Aufbereitung (2026-09-14):**
- 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls)
- 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px
- geschätzt 49 MB Grafikspeicher, Außenmaße unverändert
- Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298.
- **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB.
```
Weicht der Bericht vom Prototyp ab, gelten die Werte des Berichts.
- [ ] **Schritt 9: Alle Tests laufen lassen**
Run: `npm test`
Erwartet: `Test Files 17 passed (17)`, `Tests 186 passed | 2 skipped (188)`. Gegenüber Task 2 kommen hinzu:
- 6 Asset-Tests der Tribüne, davon 5 grün und 1 übersprungen
- 4 Lage-Tests der Tribüne, alle grün
- beim Hood Yard der neue Lage-Test, übersprungen
- [ ] **Schritt 10: Commit**
```bash
git add public/models/places/tribuene.glb src/world/places.js tests/places.test.js tests/placesSite.test.js docs/superpowers/specs/2026-09-14-tribuene-design.md
git commit -m "feat(world): Tribüne im Park J6 statt des alten Gebäudekastens" -m "tribuene.glb mit places.mjs aufbereitet (6,3 MB -> 1,7 MB, 69 557 Dreiecke, 14 Primitive, 49 MB Grafikspeicher). PLACES-Eintrag bei (1409,9 | 1051,1), 45° gedreht, lift 0, replaces auf den 18,6 m hohen Kasten. Der Lage-Test übergeht ersetzte Grundrisse und prüft, dass jeder replaces-Punkt genau ein Gebäude unter dem Ort trifft." -m "Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```
---
### Task 4: Prüfung im Browser (Controller, nicht an Subagenten)
Das übernimmt die Sitzung selbst. Sie braucht den Browser-Bereich, einen Eintrag in `.claude/launch.json` des Hauptordners (vorher den Nutzer fragen) und Rückfragen an den Nutzer. Grundlage ist `.claude/skills/placing-editor-models/browser-checks.md`, die Abschnitte werden unten mit §N zitiert.
**Files:**
- Create, nie committen, am Ende löschen: `index-debug.html` (§2)
- Modify, am Ende zurücknehmen: `/Users/Kenso/Developer/gta6/.claude/launch.json` (§1, Eintrag `place-check`, Port 5181, `cd /Users/Kenso/Developer/gta6/.claude/worktrees/tribuene && npx vite --port 5181 --strictPort`)
- Modify: `docs/superpowers/specs/2026-09-14-tribuene-design.md` (neuer Punkt „Gemessen im Spiel“ unter „Fehlerfälle und Risiken“)
**Werte** (Welt → three: z = −y):
| Zweck | Welt (x \| y) | three |
|---|---|---|
| Wurzel der Tribüne | (1409,9 \| 1051,1) | `position` (1409.9, 0, −1051.1), `rotation.y` = π/4 |
| Sprungziel auf dem Spielfeld | (1420,5 \| 1040,5) | — |
| Draufsicht | Mitte (1404,94 \| 1056,06) | `position [1404.94, 60, -1056.06]`, `target [1404.94, 0, -1056.06]`, `up [0, 0, -1]` |
| Blick vom Spielfeld | (1427,58 \| 1033,42) | `position [1427.58, 1.7, -1033.42]`, `target [1404.94, 5, -1056.06]` |
| Blick von hinten | (1388,69 \| 1072,31) | `position [1388.69, 1.7, -1072.31]`, `target [1404.94, 5, -1056.06]` |
| Streifen des alten Kastens hinter der Tribüne | (1397,88 \| 1063,12) | Strahl von (1397.88, 60, −1063.12) nach unten |
| Kapsel hinter der Rückwand | (1389,39 \| 1054,64) | `capsule.position.set(1389.39, 1, -1054.64)`, Blick 135° |
| Kapsel vor dem Tunnel (x 6–8) | (1402,83 \| 1068,07) | `capsule.position.set(1402.83, 1, -1068.07)`, Blick 135° |
| Kapsel auf der Sitzstufe y 6,0 | (1394,34 \| 1049,69) | `capsule.position.set(1394.34, 7, -1049.69)`, Blick 45° (längs der Reihe) |
- [ ] **Schritt 1: Server und Debug-Seite**
- Den Nutzer fragen, ob der Eintrag `place-check` in `.claude/launch.json` des Hauptordners gesetzt werden darf.
- Dann `ExitWorktree(keep)`, Eintrag setzen, `preview_start({ name: "place-check" })`, `EnterWorktree({ path: "/Users/Kenso/Developer/gta6/.claude/worktrees/tribuene" })`.
- `index-debug.html` nach §2 anlegen, `resize_window` auf 1280 × 800 setzen, `http://localhost:5181/index-debug.html` öffnen und den Hook nach §3 einrichten.
- [ ] **Schritt 2: Vorwärmen am Startpunkt**
- Warten, bis `renderer.info.memory.textures` 5 s lang gleich bleibt.
- Die verschiedenen Texturen zählen, die über `scene.traverse` an Materialien hängen.
- Erwartet: Die GPU-Zahl liegt um mindestens 108 höher (69 Hood Yard + 39 Tribüne), weil beide Orte vorgeladen, aber noch nicht in der Szene sind.
- [ ] **Schritt 3: Sprung**
- Minimap nach §4 öffnen und einen Screenshot machen.
- Bei (1420,5 \| 1040,5) klicken und warten, bis die Wurzel der Tribüne in `scene.children` hängt.
- Die Konsole mit `read_console_messages({ onlyErrors: true })` lesen: keine Fehler.
- [ ] **Schritt 4: Alter Kasten weg**
- Mit `THREE.Raycaster` von (1397.88, 60, −1063.12) senkrecht nach unten gegen `scene.children` schießen.
- Erwartet: Der erste Treffer mit `object.isMesh` liegt unter y 1. Die Oberkante des alten Kastens läge bei 18,6 m.
- Denselben Strahl an der alten Mitte (1403.25, 60, −1057.75) schießen: Der erste Treffer liegt höchstens bei 12,6 m, also auf dem Dach der Tribüne.
- [ ] **Schritt 5: Kollisionsmarken und Quantisierung (§5)**
- Die Meshes ohne `userData.excludeFromCollider` haben genau die Materialnamen `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01`.
- Alle haben `Float32Array`-Positionen.
- [ ] **Schritt 6: Blicke (§6)**
- Draufsicht, Blick vom Spielfeld und Blick von hinten aufnehmen und mit `decode-view.py` speichern.
- Die Bilder per `SendUserFile` an den Nutzer schicken.
- Achten auf: zerrissene Kabel oder Leuchten, Schatten des Glases, Helligkeit.
- [ ] **Schritt 7: Kollision (§7)**, jede Sekunde die Lage notieren, danach `keyup`:
- **Rückwand:** Kapsel hinter die Rückwand setzen, Blick 135°, 15 s `KeyW`. Erwartet: Sie steht bei y 0,75 und bleibt wenige cm vor der Wand stehen, also Modell-z knapp unter −14,8.
- **Tunnel:** Kapsel vor den Tunnel setzen, Blick 135°, 25 s `KeyW`. Festhalten, ob sie über die 25-cm-Kante hineinkommt und die Treppe hinaufsteigt. Nur feststellen, nichts ändern.
- **Sitzstufe:** Kapsel auf die Sitzstufe setzen, Blick 45°, 15 s `KeyW`. Erwartet: Sie steht bei etwa y 6,75 und läuft durch die Sitzschalen hindurch, ohne Halt bis zum nächsten Geländer.
- [ ] **Schritt 8: Kosten (§8)**
- Den Median aus 60 Bildern mit `gl.finish` messen: Blick vom Spielfeld, einmal mit und einmal ohne die Wurzel der Tribüne in der Szene.
- Dasselbe in der Draufsicht.
- Erwartet: deutlich unter dem Hood Yard (+0,5 ms).
- [ ] **Schritt 9: Aufräumen**
- `window.__view = null`, `index-debug.html` löschen, `preview_stop`.
- `ExitWorktree(keep)`, den Eintrag `place-check` aus `.claude/launch.json` entfernen, `EnterWorktree(path)`.
- `git status --short` zeigt nur die Spec.
- [ ] **Schritt 10: Messwerte in die Spec und Commit**
- Unter „Fehlerfälle und Risiken“ einen Punkt **Gemessen im Spiel (2026-09-14)** anlegen:
- Kosten mit und ohne Tribüne
- Vorwärmen (Differenz des Texturzählers)
- Ergebnis der drei Kollisionsläufe
- Auffälligkeiten aus den Blicken
- Commit mit `git add docs/superpowers/specs/2026-09-14-tribuene-design.md`, Nachricht `docs: Tribünen-Spec mit Messwerten aus dem Spiel` plus Co-Authored-By-Zeile.
@@ -173,8 +173,8 @@ Spieler.
- **Autos:** vier SUVs an Straßenpunkten nahe dem Start, Konfiguration aus der Showcase
übernommen. Ihre Chunks bleiben geladen, weil jedes Fahrzeug in jedem Frame Physik rechnet.
- **Figuren:** Modul aus `showcase.js` nach `src/characters/`, inklusive Delta-Retargeting
und ans Mesh angepasster Hitbox. Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743 auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan —,
abgeleitet aus dem Schwerpunkt der 259 Ocean-Beach-Landmarks im Ausschnitt.
und ans Mesh angepasster Hitbox. Start ist der Rasen am Südrand des Parks von Vice Beach bei X 1600, Y 690 (three.js: x = 1600, z = -690) — ursprünglich X 1699, Y 743, abgeleitet aus dem Schwerpunkt der 259 Ocean-Beach-Landmarks im Ausschnitt,
auf einer Buchstabenkontur von „(MIAMI BEACH)“, versetzt mit dem Beschriftungs-Plan.
- **Debug-Panel:** Figurenauswahl, Chunk-Grenzen, Hitbox, Kartenbild auf dem Boden,
Sichtweite, Höhenfaktor, Sprung zu einem der 54 Wahrzeichen.
- **Namensnennung im HUD:** Kartendaten und Landmarks von map.gtadb.org (CC BY 4.0,
@@ -41,10 +41,10 @@ Gemessen an der Vorlage (`tools/extract/cache`, YANIS v15) und den ausgelieferte
## Einbau in die Pipeline
```
Kacheln → classify → drop_map_labels → remove_annotations → Farbanteil-Prüfung → Land, Straßen, Gebäude, Höhen, Palmen → Markierung ergänzter Gebäude → Chunks → Minimap
Kacheln → classify → drop_map_labels → remove_annotations → drop_map_labels → Farbanteil-Prüfung → Land, Straßen, Gebäude, Höhen, Palmen → Markierung ergänzter Gebäude → Chunks → Minimap
```
`remove_annotations` ändert ausschließlich das Klassenbild — welches Pixel Wasser, Land, Park, Sand, Stadtfläche, Gebäude, Straße, Weg oder spekulativ ist. Alles danach bleibt Code von heute. `drop_map_labels` bleibt als Absicherung für Schrift im offenen Wasser.
`remove_annotations` ändert ausschließlich das Klassenbild — welches Pixel Wasser, Land, Park, Sand, Stadtfläche, Gebäude, Straße, Weg oder spekulativ ist. Alles danach bleibt Code von heute. `drop_map_labels` bleibt als Absicherung für Schrift im offenen Wasser und läuft nach `remove_annotations` ein zweites Mal, für Inselreste, die erst das Entfernen im Wasser hinterlässt.
| Einheit | Aufgabe |
|---|---|
@@ -95,6 +95,8 @@ reason = "knallrotes Gebäude in A10, laut Nutzer echt"
Der Rahmen soll die Beschriftung mit mindestens 8 m Luft umschließen; `--propose` schlägt Rahmen mit 10 m Luft vor. Wo keine Farbregel sicher greift — blasse Schrift, winzige Straßennamen in Gebäudegrau, Piktogramme aus dünnen dunklen Strichen —, bekommt die Stelle einen engen `region`-Rahmen: dann wird der ganze Rahmen neu aufgebaut.
Interstate-Schilder mit rotem Banner stehen als `region` in der Liste, weil keine Farbregel das dunkelrote Banner sicher trifft (ΔE 20,5 zu `spekulativ`) und der Rest sonst als spekulatives Gebäude auf der Fahrbahn stünde.
## Erkennung je Art
Je Eintrag entsteht eine Maske in fünf Schritten:
@@ -121,9 +123,11 @@ Die **Gitternetz-Maske** sind die Streifen aus `[grid]`, auf den Ausschnitt besc
Reihenfolge in `inpaint.py`:
1. **Gitternetz:** Jedes Pixel der Gitternetz-Maske, das in keiner Eintragsmaske liegt, übernimmt die Klasse des nächsten Pixels außerhalb aller Masken, gleich welcher Klasse. Gefüllt wird in Streifen von 10 m beiderseits jeder Linie.
2. **Eintragsmasken:** Alle Eintragsmasken werden vereinigt und in zusammenhängende Masken zerlegt; überlappende Einträge landen so in derselben Maske. Je Maske:
2. **Eintragsmasken:** Alle Eintragsmasken werden vereinigt und in zusammenhängende Masken zerlegt; überlappende Einträge landen so in derselben Maske. Zuerst wird jede Maske aufgebaut:
- **dünn** (größter Abstand eines Maskenpixels zum Maskenrand ≤ 2 m — Straßennamen, Zeigerlinien): wie das Gitternetz.
- **sonst:** Straßen und Wege → Flächen → Gebäude schließen in einem Fenster aus dem Hüllrechteck der Maske plus 60 m, danach Gebäude ergänzen im Nachbarschaftsfenster (plus 150 m).
- **sonst:** Straßen und Wege → Flächen → Gebäude schließen in einem Fenster aus dem Hüllrechteck der Maske plus 60 m.
Erst danach werden für alle nicht dünnen Masken, in derselben Reihenfolge, Gebäude ergänzt im Nachbarschaftsfenster (plus 150 m). Sonst zählten die Buchstabenkonturen noch nicht aufgebauter Masken als Straße und teilten Blöcke, und wo Gebäude entstehen, hinge von der Reihenfolge der Masken ab.
### Straßen und Wege
@@ -156,7 +156,7 @@ Die Namensnennung steht unten links und reicht auf schmalen Fenstern bis in die
- Ein Test, der die Namen mit der tatsächlichen Lage verbindet statt nur mit den Indizes: der Downtown-Bezugspunkt aus `tools/extract/data/districts.json` bei Welt `(−511, 254)` ergibt über `chunkForWorld` `(i = 1, j = 8)` und damit `B9`; der höchste Turm, das Southeast Financial Center bei `(−461, −460)`, ergibt `C12`. An der bestehenden Funktion nachgerechnet. (Korrigiert nach Task 4: `(−511, 254)` ist der Mittelpunkt der westlichen Hochhäuser, nicht der Hochhauskern selbst.)
**Ohne Browser (vitest), für `mapMath.js`:**
- `worldToMap` und `mapToWorld` an bekannten Punkten: dem früheren Spawn `(1699, 3, −743)` und alle vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
- `worldToMap` und `mapToWorld` an bekannten Punkten: dem früheren Spawn `(1699, 3, −743)` und allen vier Ecken des Ausschnitts; dazu Hin- und Rückweg ergibt wieder den Ausgangspunkt. Ein Test, der eine gespiegelte Achse sicher fängt.
- Zoomgrenzen: nie kleiner als „ganzer Ausschnitt", nie größer als vierfach; Verschiebung bleibt innerhalb des Kartenrands.
- Klick gegen Ziehen an der 4-px-Schwelle, knapp darunter und knapp darüber, und ein Ziehen, das zum Startpunkt zurückkehrt.
- `nearestWalkable` an einer künstlichen Maske: begehbarer Treffer landet in der Mitte seines Pixels; ein Gebäudepixel neben einer Straße findet die Straße; nur Wasser im Umkreis ergibt kein Ergebnis; ein Treffer knapp außerhalb von 20 m zählt nicht.
@@ -143,11 +143,11 @@ export const PLACES = [
| `PLACE_SHOW_RADIUS` | 250 m | einsetzen |
| `PLACE_HIDE_RADIUS` | 450 m | herausnehmen |
Chunks laden bei 400 m und bauen bei 600 m ab, beides gemessen zu ihrem Mittelpunkt. Der fernste Chunk-Mittelpunkt unter dem Hof liegt 148 m von dessen Mitte entfernt. 250 + 148 ≤ 400 und 450 + 148 ≤ 600 heißt: Solange der Hof in der Szene ist, ist sein Boden geladen. Der Datentest prüft das für jeden Ort.
Chunks laden bei 400 m und bauen bei 600 m ab, beides gemessen zu ihrem Mittelpunkt. Der fernste Chunk-Mittelpunkt unter dem Hof liegt 148 m von dessen Mitte entfernt. 250 + 148 ≤ 400 und 450 + 148 ≤ 600 heißt: Solange der Hof in der Szene ist, ist sein Boden angefordert. `ChunkStreamer` baut aber höchstens einen Chunk je Bild; direkt nach einem Kartensprung neben den Hof kann er deshalb ein paar Bilder lang vor seinem Boden erscheinen. Der Datentest prüft das für jeden Ort.
- `wantedPlaces(position, places, { show, hide, active })` arbeitet wie `wantedChunks`: `add` ab `show`, `remove` erst jenseits von `hide`.
- `PlaceLayer.update(position)` stößt innerhalb von 600 m das Laden an. Dafür gibt es je Ort ein einziges Laden mit `GLTFLoader` und `MeshoptDecoder`. Danach setzt es einen fertig geladenen Ort ein oder nimmt ihn heraus. Ist der Spieler beim Ende des Ladens schon jenseits von 450 m, bleibt der Ort draußen, bis er wieder näher als 250 m kommt.
- **Vorbereiten** (`prepareModel`, einmal nach dem Laden): Wurzel an `placeTransform`, jedes Mesh `castShadow` und `receiveShadow`, nicht feste Meshes `userData.excludeFromCollider = true`. Hat kein Mesh ein festes Material, gibt es eine Warnung und später keinen Collider (eine leere Quelle liefert im Controller nur ein Handle, das nie fertig wird, vgl. Ruling 51).
- **Einsetzen:** `scene.add`, danach `player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`.
- **Vorbereiten** (`prepareModel`, einmal nach dem Laden): Wurzel an `placeTransform`, jedes Mesh `castShadow` und `receiveShadow`, nicht feste Meshes `userData.excludeFromCollider = true`. Hat kein Mesh ein festes Material, gibt es eine Warnung und später keinen Collider (eine leere Quelle liefert im Controller nur ein Handle, das nie fertig wird, vgl. Ruling 51). Ist ein `warmUp` gesetzt, läuft es danach einmalig (siehe „Gemessen im Spiel" unten).
- **Einsetzen:** Collider-Handle bauen (`player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: wurzel } })`), erst danach `scene.add` — wirft `addCollider`, bleibt die Szene unverändert (siehe Fehlerfälle).
- **Entfernen:** `scene.remove` und `player.removeCollider(handle)`. Die geladenen Daten bleiben erhalten, eine Rückkehr setzt sofort wieder ein. Kosten: geschätzt 231 MB Grafikspeicher, sobald der Hof einmal gezeichnet war.
- Die Kaskaden-Schatten richten die neuen Materialien selbst ein (`prepareLitMaterials` in `sun.update()`).
@@ -166,14 +166,17 @@ export const PLACES = [
Durchlässig bleiben Gras (`grass_medium_*`), Blätter und Zweige, die Büsche (`searsia_lucida*`), `contHardware`, `modular_electricity_poles_pieces`, `security_camera_01*` und `trashbag`.
Positionen fester Meshes, die quantisiert vorliegen (KHR_mesh_quantization aus `meshopt()`), wandelt `prepareModel` in Float32 um: `colliderBuild.ts` rechnet `matrixWorld` per `applyMatrix4` in eine Kopie der Geometrie ein, und normalisierte Int16-Werte würden dabei auf ±1 geklemmt.
## Fehlerfälle und Risiken
- **Datei lädt nicht:** `console.warn` mit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht. Das Spiel läuft ohne ihn weiter.
- **Datei lädt nicht oder lässt sich nicht einsetzen:** `console.warn` mit Ort und Fehler, der Ort gilt als fehlgeschlagen und wird nicht erneut versucht — auch wenn `addCollider` beim Einsetzen wirft (die Szene bleibt dann unverändert statt einer Wurzel ohne Collider). Das Spiel läuft ohne ihn weiter.
- **Entfernen, bevor der Collider fertig ist:** `removeCollider` räumt auch unfertige Worker-Collider ab, genau wie beim Chunk-Abbau.
- **Beleuchtung weicht vom Editor ab:** Das Spiel leuchtet mit HDRI ×5 und Sonne 2,5. Wirkt der Hof zu hell oder zu dunkel, wird das im Browser verglichen und im Gespräch entschieden, nicht still angepasst.
- **Vereinfachung dünnt Blätter aus:** Die Stärke wird nur an Pflanzen und Kleinteilen eingestellt, mit Vorher-Nachher-Screenshot.
- **Collider zu schwer:** Wird der BVH-Aufbau im Worker spürbar langsam, fallen die Baumkronen (`quiver_tree_02`) aus der Kollisionsliste.
- **Neue Karte:** Erzeugt die Pipeline Straßen, Palmen oder Laternen neu, prüft der Datentest die Hoffläche erneut.
- **Gemessen im Spiel (2026-09-14, 2560 × 1600, mit Schattenstufen):** +0,4–0,6 ms je Bild (Blick vom Baseballfeld 2,7–2,9 ms statt 2,3 ms, im Hof 2,1 statt 1,6 ms). Das erste Zeichnen kostete einmalig rund 130 ms Texturupload; `warmUp` legt das und die Shader-Übersetzung ins Vorladen.
## Tests
@@ -0,0 +1,154 @@
# Tribüne im Park J6 — Entwurf
**Datum:** 2026-09-14
**Zusammenhang:** Der Nutzer hat eine Tribüne gebaut (`~/Downloads/tribuene.glb`, erzeugt mit trimesh). Sie soll 1:1 die Tribüne des Flamingo Park Baseball Stadium (1435 Michigan Ave, Miami Beach) ersetzen, die das Spiel in J6 als einfachen Gebäudekasten zeigt. Den Kasten soll es danach nicht mehr geben. Vorlage ist ein Satellitenbild des Nutzers im Gespräch. Es liegt nicht als Datei vor. Verglichen wurde deshalb mit dem Screenshot aus dem Hood Yard (`~/Desktop/Bildschirmfoto 2026-09-14 um 00.15.53.png`), auf dem die Tribüne ebenfalls zu sehen ist.
**Baut auf:** `docs/superpowers/specs/2026-09-14-hood-yard-design.md` (Orte-Schicht, Aufbereitung, Radien, Tests). Neu ist nur, dass ein Ort ein Gebäude aus den Chunk-Daten ersetzt.
## Ziel
Die Tribüne steht im Spiel an der Stelle des alten Gebäudes, in Originalgröße, mit Schatten und Kollision. Das alte Gebäude ist weg.
## Befund (gemessen)
### Modell
| Punkt | Wert |
|---|---|
| Datei | 6,3 MB, Generator trimesh, 19 Knoten mit je eigenem Mesh, 14 Materialien, keine Erweiterungen (kein Bump, keine Texturtransformation) |
| Texturen | 40 JPEG: 21 × 512 px, 13 × 256 px, 6 × 768 px (Grundfarbe von `Verkleidung`, `Estrich`, `Stahl`, `Trapezblech`, `Riffelblech`, `Sitzschale`), geschätzt 53 MB Grafikspeicher |
| Dreiecke | 117 916 gezeichnet, kein Mesh wird mehrfach benutzt. Die größten: `Sitzschale` 44 256, `modular_electric_cables` 21 796, `industrial_wall_lamp` 13 845, `power_box_01` 13 398, `modular_electric_cables_boxes` 8 491, `Stahl` 6 804 |
| Maße | x −20,8 bis 20,8 · z −14,82 bis 0,8 · y 0 bis 12,57, also 41,6 × 15,6 m und 12,6 m hoch (`getBounds`) |
| Einheit | Meter: Sitzstufen 0,45 m hoch und 0,8 m tief |
| Ausrichtung | Front mit der untersten Stufe bei +z, Rückwand mit Dach, Pressekabine und Tunnelportal bei −z. Ohne Drehung schaute die Tribüne nach Süden. |
| Boden | Keine Bodenplatte. Die Unterseite des Betonkörpers (`Sichtbeton`) liegt bei y 0, die tiefsten Trittflächen bei 0,25 m (Tunnel) und 0,55 m (vorderste Stufe). |
| Tunnel | Zwei 2 m breite Gänge (x −8 bis −6 und 6 bis 8) führen von der Rückseite mit zwölf 20-cm-Stufen auf die Sitzstufe bei y 2,85. |
### Stelle im Spiel (J6, `chunk_9_5`)
- **Altes Gebäude:**
- Ecken (1384,5 | 1052), (1409 | 1076,5), (1422 | 1063,5), (1397,5 | 1039).
- 34,65 × 18,38 m, 18,6 m hoch, 45° gedreht, Mitte (1403,25 | 1057,75).
- Kein anderes Gebäude steht näher als 21 m.
- **Nachbarschaft:**
- Südwestlich verläuft die Michigan Ave. An ihr stehen zwei Laternen, bei (1384,17 | 1048,56) und (1382,18 | 1048,37).
- Nördlich liegt die Straße am Parkrand.
- Südöstlich liegt das Spielfeld (`surfaces`, `kind: "park"`).
- **Boden unter der Tribüne:** nur Land, keine Straße und keine Bodenfläche.
### Satellitenbild
- Anker und Maßstab wie beim Hood Yard: Nordost-Ecke des Baseballfelds (1000 | 213) ↔ (1525 | 1058), 0,15 m je Pixel.
- Die echte Tribüne (Dach und Sitzreihen) misst etwa 24 × 15 m. Sie ist ebenfalls 45° gedreht und schaut nach Südosten auf die Home Plate. Ihre Mitte liegt bei etwa (1405 | 1058,6), 2 m neben der Mitte des alten Gebäudes.
- Das Modell ist 7 m länger als das alte Gebäude und 17 m länger als die echte Tribüne. Längs bleibt es mittig, weil an beiden Enden Straßen liegen. An der gewählten Lage steht nach 1,25 m Verschiebung nach Nordosten oder 1,8 m nach Südwesten eine Ecke auf der Fahrbahn.
## Entscheidungen des Nutzers
| Frage | Entscheidung |
|---|---|
| Größe | 1:1 |
| Lage | Längs mittig auf dem alten Gebäude, 45° gedreht, Front 1 m vor dessen Feldseite (Vergleichsbild im Gespräch bestätigt) |
| Ersetzen | `replaces` im Orte-Eintrag, der `ChunkStreamer` lässt den Grundriss beim Aufbau weg. Verworfen: ein Platzhalter-Kasten bis zum Einsetzen und eine Korrektur in der Extraktion. |
| Materialrollen, Aufbereitung, Tests | bestätigt |
| Arbeitsweise | Arbeitsbaum `.claude/worktrees/tribuene`, Branch `leonida/tribuene`, weil im Hauptordner nicht committete Änderungen einer anderen Sitzung liegen |
## Lage
`at = [1409.9, 1051.1]`, `rotation = 45`, `lift = 0`, `replaces = [[1403.25, 1057.75]]`.
Die positive Drehung bringt +x nach Nordosten und die Front (+z) nach Südosten.
| Nachbar | Abstand zur Außenkante |
|---|---|
| Straße am Parkrand | 0,8 m von der hinteren Nordost-Ecke (1414,13 \| 1076,29) |
| Michigan Ave | 1,3 m von der hinteren Südwest-Ecke (1384,71 \| 1046,87) |
| Laternen | 1,6 m und 2,9 m |
| Palmen | 10,1 m |
| Spielfeld | 3,2 m vor der Front |
| altes Gebäude | Die Tribüne ragt an beiden Enden 3,5 m hinaus. Ihre Front liegt 1,0 m vor der Feldseite des alten Gebäudes, ihre Rückseite 3,8 m vor dessen Rückseite. |
**Höhe:** Es gibt keine Bodenplatte, also `lift = 0`. Keine nach oben zeigende Fläche liegt auf y 0, deshalb flackert nichts.
**Radien:**
- Unter der Tribüne liegt nur J6. Seine Mitte ist 81,7 m von `at` entfernt, die Bedingungen 81,7 + 250 ≤ 400 und 81,7 + 450 ≤ 600 sind erfüllt.
- Vom Startpunkt (1600 | 690) sind es 408 m. Die Datei lädt also schon beim Start vor, wie der Hood Yard, der 81 m entfernt steht.
## Altes Gebäude ersetzen
- **`PLACES`-Eintrag:** `replaces` ist eine Liste von Weltpunkten (x, y). Jeder Grundriss eines Chunks, in dem ein solcher Punkt liegt, entfällt. Das wirkt wie `remove_building` aus der Feinkalibrierung (`docs/superpowers/specs/2026-09-13-feinkalibrierung-design.md`), nur zur Laufzeit.
- **`src/world/streaming.js`:** Die reine Funktion `withoutReplacedBuildings(chunk, points)` gibt den Chunk ohne diese Grundrisse zurück, als flache Kopie mit neuer `buildings`-Liste.
- Der Punkt wird in Chunk-Koordinaten umgerechnet: u = x − origin[0], v = −y − origin[1]. Geprüft wird mit `pointInPolygon` aus `props.js`.
- Trifft kein Punkt, kommt der Chunk selbst zurück.
- **`ChunkStreamer`:** Neue Option `replacedBuildings` (Vorgabe `[]`). Die Geometrie entsteht aus `withoutReplacedBuildings(chunk, this.replacedBuildings)`. Gebäude liegen in der Kollisionsgruppe, mit dem Grundriss entfallen deshalb Kasten, Schatten und Kollision zugleich.
- **`src/main.js`:** `replacedBuildings: PLACES.flatMap((place) => place.replaces ?? [])`.
- **Folgen:**
- Den Kasten sieht man nie. Die Stelle bleibt leer, bis die Tribüne ab 250 m eingesetzt wird, genau wie beim Hood Yard.
- Die Minimap zeigt weiter den alten Umriss, ihr Bild kommt aus der Extraktion. Er liegt ungefähr unter der Tribüne. Ihre Begehbarkeitsmaske (`tools/extract/overview.py`) sperrt dort weiter das Landen.
- Lädt die Tribüne nicht, steht an der Stelle gar kein Gebäude.
## Materialrollen
`SOLID_MATERIALS`, `NEAR_MATERIALS` und `SIMPLIFY_RATIOS` gelten für alle Orte und werden über den Materialnamen nachgeschlagen. Keiner der neuen Namen steht dort schon.
| Rolle | Materialien |
|---|---|
| fest (`SOLID_MATERIALS`) | `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Trapezblech`, `Riffelblech`, `Verglasung`, `utility_box_01` (Anschlusskästen am Boden, 1,1 m hoch) |
| durchlässig | `Sitzschale`: Zwischen den Sitzreihen bleiben nur 38 cm, man läuft auf den Betonstufen. Außerdem `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01` (Verteilerkästen an der Wand in 1,5 m Höhe) |
| Nahflächen, 1024 px (`NEAR_MATERIALS`) | `Sichtbeton`, `Estrich`, `Verkleidung`, `Stahl`, `Sitzschale`. Die Quelle hat höchstens 768 px, und `compressTexture` vergrößert nicht, 768 bleibt also 768. Alle übrigen Texturen gehen auf höchstens 512 px, darunter die von `Trapezblech` und `Riffelblech`. |
| Vereinfachung 0,25 (`SIMPLIFY_RATIOS`) | `modular_electric_cables`, `modular_electric_cables_boxes`, `industrial_wall_lamp`, `industrial_wall_lamp_glass`, `power_box_01`, `utility_box_01`: 64 298 → rund 16 000 Dreiecke. Körper, Dach, Geländer und Sitze bleiben unverändert. |
## Budget
- **Gemessen an der Aufbereitung (2026-09-14):**
- 1,7 MB, 69 557 Dreiecke, 14 Primitive (Draw Calls)
- 39 WebP-Texturen: 4 × 768, 23 × 512, 12 × 256 px
- geschätzt 49 MB Grafikspeicher, Außenmaße unverändert
- Die sechs Kleinteil-Materialien haben 15 939 Dreiecke statt 64 298.
- **Zum Vergleich der Hood Yard:** 657 653 Dreiecke, 43 Primitive, 231 MB.
## Tests
- **`tests/streaming.test.js`:**
- `withoutReplacedBuildings` entfernt nur den Grundriss, der den Punkt enthält, und rechnet dabei mit dem Chunk-Ursprung.
- Ohne Treffer gibt die Funktion den Chunk selbst zurück.
- Ein `ChunkStreamer` mit `replacedBuildings` baut aus einem Chunk mit nur diesem Gebäude keine Gebäudegeometrie.
- **`tests/placesSite.test.js`:**
- „in keinem Gebäude“ übergeht die Grundrisse, die ein Ort ersetzt.
- Neu: Der Punkt trifft genau einen Grundriss, liegt in den Außenmaßen, und alle Ecken dieses Grundrisses liegen höchstens 5 m außerhalb. Das fängt einen Lauf der Extraktion ab, der das Gebäude verschiebt, teilt oder mit einem Nachbarn zusammenlegt.
- **`tests/placesAsset.test.js`** wird auf `describe.each(PLACES)` umgebaut:
- Je Ort geprüft werden Dateigröße, meshopt und WebP, fehlende Instanzen, Texturen bis 1024 px und `bounds` auf 5 cm.
- Die festen Materialien je Ort kommen aus einer Tabelle im Test, deren Vereinigung `SOLID_MATERIALS` ergeben muss.
- Bump wird nur beim Hood Yard geprüft.
- **Browser** (`.claude/skills/placing-editor-models/browser-checks.md`):
- Über die Minimap ans Spielfeld vor der Tribüne springen, der alte Kasten ist nicht mehr da.
- Draufsicht zum Vergleich mit dem Satellitenbild, dazu Blicke vom Spielfeld und von der Rückseite.
- Kollision: Die Kapsel steht neben der Tribüne bei y 0,75, die Rückwand stoppt sie, die Sitze lässt sie durch. Ob der Tunnel begehbar ist, wird festgehalten.
- Kosten mit `gl.finish` messen, mit und ohne Tribüne. Vorwärmen am Startpunkt über den Texturzähler prüfen.
- Die Konsole zeigt keine Fehler.
## Fehlerfälle und Risiken
- **Karte neu erzeugt:** Verschiebt die Extraktion das Gebäude, trifft der Punkt nichts mehr oder ein anderes Gebäude. Dann schlägt der Datentest an.
- **Glas wirft Schatten:** Jedes Mesh eines Orts wirft Schatten, auch `Verglasung` und das Lampenglas. Fällt das im Browser auf, wird es im Gespräch entschieden.
- **Vereinfachte Kabel und Leuchten wirken zerrissen:** Dann wird der Anteil hochgesetzt, mit einem Vorher-Nachher-Bild.
- **Stufen:** Die vorderste Stufe (55 cm), die Sitzstufen (45 cm) und der Tunnelboden (25 cm) sind vielleicht zu hoch für die Kapsel. Das wird im Browser festgestellt und nicht nachgebaut.
- **Gemessen im Spiel (2026-09-14, 2560 × 1600, mit Schattenstufen):**
- **Alter Kasten:** Er ist weg. Ein Strahl im Streifen hinter der Tribüne trifft den Boden (y 0), in der alten Mitte das Tribünendach (y 10,1) statt 18,6 m. Ein Nachbargebäude in J6 steht unverändert.
- **Kollision:**
- Genau die acht festen Materialien haben Float32-Positionen und kommen in den Collider, die sechs durchlässigen nicht.
- Die Kapsel (Radius 0,15 m) steht auf dem Boden bei y 0,75. An der Rückwand bleibt ihre Mitte bei Modell-z −14,54 stehen; die Sockelfläche (Sichtbeton, y 0–1,15) liegt bei −14,39 — die Kapsel berührt die Wand also, ohne einzudringen.
- Sie steigt über die 25-cm-Kante in den Tunnel, läuft die Treppe hinauf auf die Sitzstufe (y 3,6) und im Gang hinunter bis ans vordere Geländer.
- Auf der Sitzstufe in 6 m Höhe läuft sie ohne Halt 5,7 m durch eine Sitzreihe.
- **Kosten:** Median aus 60 Bildern mit `gl.finish`:
- Blick vom Spielfeld 1,4–1,5 ms statt 1,3 ms
- Draufsicht 2,4–2,5 ms statt 2,2 ms
- Blick von hinten 4,5–4,6 ms statt 4,3 ms
- Die Tribüne kostet also +0,1–0,3 ms je Bild.
- **Vorwärmen:** Am Startpunkt hängen 112 der 139 GPU-Texturen an keinem Objekt der Szene (Hood Yard 69 und Tribüne 39, beide vorgeladen). Nach einem Rücksprung aus 511 m erscheint die Tribüne 48 ms nach dem Klick, die größte Bildlücke ist 56 ms bei 33-ms-Takt, und es kommen keine Texturen hinzu.
- **Aussehen:** Vereinfachte Kabel und Leuchten wirken nicht zerrissen, ein Schatten des Glases fällt nicht auf.
- **Konsole:** keine Warnung zu Orten. Nur Pointer-Lock-Fehler des verborgenen Browser-Bereichs und Fehler aus den künstlichen Mausereignissen der Prüfung.
## Nicht Teil dieser Arbeit
- Minimap-Bild, Begehbarkeitsmaske und Feinkalibrierung.
- Kein Platzhalter-Kasten von weitem, keine eigenen Radien je Ort.
- Das Modell wird nur verkleinert, nicht umgebaut.
Binary file not shown.
+31 -2
View File
@@ -6,8 +6,9 @@ import { playerController } from "three-player-controller";
import { ChunkStreamer } from "./world/streaming.js";
import { createMaterials } from "./world/materials.js";
import { createSky } from "./world/sky.js";
import { createSunShadows } from "./world/shadows.js";
import { createSunShadows, prepareLitMaterials } from "./world/shadows.js";
import { PropLayer } from "./world/props.js";
import { PLACES, PlaceLayer } from "./world/places.js";
import { chunkForWorld, threeToWorld } from "./world/coords.js";
import { createCharacterModule } from "./characters/index.js";
import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
@@ -235,7 +236,34 @@ await props.load();
// Zeitschranke, die danach die Kollision ohne vorhandenen Boden einschaltet, würde in eine
// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });
// Gebäudekästen aus der Extraktion, an deren Stelle ein Ort steht (PLACES[].replaces), baut der Streamer
// gar nicht erst — sonst stünde zum Beispiel die Tribüne mitten in ihrem alten Kasten.
const streamer = new ChunkStreamer({
scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS,
replacedBuildings: PLACES.flatMap((place) => place.replaces ?? []),
});
// warmUp fürs Vorladen eines Orts (PlaceLayer unten): lädt seine Texturen schon hier auf die GPU und
// übersetzt seine CSM-Shader, statt das im ersten Bild nach dem Einsetzen zu tun — dort kostete genau
// das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md,
// "Fehlerfälle und Risiken").
async function warmUpPlace(root) {
prepareLitMaterials(root, sun.csm);
const textures = new Set();
root.traverse((object) => {
if (!object.isMesh) return;
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
if (!material) continue;
for (const value of Object.values(material)) {
if (value?.isTexture) textures.add(value);
}
}
});
for (const texture of textures) renderer.initTexture(texture);
await renderer.compileAsync(root, camera, scene);
}
// Fest platzierte Modelle aus three-d-stage (src/world/places.js). Eigene Radien, damit ein Ort nur in
// der Szene ist, solange die Chunks unter ihm angefordert sind.
const places = new PlaceLayer({ scene, player, warmUp: warmUpPlace });
player.setSkipCapsuleCollision(true);
let settled = false;
let warnedSlowSpawn = false;
@@ -250,6 +278,7 @@ function tick(delta) {
// Sitzplatz-Offset, nicht die Weltposition (Begründung in src/player.js). Ohne diese
// Umstellung hört das Streaming beim Fahren auf nachzuladen.
streamer.update(playerPosition(player));
places.update(playerPosition(player));
if (!settled) {
// Ein Sprung vor dem ersten Boden — Karte und Panel sind schon vor props.load() bedienbar —
// ersetzt den Spawn als Startpunkt, statt beim Einrasten stillschweigend verloren zu gehen.
+145 -5
View File
@@ -1,12 +1,15 @@
// 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 { BufferAttribute } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
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).
// Boden angefordert, 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;
@@ -16,7 +19,9 @@ 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.
* prüfen damit Datei (placesAsset.test.js) und Lage (placesSite.test.js) gegeneinander. replaces
* (optional): Weltpunkte [x, y]; jeder Gebäudegrundriss aus der Extraktion, in dem einer liegt, wird beim
* Chunk-Aufbau weggelassen (withoutReplacedBuildings in streaming.js).
*/
export const PLACES = [
{
@@ -29,9 +34,23 @@ export const PLACES = [
lift: -0.28,
bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 },
},
{
id: "tribuene",
url: "./models/places/tribuene.glb",
// Längs mittig auf dem alten Gebäudekasten, Front 1 m vor dessen Feldseite (Vergleichsbild des Nutzers);
// längs bleiben bis zur Fahrbahn nur 1,25 m nach Nordosten und 1,8 m nach Südwesten.
at: [1409.9, 1051.1],
// Die Sitzreihen schauen wie in echt nach Südosten auf die Home Plate.
rotation: 45,
// Keine Bodenplatte: der Betonkörper steht mit seiner Unterseite auf 0, flach auf 0 liegt nichts.
lift: 0,
bounds: { minX: -20.8, maxX: 20.8, minZ: -14.82, maxZ: 0.8 },
// Der 18,6 m hohe Gebäudekasten aus der Extraktion an dieser Stelle (J6).
replaces: [[1403.25, 1057.75]],
},
];
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Kleinteile — läuft man durch. */
/** Materialien, die kollidieren. Alles andere — Gras, Blätter, Büsche, Sitzschalen, Kleinteile — läuft man durch. */
export const SOLID_MATERIALS = [
// Boden
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
@@ -46,6 +65,9 @@ export const SOLID_MATERIALS = [
"modular_electricity_poles", "barrel_03", "plastic_container",
// Bäume
"quiver_tree_01_trunk", "quiver_tree_02",
// Tribüne: Betonkörper und Tunnel, Verkleidung, Stahl (Geländer, Dachtragwerk, Rinne), Dach, Kabinenboden,
// Glas der Pressekabine, Anschlusskästen am Boden
"Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01",
];
const SOLID = new Set(SOLID_MATERIALS);
@@ -77,6 +99,23 @@ export function wantedPlaces(position, places, { show, hide, active }) {
return { add, remove };
}
// tools/assets/places.mjs komprimiert mit meshopt() (KHR_mesh_quantization): Positionen fester Meshes
// liegen als normalisierte Ganzzahlen vor, erst über die Mesh-Transform auf echte Maße skaliert.
// colliderBuild.ts im three-player-controller klont die Geometrie aber und rechnet matrixWorld per
// applyMatrix4 in die Kopie hinein — dabei würden normalisierte Werte beim Zurückschreiben auf ±1
// geklemmt. Deshalb hier vorher in echte, nicht normalisierte Float32-Koordinaten entquantisieren.
function dequantizePosition(geometry) {
const position = geometry.attributes.position;
if (!position.normalized && position.array instanceof Float32Array) return;
const values = new Float32Array(position.count * 3);
for (let i = 0; i < position.count; i++) {
values[i * 3] = position.getX(i);
values[i * 3 + 1] = position.getY(i);
values[i * 3 + 2] = position.getZ(i);
}
geometry.setAttribute("position", new BufferAttribute(values, 3));
}
/**
* 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.
@@ -91,9 +130,110 @@ export function prepareModel(root, place) {
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;
if (materials.some((material) => isSolidMaterial(material?.name))) {
solid = true;
dequantizePosition(object.geometry);
} else {
object.userData.excludeFromCollider = true;
}
});
root.updateMatrixWorld(true);
return solid;
}
function loadModel(url) {
const loader = new GLTFLoader();
// tools/assets/places.mjs komprimiert mit EXT_meshopt_compression, wie die Straßenlaterne (props.js).
loader.setMeshoptDecoder(MeshoptDecoder);
return loader.loadAsync(url).then((gltf) => gltf.scene);
}
export class PlaceLayer {
constructor({ scene, player, places = PLACES, load = loadModel, warmUp = null }) {
Object.assign(this, { scene, player, places, load, warmUp });
this.models = new Map(); // id -> { root, solid }; bleibt nach dem ersten Laden erhalten
this.pending = new Set();
this.failed = new Set();
this.wanted = new Set(); // innerhalb von show gewesen, noch nicht jenseits von hide
this.active = new Map(); // id -> { root, handle }
}
update(position) {
for (const place of this.places) {
if (placeDistance(position, place) <= PLACE_PRELOAD_RADIUS) this.#preload(place);
}
const { add, remove } = wantedPlaces(position, this.places, {
show: PLACE_SHOW_RADIUS, hide: PLACE_HIDE_RADIUS, active: this.wanted,
});
for (const id of add) {
this.wanted.add(id);
this.#show(id);
}
for (const id of remove) {
this.wanted.delete(id);
this.#hide(id);
}
}
#preload(place) {
const { id } = place;
if (this.models.has(id) || this.pending.has(id) || this.failed.has(id)) return;
this.pending.add(id);
this.load(place.url)
.then(async (root) => {
const solid = prepareModel(root, place);
if (!solid) console.warn(`Ort ${id} hat keine festen Teile und bekommt keine Kollision.`);
// Vorwärmen (Texturupload, Shader-Übersetzung) noch hier im Vorladen: schlägt es fehl,
// wird der Ort trotzdem eingesetzt, nur ohne den Vorteil (siehe main.js, warmUpPlace).
if (this.warmUp) {
try {
await this.warmUp(root);
} catch (err) {
console.warn(`Ort ${id} konnte nicht vorgewärmt werden:`, err);
}
}
this.models.set(id, { root, solid });
// Nur einsetzen, wenn der Spieler noch da ist; sonst beim nächsten Herankommen.
if (this.wanted.has(id)) this.#show(id);
})
.catch((err) => {
this.failed.add(id);
console.warn(`Ort ${id} konnte nicht geladen werden:`, err);
})
.finally(() => this.pending.delete(id));
}
#show(id) {
if (this.failed.has(id)) return;
const model = this.models.get(id);
if (!model || this.active.has(id)) return;
// Collider vor der Szene bauen: prepareModel hat schon updateMatrixWorld(true) auf der Wurzel
// aufgerufen, die Quelle hat also eine gültige Weltmatrix, obwohl sie noch nicht in der Szene
// hängt. Wirft addCollider (z. B. toter Worker), bleibt die Szene unverändert statt eine
// Wurzel ohne active-Eintrag (und damit nie wieder entfernbar) stehen zu lassen — und aus
// update(), das #show synchron aufruft, entkäme sonst eine Ausnahme in die Spielschleife.
let handle;
try {
// Ohne feste Teile keinen Collider: eine leere Quelle liefert im Controller nur ein Handle,
// das nie fertig wird (vgl. Ruling 51 in streaming.js).
handle = model.solid
? this.player.addCollider({ motion: "static", useWorker: true, shape: { kind: "mesh", source: model.root } })
: null;
} catch (err) {
this.failed.add(id);
console.warn(`Ort ${id} konnte nicht eingesetzt werden:`, err);
return;
}
this.scene.add(model.root);
model.root.updateMatrixWorld(true);
this.active.set(id, { root: model.root, handle });
}
#hide(id) {
const entry = this.active.get(id);
if (!entry) return;
this.scene.remove(entry.root);
if (entry.handle) this.player.removeCollider(entry.handle);
this.active.delete(id);
}
}
+22 -3
View File
@@ -1,6 +1,7 @@
import { Group, Mesh } from "three";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
import { buildChunkGeometry } from "./geometry.js";
import { pointInPolygon } from "./props.js";
const key = (i, j) => `${i}_${j}`;
const DECALS = new Set(["roads", "sand", "park", "urban"]);
@@ -31,9 +32,27 @@ function dist(k, position) {
return Math.hypot(c.x - position.x, c.z - position.z);
}
/**
* Der Chunk ohne die Gebäudegrundrisse, die ein Ort ersetzt (PLACES[].replaces in src/world/places.js):
* jeder Grundriss, in dem einer der Weltpunkte [x, y] liegt, entfällt — und mit ihm Kasten, Schatten und
* Kollision. Wirkt wie remove_building der Feinkalibrierung, nur zur Laufzeit. Trifft kein Punkt, kommt
* derselbe Chunk zurück.
*
* Ein Gebäude an einer Chunkgrenze schreibt die Extraktion ungeschnitten in jeden berührten Chunk. Deshalb
* wird hier jeder Punkt gegen jeden Chunk geprüft, statt ihn vorab einem Chunk zuzuordnen — sonst bliebe
* die Kopie im Nachbarchunk stehen.
*/
export function withoutReplacedBuildings(chunk, points) {
if (points.length === 0) return chunk;
const [ox, oz] = chunk.origin;
const buildings = chunk.buildings.filter((building) =>
!points.some(([x, y]) => pointInPolygon(x - ox, -y - oz, building.poly)));
return buildings.length === chunk.buildings.length ? chunk : { ...chunk, buildings };
}
export class ChunkStreamer {
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) {
Object.assign(this, { scene, player, materials, props, baseUrl });
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) {
Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings });
this.active = new Map(); // key -> { group, handle }
this.pending = new Set();
this.queue = [];
@@ -63,7 +82,7 @@ export class ChunkStreamer {
const [i, j] = k.split("_").map(Number);
const group = new Group();
group.position.set(chunk.origin[0], 0, chunk.origin[1]);
const geos = buildChunkGeometry(chunk);
const geos = buildChunkGeometry(withoutReplacedBuildings(chunk, this.replacedBuildings));
const collide = new Group();
for (const [name, geo] of Object.entries(geos)) {
if (!geo) continue;
+262 -6
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { BoxGeometry, Group, Mesh, MeshStandardMaterial, Vector3 } from "three";
import { PLACES, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
import { afterEach, describe, expect, it, vi } from "vitest";
import { BoxGeometry, BufferAttribute, BufferGeometry, Group, InterleavedBuffer,
InterleavedBufferAttribute, Mesh, MeshStandardMaterial, Scene, Vector3 } from "three";
import { PLACES, PlaceLayer, SOLID_MATERIALS, isSolidMaterial, placeTransform, prepareModel,
wantedPlaces } from "../src/world/places.js";
describe("placeTransform", () => {
@@ -60,6 +61,19 @@ describe("isSolidMaterial", () => {
}
});
// Bruch: Die Tribüne wäre an Beton, Stahl, Dach, Glas oder den Anschlusskästen durchlässig, oder man
// bliebe an Sitzschalen, Kabeln und Wandleuchten hängen (zwischen den Sitzreihen sind nur 38 cm).
it("lässt Beton, Stahl, Dach, Glas und Anschlusskästen der Tribüne kollidieren, Sitze, Kabel und Leuchten nicht", () => {
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung",
"utility_box_01"]) {
expect(isSolidMaterial(name)).toBe(true);
}
for (const name of ["Sitzschale", "modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp",
"industrial_wall_lamp_glass", "power_box_01"]) {
expect(isSolidMaterial(name)).toBe(false);
}
});
it("kennt jedes Material der Liste", () => {
expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true);
});
@@ -91,18 +105,260 @@ describe("prepareModel", () => {
it("meldet, wenn kein Teil fest ist", () => {
expect(prepareModel(fakeModel(["grass_medium_01"]), { at: [0, 0], rotation: 0, lift: 0 })).toBe(false);
});
// Bruch: colliderBuild.ts im three-player-controller klont die Geometrie und rechnet matrixWorld
// per applyMatrix4 hinein. Bei einer normalisierten Int16-Position (KHR_mesh_quantization aus
// meshopt() in tools/assets/places.mjs) klemmt das Zurückschreiben die Weltkoordinaten auf ±1 —
// der Hof kollidierte dann nahe dem Weltursprung statt an seiner Stelle im Hood Yard.
it("entquantisiert feste Positionen, bevor der Collider die Weltmatrix hineinrechnet", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position",
new BufferAttribute(new Int16Array([-32767, 0, -32767, 32767, 32767, 32767]), 3, true));
const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" }));
mesh.scale.setScalar(17);
const root = new Group();
root.add(mesh);
prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 });
expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array);
expect(mesh.geometry.attributes.position.normalized).toBe(false);
// Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen.
const worldGeometry = mesh.geometry.clone();
worldGeometry.applyMatrix4(mesh.matrixWorld);
worldGeometry.computeBoundingBox();
expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472);
expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506);
expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5);
expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5);
});
// Bruch: GLTFLoader liefert quantisierte Positionen in echt als InterleavedBufferAttribute
// (gemeinsamer Puffer mit weiteren Attributen wie Normale und UV), nicht als eigene
// BufferAttribute — eine Entquantisierung, die nur den einfachen Fall abdeckt, ließe die echten
// Modelldaten quantisiert und der Hof kollidierte wie oben nahe dem Weltursprung.
it("entquantisiert feste Positionen auch als interleaved Attribut", () => {
const buffer = new InterleavedBuffer(
new Int16Array([-32767, 0, -32767, 0, 0, 0, 0, 0, 32767, 32767, 32767, 0, 0, 0, 0, 0]), 8);
const geometry = new BufferGeometry();
geometry.setAttribute("position", new InterleavedBufferAttribute(buffer, 3, 0, true));
const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "cont_a_side" }));
mesh.scale.setScalar(17);
const root = new Group();
root.add(mesh);
prepareModel(root, { at: [1489, 1067.5], rotation: 0, lift: -0.28 });
expect(mesh.geometry.attributes.position.array).toBeInstanceOf(Float32Array);
expect(mesh.geometry.attributes.position.normalized).toBe(false);
// Wie colliderBuild.ts: Geometrie klonen und die Weltmatrix hineinrechnen.
const worldGeometry = mesh.geometry.clone();
worldGeometry.applyMatrix4(mesh.matrixWorld);
worldGeometry.computeBoundingBox();
expect(worldGeometry.boundingBox.min.x).toBeCloseTo(1472);
expect(worldGeometry.boundingBox.max.x).toBeCloseTo(1506);
expect(worldGeometry.boundingBox.min.z).toBeCloseTo(-1084.5);
expect(worldGeometry.boundingBox.max.z).toBeCloseTo(-1050.5);
});
it("lässt eine durchlässige Position quantisiert", () => {
const geometry = new BufferGeometry();
const position = new BufferAttribute(new Int16Array([0, 0, 0, 32767, 32767, 32767]), 3, true);
geometry.setAttribute("position", position);
const mesh = new Mesh(geometry, new MeshStandardMaterial({ name: "grass_medium_01" }));
const root = new Group();
root.add(mesh);
prepareModel(root, { at: [0, 0], rotation: 0, lift: 0 });
expect(mesh.geometry.attributes.position).toBe(position);
});
});
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) {
// prüften nichts; oder replaces ist keine Liste von Punkten [x, y], und der Streamer ließe nichts weg.
it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => {
for (const { replaces = [], ...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) },
});
for (const point of replaces) expect(point).toEqual([expect.any(Number), 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, { warmUp } = {}) {
const scene = new Scene();
const player = { addCollider: vi.fn(() => ({ id: 7 })), removeCollider: vi.fn() };
const layer = new PlaceLayer({ scene, player, places: [place], load, warmUp });
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);
});
// Bruch: warmUp läuft erst nach dem Einsetzen oder gar nicht — der einmalige Stall durch
// Texturupload und Shader-Übersetzung (~130 ms, gemessen im Spiel) bliebe dann im ersten Bild,
// statt im Vorladen zu verschwinden.
it("wärmt ein Modell vor dem Einsetzen einmalig vor", async () => {
const model = fakeModel();
const load = vi.fn(async () => model);
const warmUp = vi.fn(async (root) => {
expect(scene.children).not.toContain(root);
});
const { scene, player, layer } = makeLayer(load, { warmUp });
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
expect(warmUp).toHaveBeenCalledTimes(1);
expect(warmUp).toHaveBeenCalledWith(model);
});
// Bruch: eine fehlschlagende Vorwärmung lässt den Ort für immer draußen, obwohl das Modell
// längst geladen und vorbereitet ist.
it("setzt einen Ort trotz fehlgeschlagener Vorwärmung ein und warnt", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const model = fakeModel();
const load = vi.fn(async () => model);
const warmUp = vi.fn(async () => { throw new Error("Kontextverlust"); });
const { scene, layer } = makeLayer(load, { warmUp });
layer.update(south(100));
await vi.waitFor(() => expect(scene.children).toContain(model));
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error));
});
// Bruch: addCollider wirft, scene.add lief aber schon davor — die Wurzel bliebe unsichtbar und
// für immer in der Szene, ohne active-Eintrag also nie mehr entfernbar; oder die Ausnahme entkäme
// synchron aus update() in die Spielschleife (#show läuft hier direkt, nicht über die Ladekette).
it("hält die Szene unverändert und warnt, wenn addCollider beim Einsetzen wirft", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const model = fakeModel();
const load = vi.fn(async () => model);
const { scene, player, layer } = makeLayer(load);
player.addCollider.mockImplementation(() => { throw new Error("Worker tot"); });
layer.update(south(500));
await vi.waitFor(() => expect(layer.models.has("hof")).toBe(true));
expect(() => layer.update(south(100))).not.toThrow();
expect(warn).toHaveBeenCalledWith(expect.stringContaining("hof"), expect.any(Error));
expect(scene.children).not.toContain(model);
const callsBefore = player.addCollider.mock.calls.length;
layer.update(south(500));
layer.update(south(100));
expect(scene.children).not.toContain(model);
expect(player.addCollider.mock.calls.length).toBe(callsBefore);
});
});
+64 -16
View File
@@ -5,14 +5,41 @@ import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { PLACES, SOLID_MATERIALS } from "../src/world/places.js";
import { isSolidMaterial, PLACES, SOLID_MATERIALS } from "../src/world/places.js";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
const place = PLACES.find((entry) => entry.id === "hood-yard");
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
const CONTAINERS = ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"];
// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle
// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile.
const SOLID_BY_PLACE = {
"hood-yard": [
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
"cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end", "contFrame",
"ironPlain", "ironTeal", "ironBlue", "galv", "rust", "darkTrim", "osb", "osbB",
"rollershutter_window_01", "rollershutter_window_01_graffiti",
"chainlink_post", "chainlink_wire",
"modular_electricity_poles", "barrel_03", "plastic_container",
"quiver_tree_01_trunk", "quiver_tree_02",
],
tribuene: ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung", "utility_box_01"],
};
describe("hood-yard.glb", () => {
// Materialien, deren Relief nur aus EXT_materials_bump kommt. Die Tribüne hat keine Bump-Maps.
const BUMP_BY_PLACE = {
"hood-yard": ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"],
tribuene: [],
};
// Bruch: ein festes Material steht in keiner Zeile — die Datei seines Orts würde nie darauf geprüft. Als
// Mengen verglichen, damit ein Name, der in zwei Orten fest ist, keinen Fehlalarm auslöst.
it("verteilt SOLID_MATERIALS vollständig auf die Orte", () => {
expect(new Set(Object.values(SOLID_BY_PLACE).flat())).toEqual(new Set(SOLID_MATERIALS));
});
describe.each(PLACES.map((place) => [place.id, place]))("%s.glb", (id, place) => {
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
// Kein "?? []": eine fehlende Zeile soll die Prüfung darunter rot machen (Bruch), nicht still
// durchlaufen. it.runIf gleich unten sichert nur seine eigene Bedingung gegen "undefined" ab.
const bump = BUMP_BY_PLACE[id];
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
@@ -22,17 +49,16 @@ describe("hood-yard.glb", () => {
document = await io.read(file);
}, 30000);
// Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle hatte 653 MB.
// Bruch: Aufbereitung übersprungen oder falsch eingestellt — die Quelle des Hood Yard hatte 653 MB.
it("ist kleiner als 30 MB", () => {
expect(statSync(file).size).toBeLessThan(30e6);
});
// Bruch: das Wellblech ist weg, die Geometrie unkomprimiert, oder GPU-Instanzen, deren Matrizen die
// Kollision des Controllers nicht liest.
it("nutzt meshopt und WebP, behält EXT_materials_bump und hat keine Instanzen", () => {
// Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest.
it("nutzt meshopt und WebP und hat keine Instanzen", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp", EXT_MATERIALS_BUMP]));
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"]));
expect(used).not.toContain("EXT_mesh_gpu_instancing");
});
@@ -43,18 +69,40 @@ describe("hood-yard.glb", () => {
}
});
it("gibt allen sechs Container-Materialien ihre Bump-Textur", () => {
// Bruch: der Ort hat keine Zeile in BUMP_BY_PLACE — ohne diesen Test liefe die Bump-Prüfung darunter
// per it.runIf(bump?.length > 0) einfach übersprungen durch, statt rot zu werden.
it("hat eine Zeile in BUMP_BY_PLACE", () => {
expect(bump).toBeDefined();
});
// Bruch: die Aufbereitung verwirft EXT_materials_bump (wie die gltf-transform-CLI) — das Wellblech der
// Container wäre flach.
it.runIf((bump ?? []).length > 0)("gibt den Bump-Materialien ihre Bump-Textur", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
const materials = new Map(document.getRoot().listMaterials().map((material) => [material.getName(), material]));
for (const name of CONTAINERS) {
expect(used).toContain(EXT_MATERIALS_BUMP);
for (const name of bump) {
expect(materials.get(name)?.getExtension(EXT_MATERIALS_BUMP)?.getBumpTexture()).toBeTruthy();
}
});
// Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Hof wäre dort ohne Warnung
// durchlässig, weil die Kollision nach Namen auswählt.
it("enthält jedes feste Material der Kollisionsliste", () => {
// Bruch: dedup oder ein neues Werkzeug benennt ein Material um — der Ort wäre dort ohne Warnung
// durchlässig, weil die Kollision nach Namen auswählt. Oder der Ort hat keine Zeile in SOLID_BY_PLACE.
it("enthält jedes feste Material seines Orts", () => {
const names = document.getRoot().listMaterials().map((material) => material.getName());
expect(SOLID_MATERIALS.filter((name) => !names.includes(name))).toEqual([]);
expect(SOLID_BY_PLACE[id]).toBeDefined();
expect(SOLID_BY_PLACE[id].filter((name) => !names.includes(name))).toEqual([]);
});
// Bruch: ein Material der Datei ist laut isSolidMaterial fest (weil sein Name anderswo fest ist), ohne
// in der Zeile seines Orts zu stehen — es würde hier still mitkollidieren, ungeprüft von SOLID_BY_PLACE.
it("hat kein fremdes festes Material ohne Zeile in SOLID_BY_PLACE", () => {
const names = document.getRoot().listMaterials().map((material) => material.getName());
const ownSolid = new Set(SOLID_BY_PLACE[id]);
expect(names.filter((name) => isSolidMaterial(name) && !ownSolid.has(name))).toEqual([]);
});
// Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche.
+43 -1
View File
@@ -1,8 +1,10 @@
import { isAbsolute } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";
import { Document } from "@gltf-transform/core";
import sharp from "sharp";
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace } from "../tools/assets/places.mjs";
import { FAR_TEXTURE_SIZE, NEAR_MATERIALS, NEAR_TEXTURE_SIZE, SIMPLIFY_RATIOS, optimizePlace, placeTarget }
from "../tools/assets/places.mjs";
/** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */
function stripes(seed) {
@@ -95,3 +97,43 @@ describe("optimizePlace", () => {
expect(used).not.toContain("EXT_mesh_gpu_instancing");
});
});
describe("placeTarget", () => {
// Bruch: ein Pfad relativ zum Arbeitsverzeichnis statt zum Projekt — bei einem Aufruf aus einem
// anderen Ordner (z. B. aus einem Skript) landete die Datei am falschen Ort oder mkdir schlug fehl.
it("liefert den absoluten Zielpfad unter public/models/places/", () => {
const target = placeTarget("hood-yard");
expect(isAbsolute(target)).toBe(true);
expect(target.endsWith("public/models/places/hood-yard.glb")).toBe(true);
});
// Bruch: eine ID mit Pfadtrennzeichen oder Großbuchstaben schreibt außerhalb von
// public/models/places/ oder unter einem Namen, den PLACES so nie referenziert.
it("lehnt IDs mit Pfadtrennzeichen oder Großbuchstaben ab", () => {
expect(() => placeTarget("../x")).toThrow();
expect(() => placeTarget("Hood Yard")).toThrow();
});
});
describe("Materiallisten der Tribüne", () => {
// Bruch: Beton, Estrich, Verkleidung, Stahl oder Sitze fielen auf 512 px, obwohl der Spieler direkt
// davorsteht — oder Dach und Kabinenboden, die man nur von weitem sieht, behielten 768 px.
it("führt Beton, Estrich, Verkleidung, Stahl und Sitze als Nahflächen, Dach und Kabinenboden nicht", () => {
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Sitzschale"]) expect(NEAR_MATERIALS.has(name)).toBe(true);
for (const name of ["Trapezblech", "Riffelblech"]) expect(NEAR_MATERIALS.has(name)).toBe(false);
});
// Bruch: Kabel, Kästen und Leuchten blieben bei 64 000 Dreiecken, oder die Vereinfachung träfe Körper,
// Dach, Geländer oder Sitze (Löcher in Wänden, schiefe Stufenkanten).
it("vereinfacht nur die Kleinteile der Tribüne, auf 0,25", () => {
for (const name of ["modular_electric_cables", "modular_electric_cables_boxes", "industrial_wall_lamp",
"industrial_wall_lamp_glass", "power_box_01", "utility_box_01"]) {
expect(SIMPLIFY_RATIOS.get(name)).toBe(0.25);
}
for (const name of ["Sichtbeton", "Estrich", "Verkleidung", "Stahl", "Trapezblech", "Riffelblech", "Verglasung",
"Sitzschale"]) {
expect(SIMPLIFY_RATIOS.has(name)).toBe(false);
}
});
});
+37 -7
View File
@@ -3,11 +3,15 @@ import { describe, expect, it } from "vitest";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js";
import { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js";
import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.js";
import { withoutReplacedBuildings } from "../src/world/streaming.js";
// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt.
const CHUNK_LOAD_RADIUS = 400;
const CHUNK_UNLOAD_RADIUS = 600;
const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche
// Wie weit darf ein ersetzter Grundriss über die Außenmaße seines Orts hinausragen? Die Tribüne liegt
// heute 3,78 m vor der Rückseite ihres alten Kastens, Modell-z −18,60 gegen −14,82.
const REPLACED_OVERHANG = 5; // Meter
const cache = new Map();
function chunk(i, j) {
@@ -18,6 +22,16 @@ function chunk(i, j) {
return cache.get(key);
}
// Grundrisse, die ein Ort ersetzt, baut der Streamer nicht (withoutReplacedBuildings, verdrahtet in
// src/main.js) — für die Prüfung auf Gebäude zählen sie deshalb nicht.
const REPLACED = PLACES.flatMap((place) => place.replaces ?? []);
const standing = new Map();
function standingBuildings(i, j) {
const key = `${i}_${j}`;
if (!standing.has(key)) standing.set(key, withoutReplacedBuildings(chunk(i, j), REPLACED).buildings);
return standing.get(key);
}
/** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */
function modelToWorld(place, x, z) {
const { position, rotationY } = placeTransform(place);
@@ -33,10 +47,10 @@ function worldToModel(place, X, Y) {
return { x: dx * cos - dz * sin, z: dx * sin + dz * cos };
}
function insideBounds(place, X, Y) {
function insideBounds(place, X, Y, margin = 0) {
const { x, z } = worldToModel(place, X, Y);
const { minX, maxX, minZ, maxZ } = place.bounds;
return x >= minX && x <= maxX && z >= minZ && z <= maxZ;
return x >= minX - margin && x <= maxX + margin && z >= minZ - margin && z <= maxZ + margin;
}
describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte", (_id, place) => {
@@ -51,7 +65,7 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
const under = keys.map((key) => key.split("_").map(Number));
// Bruch: der Ort steht auf einer Straße, in einem Gebäude oder über Wasser — nach einem Verschieben
// in PLACES oder nach einem neuen Lauf der Extraktion.
// in PLACES oder nach einem neuen Lauf der Extraktion. Gebäude, die ein Ort ersetzt, zählen nicht.
it("liegt ganz auf Land, auf keiner Straße und in keinem Gebäude", () => {
const bad = points.filter(({ X, Y }) => {
const { i, j } = chunkForWorld(X, Y);
@@ -59,12 +73,28 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
const u = X - c.origin[0], v = -Y - c.origin[1];
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly));
const inBuilding = c.buildings.some((building) => pointInPolygon(u, v, building.poly));
const inBuilding = standingBuildings(i, j).some((building) => pointInPolygon(u, v, building.poly));
return !onLand || onRoad || inBuilding;
});
expect(bad.slice(0, 5)).toEqual([]);
});
// Bruch: Nach einem neuen Lauf der Extraktion liegt das ersetzte Gebäude woanders oder ist geteilt, oder
// der Punkt trifft ein fremdes oder mit einem Nachbarn zusammengelegtes Gebäude. Der Punkt träfe dann
// nichts (der alte Kasten stünde wieder im Ort), mehrere Grundrisse, einen außerhalb, oder einen mit
// Ecken weit jenseits der Außenmaße.
it.runIf((place.replaces ?? []).length > 0)("ersetzt mit jedem replaces-Punkt genau ein Gebäude unter sich", () => {
const bad = place.replaces.filter(([X, Y]) => {
const { i, j } = chunkForWorld(X, Y);
const c = chunk(i, j);
const hits = c.buildings.filter((building) => pointInPolygon(X - c.origin[0], -Y - c.origin[1], building.poly));
if (hits.length !== 1 || !insideBounds(place, X, Y)) return true;
const corners = hits[0].poly.flat(1);
return corners.some(([u, v]) => !insideBounds(place, c.origin[0] + u, -(c.origin[1] + v), REPLACED_OVERHANG));
});
expect(bad).toEqual([]);
});
// Bruch: eine Palme oder Laterne mitten im Ort. Beide entstehen je Chunk und wissen nichts von Orten;
// Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks.
it("enthält keine Palme und keine Laterne", () => {
@@ -86,9 +116,9 @@ describe.each(PLACES.map((place) => [place.id, place]))("Ort %s auf der Karte",
expect(props.filter(({ X, Y }) => insideBounds(place, X, Y))).toEqual([]);
});
// Bruch: der Ort erscheint, bevor sein Boden geladen ist, und steht dann über dem Meer — oder der
// Boden wird abgebaut, während der Ort noch in der Szene ist.
it("ist nur in der Szene, solange die Chunks unter ihm geladen sind", () => {
// Bruch: der Ort erscheint, bevor der Boden unter ihm angefordert ist, und steht dann über dem
// Meer — oder der Boden wird abgebaut, während der Ort noch in der Szene ist.
it("ist nur in der Szene, solange die Chunks unter ihm angefordert sind", () => {
const { position } = placeTransform(place);
const farthest = Math.max(...under.map(([i, j]) => Math.hypot(
SLICE.x0 + (i + 0.5) * CHUNK_SIZE - position.x,
+72 -3
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { BufferGeometry, Group, Mesh } from "three";
import { ChunkStreamer, wantedChunks } from "../src/world/streaming.js";
import { ChunkStreamer, wantedChunks, withoutReplacedBuildings } from "../src/world/streaming.js";
describe("wantedChunks", () => {
it("lädt die Umgebung des Spielers", () => {
@@ -29,6 +29,50 @@ describe("wantedChunks", () => {
});
});
describe("withoutReplacedBuildings", () => {
// Chunk 9_5 (J6) hat den Ursprung [1250, -1250]: Welt (x, y) liegt im Chunk bei u = x − 1250, v = −y + 1250.
const square = (u, v) => [[[u, v], [u + 10, v], [u + 10, v + 10], [u, v + 10], [u, v]]];
const chunk = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [
{ poly: square(150, 190), height: 18.6 }, // Welt x 1400–1410, y 1050–1060
{ poly: square(100, 100), height: 10 }, // Welt x 1350–1360, y 1140–1150
],
};
// Bruch: Ursprung vergessen oder Welt-y nicht gespiegelt — der Punkt träfe nichts, und der alte Kasten
// stünde mitten im Ort; oder die Eingabe wird verändert statt kopiert.
it("lässt nur den Grundriss weg, in dem ein Punkt liegt, gerechnet in Chunk-Koordinaten", () => {
const result = withoutReplacedBuildings(chunk, [[1405, 1055]]);
expect(result.buildings).toEqual([chunk.buildings[1]]);
expect(result.land).toBe(chunk.land);
expect(chunk.buildings).toHaveLength(2);
});
it("gibt ohne Treffer den Chunk selbst zurück", () => {
expect(withoutReplacedBuildings(chunk, [])).toBe(chunk);
expect(withoutReplacedBuildings(chunk, [[1405, 1255]])).toBe(chunk);
});
// Bruch: eine Zuordnung des Punkts per chunkForWorld ordnete ihn nur chunk_9_5 zu (i = 9) und ließe die
// ungeschnittene Kopie desselben Grundrisses in chunk_10_5 stehen — genau das Gebäude, das der Ort ersetzt.
it("lässt denselben, ungeschnittenen Grundriss in beiden berührten Chunks weg", () => {
const chunkA = {
chunk: [9, 5], origin: [1250, -1250], land: [], surfaces: [], roads: [],
buildings: [{ poly: square(245, 190), height: 18.6 }], // Welt x 1495–1505, y 1050–1060
};
const chunkB = {
chunk: [10, 5], origin: [1500, -1250], land: [], surfaces: [], roads: [],
buildings: [{ poly: square(-5, 190), height: 18.6 }], // dieselbe Welt-Fläche, andere u
};
const points = [[1498, 1055]];
expect(withoutReplacedBuildings(chunkA, points).buildings).toEqual([]);
expect(withoutReplacedBuildings(chunkB, points).buildings).toEqual([]);
});
});
// Gemeinsame Vorrichtung für beide ChunkStreamer-describe-Blöcke unten (mit und ohne PropLayer).
// Realer Chunk ohne jede Geometrie — offenes Wasser. Entspricht wörtlich chunk_4_6.json
// (eines von vier: 4_6, 4_10, 5_6, 5_10 — per public/data/vice-a nachgezählt, Ruling 51).
@@ -36,7 +80,7 @@ const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [],
// Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE).
const emptyChunkCenter = { x: 125, z: -875 };
function makeStreamer(chunkData, props, pinned) {
function makeStreamer(chunkData, props, pinned, replacedBuildings) {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData })));
const player = {
addCollider: vi.fn(() => ({ id: 42 })),
@@ -47,7 +91,7 @@ function makeStreamer(chunkData, props, pinned) {
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
};
const scene = { add: vi.fn(), remove: vi.fn() };
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned }), player, scene };
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned, replacedBuildings }), player, scene };
}
// #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um
@@ -152,6 +196,31 @@ describe("ChunkStreamer", () => {
expect(streamer.active.has("4_6")).toBe(true);
expect(player.removeCollider).not.toHaveBeenCalled();
});
// Bruch: der Streamer baut die Geometrie aus dem ungefilterten Chunk — der alte Kasten stünde samt
// Kollision mitten in dem Ort, der ihn ersetzt.
it("baut ein Gebäude, das ein Ort ersetzt, weder als Kasten noch in die Kollision", async () => {
const chunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [],
buildings: [{ poly: [[[10, 10], [20, 10], [20, 20], [10, 20]]], height: 12 }],
};
// Gegenprobe ohne Ersatz: Land und Gebäude liegen in der Kollisionsgruppe.
const plain = makeStreamer(chunk);
plain.streamer.update(emptyChunkCenter);
await flush();
expect(plain.player.addCollider.mock.calls[0][0].shape.source.children).toHaveLength(2);
// Mitte des Gebäudes: u 15 -> Welt-x 15, v 15 -> Welt-y −(−1000 + 15) = 985.
const { streamer, player } = makeStreamer(chunk, undefined, undefined, [[15, 985]]);
streamer.update(emptyChunkCenter);
await flush();
const source = player.addCollider.mock.calls[0][0].shape.source;
expect(source.children).toHaveLength(1);
expect(source.children[0].geometry.getAttribute("color")).toBeUndefined(); // die Landplatte, kein Gebäude
});
});
// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "three-player-controller",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"url": "http://localhost:5173"
}
]
}
+50
View File
@@ -0,0 +1,50 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- master
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build examples
run: npm run build:example
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+19
View File
@@ -0,0 +1,19 @@
# dependencies
node_modules/
# build output
dist/
docs/
# logs
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# OS
.DS_Store
Thumbs.db
# editor
.vscode/
.idea/
+28
View File
@@ -0,0 +1,28 @@
# Contributing
Thanks for your interest in [three-player-controller](https://github.com/hh-hang/three-player-controller). Bug reports, ideas, and pull requests are welcome.
Before larger changes, please open or search an [Issue](https://github.com/hh-hang/three-player-controller/issues) first.
## Development
```bash
git clone https://github.com/hh-hang/three-player-controller.git
cd three-player-controller
npm install
npm run dev
```
Open `http://localhost:5173/three-player-controller/`.
- Library code: `src/`
- Demos: `example/`
Use `npm run build` to verify the library builds.
## Pull Requests
1. Branch from `master` and keep the PR focused
2. Make sure related demos work under `npm run dev`, and `npm run build` succeeds
3. Update `README.md` / `README_En.md` if public APIs change
4. Describe the change and how you tested it
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 屈航
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>three-player-controller</title>
<link rel="stylesheet" href="./styles/common.css" />
<style>
html,
body {
height: 100%;
margin: 0;
}
#container {
overflow: hidden !important;
width: 100vw;
height: 100vh;
position: absolute;
background: transparent;
}
</style>
<script src="./loader.js" data-demo="3DGS Scene"></script>
</head>
<body>
<div id="container"></div>
<div class="hud" role="note" aria-label="Controls">
<div class="hint-move">
<div class="hint-cluster">
<div class="wasd">
<kbd class="wasd-w">W</kbd>
<kbd class="wasd-a">A</kbd>
<kbd class="wasd-s">S</kbd>
<kbd class="wasd-d">D</kbd>
</div>
<span class="hint-text">Move</span>
</div>
<div class="hint-cluster">
<div class="hint-mouse" aria-hidden="true">
<div class="hint-mouse-body">
<span class="hint-mouse-wheel"></span>
<span class="hint-mouse-nub"></span>
</div>
</div>
<span class="hint-text">Look</span>
</div>
</div>
<div class="hint-actions">
<div class="hint-group">
<span class="hint-keys"><kbd>Shift</kbd></span>
<span class="hint-text">Boost</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Space</kbd></span>
<span class="hint-text">Jump</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>V</kbd></span>
<span class="hint-text">Toggle View</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>F</kbd></span>
<span class="hint-text">Toggle Fly</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Z</kbd></span>
<span class="hint-text">Scale Character</span>
</div>
</div>
</div>
<div class="model-credit">
3DGS:
<a href="https://huggingface.co/datasets/spatialverse/InteriorGS/tree/main/0267_840790" target="_blank" rel="noopener noreferrer">Huggingface</a>
</div>
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/3dgs.js" target="_blank" rel="noopener noreferrer">
<img src="./img/source.svg" alt="" />
</a>
<script type="module" src="./control-hints.js"></script>
<script type="module" src="./3dgs.js"></script>
</body>
</html>
+213
View File
@@ -0,0 +1,213 @@
import {
ACESFilmicToneMapping,
AmbientLight,
EquirectangularReflectionMapping,
PerspectiveCamera,
Scene,
Vector3,
WebGLRenderer,
} from "three";
import { MapControls } from "three/examples/jsm/Addons.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader.js";
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
import { playerController } from "../src/PlayerController";
import { SplatMesh, SparkRenderer } from "@sparkjsdev/spark";
import Stats from 'three/examples/jsm/libs/stats.module.js';
let player;
const scene = new Scene();
let camera;
let renderer;
let controls;
let gltfLoader;
let stats;
const pos = new Vector3(1.235, 1.21, -3.9);
const scaleNormal = 0.01;
const scaleSmall = scaleNormal / 9;
let isSmallScale = false;
let scaleAnimFrame = null;
let isScaling = false;
init();
async function init() {
const cont = document.querySelector("#container");
// 渲染器
renderer = new WebGLRenderer({ antialias: true });
renderer.setSize(cont.clientWidth, cont.clientHeight);
renderer.shadowMap.enabled = true;
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.setAnimationLoop(animate);
cont.appendChild(renderer.domElement);
// 相机
camera = new PerspectiveCamera(60, cont.clientWidth / cont.clientHeight, 0.01, 1000);
camera.position.copy(pos);
camera.lookAt(pos.x, pos.y, pos.z + 1);
// 控制器
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.maxDistance = 2000;
controls.dampingFactor = 0.1;
controls.rotateSpeed = 1;
controls.maxPolarAngle = Math.PI / 2;
controls.target.set(pos.x, pos.y, pos.z + 1);
// 环境光
const ambient = new AmbientLight(0xffffff, 5);
scene.add(ambient);
// 背景
new HDRLoader().load(
"./img/env.hdr",
(texture) => {
texture.mapping = EquirectangularReflectionMapping;
scene.background = texture;
},
undefined,
(err) => console.warn("HDR 加载失败:", err)
);
// 帧率
stats = new Stats();
Object.assign(stats.dom.style, {
position: "fixed",
bottom: "0px",
left: "0px",
top: "auto",
zIndex: "9998",
display: "block",
});
document.body.appendChild(stats.dom);
// 加载3DGS场景
initGltfLoader();
const spark = new SparkRenderer({ renderer });
scene.add(spark);
const splatURL = './3DGS/3DGS.sog'
const splat = new SplatMesh({
url: splatURL,
onProgress: (e) => window.setLoaderProgress?.(e.loaded, e.total),
onLoad: (mesh) => { mesh.rotateX(-Math.PI / 2); },
});
scene.add(splat);
// 加载碰撞体
const colliderGltf = await gltfLoader.loadAsync("./glb/3dgs_collider.glb");
const playerGltf = await gltfLoader.loadAsync("./glb/josh.glb");
// 人物控制器
player = new playerController();
await player.init({
scene,
camera,
controls,
playerModelConfig: {
model: playerGltf.scene,
animations: playerGltf.animations,
scale: scaleNormal,
idleAnim: "idle1",
walkAnim: "walk",
runAnim: "run",
jumpAnim: ["jump", "Jump_Loop", "Jump_Land"],
flyAnim: "fly",
flyIdleAnim: "flyIdle",
flyHoverForwardAnim: "flyHoverForward",
flyHoverBackAnim: "flyHoverBack",
flyHoverLeftAnim: "flyHoverLeft",
flyHoverRightAnim: "flyHoverRight",
flyHoverUpAnim: "flyHoverUp",
flyHoverDownAnim: "flyHoverDown",
drivingAnim: "Driving_Loop",
headBoneName: "mixamorigHead",
speed: 150,
flySpeed: 1000,
jumpHeight: 400,
},
initPos: pos,
minCamDistance: 50,
maxCamDistance: 180,
camLookAtHeightRatio: 0.8,
colliders: [
{ motion: "static", shape: { kind: "mesh", source: colliderGltf.scene } },
],
enableOverShoulderView: true,
});
window.addEventListener("resize", onWindowResize, false);
// 监听按键
window.addEventListener("keydown", (e) => {
if (e.code !== "KeyZ") return;
if (isScaling) return; // 缩放中不运行
isSmallScale = !isSmallScale;
animateToScale(isSmallScale ? scaleSmall : scaleNormal, 1);
});
// 等待高斯泼溅模型加载完毕再隐藏 loader
await splat.initialized.catch(() => { });
window.hideLoader();
}
function animateToScale(targetScale, duration = 0.5) {
if (scaleAnimFrame !== null) {
cancelAnimationFrame(scaleAnimFrame);
scaleAnimFrame = null;
}
isScaling = true;
const fromScale = player.getScale?.() ?? (isSmallScale ? scaleNormal : scaleSmall);
const startTime = performance.now();
const tick = (now) => {
const t = Math.min((now - startTime) / (duration * 1000), 1);
const current = fromScale + (targetScale - fromScale) * t;
player.setPlayerScale(current);
if (t < 1) {
scaleAnimFrame = requestAnimationFrame(tick);
} else {
scaleAnimFrame = null;
isScaling = false;
}
};
scaleAnimFrame = requestAnimationFrame(tick);
}
// 初始化glb加载器
function initGltfLoader() {
gltfLoader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/");
gltfLoader.setDRACOLoader(dracoLoader);
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/");
ktx2Loader.detectSupport(renderer);
gltfLoader.setKTX2Loader(ktx2Loader);
}
// 每帧调用
function animate() {
if (player) {
player.update();
} else {
controls.update();
}
renderer.render(scene, camera);
stats?.update();
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
renderer.setSize(window.innerWidth, window.innerHeight);
camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio * 1);
}
@@ -0,0 +1,113 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>three-player-controller</title>
<link rel="stylesheet" href="./styles/common.css" />
<style>
html,
body {
height: 100%;
margin: 0;
}
#container {
overflow: hidden !important;
width: 100vw;
height: 100vh;
position: absolute;
inset: 0;
background: transparent;
}
#google-attributions {
position: fixed;
left: 88px;
bottom: 0;
z-index: 9998;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-height: 48px;
max-width: min(520px, calc(100vw - 160px));
color: #e6eef6;
font: 12px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
#google-attributions img {
height: 18px;
}
</style>
<script src="./loader.js" data-demo="3DTiles Scene"></script>
</head>
<body>
<div id="container"></div>
<div class="hud" role="note" aria-label="Controls">
<div class="hint-move">
<div class="hint-cluster">
<div class="wasd">
<kbd class="wasd-w">W</kbd>
<kbd class="wasd-a">A</kbd>
<kbd class="wasd-s">S</kbd>
<kbd class="wasd-d">D</kbd>
</div>
<span class="hint-text">Move</span>
</div>
<div class="hint-cluster">
<div class="hint-mouse" aria-hidden="true">
<div class="hint-mouse-body">
<span class="hint-mouse-wheel"></span>
<span class="hint-mouse-nub"></span>
</div>
</div>
<span class="hint-text">Look</span>
</div>
</div>
<div class="hint-actions">
<div class="hint-group">
<span class="hint-keys"><kbd>Shift</kbd></span>
<span class="hint-text">Sprint</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Space</kbd></span>
<span class="hint-text">Jump</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>V</kbd></span>
<span class="hint-text">Toggle View</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>F</kbd></span>
<span class="hint-text">Toggle Fly</span>
</div>
</div>
</div>
<div id="top-notice">
<div class="credit-line">
Powered by:
<a href="https://mapsplatform.google.com/resources/blog/create-immersive-3d-map-experiences-photorealistic-3d-tiles/" target="_blank" rel="noopener noreferrer">Google Photorealistic 3D Tiles</a>
&
<a href="https://ion.cesium.com/" target="_blank" rel="noopener noreferrer">Cesium Ion</a>
</div>
<div class="credit-line">
3D Tiles Loader:
<a href="https://github.com/NASA-AMMOS/3DTilesRendererJS" target="_blank" rel="noopener noreferrer">3DTilesRendererJS</a>
·
Globe:
<a href="https://github.com/cyanfish-x/tellux" target="_blank" rel="noopener noreferrer">Tellux</a>
</div>
</div>
<aside id="google-attributions" aria-label="Map data attributions"></aside>
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/3dtilesScene.js" target="_blank" rel="noopener noreferrer">
<img src="./img/source.svg" alt="" />
</a>
<script type="module" src="./control-hints.js"></script>
<script type="module" src="./3dtilesScene.js"></script>
</body>
</html>
@@ -0,0 +1,574 @@
import tellux from "tellux";
import {
AxesHelper,
Box3,
Group,
Matrix4,
Quaternion,
Sphere,
Vector3,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { playerController } from "../src/PlayerController";
// Google Photorealistic 3D Tiles
const GOOGLE_PHOTOREALISTIC_ASSET_ID = 2275207;
const CESIUM_ION_TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJmMzgwMGY3ZS1jOTMwLTQyNmQtOTkyNS03MDE4ZjlkYmY0MTYiLCJpZCI6MjIzMDk3LCJpYXQiOjE3MTg3NjgwNTN9.FcpK7jiFPzWZL8m6VxRbG7ly8LMecpXnDAMZJX_UehM";
// 出生城市
const CITIES = {
paris: { longitude: 2.2945, latitude: 48.8584, height: 100, hourUTC: 12 },
tokyo: { longitude: 139.7671, latitude: 35.6812, height: 40, hourUTC: 4 },
newyork: { longitude: -74.006, latitude: 40.7128, height: 20, hourUTC: 17 },
london: { longitude: -0.1246, latitude: 51.5007, height: 40, hourUTC: 14 },
sydney: { longitude: 151.2153, latitude: -33.8568, height: 40, hourUTC: 3 },
dubai: { longitude: 55.2744, latitude: 25.1972, height: 40, hourUTC: 8 },
rome: { longitude: 12.4922, latitude: 41.8902, height: 40, hourUTC: 11 },
saopaulo: { longitude: -46.6559, latitude: -23.5615, height: 1000, hourUTC: 16 },
};
const CITY_OPTIONS = {
"New York": "newyork",
Paris: "paris",
Tokyo: "tokyo",
London: "london",
Sydney: "sydney",
Dubai: "dubai",
Rome: "rome",
"São Paulo": "saopaulo",
};
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
// 动态碰撞:靠近才建,远离再拆
const TILE_ENTER_RADIUS = 100;
const TILE_EXIT_RADIUS = 150;
const MAX_TILE_ADDS_PER_UPDATE = 1;
const MAX_TILE_REMOVES_PER_UPDATE = 4;
// 人物速度;飞行时按住 Shift 进入极速
const BASE_WALK_SPEED = 200;
const BASE_RUN_SPEED = 600;
const BASE_FLY_SPEED = 2100;
const TURBO_SPEED_SCALE = 3;
const container = document.querySelector("#container");
if (!(container instanceof HTMLElement)) {
throw new Error("Missing #container");
}
const attributionsElement = document.querySelector("#google-attributions");
const guiParams = {
city: "newyork",
playerCapsuleDebug: false,
colliderDebug: false,
localAxes: false,
};
// Tellux 视图
const viewer = new tellux.Viewer(container, {
dracoDecoderPath: "https://unpkg.com/three@0.184.0/examples/jsm/libs/draco/gltf/",
useDefaultRenderLoop: false,
camera: {
longitude: CITIES.newyork.longitude,
latitude: CITIES.newyork.latitude,
height: 80,
heading: 0,
pitch: -20,
near: 0.1,
},
scene: {
atmosphere: {
show: true,
lighting: {
mode: "post-process",
sunLight: true,
skyLight: true,
},
sky: {
sun: true,
moon: true,
stars: true,
},
},
clouds: {
show: true,
coverage: 0.35,
},
postProcess: {
lensFlare: { enabled: false },
smaa: { enabled: false },
},
},
});
// 关闭默认地球交互,改由人物控制器接管相机
viewer.controls.enabled = false;
viewer.camera.allowUnderground = true;
viewer.tileset.group.visible = false;
// 控制器
const orbitControls = new OrbitControls(viewer.camera.threeCamera, viewer.renderer.domElement);
orbitControls.enablePan = false;
// 局部坐标轴
const localAxes = new AxesHelper(5000);
localAxes.name = "local-axes";
localAxes.visible = false;
localAxes.renderOrder = 10;
localAxes.material.depthTest = false;
localAxes.material.depthWrite = false;
viewer.scene.threeScene.add(localAxes);
// 帧率
const stats = new Stats();
Object.assign(stats.dom.style, {
position: "fixed",
left: "0",
bottom: "0",
top: "auto",
zIndex: "9998",
});
document.body.appendChild(stats.dom);
let activeLayer = null;
let localFrame = null;
let tileCollision = null;
let player = null;
let skipCollisionAfterRebase = false;
let running = true;
let turboMode = false;
// 局部坐标系下每帧把世界到 ECEF 的矩阵写进大气。
function syncAtmosphereFrame() {
const atmosphere = viewer.atmosphere;
atmosphere.aerialPerspectiveEffect.worldToECEFMatrix.copy(localFrame.localToEcef);
atmosphere.cloudsEffect.worldToECEFMatrix.copy(localFrame.localToEcef);
}
// 以人物附近 ECEF 点建立局部 Y-up 坐标系,走远后 rebase。
class LocalFrameManager {
// 建局部 ENU 根节点,并按出生点设原点
constructor(ellipsoid, origin) {
this.ellipsoid = ellipsoid;
this.rebaseDistance = 10_000;
this.group = new Group();
this.group.name = "local-frame";
this.group.matrixAutoUpdate = false;
this.localToEcef = new Matrix4();
this.ecefToLocal = new Matrix4();
this.deltaMatrix = new Matrix4();
this.deltaQuat = new Quaternion();
this.scratchEcef = new Vector3();
this.scratchCartographic = { lat: 0, lon: 0, height: 0 };
this.setOrigin(origin);
}
// 按经纬高重设局部原点
setOrigin(origin) {
const height = origin.height ?? 0;
this.ellipsoid.getObjectFrame(
origin.latitude * DEG2RAD,
origin.longitude * DEG2RAD,
height,
0,
0,
0,
this.localToEcef
);
this.ecefToLocal.copy(this.localToEcef).invert();
this.group.matrix.copy(this.ecefToLocal);
this.group.updateMatrixWorld(true);
}
// 人物离原点太远时平移局部系,并同步胶囊、相机和速度
maybeRebase(target) {
const position = target.getPosition();
if (!position || position.length() < this.rebaseDistance) return false;
this.scratchEcef.copy(position).applyMatrix4(this.localToEcef);
this.ellipsoid.getPositionToCartographic(this.scratchEcef, this.scratchCartographic);
this.deltaMatrix.copy(this.localToEcef);
this.setOrigin({
latitude: this.scratchCartographic.lat * RAD2DEG,
longitude: this.scratchCartographic.lon * RAD2DEG,
height: this.scratchCartographic.height,
});
this.deltaMatrix.premultiply(this.ecefToLocal);
this.deltaQuat.setFromRotationMatrix(this.deltaMatrix);
const capsule = target.getPlayerCapsule();
if (capsule) this.applyDelta(capsule, this.deltaMatrix);
if (!this.isDescendant(target.camera, capsule)) {
this.applyDelta(target.camera, this.deltaMatrix);
}
target.controls?.target.applyMatrix4(this.deltaMatrix);
target.playerVelocity?.applyQuaternion(this.deltaQuat);
return true;
}
// 判断 object 是否挂在 ancestor 下面
isDescendant(object, ancestor) {
if (!ancestor) return false;
let current = object;
while (current) {
if (current === ancestor) return true;
current = current.parent;
}
return false;
}
// 用 rebase 增量改物体位姿
applyDelta(object, delta) {
object.updateMatrixWorld(true);
object.matrix.premultiply(delta);
object.matrix.decompose(object.position, object.quaternion, object.scale);
object.updateMatrixWorld(true);
}
}
// 登记已加载瓦片,只给当前可见且靠近人物的瓦片建运动学 mesh 碰撞。
class TileCollisionManager {
// 绑定瓦片集和人物,并备好事件回调
constructor(tileset, targetPlayer) {
this.tileset = tileset;
this.player = targetPlayer;
this.attached = false;
this.tracked = new Map();
this.scratchCenter = new Vector3();
this.scratchScale = new Vector3();
this.scratchBox = new Box3();
this.scratchSphere = new Sphere();
this.onLoadModel = (event) => this.track(event.scene, this.isTileVisible(event.tile, event.scene));
this.onDisposeModel = (event) => this.untrack(event.scene);
this.onVisibilityChange = (event) => this.setVisible(event.scene, event.visible);
}
// 监听加载 / 卸载 / 可见性,并补登记已在场的瓦片
attach() {
if (this.attached) return;
this.attached = true;
this.tileset.addEventListener("load-model", this.onLoadModel);
this.tileset.addEventListener("dispose-model", this.onDisposeModel);
this.tileset.addEventListener("tile-visibility-change", this.onVisibilityChange);
this.tileset.forEachLoadedModel((scene, tile) => this.track(scene, this.isTileVisible(tile, scene)));
}
// 去掉瓦片事件,不再跟踪新变化
detach() {
if (!this.attached) return;
this.attached = false;
this.tileset.removeEventListener("load-model", this.onLoadModel);
this.tileset.removeEventListener("dispose-model", this.onDisposeModel);
this.tileset.removeEventListener("tile-visibility-change", this.onVisibilityChange);
}
// 拆掉全部碰撞并停止跟踪
dispose() {
this.detach();
for (const entry of this.tracked.values()) this.removeCollider(entry);
this.tracked.clear();
}
// 只给当前可见且靠近的瓦片建碰撞;隐藏的(含 REPLACE 父级)立刻拆
update(playerPos) {
let adds = 0;
let removes = 0;
for (const entry of this.tracked.values()) {
if (!entry.visible) {
this.removeCollider(entry);
continue;
}
const distance = this.distanceToTile(entry, playerPos);
if (!entry.handle && distance < TILE_ENTER_RADIUS) {
if (adds >= MAX_TILE_ADDS_PER_UPDATE) continue;
const handle = this.player.addCollider({
motion: "kinematic",
useWorker: true,
shape: { kind: "mesh", source: entry.scene },
follow: entry.scene,
});
if (handle.id < 0) continue;
entry.handle = handle;
adds += 1;
} else if (entry.handle && distance > TILE_EXIT_RADIUS) {
if (removes >= MAX_TILE_REMOVES_PER_UPDATE) continue;
this.removeCollider(entry);
removes += 1;
}
}
}
// 人物到瓦片包围球表面的距离;负值表示人在球内
distanceToTile(entry, playerPos) {
entry.scene.updateWorldMatrix(true, false);
this.scratchCenter.copy(entry.localCenter).applyMatrix4(entry.scene.matrixWorld);
entry.scene.getWorldScale(this.scratchScale);
const worldRadius = entry.localRadius * (this.scratchScale.x || 1);
return playerPos.distanceTo(this.scratchCenter) - worldRadius;
}
// 以 traversal.visible 为准,没有则看 scene 是否还挂在树上
isTileVisible(tile, scene) {
if (typeof tile?.traversal?.visible === "boolean") return tile.traversal.visible;
return Boolean(scene?.parent);
}
// 同步可见性;藏起来就拆碰撞,避免粗父级残留
setVisible(scene, visible) {
const entry = this.tracked.get(scene) ?? this.track(scene, visible);
if (!entry) return;
entry.visible = visible;
if (!visible) this.removeCollider(entry);
}
// 登记瓦片局部中心和半径,已跟踪则只改可见性
track(scene, visible = Boolean(scene.parent)) {
const existing = this.tracked.get(scene);
if (existing) {
existing.visible = visible;
return existing;
}
scene.updateWorldMatrix(true, true);
this.scratchBox.setFromObject(scene);
if (this.scratchBox.isEmpty()) return null;
this.scratchBox.getBoundingSphere(this.scratchSphere);
const localCenter = scene.worldToLocal(this.scratchSphere.center.clone());
scene.getWorldScale(this.scratchScale);
const scale = this.scratchScale.x || 1;
const entry = {
scene,
localCenter,
localRadius: this.scratchSphere.radius / scale,
visible,
handle: null,
};
this.tracked.set(scene, entry);
return entry;
}
// 瓦片卸载时拆碰撞并移出跟踪表
untrack(scene) {
const entry = this.tracked.get(scene);
if (!entry) return;
this.removeCollider(entry);
this.tracked.delete(scene);
}
// 从人物控制器移除该瓦片的运动学碰撞
removeCollider(entry) {
if (!entry.handle) return;
this.player.removeCollider(entry.handle);
entry.handle = null;
}
}
// 当前选中的出生城市
function currentCity() {
return guiParams.city in CITIES ? guiParams.city : "newyork";
}
// 局部坐标系中的出生点
function spawnLocalPosition() {
return new Vector3(0, 100, 0);
}
// 渲染 Google / Cesium 版权信息
function renderAttributions() {
if (!attributionsElement) return;
const attributions = activeLayer?.tileset.getAttributions() ?? [];
attributionsElement.replaceChildren();
for (const attribution of attributions) {
if (attribution.type === "image") {
const image = document.createElement("img");
image.src = String(attribution.value);
image.alt = "";
attributionsElement.append(image);
continue;
}
if (attribution.value) {
const item = document.createElement("span");
item.innerHTML = String(attribution.value);
attributionsElement.append(item);
}
}
}
// 创建或更新局部坐标系
function ensureLocalFrame(origin) {
if (!localFrame) {
localFrame = new LocalFrameManager(viewer.tileset.ellipsoid, origin);
viewer.scene.threeScene.add(localFrame.group);
return;
}
localFrame.setOrigin(origin);
}
// 飞行时按住 Shift 切换极速
function syncTurboMode() {
if (!player) return;
const next = player.isFlying && player.input.shift;
if (next === turboMode) return;
turboMode = next;
player.setPlayerFlySpeed(BASE_FLY_SPEED * (turboMode ? TURBO_SPEED_SCALE : 1));
}
// 极速时拉宽视野
function updateTurboFov() {
const camera = viewer.camera.threeCamera;
const targetFov = turboMode ? 120 : 60;
if (Math.abs(camera.fov - targetFov) > 0.01) {
camera.fov += (targetFov - camera.fov) * 0.01;
camera.updateProjectionMatrix();
}
}
// 切换城市或重新出生时悬停,等待周围地形加载
function respawn() {
ensureLocalFrame(CITIES[currentCity()]);
viewer.clock.hourUTC = CITIES[currentCity()].hourUTC;
player?.reset(spawnLocalPosition());
if (player && !player.isFlying) player.setInput({ toggleFly: true });
player?.setSkipCapsuleCollision(true);
skipCollisionAfterRebase = true;
}
// 加载 Google Photorealistic 3D Tiles
function loadPhotorealisticTiles() {
ensureLocalFrame(CITIES[currentCity()]);
activeLayer = viewer.load3DTileset({
type: "cesium-ion",
id: "photorealistic-3d-tiles",
assetId: GOOGLE_PHOTOREALISTIC_ASSET_ID,
apiToken: CESIUM_ION_TOKEN,
creasedNormals: true,
materialMode: "unlit",
});
localFrame.group.add(activeLayer.tileset.group);
localFrame.group.updateMatrixWorld(true);
}
// 玩家控制器
async function initPlayer() {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync("./glb/josh.glb");
player = new playerController();
await player.init({
scene: viewer.scene.threeScene,
camera: viewer.camera.threeCamera,
controls: orbitControls,
initPos: spawnLocalPosition(),
minCamDistance: 5,
maxCamDistance: 200,
playerModelConfig: {
model: gltf.scene,
animations: gltf.animations ?? [],
scale: 0.01,
idleAnim: "idle1",
walkAnim: "walk",
runAnim: "run",
jumpAnim: ["jump", "Jump_Loop", "Jump_Land"],
flyAnim: "fly",
flyIdleAnim: "flyIdle",
flyHoverForwardAnim: "flyHoverForward",
flyHoverBackAnim: "flyHoverBack",
flyHoverLeftAnim: "flyHoverLeft",
flyHoverRightAnim: "flyHoverRight",
flyHoverUpAnim: "flyHoverUp",
flyHoverDownAnim: "flyHoverDown",
headBoneName: "mixamorigHead",
rotateY: Math.PI,
speed: BASE_WALK_SPEED,
runSpeed: BASE_RUN_SPEED,
flySpeed: BASE_FLY_SPEED,
acceleration: 500,
deceleration: 10000000,
},
});
player.setPlayerCapsuleDebug(guiParams.playerCapsuleDebug);
player.setColliderDebug(guiParams.colliderDebug);
// 启用实际太阳光、天空光和环境反射。
viewer.atmosphere.setPostProcessMaterialLights(true);
}
// 创建调试面板
function initGUI() {
const gui = new GUI({ title: "3D Tiles Scene", width: 260 });
if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close();
Object.assign(gui.domElement.style, {
position: "fixed",
top: "12px",
right: "12px",
zIndex: "9999",
});
gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation());
gui.add(guiParams, "city", CITY_OPTIONS).name("City").onChange(respawn);
gui.add(guiParams, "playerCapsuleDebug").name("Capsule Debug").onChange((value) => {
player?.setPlayerCapsuleDebug(value);
});
gui.add(guiParams, "colliderDebug").name("Collider Debug").onChange((value) => {
player?.setColliderDebug(value);
});
gui.add(guiParams, "localAxes").name("Local Axes").onChange((value) => {
localAxes.visible = value;
});
}
// 启动示例
async function start() {
await viewer.ready;
loadPhotorealisticTiles();
await initPlayer();
tileCollision = new TileCollisionManager(activeLayer.tileset, player);
tileCollision.attach();
respawn();
initGUI();
requestAnimationFrame(loop);
// 关闭加载页面
window.hideLoader?.();
}
// 每帧调用
function loop(time) {
if (!running) return;
if (player) {
if (skipCollisionAfterRebase) {
player.setSkipCapsuleCollision(false);
skipCollisionAfterRebase = false;
}
syncTurboMode();
void player.update();
if (localFrame?.maybeRebase(player)) {
player.setSkipCapsuleCollision(true);
skipCollisionAfterRebase = true;
}
const pos = player.getPosition();
if (pos) tileCollision?.update(pos);
}
updateTurboFov();
// 局部坐标系下同步大气世界到 ECEF 的变换
syncAtmosphereFrame();
viewer.render(time);
renderAttributions();
stats.update();
requestAnimationFrame(loop);
}
window.viewer = viewer;
void start();
// 页面卸载时释放资源
window.addEventListener("beforeunload", () => {
running = false;
tileCollision?.dispose();
player?.destroy();
stats.dom.remove();
viewer.destroy();
});
@@ -0,0 +1,173 @@
// 按键文字 -> KeyboardEvent.code(可被 data-code 覆盖)
const LABEL_CODES = {
W: ["KeyW", "ArrowUp"],
A: ["KeyA", "ArrowLeft"],
S: ["KeyS", "ArrowDown"],
D: ["KeyD", "ArrowRight"],
Shift: ["ShiftLeft", "ShiftRight"],
Space: ["Space"],
RMB: ["Mouse2"],
LMB: ["Mouse0"],
1: ["Digit1"],
4: ["Digit4"],
};
// 在 GUI / 输入框里操作时不点亮 HUD
function shouldIgnore(el) {
if (!(el instanceof Element)) return false;
return Boolean(el.closest("input, textarea, select, [contenteditable='true'], .lil-gui"));
}
// 解析 kbd 对应的键码:优先 data-code,否则按显示文字查表
function codesFor(kbd) {
const explicit = kbd.getAttribute("data-code");
if (explicit) return explicit.trim().split(/\s+/).filter(Boolean);
const label = kbd.textContent.replace(/\s+/g, " ").trim();
if (LABEL_CODES[label]) return LABEL_CODES[label];
if (/^[A-Za-z]$/.test(label)) return [`Key${label.toUpperCase()}`];
return [];
}
const LOOK_SCALE = 0.28; // 鼠标位移换算到指示点的系数
const LOOK_MAX = 5; // 指示点最大偏移(px)
const LOOK_DECAY = 0.84; // 每帧回弹衰减
const LOOK_REST = 0.04; // 小于此值视为静止
// 确保每个移动提示块都有鼠标图标(HTML 未写时补一个)
function ensureLookMice(hud) {
const mice = [];
for (const move of hud.querySelectorAll(".hint-move")) {
let mouse = move.querySelector(".hint-mouse");
if (!mouse) {
const cluster = document.createElement("div");
cluster.className = "hint-cluster";
cluster.innerHTML = '<div class="hint-mouse" aria-hidden="true"><div class="hint-mouse-body"><span class="hint-mouse-wheel"></span><span class="hint-mouse-nub"></span></div></div><span class="hint-text">Look</span>';
move.append(cluster);
mouse = cluster.querySelector(".hint-mouse");
}
mice.push(mouse);
}
return mice;
}
// 绑定 HUD 按键高亮与鼠标 Look 指示
export function bindControlHints(hud = document.querySelector(".hud")) {
if (!hud) return;
const keys = [...hud.querySelectorAll("kbd")];
const mice = ensureLookMice(hud);
if (!keys.length && !mice.length) return;
const pressed = new Set(); // 当前按下的 KeyboardEvent.code / MouseN
let lookX = 0;
let lookY = 0;
let lookRaf = 0;
// 同步 kbd.is-down
const syncKeys = () => {
for (const kbd of keys) {
kbd.classList.toggle("is-down", codesFor(kbd).some((code) => pressed.has(code)));
}
};
// 把 Look 偏移写到鼠标图标
const applyLook = () => {
const x = `${lookX}px`;
const y = `${lookY}px`;
const active = Math.hypot(lookX, lookY) > LOOK_REST;
for (const mouse of mice) {
mouse.style.setProperty("--look-x", x);
mouse.style.setProperty("--look-y", y);
mouse.classList.toggle("is-active", active);
}
};
// 指示点回弹;静止后停掉 rAF
const tickLook = () => {
lookX *= LOOK_DECAY;
lookY *= LOOK_DECAY;
if (Math.abs(lookX) < LOOK_REST && Math.abs(lookY) < LOOK_REST) {
lookX = 0;
lookY = 0;
lookRaf = 0;
applyLook();
return;
}
applyLook();
lookRaf = requestAnimationFrame(tickLook);
};
// 键盘按下 / 抬起
const onKey = (event, down) => {
if (down && shouldIgnore(event.target)) return;
if (down) pressed.add(event.code);
else pressed.delete(event.code);
syncKeys();
};
// 鼠标按键(LMB / RMB 等)
const onMouse = (event, down) => {
if (down && shouldIgnore(event.target)) return;
const code = `Mouse${event.button}`;
if (down) pressed.add(code);
else pressed.delete(code);
syncKeys();
};
// 指针锁定时始终跟踪;否则仅在按住拖拽转视角时跟踪
const onMouseMove = (event) => {
if (!document.pointerLockElement && event.buttons === 0) return;
if (!document.pointerLockElement && shouldIgnore(event.target)) return;
lookX = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookX + event.movementX * LOOK_SCALE));
lookY = Math.max(-LOOK_MAX, Math.min(LOOK_MAX, lookY + event.movementY * LOOK_SCALE));
applyLook();
if (!lookRaf) lookRaf = requestAnimationFrame(tickLook);
};
// 失焦时清掉按下态和 Look 偏移
const clear = () => {
pressed.clear();
syncKeys();
lookX = 0;
lookY = 0;
if (lookRaf) {
cancelAnimationFrame(lookRaf);
lookRaf = 0;
}
applyLook();
};
window.addEventListener("keydown", (event) => onKey(event, true));
window.addEventListener("keyup", (event) => onKey(event, false));
window.addEventListener("mousedown", (event) => onMouse(event, true));
window.addEventListener("mouseup", (event) => onMouse(event, false));
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("blur", clear);
document.addEventListener("visibilitychange", () => {
if (document.hidden) clear();
});
}
// 按上下车切换走路 / 载具两套提示,并转调原有回调
export function bindVehicleHintMode(player, hud = document.querySelector(".hud")) {
if (!player || !hud) return;
const setMode = (mode) => {
hud.dataset.hintMode = mode;
};
setMode(player.getControllerMode?.() === 1 ? "vehicle" : "on-foot");
const prevEnter = player.onVehicleEnter;
const prevExit = player.onVehicleExit;
player.onVehicleEnter = (vehicle) => {
setMode("vehicle");
prevEnter?.(vehicle);
};
player.onVehicleExit = (vehicle) => {
setMode("on-foot");
prevExit?.(vehicle);
};
}
bindControlHints();
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Foot IK</title>
<link rel="stylesheet" href="./styles/common.css" />
<style>
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background: #dfe8ef;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
}
#container {
width: 100vw;
height: 100vh;
}
#top-notice {
white-space: nowrap;
line-height: normal;
text-align: start;
}
</style>
<script src="./loader.js" data-demo="Foot IK"></script>
</head>
<body>
<div id="container"></div>
<div id="top-notice">
Character Model:
<a href="https://quaternius.itch.io/universal-animation-library" target="_blank" rel="noopener noreferrer">
Universal Animation Library
</a>
</div>
<div class="hud" role="note" aria-label="Controls">
<div class="hint-move">
<div class="hint-cluster">
<div class="wasd">
<kbd class="wasd-w">W</kbd>
<kbd class="wasd-a">A</kbd>
<kbd class="wasd-s">S</kbd>
<kbd class="wasd-d">D</kbd>
</div>
<span class="hint-text">Move</span>
</div>
<div class="hint-cluster">
<div class="hint-mouse" aria-hidden="true">
<div class="hint-mouse-body">
<span class="hint-mouse-wheel"></span>
<span class="hint-mouse-nub"></span>
</div>
</div>
<span class="hint-text">Look</span>
</div>
</div>
<div class="hint-actions">
<div class="hint-group">
<span class="hint-keys"><kbd>Shift</kbd></span>
<span class="hint-text">Sprint</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Space</kbd></span>
<span class="hint-text">Jump</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>V</kbd></span>
<span class="hint-text">Toggle View</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>F</kbd></span>
<span class="hint-text">Toggle Fly</span>
</div>
</div>
</div>
<script type="module" src="./control-hints.js"></script>
<script type="module" src="./footIK.js"></script>
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/footIK.js" target="_blank" rel="noopener noreferrer">
<img src="./img/source.svg" alt="" />
</a>
</body>
</html>
+610
View File
@@ -0,0 +1,610 @@
import {
ACESFilmicToneMapping,
AmbientLight,
BoxGeometry,
DirectionalLight,
Group,
LineBasicMaterial,
LineSegments,
Mesh,
MeshStandardMaterial,
VSMShadowMap,
PerspectiveCamera,
Scene,
BufferGeometry,
Float32BufferAttribute,
Vector3,
WebGLRenderer,
} from "three";
import { Sky } from "three/addons/objects/Sky.js";
import { MapControls } from "three/examples/jsm/Addons.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { playerController } from "../src/PlayerController";
import { FootIK } from "../src/foot-ik";
const scene = new Scene();
let camera;
let renderer;
let controls;
let player;
let footIK;
let sky;
let gui;
let footIKDebugParams;
let writePelvisOffsetController;
let stats;
let leftMouseSlowMotion = false;
let rightMouseSlowMotion = false;
let savedPlayerTimeScale = 1;
init();
async function init() {
const container = document.querySelector("#container");
// 渲染器
renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.8;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = VSMShadowMap;
renderer.setAnimationLoop(animate);
container.appendChild(renderer.domElement);
// 帧率
stats = new Stats();
Object.assign(stats.dom.style, {
position: "fixed",
bottom: "0px",
left: "0px",
top: "auto",
zIndex: "9998",
display: "block",
});
document.body.appendChild(stats.dom);
// 相机
camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.01, 500);
camera.position.set(-6, 6, 9);
// 控制器
controls = new MapControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.maxDistance = 80;
controls.maxPolarAngle = Math.PI / 2.05;
controls.target.set(0, 0.8, 0);
// 环境光
scene.add(new AmbientLight(0xffffff, 1.8));
// 平行光
const sun = new DirectionalLight(0xffffff, 2);
sun.position.set(6, 10, 7);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 0.5;
sun.shadow.camera.far = 35;
sun.shadow.camera.left = -12;
sun.shadow.camera.right = 12;
sun.shadow.camera.top = 10;
sun.shadow.camera.bottom = -10;
sun.shadow.bias = -0.0005;
scene.add(sun);
sky = new Sky();
sky.scale.setScalar(450000);
scene.add(sky);
const skyUniforms = sky.material.uniforms;
skyUniforms["turbidity"].value = 10;
skyUniforms["rayleigh"].value = 2;
skyUniforms["mieCoefficient"].value = 0.005;
skyUniforms["mieDirectionalG"].value = 0.99;
updateSkySun(sun);
// 测试场景
const staticCollider = buildTestCourse();
scene.add(staticCollider);
addGroundGrid(scene);
// 人物控制器
const gltfLoader = new GLTFLoader();
const playerGltf = await gltfLoader.loadAsync("./glb/ual.glb");
player = new playerController();
await player.init({
scene,
camera,
controls,
colliders: [
{ motion: "static", shape: { kind: "mesh", source: staticCollider } },
],
initPos: new Vector3(-7.5, 0.25, -3.5),
minCamDistance: 10,
maxCamDistance: 300,
camLookAtHeightRatio: 0.5,
enableSpringCamera: true,
playerModelConfig: {
model: playerGltf.scene,
animations: playerGltf.animations,
scale: 0.005,
idleAnim: "Idle_Loop",
walkAnim: "Walk_Loop",
runAnim: "Sprint_Loop",
jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
flyAnim: "fly",
flyIdleAnim: "flyIdle",
flyHoverForwardAnim: "flyHoverForward",
flyHoverBackAnim: "flyHoverBack",
flyHoverLeftAnim: "flyHoverLeft",
flyHoverRightAnim: "flyHoverRight",
flyHoverUpAnim: "flyHoverUp",
flyHoverDownAnim: "flyHoverDown",
speed: 100,
runSpeed: 600,
headBoneName: "Head",
firstPersonCameraOffset: [0, 0.15, 0.12],
rotateY: Math.PI / 2,
},
// keyMap: { toggleFly: null }, // 关闭飞行
});
enableModelShadows(player.playerModel);
// 骨骼配置
const skeleton = {
hips: "pelvis",
legs: {
left: {
upper: "thigh_l",
lower: "calf_l",
foot: "foot_l",
toe: "ball_l",
},
right: {
upper: "thigh_r",
lower: "calf_r",
foot: "foot_r",
toe: "ball_r",
},
},
};
footIK = new FootIK({
skeleton,
soleHalfWidth: 7,
soleToeExtend: 7,
soleSkinThickness: 1.6,
plantedHeightSpeed: 200,
penetrationLiftSpeed: 200,
predictivePlacement: true,
straightPoleEnabled: true,
});
player.use(footIK);
createDebugPanel();
window.addEventListener("resize", onResize);
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("mouseup", onMouseUp);
window.addEventListener("blur", releaseRightMouseSlowMotion);
renderer.domElement.addEventListener("contextmenu", event => event.preventDefault());
window.hideLoader?.();
}
// 创建调试面板
function createDebugPanel() {
const options = footIK.getOptions();
const params = {
colliderDebug: false,
playerCapsuleDebug: false,
footIKEnabled: options.enabled,
footIKDebug: options.debug,
predictivePlacement: options.predictivePlacement,
straightPoleEnabled: options.straightPoleEnabled,
pelvisOffset: 0,
writePelvisOffset: 0,
clearPelvisOffset: () => {
params.writePelvisOffset = 0;
writePelvisOffsetController.updateDisplay();
footIK.clearPelvisOffset();
},
leftFootPhase: "",
leftFootLandIK: "-- / 0.00",
leftPrediction: "disabled",
leftPredictionScore: "--",
rightFootPhase: "",
rightFootLandIK: "-- / 0.00",
rightPrediction: "disabled",
rightPredictionScore: "--",
maxPelvisRaise: options.maxPelvisRaise,
maxPelvisDrop: options.maxPelvisDrop,
maxFootRaise: options.maxFootRaise,
maxFootDrop: options.maxFootDrop,
plantedHeightSpeed: options.plantedHeightSpeed,
penetrationLiftSpeed: options.penetrationLiftSpeed,
soleHalfWidth: options.soleHalfWidth,
soleToeExtend: options.soleToeExtend,
soleHeelExtend: options.soleHeelExtend,
soleSkinThickness: options.soleSkinThickness,
maxPredictionClearance: options.maxPredictionClearance,
};
footIKDebugParams = params;
const applyFootIKOptions = (patch) => {
footIK.configure(patch);
};
footIK.setDebugEnabled(params.footIKDebug && params.footIKEnabled);
gui = new GUI({ title: "Foot IK Controls", width: 320 });
if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close();
Object.assign(gui.domElement.style, {
position: "fixed",
top: "12px",
right: "12px",
maxHeight: "calc(100vh - 24px)",
overflowY: "auto",
});
gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation());
const collisionFolder = gui.addFolder("Collision Debug");
collisionFolder.add(params, "colliderDebug").name("Static / Kinematic Meshes").onChange((value) => {
player.setColliderDebug(value);
});
collisionFolder.add(params, "playerCapsuleDebug").name("Capsule").onChange((value) => {
player.setPlayerCapsuleDebug(value);
});
collisionFolder.open();
const footIKFolder = gui.addFolder("Foot IK");
footIKFolder.add(params, "footIKEnabled").name("Enabled").onChange((value) => {
footIK.setEnabled(value);
footIK.setDebugEnabled(value && params.footIKDebug);
});
footIKFolder.add(params, "footIKDebug").name("Debug Markers").onChange((value) => {
footIK.setDebugEnabled(value && params.footIKEnabled);
});
footIKFolder.add(params, "predictivePlacement").name("Predictive Placement").onChange((value) => {
footIK.configure({ predictivePlacement: value });
});
footIKFolder.add(params, "straightPoleEnabled").name("Straight Pole").onChange((value) => {
footIK.configure({ straightPoleEnabled: value });
});
const footIKRuntimeFolder = footIKFolder.addFolder("Runtime");
footIKRuntimeFolder.add(params, "pelvisOffset").name("Pelvis Offset").decimals(2).listen().disable();
footIKRuntimeFolder.add(params, "leftFootPhase").name("Left Phase").listen().disable();
footIKRuntimeFolder.add(params, "leftFootLandIK").name("Left Land / IK").listen().disable();
footIKRuntimeFolder.add(params, "leftPrediction").name("Left Prediction").listen().disable();
footIKRuntimeFolder.add(params, "leftPredictionScore").name("Left Score").listen().disable();
footIKRuntimeFolder.add(params, "rightFootPhase").name("Right Phase").listen().disable();
footIKRuntimeFolder.add(params, "rightFootLandIK").name("Right Land / IK").listen().disable();
footIKRuntimeFolder.add(params, "rightPrediction").name("Right Prediction").listen().disable();
footIKRuntimeFolder.add(params, "rightPredictionScore").name("Right Score").listen().disable();
footIKRuntimeFolder.close();
const pelvisFolder = footIKFolder.addFolder("Pelvis");
pelvisFolder.add(params, "maxPelvisRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => {
applyFootIKOptions({ maxPelvisRaise: value });
});
pelvisFolder.add(params, "maxPelvisDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => {
applyFootIKOptions({ maxPelvisDrop: value });
});
writePelvisOffsetController = pelvisFolder.add(params, "writePelvisOffset", -60, 60, 0.1)
.name("Write Offset")
.decimals(1)
.onChange((value) => {
footIK.setPelvisOffset(value);
});
pelvisFolder.add(params, "clearPelvisOffset").name("Clear Offset");
pelvisFolder.close();
const footReachFolder = footIKFolder.addFolder("Foot Reach");
footReachFolder.add(params, "maxFootRaise", 0, 60, 1).name("Max Raise").decimals(0).onChange((value) => {
applyFootIKOptions({ maxFootRaise: value });
});
footReachFolder.add(params, "maxFootDrop", 0, 60, 1).name("Max Drop").decimals(0).onChange((value) => {
applyFootIKOptions({ maxFootDrop: value });
});
footReachFolder.add(params, "plantedHeightSpeed", 50, 500, 1).name("Planted Speed").decimals(0).onChange((value) => {
applyFootIKOptions({ plantedHeightSpeed: value });
});
footReachFolder.add(params, "penetrationLiftSpeed", 50, 500, 1).name("Penetration Speed").decimals(0).onChange((value) => {
applyFootIKOptions({ penetrationLiftSpeed: value });
});
footReachFolder.close();
const soleFolder = footIKFolder.addFolder("Sole Layout");
soleFolder.add(params, "soleHalfWidth", 0, 24, 0.1).name("Half Width").decimals(1).onChange((value) => {
applyFootIKOptions({ soleHalfWidth: value });
});
soleFolder.add(params, "soleToeExtend", 0, 24, 0.1).name("Toe Extend").decimals(1).onChange((value) => {
applyFootIKOptions({ soleToeExtend: value });
});
soleFolder.add(params, "soleHeelExtend", 0, 24, 0.1).name("Heel Extend").decimals(1).onChange((value) => {
applyFootIKOptions({ soleHeelExtend: value });
});
soleFolder.add(params, "soleSkinThickness", 0, 16, 0.1).name("Skin Thickness").decimals(1).onChange((value) => {
applyFootIKOptions({ soleSkinThickness: value });
});
soleFolder.close();
const predictionFolder = footIKFolder.addFolder("Prediction");
predictionFolder.add(params, "maxPredictionClearance", 0, 100, 1).name("Max Clearance").decimals(0).onChange((value) => {
applyFootIKOptions({ maxPredictionClearance: value });
});
predictionFolder.close();
footIKFolder.open();
}
// 更新天空太阳方向。
function updateSkySun(light) {
sky.material.uniforms["sunPosition"].value.copy(light.position).normalize();
}
// 创建测试场景
function buildTestCourse() {
const root = new Group();
const groundMat = new MeshStandardMaterial({ color: 0xd8d5cf, roughness: 0.9 });
const stairMats = [
new MeshStandardMaterial({ color: 0xb8b0a1, roughness: 0.75 }),
new MeshStandardMaterial({ color: 0xc3a28f, roughness: 0.75 }),
new MeshStandardMaterial({ color: 0xa8b4c4, roughness: 0.75 }),
];
const rampMats = [
new MeshStandardMaterial({ color: 0xadbca8, roughness: 0.8 }),
new MeshStandardMaterial({ color: 0x9fb7b4, roughness: 0.8 }),
new MeshStandardMaterial({ color: 0xc0b48c, roughness: 0.8 }),
];
const speedBumpMats = [
new MeshStandardMaterial({ color: 0xe5b73b, roughness: 0.72 }),
new MeshStandardMaterial({ color: 0x4b4a46, roughness: 0.82 }),
];
const wallMat = new MeshStandardMaterial({ color: 0x9a9691, roughness: 0.88 });
addBox(root, new Vector3(0, -0.06, 0), new Vector3(20, 0.12, 12), groundMat);
createBoundaryWalls(root, wallMat);
createStairs(root, new Vector3(-6.5, 0, -3.5), 0.08, stairMats[0]);
createStairs(root, new Vector3(-1.2, 0, -3.5), 0.12, stairMats[1]);
createStairs(root, new Vector3(4.1, 0, -3.5), 0.16, stairMats[2]);
createSpeedBumpCourse(root, -0.25, speedBumpMats);
createRamp(root, new Vector3(-6.1, 0, 3), 10, rampMats[0]);
createRamp(root, new Vector3(-0.3, 0, 3), 18, rampMats[1]);
createRamp(root, new Vector3(5.8, 0, 3), 28, rampMats[2]);
return root;
}
// 创建地面网格
function addGroundGrid(root) {
const width = 20;
const depth = 12;
const step = 1;
const points = [];
for (let x = -width / 2; x <= width / 2 + 0.0001; x += step) {
points.push(new Vector3(x, 0, -depth / 2), new Vector3(x, 0, depth / 2));
}
for (let z = -depth / 2; z <= depth / 2 + 0.0001; z += step) {
points.push(new Vector3(-width / 2, 0, z), new Vector3(width / 2, 0, z));
}
const geometry = new BufferGeometry().setFromPoints(points);
const material = new LineBasicMaterial({ color: 0xbcb7af });
const grid = new LineSegments(geometry, material);
grid.position.y = 0.004;
root.add(grid);
return grid;
}
// 创建一组边界墙
function createBoundaryWalls(root, material) {
const wallHeight = 1.2;
const wallThickness = 0.16;
const halfWidth = 10;
const halfDepth = 6;
const centerY = wallHeight / 2;
addBox(root, new Vector3(0, centerY, -halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material);
addBox(root, new Vector3(0, centerY, halfDepth), new Vector3(halfWidth * 2, wallHeight, wallThickness), material);
addBox(root, new Vector3(-halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material);
addBox(root, new Vector3(halfWidth, centerY, 0), new Vector3(wallThickness, wallHeight, halfDepth * 2), material);
}
// 创建一组楼梯
function createStairs(root, start, stepHeight, material) {
const stepCount = 7;
const stepDepth = 0.42;
const width = 1.9;
for (let i = 0; i < stepCount; i++) {
const height = stepHeight * (i + 1);
addBox(
root,
new Vector3(start.x + i * stepDepth, height / 2, start.z),
new Vector3(stepDepth, height, width),
material,
);
}
}
// 在楼梯和斜坡之间创建 10 个高度统一、宽度不超过 0.32 的长方体。
function createSpeedBumpCourse(root, centerZ, materials) {
const height = 0.140;
const depth = 1.7;
const widths = [0.14, 0.16, 0.18, 0.20, 0.22, 0.24, 0.26, 0.28, 0.30, 0.32];
const gaps = [0.32, 0.58, 0.41, 0.75, 0.36, 0.63, 0.48, 0.71, 0.54];
const totalWidth = widths.reduce((sum, width) => sum + width, 0)
+ gaps.reduce((sum, gap) => sum + gap, 0);
let cursorX = -totalWidth / 2;
widths.forEach((width, index) => {
addBox(
root,
new Vector3(cursorX + width / 2, height / 2, centerZ),
new Vector3(width, height, depth),
materials[index % materials.length],
);
cursorX += width + (gaps[index] ?? 0);
});
}
// 创建一个斜坡
function createRamp(root, position, angleDeg, material) {
const length = 3.4;
const width = 1.8;
const height = Math.tan(angleDeg * Math.PI / 180) * length;
const ramp = new Mesh(createRampGeometry(length, height, width), material);
ramp.position.copy(position);
ramp.castShadow = true;
ramp.receiveShadow = true;
root.add(ramp);
return ramp;
}
// 创建斜坡碰撞几何体。
function createRampGeometry(length, height, width) {
const halfLength = length / 2;
const halfWidth = width / 2;
const vertices = [
-halfLength, 0, -halfWidth,
halfLength, 0, -halfWidth,
halfLength, height, -halfWidth,
-halfLength, 0, halfWidth,
halfLength, 0, halfWidth,
halfLength, height, halfWidth,
];
const indices = [
0, 1, 4, 0, 4, 3,
1, 2, 5, 1, 5, 4,
0, 3, 5, 0, 5, 2,
0, 2, 1,
3, 4, 5,
];
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(vertices, 3));
geometry.setIndex(indices);
const flatGeometry = geometry.toNonIndexed();
flatGeometry.computeVertexNormals();
return flatGeometry;
}
function addBox(root, position, scale, material) {
const mesh = new Mesh(new BoxGeometry(1, 1, 1), material);
mesh.position.copy(position);
mesh.scale.copy(scale);
mesh.castShadow = true;
mesh.receiveShadow = true;
root.add(mesh);
return mesh;
}
// 启用模型阴影
function enableModelShadows(root) {
root?.traverse(obj => {
if (!obj.isMesh) return;
obj.castShadow = true;
obj.receiveShadow = true;
});
}
// 每帧调用
function animate() {
if (player) {
player.update();
} else {
controls.update();
}
updateFootIKDebugPanel();
renderer.render(scene, camera);
stats?.update();
}
// 更新 Foot IK 运行状态只读字段。
function updateFootIKDebugPanel() {
if (!footIK || !footIKDebugParams) return;
footIKDebugParams.pelvisOffset = footIK.getPelvisOffset();
footIKDebugParams.leftFootPhase = footIK.getFootPhaseDebugText("left");
footIKDebugParams.leftFootLandIK = formatFootLandIK(
footIK.getFootTimeToLand("left"),
footIK.getFootIKWeight("left"),
);
footIKDebugParams.leftPrediction = footIK.getPredictiveFootDebugText("left");
footIKDebugParams.leftPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("left"));
footIKDebugParams.rightFootPhase = footIK.getFootPhaseDebugText("right");
footIKDebugParams.rightFootLandIK = formatFootLandIK(
footIK.getFootTimeToLand("right"),
footIK.getFootIKWeight("right"),
);
footIKDebugParams.rightPrediction = footIK.getPredictiveFootDebugText("right");
footIKDebugParams.rightPredictionScore = formatPredictionScore(footIK.getPredictiveFootScore("right"));
}
function formatFootLandIK(landTime, ikWeight) {
const land = Number.isFinite(landTime) ? `${landTime.toFixed(2)}s` : "--";
const weight = Number.isFinite(ikWeight) ? ikWeight.toFixed(2) : "--";
return `${land} / ${weight}`;
}
function formatPredictionScore(value) {
return Number.isFinite(value) ? value.toFixed(2) : "--";
}
// 鼠标右键慢动作
function onMouseDown(event) {
if (!player) return;
if (event.button == 2) {
rightMouseSlowMotion = true;
}
if (event.button == 0) {
leftMouseSlowMotion = true;
}
updatePlayerTimeScale();
}
// 鼠标右键慢动作释放
function onMouseUp(event) {
if (event.button == 2) {
rightMouseSlowMotion = false;
}
if (event.button == 0) {
leftMouseSlowMotion = false;
}
updatePlayerTimeScale();
}
// 集中更新 player.timeScale
function releaseRightMouseSlowMotion() {
rightMouseSlowMotion = false;
leftMouseSlowMotion = false;
updatePlayerTimeScale();
}
// 更新 player.timeScale
function updatePlayerTimeScale() {
if (!player) return;
if (leftMouseSlowMotion) {
player.timeScale = 0;
} else if (rightMouseSlowMotion) {
player.timeScale = 0.1;
} else {
player.timeScale = savedPlayerTimeScale;
}
}
// 窗口尺寸变化
function onResize() {
const container = document.querySelector("#container");
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
+133
View File
@@ -0,0 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>three-player-controller</title>
<link rel="stylesheet" href="./styles/common.css" />
<style>
html,
body {
height: 100%;
margin: 0;
}
#container {
overflow: hidden !important;
width: 100vw;
height: 100vh;
position: absolute;
background: transparent;
}
</style>
<script src="./loader.js" data-demo="glTF Scene"></script>
</head>
<body>
<div id="container"></div>
<div class="hud" data-hint-mode="on-foot" role="note" aria-label="Controls">
<div class="hint-set" data-for="on-foot">
<div class="hint-move">
<div class="hint-cluster">
<div class="wasd">
<kbd class="wasd-w">W</kbd>
<kbd class="wasd-a">A</kbd>
<kbd class="wasd-s">S</kbd>
<kbd class="wasd-d">D</kbd>
</div>
<span class="hint-text">Move</span>
</div>
<div class="hint-cluster">
<div class="hint-mouse" aria-hidden="true">
<div class="hint-mouse-body">
<span class="hint-mouse-wheel"></span>
<span class="hint-mouse-nub"></span>
</div>
</div>
<span class="hint-text">Look</span>
</div>
</div>
<div class="hint-actions">
<div class="hint-group">
<span class="hint-keys"><kbd>Shift</kbd></span>
<span class="hint-text">Sprint</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Space</kbd></span>
<span class="hint-text">Jump</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>E</kbd></span>
<span class="hint-text">Enter</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>V</kbd></span>
<span class="hint-text">Toggle View</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>F</kbd></span>
<span class="hint-text">Toggle Fly</span>
</div>
</div>
</div>
<div class="hint-set" data-for="vehicle">
<div class="hint-move">
<div class="hint-cluster">
<div class="wasd">
<kbd class="wasd-w">W</kbd>
<kbd class="wasd-a">A</kbd>
<kbd class="wasd-s">S</kbd>
<kbd class="wasd-d">D</kbd>
</div>
<span class="hint-text">Drive</span>
</div>
<div class="hint-cluster">
<div class="hint-mouse" aria-hidden="true">
<div class="hint-mouse-body">
<span class="hint-mouse-wheel"></span>
<span class="hint-mouse-nub"></span>
</div>
</div>
<span class="hint-text">Look</span>
</div>
</div>
<div class="hint-actions">
<div class="hint-group">
<span class="hint-keys"><kbd>Shift</kbd></span>
<span class="hint-text">Drift</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>Space</kbd></span>
<span class="hint-text">Brake</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>E</kbd></span>
<span class="hint-text">Exit</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>V</kbd></span>
<span class="hint-text">Toggle View</span>
</div>
<div class="hint-group">
<span class="hint-keys"><kbd>R</kbd></span>
<span class="hint-text">Reset Vehicle</span>
</div>
</div>
</div>
</div>
<div class="model-credit">
Scene model:
<a href="https://sketchfab.com/3d-models/burnout-revenge-central-route-crash-junction-cfb4bec737644befb4a39d28e30a3926" target="_blank" rel="noopener noreferrer">Sketchfab</a>
<br />
Vehicle model:
<a href="https://sketchfab.com/3d-models/generic-sedan-car-58c33766470d46e7b2aed542650494e5" target="_blank" rel="noopener noreferrer">Generic Sedan Car</a>
</div>
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/glTF.js" target="_blank" rel="noopener noreferrer">
<img src="./img/source.svg" alt="" />
</a>
<script type="module" src="./control-hints.js"></script>
<script type="module" src="./glTF.js"></script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+383
View File
@@ -0,0 +1,383 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>three-player-controller</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>" />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@900&family=Nunito:wght@700&display=swap" rel="stylesheet" />
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--accent: #e8a020;
--card-bg: #f0f0f0;
--card-hover: #e6e6e6;
--text: #1a1a2e;
--muted: #999;
}
body {
min-height: 100vh;
background: #ececec;
color: var(--text);
font-family: "Nunito", sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 2.5rem 2rem 4rem;
}
/* ── GitHub corner ── */
.github-link {
position: fixed;
top: 1.2rem;
right: 1.4rem;
color: #333;
transition: color 0.15s;
z-index: 10;
}
.github-link:hover {
color: #000;
}
.github-link svg {
width: 28px;
height: 28px;
}
.version-switch {
position: fixed;
top: 1.2rem;
left: 1.4rem;
z-index: 10;
display: inline-flex;
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.version-switch-active {
padding: 6px 14px;
background: var(--accent);
color: #fff;
}
.version-switch-link {
padding: 6px 14px;
color: var(--text);
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.version-switch-link:hover {
background: rgba(0, 0, 0, 0.05);
color: #000;
}
h1 {
font-family: "Cinzel", serif;
font-size: clamp(1.4rem, 3.5vw, 2.2rem);
font-weight: 900;
letter-spacing: 0.06em;
text-align: center;
color: var(--text);
margin-bottom: 0.3rem;
}
.desc {
font-size: 0.88rem;
color: #555;
text-align: center;
max-width: 520px;
line-height: 1.6;
margin-bottom: 0.8rem;
}
.subtitle {
font-size: 0.78rem;
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 2.5rem;
}
.sections {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: 2.2rem;
}
.section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.7rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.section-title {
font-size: 1.1rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.section-desc {
font-size: 0.9rem;
color: #666;
text-align: right;
}
/* ── grid ── */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.2rem;
width: 100%;
max-width: 1200px;
}
/* ── card ── */
.card {
background: var(--card-bg);
border-radius: 14px;
overflow: hidden;
text-decoration: none;
color: var(--text);
display: flex;
flex-direction: column;
aspect-ratio: 4/3;
border: 1px solid rgba(0, 0, 0, 0.06);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
opacity: 0;
animation: rise 0.4s ease forwards;
}
.card:hover {
transform: translateY(-4px) scale(1.01);
background: var(--card-hover);
box-shadow:
0 12px 36px rgba(0, 0, 0, 0.12),
0 0 0 2px var(--accent);
}
.card:active {
transform: translateY(-1px) scale(1.005);
}
.card-thumb {
flex: 1;
min-height: 0;
overflow: hidden;
}
.card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-thumb-placeholder {
display: grid;
place-items: center;
color: #fff;
background:
radial-gradient(circle at 30% 25%, rgba(245, 181, 53, 0.7), transparent 35%),
linear-gradient(135deg, #263238, #455a64);
font-size: clamp(1.8rem, 5vw, 3.5rem);
font-weight: 900;
letter-spacing: 0.12em;
}
.card-info {
padding: 0.9rem 1.2rem;
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.card-name {
font-size: 1rem;
font-weight: 700;
}
.play-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35);
transition:
transform 0.15s,
background 0.15s;
}
.card:hover .play-btn {
transform: scale(1.12);
background: #f5b535;
}
.play-btn svg {
width: 10px;
height: 10px;
fill: #fff;
margin-left: 2px;
}
/* stagger */
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card:nth-child(1) {
animation-delay: 0.05s;
}
.card:nth-child(2) {
animation-delay: 0.12s;
}
.card:nth-child(3) {
animation-delay: 0.19s;
}
.card:nth-child(4) {
animation-delay: 0.26s;
}
.card:nth-child(5) {
animation-delay: 0.33s;
}
.card:nth-child(6) {
animation-delay: 0.4s;
}
@media (max-width: 720px) {
.section-head {
align-items: flex-start;
flex-direction: column;
}
.section-desc {
text-align: left;
}
}
</style>
</head>
<body>
<a class="github-link" href="https://github.com/hh-hang/three-player-controller" target="_blank" title="GitHub">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577
0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.385-1.335-1.755-1.335-1.755
-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998
.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22
-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405
1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176
.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22
0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297
c0-6.627-5.373-12-12-12"
/>
</svg>
</a>
<div class="version-switch">
<span class="version-switch-active">Three.js</span>
<a href="https://hh-hang.github.io/cesium-player-controller/" class="version-switch-link">CesiumJS</a>
</div>
<h1>three-player-controller</h1>
<p class="desc">A lightweight character controller for Three.js, accelerated by three-mesh-bvh for smooth performance in large-scale 3D scenes.</p>
<div class="sections">
<section class="section">
<div class="section-head">
<div class="section-title">Demo</div>
</div>
<div class="grid">
<a class="card" href="./showcase.html">
<div class="card-thumb">
<img src="./img/covers/showcase.png" />
</div>
<div class="card-info">
<span class="card-name">Showcase</span><em class="badge">Full Controls</em>
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
</div>
</a>
<a class="card" href="./footIK.html">
<div class="card-thumb">
<img src="./img/covers/footik.png" />
</div>
<div class="card-info">
<span class="card-name">Foot IK</span>
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
</div>
</a>
<a class="card" href="./shooting/shooting.html">
<div class="card-thumb">
<img src="./img/covers/shooting.jpg" />
</div>
<div class="card-info">
<span class="card-name">FPS Game</span>
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
</div>
</a>
</div>
</section>
<section class="section">
<div class="section-head">
<div class="section-title">Model Formats</div>
</div>
<div class="grid">
<a class="card" href="./glTF.html">
<div class="card-thumb">
<img src="./img/covers/glTF.png" />
</div>
<div class="card-info">
<span class="card-name">glTF Scene</span><em class="badge">Full Controls</em>
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
</div>
</a>
<a class="card" href="./3dtilesScene.html">
<div class="card-thumb">
<img src="./img/covers/3dtiles.png" />
</div>
<div class="card-info">
<span class="card-name">3DTiles Scene</span>
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
</div>
</a>
<a class="card" href="./3dgs.html">
<div class="card-thumb">
<img src="./img/covers/3dgs.png" />
</div>
<div class="card-info">
<span class="card-name">3DGS Scene</span>
<span class="play-btn"><svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg></span>
</div>
</a>
</div>
</section>
</div>
</body>
</html>
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.20467473566532135,-4.759437705400649e-21,1.2439442348830942e-21,0.9788300395011902],"translation":[-1.5903435849255598e-27,0.0711912140250206,2.0489096641540527e-08]},{"children":[0],"name":"neck_01","rotation":[0.3088374435901642,8.337463586651673e-21,-5.849849968646638e-21,0.95111483335495],"translation":[-1.0141478263557337e-20,0.19815585017204285,2.60770320892334e-08]},{"name":"index_04_leaf_l","rotation":[-8.381903171539307e-09,1,-1.5432005174599583e-14,3.6517765238386346e-06],"translation":[9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[2],"name":"index_03_l","rotation":[1.0023382124302316e-09,7.603918037758783e-13,-0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[3],"name":"index_02_l","rotation":[1.8987577732332284e-08,1.4024364247688936e-07,-0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[4],"name":"index_01_l","rotation":[-0.0033091865479946136,0.7071018815040588,-0.003309202380478382,0.7070961594581604],"translation":[0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_l","rotation":[2.0489096641540527e-08,1,6.335320587363621e-14,3.6517765238386346e-06],"translation":[2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[6],"name":"middle_03_l","rotation":[2.420814304571195e-08,1.4173854798182894e-11,-0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[-4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[7],"name":"middle_02_l","rotation":[-2.949975197452659e-08,1.402399334438087e-07,0.0038676888216286898,0.9999924898147583],"translation":[1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[8],"name":"middle_01_l","rotation":[-0.007979890331625938,0.7070646286010742,-0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_l","rotation":[8.381903171539307e-09,1,5.647658583201112e-14,3.65177675121231e-06],"translation":[-1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[10],"name":"pinky_03_l","rotation":[-2.1800540039862426e-08,5.25581791629981e-12,0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[-1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[11],"name":"pinky_02_l","rotation":[1.03155857189563e-08,1.4017835781032773e-07,-0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[-2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[12],"name":"pinky_01_l","rotation":[-0.01044775452464819,0.7070325016975403,-0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_l","rotation":[2.7474015951156616e-08,1,1.0721373739979931e-13,3.65177675121231e-06],"translation":[9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[14],"name":"ring_03_l","rotation":[2.6234243222234e-08,4.3393351412124304e-11,-0.0034884933847934008,0.9999939203262329],"translation":[7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[15],"name":"ring_02_l","rotation":[-1.8197205520209536e-08,1.4009778226409253e-07,0.002474082401022315,0.9999969601631165],"translation":[-9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[16],"name":"ring_01_l","rotation":[0.0025809321086853743,0.7071049213409424,0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_l","rotation":[-1.2782692238033633e-08,0.48539119958877563,2.0375746601075662e-07,0.8742970824241638],"scale":[0.9999999403953552,1,1],"translation":[-1.2670352589339018e-07,0.03127264976501465,3.073364496231079e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-0.0012816648231819272,5.811178561998531e-05,0.0002541383437346667,0.9999991655349731],"scale":[1,1,0.9999999403953552],"translation":[2.2436142899096012e-07,0.035620346665382385,5.122274160385132e-09]},{"children":[19],"name":"thumb_02_l","rotation":[0.0019128327257931232,0.00019216042710468173,-0.0004484721284825355,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[1.453081495128572e-07,0.03582108020782471,-1.3038516044616Line truncated
@@ -0,0 +1 @@
{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.13207007944583893,0,0,0.991240382194519],"translation":[0,0.08285234868526459,9.313225746154785e-09]},{"children":[0],"name":"neck_01","rotation":[0.25725916028022766,0,0,0.9663424491882324],"translation":[0,0.21509313583374023,1.862645149230957e-08]},{"name":"index_04_leaf_l","rotation":[-1.789221437675792e-09,0.9998229742050171,3.712300440383842e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[-1.1732481652870774e-09,0.024110054597258568,-1.3058706826996058e-07]},{"children":[2],"name":"index_03_l","rotation":[1.9416402210481465e-06,-7.145895324356388e-06,-0.00018566079961601645,1],"scale":[1,1,1.0000001192092896],"translation":[-5.757101462222636e-10,0.027911342680454254,5.889705789741129e-08]},{"children":[3],"name":"index_02_l","rotation":[3.8516853351211466e-07,4.9490859964862466e-05,-0.0005330402054823935,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[-8.440110832452774e-10,0.032612428069114685,1.2031887308694422e-07]},{"children":[4],"name":"index_01_l","rotation":[0.008505093865096569,0.7068116664886475,-0.01852414943277836,0.7071080803871155],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.007159475702792406,0.1174025684595108,0.040643345564603806]},{"name":"middle_04_leaf_l","rotation":[-1.5724443969133972e-08,0.9998255372047424,5.420716675530457e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[2.2610038286074996e-09,0.030519673600792885,1.955777406692505e-07]},{"children":[6],"name":"middle_03_l","rotation":[-4.686012857746391e-07,-5.432930265669711e-05,-0.0014198303688317537,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-5.793481250293553e-10,0.030116640031337738,-6.39629433862865e-08]},{"children":[7],"name":"middle_02_l","rotation":[5.612571953861334e-07,7.84045914770104e-05,0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[-4.098183126188815e-09,0.03752607852220535,-1.7083948478102684e-08]},{"children":[8],"name":"middle_01_l","rotation":[0.005067416001111269,0.7067627906799316,-0.02195817232131958,0.7070916295051575],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.004682549275457859,0.1154387816786766,0.015113381668925285]},{"name":"pinky_04_leaf_l","rotation":[-6.453817924523264e-09,0.9998291730880737,2.147510969052746e-07,0.018483733758330345],"translation":[2.8803697205148637e-09,0.021523315459489822,-3.95521055907011e-08]},{"children":[10],"name":"pinky_03_l","rotation":[3.3508222259115428e-06,-0.00013744289753958583,0.0004660093109123409,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[-4.249159246683121e-09,0.027430830523371696,1.7808633856475353e-07]},{"children":[11],"name":"pinky_02_l","rotation":[3.9539517615594377e-07,9.536039578961208e-05,-0.0015674401074647903,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[1.1932570487260818e-08,0.03163102641701698,-1.7530692275613546e-07]},{"children":[12],"name":"pinky_01_l","rotation":[0.002601056592538953,0.7066821455955505,-0.024414697661995888,0.7071049809455872],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.0060377283953130245,0.09854141622781754,-0.03461737558245659]},{"name":"ring_04_leaf_l","rotation":[8.940817863845041e-09,0.9998185634613037,5.402291662903735e-07,0.019049758091568947],"translation":[-9.167706593871117e-09,0.02910165674984455,5.82986103836447e-09]},{"children":[14],"name":"ring_03_l","rotation":[3.849756012641592e-06,-0.00025297756656073034,-0.003387551987543702,0.9999942183494568],"translation":[1.204170985147357e-09,0.028300179168581963,5.35583239980042e-08]},{"children":[15],"name":"ring_02_l","rotation":[-4.73642239740002e-06,0.00021853116049896926,0.00239976542070508,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[7.916241884231567e-09,0.03600123152136803,-8.07840478955768e-08]},{"children":[16],"name":"ring_01_l","rotation":[0.015323746018111706,0.7069170475006104,-0.011713176034390926,0.7070334553718567],"scale":[1,0.9999997615814209,1],"translation":[0.004751814063638449,0.10940060019493103,-0.012370164506137371]},{"name":"thumb_04_leaf_l","rotation":[1.285916511051255e-07,0.38270875811576843,-4.782022244853579e-08,0.9238690137863159],"scale":[1,0.9999999403953552,1],"translation":[-1.0035000741481781e-07,0.034558095037937164,2.1187588572502136e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-5.349284037947655e-08,-2.9730843380093575e-05,1.7080456018447876e-06,1],"translation":[-1.2048985809087753e-08,0.03549090400338173,-2.9336661100387573e-08]},{"children":[19],"name":"thumb_02_l","rotation":[6.149784894660115e-07,4.712928785011172e-06,-3.21120Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 837 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#000000">
<path fill="none" d="M0 0h24v24H0V0z"/>
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 261 B

@@ -0,0 +1,221 @@
(function () {
const script = document.currentScript || document.querySelector("script[src*='loader.js']");
const demo = script?.dataset?.demo || "";
const fade = 600;
if (!document.querySelector('link[href*="Cinzel"]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://fonts.googleapis.com/css2?family=Cinzel:wght@900&family=Nunito:wght@700&display=swap";
document.head.appendChild(link);
}
const style = document.createElement("style");
style.id = "__loader-style__";
style.textContent = `
html { height: 100%; }
#__loading-overlay__ {
position: fixed;
inset: 0;
z-index: 99999;
background: #ececec;
display: flex;
align-items: center;
justify-content: center;
transition: opacity ${fade}ms cubic-bezier(0.4, 0, 0.2, 1);
}
#__loading-overlay__.fade-out {
opacity: 0;
pointer-events: none;
}
.__ldr-content__ {
display: flex;
flex-direction: column;
align-items: center;
padding: 0 1.5rem;
user-select: none;
}
.__ldr-play__ {
width: 3.25rem;
height: 3.25rem;
border-radius: 50%;
background: #e8a020;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.4rem;
box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35);
animation: __ldr-pulse__ 1.6s ease-in-out infinite;
}
.__ldr-play__ svg {
width: 14px;
height: 14px;
fill: #fff;
margin-left: 2px;
}
.__ldr-title__ {
font-family: "Cinzel", serif;
font-size: clamp(1.4rem, 3.5vw, 2.2rem);
font-weight: 900;
letter-spacing: 0.06em;
color: #1a1a2e;
text-align: center;
margin-bottom: 0.3rem;
}
.__ldr-meta__ {
display: flex;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
gap: 0.45em;
margin-bottom: 1.5rem;
}
.__ldr-kicker__ {
font-family: "Nunito", sans-serif;
font-size: 0.78rem;
font-weight: 700;
color: #999;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.__ldr-status__ {
font-family: "Nunito", sans-serif;
font-size: 0.88rem;
font-weight: 700;
color: #555;
line-height: 1.4;
}
.__ldr-status__:empty {
display: none;
}
.__ldr-progress__ {
width: min(220px, 70vw);
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.__ldr-track__ {
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.12);
border-radius: 2px;
overflow: hidden;
}
.__ldr-fill__ {
height: 100%;
width: 40%;
background: #e8a020;
border-radius: 2px;
transform: translateX(-120%);
animation: __ldr-slide__ 1.35s ease-in-out infinite;
}
.__ldr-fill__.is-determinate {
width: 0%;
transform: none;
animation: none;
transition: width 0.25s ease;
}
.__ldr-pct__ {
font-family: "Nunito", system-ui, sans-serif;
font-size: 12px;
font-weight: 700;
color: #555;
letter-spacing: 0.05em;
min-height: 1em;
}
@keyframes __ldr-pulse__ {
0%, 100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(232, 160, 32, 0.35);
}
50% {
transform: scale(1.08);
background: #f5b535;
box-shadow: 0 8px 24px rgba(232, 160, 32, 0.45);
}
}
@keyframes __ldr-slide__ {
0% { transform: translateX(-120%); }
100% { transform: translateX(350%); }
}
@media (prefers-reduced-motion: reduce) {
.__ldr-play__,
.__ldr-fill__ {
animation: none;
}
.__ldr-fill__:not(.is-determinate) {
width: 100%;
transform: none;
}
#__loading-overlay__ {
transition: none;
}
}
`;
document.head.appendChild(style);
const overlay = document.createElement("div");
overlay.id = "__loading-overlay__";
overlay.setAttribute("role", "status");
overlay.setAttribute("aria-busy", "true");
overlay.setAttribute("aria-live", "polite");
overlay.innerHTML = `
<div class="__ldr-content__">
<div class="__ldr-play__" aria-hidden="true">
<svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9" /></svg>
</div>
<div class="__ldr-title__">three-player-controller</div>
<div class="__ldr-meta__">
<div class="__ldr-kicker__">Loading</div>
<div class="__ldr-status__" id="__ldr-status__"></div>
</div>
<div class="__ldr-progress__" id="__ldr-progress__">
<div class="__ldr-track__"><div class="__ldr-fill__" id="__ldr-fill__"></div></div>
<div class="__ldr-pct__" id="__ldr-pct__"></div>
</div>
</div>`;
document.documentElement.appendChild(overlay);
const fill = overlay.querySelector("#__ldr-fill__");
const pct = overlay.querySelector("#__ldr-pct__");
const status = overlay.querySelector("#__ldr-status__");
if (demo) status.textContent = demo;
window.setLoaderStatus = function (text) {
if (status) status.textContent = text || "";
};
window.setLoaderProgress = function (loaded, total) {
if (!fill) return;
if (total > 0) {
fill.classList.add("is-determinate");
const p = Math.min(100, Math.round((loaded / total) * 100));
fill.style.width = p + "%";
if (pct) pct.textContent = p + "%";
} else {
fill.classList.remove("is-determinate");
fill.style.width = "";
if (pct) pct.textContent = (loaded / 1048576).toFixed(1) + " MB";
}
};
window.hideLoader = function () {
const el = document.getElementById("__loading-overlay__");
if (!el || el.dataset.hiding) return;
el.dataset.hiding = "1";
el.classList.add("fade-out");
el.setAttribute("aria-busy", "false");
let cleaned = false;
const done = () => {
if (cleaned) return;
cleaned = true;
el.remove();
document.getElementById("__loader-style__")?.remove();
};
el.addEventListener("transitionend", done, { once: true });
setTimeout(done, fade + 80);
};
})();
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more