Merge pull request 'main' (#1) from main into leonida/vice-slice
Reviewed-on: #1
No files matched your search
@@ -10,9 +10,9 @@ description: Use when a model built in the three-d-stage editor (GLB export, oft
|
|||||||
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`.
|
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:
|
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,
|
- einem `PLACES`-Eintrag, optional mit `replaces` (Weltpunkte), wenn an der Stelle schon ein Gebäudekasten aus den Chunk-Daten steht,
|
||||||
- einer aufbereiteten `.glb`,
|
- einer aufbereiteten `.glb`,
|
||||||
- Ergänzungen in den Materiallisten und Tests.
|
- 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.
|
Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spiel.
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spi
|
|||||||
node .claude/skills/placing-editor-models/scripts/site-data.mjs <x0> <x1> <y0> <y1> <scratch>/site.json
|
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.
|
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:**
|
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.
|
- **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.
|
- **Maßstab:** Meter pro Pixel aus Autos (~4,5 m) oder Feldmarkierungen schätzen, mit ±15 % Unsicherheit rechnen.
|
||||||
@@ -67,8 +68,8 @@ Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spi
|
|||||||
- Browserprüfung
|
- 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).
|
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:**
|
8. **Tests:**
|
||||||
- `tests/placesSite.test.js` prüft jeden `PLACES`-Eintrag schon von selbst.
|
- `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` ist auf `hood-yard` zugeschnitten. Beim zweiten Ort auf `describe.each(PLACES)` umbauen: feste Materialien **je Ort** prüfen statt ganz `SOLID_MATERIALS`, die Bump-Prüfung nur dort, wo das Modell Bump hat.
|
- `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`:
|
9. **Browserprüfung** nach `browser-checks.md`:
|
||||||
- Lage prüfen.
|
- Lage prüfen.
|
||||||
- Kollision: Kapsel steht auf der Platte, eine Wand stoppt sie, Pflanzen sind durchlässig.
|
- Kollision: Kapsel steht auf der Platte, eine Wand stoppt sie, Pflanzen sind durchlässig.
|
||||||
@@ -87,7 +88,8 @@ Die eigentliche Arbeit sind die Lage, die Materialrollen und die Prüfung im Spi
|
|||||||
| Boden flackert im Ort | Plattenoberkante auf y 0 | `lift` für +6 cm |
|
| 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 |
|
| 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) |
|
| Ort schwebt nach einem Minimap-Sprung kurz über dem Meer | der Streamer baut einen Chunk pro Frame | hingenommen (Ruling Hood Yard) |
|
||||||
| Asset-Test wird mit dem zweiten Ort rot | globaler `SOLID_MATERIALS`-Check | je Ort prüfen (Schritt 8) |
|
| 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 |
|
| `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 |
|
| 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) |
|
| Draw-Call- und Dreieckszahlen zu optimistisch | `renderer.info` wird nach den Schattenstufen zurückgesetzt | `gl.finish`-Messung (`browser-checks.md` §8) |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -8,7 +8,7 @@ import { createMaterials } from "./world/materials.js";
|
|||||||
import { createSky } from "./world/sky.js";
|
import { createSky } from "./world/sky.js";
|
||||||
import { createSunShadows, prepareLitMaterials } from "./world/shadows.js";
|
import { createSunShadows, prepareLitMaterials } from "./world/shadows.js";
|
||||||
import { PropLayer } from "./world/props.js";
|
import { PropLayer } from "./world/props.js";
|
||||||
import { PlaceLayer } from "./world/places.js";
|
import { PLACES, PlaceLayer } from "./world/places.js";
|
||||||
import { chunkForWorld, threeToWorld } from "./world/coords.js";
|
import { chunkForWorld, threeToWorld } from "./world/coords.js";
|
||||||
import { createCharacterModule } from "./characters/index.js";
|
import { createCharacterModule } from "./characters/index.js";
|
||||||
import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
|
import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
|
||||||
@@ -236,7 +236,12 @@ await props.load();
|
|||||||
// Zeitschranke, die danach die Kollision ohne vorhandenen Boden einschaltet, würde in eine
|
// 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
|
// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt
|
||||||
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
|
// 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
|
// 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
|
// ü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,
|
// das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md,
|
||||||
|
|||||||
@@ -19,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
|
* 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
|
* 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
|
* 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 = [
|
export const PLACES = [
|
||||||
{
|
{
|
||||||
@@ -32,9 +34,23 @@ export const PLACES = [
|
|||||||
lift: -0.28,
|
lift: -0.28,
|
||||||
bounds: { minX: -17.15, maxX: 17, minZ: -13.4, maxZ: 8.6 },
|
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 = [
|
export const SOLID_MATERIALS = [
|
||||||
// Boden
|
// Boden
|
||||||
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
|
"gravel", "asphalt", "curbAsphalt", "padAsphalt", "puddleAsphalt", "cobble", "grass", "concrete",
|
||||||
@@ -49,6 +65,9 @@ export const SOLID_MATERIALS = [
|
|||||||
"modular_electricity_poles", "barrel_03", "plastic_container",
|
"modular_electricity_poles", "barrel_03", "plastic_container",
|
||||||
// Bäume
|
// Bäume
|
||||||
"quiver_tree_01_trunk", "quiver_tree_02",
|
"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);
|
const SOLID = new Set(SOLID_MATERIALS);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Group, Mesh } from "three";
|
import { Group, Mesh } from "three";
|
||||||
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
|
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "./coords.js";
|
||||||
import { buildChunkGeometry } from "./geometry.js";
|
import { buildChunkGeometry } from "./geometry.js";
|
||||||
|
import { pointInPolygon } from "./props.js";
|
||||||
|
|
||||||
const key = (i, j) => `${i}_${j}`;
|
const key = (i, j) => `${i}_${j}`;
|
||||||
const DECALS = new Set(["roads", "sand", "park", "urban"]);
|
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);
|
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 {
|
export class ChunkStreamer {
|
||||||
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) {
|
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [], replacedBuildings = [] }) {
|
||||||
Object.assign(this, { scene, player, materials, props, baseUrl });
|
Object.assign(this, { scene, player, materials, props, baseUrl, replacedBuildings });
|
||||||
this.active = new Map(); // key -> { group, handle }
|
this.active = new Map(); // key -> { group, handle }
|
||||||
this.pending = new Set();
|
this.pending = new Set();
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
@@ -63,7 +82,7 @@ export class ChunkStreamer {
|
|||||||
const [i, j] = k.split("_").map(Number);
|
const [i, j] = k.split("_").map(Number);
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
group.position.set(chunk.origin[0], 0, chunk.origin[1]);
|
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();
|
const collide = new Group();
|
||||||
for (const [name, geo] of Object.entries(geos)) {
|
for (const [name, geo] of Object.entries(geos)) {
|
||||||
if (!geo) continue;
|
if (!geo) continue;
|
||||||
|
|||||||
@@ -61,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", () => {
|
it("kennt jedes Material der Liste", () => {
|
||||||
expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true);
|
expect(SOLID_MATERIALS.every(isSolidMaterial)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -168,14 +181,15 @@ describe("prepareModel", () => {
|
|||||||
|
|
||||||
describe("PLACES", () => {
|
describe("PLACES", () => {
|
||||||
// Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds
|
// Bruch: ein Eintrag ohne Pflichtfeld — placeTransform rechnete mit undefined, die Tests mit bounds
|
||||||
// prüften nichts.
|
// 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", () => {
|
it("hat für jeden Ort id, url, at, rotation, lift und bounds, dazu optional replaces mit Weltpunkten", () => {
|
||||||
for (const entry of PLACES) {
|
for (const { replaces = [], ...entry } of PLACES) {
|
||||||
expect(entry).toEqual({
|
expect(entry).toEqual({
|
||||||
id: expect.any(String), url: expect.stringMatching(/^\.\/models\/places\/.+\.glb$/),
|
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),
|
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) },
|
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)]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,14 +5,41 @@ import { NodeIO } from "@gltf-transform/core";
|
|||||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||||
import { getBounds } from "@gltf-transform/functions";
|
import { getBounds } from "@gltf-transform/functions";
|
||||||
import { MeshoptDecoder } from "meshoptimizer";
|
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";
|
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
|
||||||
|
|
||||||
const place = PLACES.find((entry) => entry.id === "hood-yard");
|
// Feste Materialien je Ort. SOLID_MATERIALS gilt für alle Orte zusammen, aber keine Datei enthält alle
|
||||||
const file = fileURLToPath(new URL(`../public/${place.url.slice(2)}`, import.meta.url));
|
// Namen — geprüft wird deshalb jede Datei gegen ihre eigene Zeile.
|
||||||
const CONTAINERS = ["cont_a_side", "cont_a_end", "cont_b_side", "cont_b_end", "cont_c_side", "cont_c_end"];
|
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;
|
let document;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await MeshoptDecoder.ready;
|
await MeshoptDecoder.ready;
|
||||||
@@ -22,17 +49,16 @@ describe("hood-yard.glb", () => {
|
|||||||
document = await io.read(file);
|
document = await io.read(file);
|
||||||
}, 30000);
|
}, 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", () => {
|
it("ist kleiner als 30 MB", () => {
|
||||||
expect(statSync(file).size).toBeLessThan(30e6);
|
expect(statSync(file).size).toBeLessThan(30e6);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bruch: das Wellblech ist weg, die Geometrie unkomprimiert, oder GPU-Instanzen, deren Matrizen die
|
// Bruch: Geometrie unkomprimiert oder GPU-Instanzen, deren Matrizen die Kollision des Controllers nicht liest.
|
||||||
// Kollision des Controllers nicht liest.
|
it("nutzt meshopt und WebP und hat keine Instanzen", () => {
|
||||||
it("nutzt meshopt und WebP, behält EXT_materials_bump und hat keine Instanzen", () => {
|
|
||||||
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
|
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");
|
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]));
|
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();
|
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
|
// 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.
|
// 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 der Kollisionsliste", () => {
|
it("enthält jedes feste Material seines Orts", () => {
|
||||||
const names = document.getRoot().listMaterials().map((material) => material.getName());
|
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.
|
// Bruch: bounds in PLACES passt nicht mehr zur Datei — der Datentest prüfte dann die falsche Fläche.
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { beforeAll, describe, expect, it } from "vitest";
|
|||||||
import { Document } from "@gltf-transform/core";
|
import { Document } from "@gltf-transform/core";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
|
import { EXT_MATERIALS_BUMP, EXTMaterialsBump } from "../tools/assets/bumpExtension.mjs";
|
||||||
import { FAR_TEXTURE_SIZE, NEAR_TEXTURE_SIZE, optimizePlace, placeTarget } 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. */
|
/** 1100 px große Streifen. seed macht die Bilder verschieden, sonst legt dedup() sie zusammen. */
|
||||||
function stripes(seed) {
|
function stripes(seed) {
|
||||||
@@ -114,3 +115,25 @@ describe("placeTarget", () => {
|
|||||||
expect(() => placeTarget("Hood Yard")).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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,15 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE, chunkForWorld } from "../src/world/coords.js";
|
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 { PLACES, PLACE_HIDE_RADIUS, PLACE_SHOW_RADIUS, placeTransform } from "../src/world/places.js";
|
||||||
import { lampPoints, pointInPolygon, vergeLampPoints } from "../src/world/props.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.
|
// Radien aus ChunkStreamer.update (src/world/streaming.js), gemessen zum Chunk-Mittelpunkt.
|
||||||
const CHUNK_LOAD_RADIUS = 400;
|
const CHUNK_LOAD_RADIUS = 400;
|
||||||
const CHUNK_UNLOAD_RADIUS = 600;
|
const CHUNK_UNLOAD_RADIUS = 600;
|
||||||
const STEP = 0.5; // Meter zwischen zwei Prüfpunkten auf der Ortsfläche
|
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();
|
const cache = new Map();
|
||||||
function chunk(i, j) {
|
function chunk(i, j) {
|
||||||
@@ -18,6 +22,16 @@ function chunk(i, j) {
|
|||||||
return cache.get(key);
|
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). */
|
/** Modellpunkt (x, z) -> Welt (X, Y), mit derselben Drehung wie three (rotation.y). */
|
||||||
function modelToWorld(place, x, z) {
|
function modelToWorld(place, x, z) {
|
||||||
const { position, rotationY } = placeTransform(place);
|
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 };
|
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 { x, z } = worldToModel(place, X, Y);
|
||||||
const { minX, maxX, minZ, maxZ } = place.bounds;
|
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) => {
|
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));
|
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
|
// 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", () => {
|
it("liegt ganz auf Land, auf keiner Straße und in keinem Gebäude", () => {
|
||||||
const bad = points.filter(({ X, Y }) => {
|
const bad = points.filter(({ X, Y }) => {
|
||||||
const { i, j } = chunkForWorld(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 u = X - c.origin[0], v = -Y - c.origin[1];
|
||||||
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
|
const onLand = c.land.some((rings) => pointInPolygon(u, v, rings));
|
||||||
const onRoad = c.roads.some((road) => pointInPolygon(u, v, road.poly));
|
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;
|
return !onLand || onRoad || inBuilding;
|
||||||
});
|
});
|
||||||
expect(bad.slice(0, 5)).toEqual([]);
|
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;
|
// 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.
|
// Laternen stehen bis 16 m neben ihrer Mittellinie, deshalb zählen auch die Nachbarchunks.
|
||||||
it("enthält keine Palme und keine Laterne", () => {
|
it("enthält keine Palme und keine Laterne", () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BufferGeometry, Group, Mesh } from "three";
|
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", () => {
|
describe("wantedChunks", () => {
|
||||||
it("lädt die Umgebung des Spielers", () => {
|
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).
|
// 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
|
// 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).
|
// (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).
|
// 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 };
|
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 })));
|
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData })));
|
||||||
const player = {
|
const player = {
|
||||||
addCollider: vi.fn(() => ({ id: 42 })),
|
addCollider: vi.fn(() => ({ id: 42 })),
|
||||||
@@ -47,7 +91,7 @@ function makeStreamer(chunkData, props, pinned) {
|
|||||||
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
|
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
|
||||||
};
|
};
|
||||||
const scene = { add: vi.fn(), remove: vi.fn() };
|
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
|
// #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(streamer.active.has("4_6")).toBe(true);
|
||||||
expect(player.removeCollider).not.toHaveBeenCalled();
|
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
|
// 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 4.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 756 KiB |
|
After Width: | Height: | Size: 837 KiB |
|
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);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
ACESFilmicToneMapping,
|
||||||
|
DirectionalLight,
|
||||||
|
MathUtils,
|
||||||
|
PerspectiveCamera,
|
||||||
|
Scene,
|
||||||
|
VSMShadowMap,
|
||||||
|
WebGLRenderer,
|
||||||
|
} from "three";
|
||||||
|
import { MapControls } from "three/examples/jsm/Addons.js";
|
||||||
|
import { LightProbeGrid } from "three/addons/lighting/LightProbeGrid.js";
|
||||||
|
import { LightProbeGridHelper } from "three/addons/helpers/LightProbeGridHelper.js";
|
||||||
|
|
||||||
|
|
||||||
|
export function createSceneSetup({ container }) {
|
||||||
|
// ==================== 渲染器 ====================
|
||||||
|
const renderer = new WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
renderer.toneMapping = ACESFilmicToneMapping;
|
||||||
|
renderer.toneMappingExposure = 1;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.shadowMap.type = VSMShadowMap;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// ==================== 场景 & 相机 ====================
|
||||||
|
const scene = new Scene();
|
||||||
|
const camera = new PerspectiveCamera(
|
||||||
|
60,
|
||||||
|
container.clientWidth / container.clientHeight,
|
||||||
|
0.1,
|
||||||
|
2000
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 控制器 ====================
|
||||||
|
const controls = new MapControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.1;
|
||||||
|
controls.maxDistance = 2000;
|
||||||
|
|
||||||
|
// ==================== 参数 ====================
|
||||||
|
const params = {
|
||||||
|
showLightHelper: true,
|
||||||
|
lightAzimuth: 45,
|
||||||
|
lightElevation: 45,
|
||||||
|
lightIntensity: 10,
|
||||||
|
shadows: true,
|
||||||
|
probesEnabled: true,
|
||||||
|
showProbes: false,
|
||||||
|
probeSize: 0.5,
|
||||||
|
boundsX: 8, boundsY: 3.2, boundsZ: -2,
|
||||||
|
sizeX: 40, sizeY: 3, sizeZ: 25,
|
||||||
|
countX: 20, countY: 4, countZ: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 平行光 ====================
|
||||||
|
const dirLight = new DirectionalLight(0xfff2dc, params.lightIntensity);
|
||||||
|
dirLight.castShadow = true;
|
||||||
|
dirLight.shadow.mapSize.setScalar(2048);
|
||||||
|
dirLight.shadow.camera.left = -50;
|
||||||
|
dirLight.shadow.camera.right = 50;
|
||||||
|
dirLight.shadow.camera.top = 50;
|
||||||
|
dirLight.shadow.camera.bottom = -50;
|
||||||
|
dirLight.shadow.camera.near = 0.1;
|
||||||
|
dirLight.shadow.camera.far = 200;
|
||||||
|
dirLight.shadow.bias = -0.001;
|
||||||
|
scene.add(dirLight);
|
||||||
|
scene.add(dirLight.target);
|
||||||
|
|
||||||
|
function updateLightPosition() {
|
||||||
|
const azimuth = MathUtils.degToRad(params.lightAzimuth);
|
||||||
|
const elevation = MathUtils.degToRad(params.lightElevation);
|
||||||
|
const radius = 100;
|
||||||
|
const h = Math.cos(elevation) * radius;
|
||||||
|
const v = Math.sin(elevation) * radius;
|
||||||
|
dirLight.position.set(Math.cos(azimuth) * h, v, Math.sin(azimuth) * h);
|
||||||
|
dirLight.target.position.set(0, 0, 0);
|
||||||
|
dirLight.target.updateMatrixWorld();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLightPosition();
|
||||||
|
|
||||||
|
// ==================== 光探针 ====================
|
||||||
|
let probes = null;
|
||||||
|
let probesHelper = null;
|
||||||
|
let isBaking = false;
|
||||||
|
let bakeQueued = false;
|
||||||
|
|
||||||
|
async function bakeProbes() {
|
||||||
|
if (isBaking) { bakeQueued = true; return; }
|
||||||
|
isBaking = true;
|
||||||
|
do {
|
||||||
|
bakeQueued = false;
|
||||||
|
if (probes) { scene.remove(probes); probes.dispose(); }
|
||||||
|
|
||||||
|
probes = new LightProbeGrid(
|
||||||
|
params.sizeX, params.sizeY, params.sizeZ,
|
||||||
|
params.countX, params.countY, params.countZ
|
||||||
|
);
|
||||||
|
probes.position.set(params.boundsX, params.boundsY, params.boundsZ);
|
||||||
|
probes.updateMatrixWorld(true); // 强制更新世界矩阵,否则 bake() 读到旧位置
|
||||||
|
if (probesHelper) probesHelper.visible = false;
|
||||||
|
|
||||||
|
const far = Math.max(params.sizeX, params.sizeY, params.sizeZ) * 2;
|
||||||
|
probes.bake(renderer, scene, { cubemapSize: 32, near: 0.05, far });
|
||||||
|
probes.visible = params.probesEnabled;
|
||||||
|
scene.add(probes);
|
||||||
|
|
||||||
|
if (!probesHelper) {
|
||||||
|
probesHelper = new LightProbeGridHelper(probes, params.probeSize);
|
||||||
|
scene.add(probesHelper);
|
||||||
|
} else {
|
||||||
|
probesHelper.probes = probes;
|
||||||
|
probesHelper.update();
|
||||||
|
}
|
||||||
|
probesHelper.position.set(params.boundsX, params.boundsY, params.boundsZ);
|
||||||
|
probesHelper.visible = params.showProbes;
|
||||||
|
} while (bakeQueued);
|
||||||
|
isBaking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return { scene, renderer, camera, controls, dirLight, bakeProbes };
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { MathUtils, Quaternion, Vector3, AnimationMixer, AnimationClip, LoopOnce, LoopRepeat } from "three";
|
||||||
|
import { playerController } from "../../../src/PlayerController";
|
||||||
|
import { SpineIK } from "./spineIK.js";
|
||||||
|
|
||||||
|
const spineBoneNames = ["mixamorigSpine", "mixamorigSpine1", "mixamorigSpine2"];
|
||||||
|
|
||||||
|
// 运动状态集合(用于判断 isMoving)
|
||||||
|
const locomotionStates = new Set([
|
||||||
|
"idle",
|
||||||
|
"walking",
|
||||||
|
"walking_backward",
|
||||||
|
"left_walking",
|
||||||
|
"right_walking",
|
||||||
|
"running",
|
||||||
|
"jumping",
|
||||||
|
"flyidle",
|
||||||
|
"flying",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 允许执行战斗逻辑的运动状态(上半身分离后,奔跑时也可开火/换弹)
|
||||||
|
const combatAllowedLocomotion = new Set([
|
||||||
|
"idle", "walking", "walking_backward", "left_walking", "right_walking", "running",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const minPitchAngle = -Math.PI * (60 / 180); // 俯仰最小角(向下)
|
||||||
|
const maxPitchAngle = Math.PI * (40 / 180); // 俯仰最大角(向上)
|
||||||
|
|
||||||
|
export class LocalPlayer {
|
||||||
|
constructor({ scene, camera, controls }) {
|
||||||
|
// ==================== 场景引用 ====================
|
||||||
|
this._scene = scene;
|
||||||
|
this._camera = camera;
|
||||||
|
this._controls = controls;
|
||||||
|
|
||||||
|
// ==================== 核心对象 ====================
|
||||||
|
this._player = null; // playerController 实例
|
||||||
|
this.spineIK = null; // 脊椎 IK 实例
|
||||||
|
|
||||||
|
// ==================== 运动状态 ====================
|
||||||
|
this.pitchTarget1P = 0; // 第一人称俯仰累积值
|
||||||
|
this.isMoving = false; // 当前帧是否处于移动状态(由 onAnimationChange 更新)
|
||||||
|
this._locomotionState = "idle"; // 当前运动动画状态名
|
||||||
|
|
||||||
|
// ==================== 配置 ====================
|
||||||
|
this._mouseSensitivity = 5;
|
||||||
|
this._firstPersonPitchOffset = 0; // 第一人称相机俯仰初始偏移
|
||||||
|
|
||||||
|
// ==================== 外部注入 ====================
|
||||||
|
this._isGunEngagedFn = null; // 由 WeaponController 注入,判断是否持枪
|
||||||
|
|
||||||
|
// ==================== 上半身动画层 ====================
|
||||||
|
this._upperMixer = null; // 上半身专用 AnimationMixer(root = 模型根节点)
|
||||||
|
this._upperBodyBoneNames = null; // 脊椎以上所有骨骼名称集合,用于过滤 partial clip
|
||||||
|
this._upperBodyBones = null; // 脊椎以上骨骼引用数组(缓存,避免每帧 getObjectByName)
|
||||||
|
this._upperBoneSnapshots = null; // 主 mixer 骨骼值快照
|
||||||
|
this._upperActions = new Map(); // key → AnimationAction
|
||||||
|
this._upperState = null; // 当前上半身动作
|
||||||
|
|
||||||
|
// ==================== 走路瞄准修正 ====================
|
||||||
|
this._idleHipsQ = null; // 静止时保存的 hips 本地四元数,用于走路时抵消 hips 走路偏移
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
|
// 初始化 playerController、骨骼 IK、事件回调
|
||||||
|
async init(config) {
|
||||||
|
const { mouseSensitivity = 5, ...rest } = config;
|
||||||
|
this._mouseSensitivity = mouseSensitivity;
|
||||||
|
this._firstPersonPitchOffset = config.playerModelConfig?.firstPersonPitchOffset
|
||||||
|
?? this._firstPersonPitchOffset;
|
||||||
|
|
||||||
|
this._player = new playerController();
|
||||||
|
await this._player.init({
|
||||||
|
scene: this._scene,
|
||||||
|
camera: this._camera,
|
||||||
|
controls: this._controls,
|
||||||
|
mouseSensitivity,
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 绑定脊椎 & 头部骨骼
|
||||||
|
const model = this._player.getPlayerModel();
|
||||||
|
const spineBones = spineBoneNames
|
||||||
|
.map((n) => model?.getObjectByName(n))
|
||||||
|
.filter(Boolean);
|
||||||
|
const headBoneName = config.playerModelConfig?.headBoneName;
|
||||||
|
const headBone = model?.getObjectByName(headBoneName) ?? null;
|
||||||
|
this.spineIK = new SpineIK(spineBones, headBone);
|
||||||
|
|
||||||
|
// 上半身 mixer:root = 模型根节点(与主 mixer 一致,路径解析最可靠)
|
||||||
|
// 通过 partial clip(只含脊椎以上骨骼的 track)来限制写入范围,
|
||||||
|
// 主 mixer 更新后再更新 upper mixer,后者覆写脊椎以上骨骼,下半身保持 locomotion 值。
|
||||||
|
if (spineBones.length > 0) {
|
||||||
|
this._upperBodyBoneNames = new Set();
|
||||||
|
this._upperBodyBones = [];
|
||||||
|
spineBones[0].traverse(b => {
|
||||||
|
this._upperBodyBoneNames.add(b.name);
|
||||||
|
this._upperBodyBones.push(b);
|
||||||
|
});
|
||||||
|
// 预分配快照数组,避免每帧 GC
|
||||||
|
this._upperBoneSnapshots = this._upperBodyBones.map(() => new Quaternion());
|
||||||
|
this._upperMixer = new AnimationMixer(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听动画切换,更新 isMoving
|
||||||
|
this._player.onAnimationChange = (name) => {
|
||||||
|
if (locomotionStates.has(name)) this._locomotionState = name;
|
||||||
|
this.isMoving =
|
||||||
|
name === "walking" ||
|
||||||
|
name === "left_walking" ||
|
||||||
|
name === "right_walking" ||
|
||||||
|
name === "walking_backward" ||
|
||||||
|
name === "running";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 接管第一人称鼠标移动
|
||||||
|
this._player.onTowardChange = (dx, dy, speed) => {
|
||||||
|
if (!this._player.getIsFirstPerson()) return;
|
||||||
|
|
||||||
|
// 水平朝向
|
||||||
|
this._player.getPlayerCapsule().rotateY(
|
||||||
|
-dx * speed * this._mouseSensitivity
|
||||||
|
);
|
||||||
|
|
||||||
|
// 俯仰角累积
|
||||||
|
this.pitchTarget1P = MathUtils.clamp(
|
||||||
|
this.pitchTarget1P + (-dy * speed * this._mouseSensitivity),
|
||||||
|
minPitchAngle,
|
||||||
|
maxPitchAngle
|
||||||
|
);
|
||||||
|
|
||||||
|
// 未持枪时直接驱动相机
|
||||||
|
if (!this._isGunEngagedFn?.()) {
|
||||||
|
this._camera.rotation.x = MathUtils.clamp(
|
||||||
|
this._camera.rotation.x + (-dy * speed * this._mouseSensitivity),
|
||||||
|
minPitchAngle,
|
||||||
|
maxPitchAngle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视角切换
|
||||||
|
this._player.onViewChange = (isFirstPerson) => {
|
||||||
|
if (isFirstPerson) {
|
||||||
|
if (headBoneName) {
|
||||||
|
console.log(headBoneName);
|
||||||
|
this._camera.position.z = 8;
|
||||||
|
this._camera.position.x = 15;
|
||||||
|
} else {
|
||||||
|
this._camera.position.z = 0;
|
||||||
|
this._camera.position.x = 0;
|
||||||
|
}
|
||||||
|
this._camera.rotation.x = this._firstPersonPitchOffset;
|
||||||
|
this._player.setEnableToward(false);
|
||||||
|
// 同步控制器俯仰角
|
||||||
|
const targetPolar = this._controls.getPolarAngle() - Math.PI / 2 + Math.PI * (7.5 / 180);
|
||||||
|
this.pitchTarget1P = targetPolar;
|
||||||
|
// 航向角偏移
|
||||||
|
this._player.getPlayerCapsule().rotateY(-Math.PI * (17 / 180));
|
||||||
|
} else {
|
||||||
|
this._player.setEnableToward(true);
|
||||||
|
// 持枪瞄准状态下刷新一次动画,保证骨骼复原
|
||||||
|
if (this._player.getCurrentPlayerAnimationName().includes("rifle_idle_aim")) {
|
||||||
|
this._player.playAnimation("idle");
|
||||||
|
}
|
||||||
|
// 同步第一人称俯仰角
|
||||||
|
const targetPolar = Math.PI / 2 + this.pitchTarget1P - Math.PI * (7.5 / 180);
|
||||||
|
this._controls.minPolarAngle = targetPolar;
|
||||||
|
this._controls.maxPolarAngle = targetPolar;
|
||||||
|
this._controls.update();
|
||||||
|
this._controls.minPolarAngle = minPitchAngle + Math.PI / 2;
|
||||||
|
this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2;
|
||||||
|
// 航向角偏移
|
||||||
|
const delta = Math.PI * (17 / 180);
|
||||||
|
const offset = this._camera.position.clone().sub(this._controls.target);
|
||||||
|
offset.applyAxisAngle(new Vector3(0, 1, 0), delta);
|
||||||
|
this._camera.position.copy(this._controls.target).add(offset);
|
||||||
|
this._controls.update();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 限制第三人称俯仰角
|
||||||
|
this._controls.minPolarAngle = minPitchAngle + Math.PI / 2;
|
||||||
|
this._controls.maxPolarAngle = maxPitchAngle + Math.PI / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 外部注入 ====================
|
||||||
|
|
||||||
|
// 由 WeaponController 注入,让 1P 俯仰驱动能感知持枪状态
|
||||||
|
setGunEngagedGetter(fn) {
|
||||||
|
this._isGunEngagedFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主循环 ====================
|
||||||
|
|
||||||
|
// 每帧驱动动画与物理
|
||||||
|
// dt 须由主循环传入,上半身 mixer 在主 mixer 之后更新才能正确覆写骨骼
|
||||||
|
update(dt) {
|
||||||
|
this._player?.update(dt);
|
||||||
|
|
||||||
|
if (this._upperMixer && dt != null) {
|
||||||
|
const ua = this._upperState;
|
||||||
|
if (!ua) return;
|
||||||
|
|
||||||
|
// Three.js PropertyMixer.apply() 有变更检测优化:仅当 accu0 ≠ accu1 时
|
||||||
|
// 才调用 setValue() 写入骨骼。对于完全相同帧的动画(两帧值一致),
|
||||||
|
// 两个 accu buffer 永远相等,导致 setValue() 被跳过,主 mixer 的
|
||||||
|
// locomotion 动画透过来。修复:每帧更新前把两个 accu buffer 填为 NaN,
|
||||||
|
// 使比较永远不相等,强制 setValue() 每帧执行,确保上半身覆盖生效。
|
||||||
|
if (ua._propertyBindings) {
|
||||||
|
for (const pm of ua._propertyBindings) {
|
||||||
|
if (pm?.buffer) {
|
||||||
|
const s = pm.valueSize;
|
||||||
|
pm.buffer.fill(NaN, s, s * 3); // dirty accu0 + accu1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._upperMixer.update(dt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 上半身动画层 ====================
|
||||||
|
|
||||||
|
// 从指定 clip 的 t=0 帧直接读取 hips 四元数
|
||||||
|
initIdleHipsQ(clipName) {
|
||||||
|
const clip = this._player?.animation?.clips?.find(c => c.name === clipName);
|
||||||
|
if (!clip) { console.warn(`initIdleHipsQ: 找不到 "${clipName}"`); return; }
|
||||||
|
const track = clip.tracks.find(t => t.name === 'mixamorigHips.quaternion');
|
||||||
|
if (!track || track.values.length < 4) return;
|
||||||
|
this._idleHipsQ = new Quaternion(track.values[0], track.values[1], track.values[2], track.values[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在上半身 mixer 上注册一个动画
|
||||||
|
registerUpperAnimation(key, clipName, opts = {}) {
|
||||||
|
if (!this._upperMixer || !this._upperBodyBoneNames) return;
|
||||||
|
const clips = this._player?.animation?.clips;
|
||||||
|
if (!clips) return;
|
||||||
|
const clip = clips.find(c => c.name === clipName);
|
||||||
|
if (!clip) { console.warn(`registerUpperAnimation: 找不到 "${clipName}"`); return; }
|
||||||
|
|
||||||
|
// 只保留脊椎以上骨骼的 track,其余 track(hips、腿部等)丢弃
|
||||||
|
const upperTracks = clip.tracks.filter(t => {
|
||||||
|
const boneName = t.name.split('.')[0];
|
||||||
|
return this._upperBodyBoneNames.has(boneName);
|
||||||
|
});
|
||||||
|
const partialClip = new AnimationClip(clip.name + '_upper_' + key, clip.duration, upperTracks);
|
||||||
|
|
||||||
|
const action = this._upperMixer.clipAction(partialClip);
|
||||||
|
action.setLoop(opts.loop === false ? LoopOnce : LoopRepeat, Infinity);
|
||||||
|
action.clampWhenFinished = opts.clampWhenFinished ?? false;
|
||||||
|
const ts = opts.duration ? clip.duration / opts.duration : (opts.timeScale ?? 1);
|
||||||
|
action.setEffectiveTimeScale(ts);
|
||||||
|
action.enabled = true;
|
||||||
|
action.setEffectiveWeight(0);
|
||||||
|
this._upperActions.set(key, action);
|
||||||
|
|
||||||
|
if (opts.onFinished) {
|
||||||
|
this._upperMixer.addEventListener("finished", (ev) => {
|
||||||
|
if (ev.action === action) opts.onFinished();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放上半身动画(仅覆写脊椎以上骨骼)
|
||||||
|
playUpperBody(key, opts = {}) {
|
||||||
|
if (!this._upperMixer) return;
|
||||||
|
const next = this._upperActions.get(key);
|
||||||
|
if (!next) { console.warn(`playUpperBody: "${key}" 未注册`); return; }
|
||||||
|
|
||||||
|
const fade = opts.fade ?? 0.18;
|
||||||
|
const prev = this._upperState;
|
||||||
|
|
||||||
|
if (!opts.force && prev === next) return;
|
||||||
|
|
||||||
|
// 直接设为 1,从第一帧起就以满权重覆盖主 mixer。
|
||||||
|
if (prev && prev !== next) prev.fadeOut(fade);
|
||||||
|
|
||||||
|
next.reset();
|
||||||
|
next.setEffectiveWeight(1);
|
||||||
|
next.play();
|
||||||
|
|
||||||
|
this._upperState = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走路/奔跑时修正 spine[0] 四元数,抵消 hips 走路旋转偏移
|
||||||
|
// 使 spine0 的世界朝向等效于静止时(idle hips × spine_local),
|
||||||
|
applyHipsCorrection() {
|
||||||
|
if (!this._idleHipsQ || !this.spineIK?.spineBones?.length) return;
|
||||||
|
const hipsBone = this._player?.getPlayerModel()?.getObjectByName("mixamorigHips");
|
||||||
|
if (!hipsBone) return;
|
||||||
|
|
||||||
|
// correction = hips_walk_local⁻¹ × hips_idle_local
|
||||||
|
const correction = new Quaternion().copy(hipsBone.quaternion).invert().multiply(this._idleHipsQ);
|
||||||
|
const spine0 = this.spineIK.spineBones[0];
|
||||||
|
spine0.quaternion.premultiply(correction);
|
||||||
|
spine0.updateWorldMatrix(false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止上半身动画,让下半身(全身)动画完全接管
|
||||||
|
stopUpperBody(fade = 0.18) {
|
||||||
|
if (!this._upperState) return;
|
||||||
|
this._upperState.fadeOut(fade);
|
||||||
|
this._upperState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工具方法 ====================
|
||||||
|
|
||||||
|
isCombatLocomotionAllowed() { return combatAllowedLocomotion.has(this._locomotionState); }
|
||||||
|
|
||||||
|
// ==================== playerController 代理 ====================
|
||||||
|
|
||||||
|
getIsFirstPerson() { return this._player?.getIsFirstPerson() ?? false; }
|
||||||
|
getIsFlying() { return this._player?.getIsFlying() ?? false; }
|
||||||
|
getPosition() { return this._player?.getPosition?.() ?? null; }
|
||||||
|
getFirstPersonPitchOffset() { return this._firstPersonPitchOffset; }
|
||||||
|
getPlayerModel() { return this._player?.getPlayerModel(); }
|
||||||
|
getColliderMeshes() { return this._player?.getColliderMeshes?.() ?? []; }
|
||||||
|
getCenterScreenRaycastHit() { return this._player?.getCenterScreenRaycastHit() ?? null; }
|
||||||
|
playAnimation(name, opts) { return this._player?.playAnimation(name, opts); }
|
||||||
|
registerAnimation(key, clipName, opts) { return this._player?.registerAnimation(key, clipName, opts); }
|
||||||
|
registerLocomotionSet(...a) { return this._player?.registerLocomotionSet(...a); }
|
||||||
|
switchLocomotionSet(name) { return this._player?.switchLocomotionSet(name); }
|
||||||
|
setMaxCamDistance(d) { return this._player?.setMaxCamDistance(d); }
|
||||||
|
setPlayerSpeed(s) { return this._player?.setPlayerSpeed(s); }
|
||||||
|
setEnableToward(v) { return this._player?.setEnableToward(v); }
|
||||||
|
setThirdMouseMode(mode) { return this._player?.setThirdMouseMode(mode); }
|
||||||
|
onAllEvent() { return this._player?.onAllEvent(); }
|
||||||
|
offAllEvent() { return this._player?.offAllEvent(); }
|
||||||
|
onViewChange(isFirstPerson) { return this._player?.onViewChange(isFirstPerson); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Euler, Quaternion, Vector3 } from "three";
|
||||||
|
|
||||||
|
// 复用的临时量(避免每帧 GC)
|
||||||
|
const _aimQ = new Quaternion();
|
||||||
|
const _yawQ = new Quaternion();
|
||||||
|
const _localPitchQ = new Quaternion();
|
||||||
|
const _parentWorldQ = new Quaternion();
|
||||||
|
const _cameraWorldQ = new Quaternion();
|
||||||
|
const _aimAxis = new Vector3(1, 0, 0);
|
||||||
|
const _yawAxis = new Vector3(0, 1, 0);
|
||||||
|
|
||||||
|
const _headWorldQ = new Quaternion();
|
||||||
|
const _headParentWorldQ = new Quaternion();
|
||||||
|
const _headEuler = new Euler();
|
||||||
|
|
||||||
|
// 脊椎骨骼 IK
|
||||||
|
export class SpineIK {
|
||||||
|
constructor(spineBones, headBone) {
|
||||||
|
// ==================== 骨骼引用 ====================
|
||||||
|
this.spineBones = spineBones; // 脊椎骨骼数组(从下到上)
|
||||||
|
this.headBone = headBone; // 头部骨骼
|
||||||
|
|
||||||
|
// 每帧 IK 应用前保存的"干净动画姿态",下一帧先恢复再驱动
|
||||||
|
this.baseQuats = spineBones.map(() => new Quaternion());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 骨骼保存 / 恢复 ====================
|
||||||
|
|
||||||
|
// 将脊椎骨骼恢复到上一帧保存的干净动画姿态
|
||||||
|
// 在 player.update()(动画驱动)之前调用,防止 IK 残留影响动画混合
|
||||||
|
restoreBones() {
|
||||||
|
for (let i = 0; i < this.spineBones.length; i++) {
|
||||||
|
this.spineBones[i].quaternion.copy(this.baseQuats[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除头骨骼的横滚(roll),防止第一人称下相机随动画倾斜
|
||||||
|
clearHeadRoll() {
|
||||||
|
const head = this.headBone;
|
||||||
|
if (!head) return;
|
||||||
|
head.updateWorldMatrix(true, false);
|
||||||
|
head.getWorldQuaternion(_headWorldQ);
|
||||||
|
_headEuler.setFromQuaternion(_headWorldQ, "YXZ");
|
||||||
|
_headEuler.z = 0;
|
||||||
|
_headWorldQ.setFromEuler(_headEuler);
|
||||||
|
head.parent.getWorldQuaternion(_headParentWorldQ);
|
||||||
|
head.quaternion.copy(_headParentWorldQ).invert().multiply(_headWorldQ);
|
||||||
|
head.updateWorldMatrix(false, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== IK 应用 ====================
|
||||||
|
|
||||||
|
// 第一人称脊椎俯仰 IK
|
||||||
|
// 将 pitchTarget 均分到每根脊椎骨骼,使持枪手臂跟随鼠标上下瞄准
|
||||||
|
applyAim1P(camera, pitchTarget) {
|
||||||
|
if (!this.spineBones.length) return;
|
||||||
|
this.clearHeadRoll();
|
||||||
|
|
||||||
|
camera.getWorldQuaternion(_cameraWorldQ);
|
||||||
|
_aimAxis.set(1, 0, 0).applyQuaternion(_cameraWorldQ);
|
||||||
|
_aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length);
|
||||||
|
|
||||||
|
this.spineBones[0].parent.updateWorldMatrix(true, false);
|
||||||
|
for (let i = 0; i < this.spineBones.length; i++) {
|
||||||
|
this.baseQuats[i].copy(this.spineBones[i].quaternion); // 保存干净姿态
|
||||||
|
// 转到父骨骼局部空间后叠加
|
||||||
|
this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ);
|
||||||
|
_localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ);
|
||||||
|
this.spineBones[i].quaternion.premultiply(_localPitchQ);
|
||||||
|
this.spineBones[i].updateWorldMatrix(false, false);
|
||||||
|
}
|
||||||
|
// 传播到头骨骼,让相机挂点跟随
|
||||||
|
this.spineBones[this.spineBones.length - 1].updateWorldMatrix(false, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三人称脊椎俯仰 + 偏航 IK,同时对 camera.rotation.x 做视觉补偿偏移
|
||||||
|
applyAim3P(camera, isGunEngaged) {
|
||||||
|
if (!this.spineBones.length) return;
|
||||||
|
|
||||||
|
// 归一化俯仰(-1 ~ +1),用于二次曲线补偿
|
||||||
|
const normalizedPitch = camera.rotation.x / (Math.PI * (50 / 180));
|
||||||
|
const pitchSq = Math.pow(Math.abs(normalizedPitch), 2.0);
|
||||||
|
|
||||||
|
const pitchTarget = isGunEngaged ? camera.rotation.x : 0;
|
||||||
|
// 相机视角补偿:向下看时额外抬高,向上看时额外压低,使瞄准更自然
|
||||||
|
camera.rotation.x += normalizedPitch > 0 ? pitchSq * 0.35 : -pitchSq * 0.1;
|
||||||
|
|
||||||
|
// 人物朝向补偿:需要往屏幕中心方向旋转
|
||||||
|
const yawTarget = isGunEngaged ? -Math.PI * ((10 * (1 + pitchSq * 0.35)) / 180) : 0;
|
||||||
|
|
||||||
|
_aimAxis.set(1, 0, 0).applyQuaternion(camera.quaternion);
|
||||||
|
_aimQ.setFromAxisAngle(_aimAxis, pitchTarget / this.spineBones.length);
|
||||||
|
_yawQ.setFromAxisAngle(_yawAxis, yawTarget);
|
||||||
|
_aimQ.premultiply(_yawQ);
|
||||||
|
|
||||||
|
this.spineBones[0].parent.updateWorldMatrix(true, false);
|
||||||
|
for (let i = 0; i < this.spineBones.length; i++) {
|
||||||
|
this.baseQuats[i].copy(this.spineBones[i].quaternion);
|
||||||
|
this.spineBones[i].parent.getWorldQuaternion(_parentWorldQ);
|
||||||
|
_localPitchQ.copy(_parentWorldQ).invert().multiply(_aimQ).multiply(_parentWorldQ);
|
||||||
|
this.spineBones[i].quaternion.premultiply(_localPitchQ);
|
||||||
|
this.spineBones[i].updateWorldMatrix(false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>three-player-controller · FPS Game</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 准心 */
|
||||||
|
#crosshair {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹药面板位于右下角源码按钮上方,间距 8px。 */
|
||||||
|
#weapon-hud { display: none; }
|
||||||
|
#br-panel {
|
||||||
|
position: fixed; bottom: 72px; right: 16px; z-index: 9999;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
#ammo-hud {
|
||||||
|
display: none; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
}
|
||||||
|
.weapon-img-box {
|
||||||
|
width: 96px; height: 44px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.ammo-row {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
padding: 3px 16px 3px 8px; border-radius: 4px;
|
||||||
|
background: linear-gradient(to right, rgba(0,0,0,0.7), transparent);
|
||||||
|
}
|
||||||
|
.ammo-label-tag {
|
||||||
|
font-family: system-ui; font-size: 9px; font-weight: bold;
|
||||||
|
letter-spacing: 0.18em; color: #fff;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
|
||||||
|
}
|
||||||
|
#ammo-current-val {
|
||||||
|
color: #e8a020; font-family: monospace; font-size: 22px;
|
||||||
|
font-weight: bold; line-height: 1;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
|
||||||
|
}
|
||||||
|
.ammo-divider { color: #fff; font-family: monospace; font-size: 15px; margin: 0 1px; }
|
||||||
|
#ammo-total-val { color: #fff; font-family: monospace; font-size: 13px;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.9); }
|
||||||
|
|
||||||
|
/* ── Start Screen ── */
|
||||||
|
#start-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99998;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
#start-btn {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 48px;
|
||||||
|
background: #c0392b;
|
||||||
|
color: #fff;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 24px rgba(192,57,43,0.55);
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
#start-btn:hover {
|
||||||
|
background: #e74c3c;
|
||||||
|
transform: scale(1.04);
|
||||||
|
box-shadow: 0 6px 32px rgba(192,57,43,0.7), 0 0 0 4px rgba(231,76,60,0.25);
|
||||||
|
}
|
||||||
|
#start-btn:active { transform: scale(0.98); }
|
||||||
|
#start-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script src="../loader.js" data-demo="FPS Game"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container"></div>
|
||||||
|
<div id="crosshair"></div>
|
||||||
|
|
||||||
|
<div id="start-screen">
|
||||||
|
<button id="start-btn">
|
||||||
|
<svg viewBox="0 0 10 10"><polygon points="2,1 9,5 2,9"/></svg>
|
||||||
|
Play
|
||||||
|
</button>
|
||||||
|
</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>1</kbd><kbd>4</kbd></span>
|
||||||
|
<span class="hint-text">Switch Weapon / Scroll</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint-group">
|
||||||
|
<span class="hint-keys"><kbd>RMB</kbd></span>
|
||||||
|
<span class="hint-text">Aim (Hold)</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint-group">
|
||||||
|
<span class="hint-keys"><kbd>LMB</kbd></span>
|
||||||
|
<span class="hint-text">Shoot</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint-group">
|
||||||
|
<span class="hint-keys"><kbd>R</kbd></span>
|
||||||
|
<span class="hint-text">Reload</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="weapon-hud"></div>
|
||||||
|
|
||||||
|
<a class="source" href="https://github.com/hh-hang/three-player-controller/blob/master/example/shooting/shooting.js" target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src="../img/source.svg" alt="" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div id="br-panel">
|
||||||
|
<div id="ammo-hud">
|
||||||
|
<div class="weapon-img-box">
|
||||||
|
<svg width="96" height="38" viewBox="0 0 96 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2 18 L2 24 L20 24 L22 20 L20 15 L2 15 Z" fill="rgba(210,195,160,0.8)"/>
|
||||||
|
<rect x="18" y="13" width="50" height="12" rx="2" fill="rgba(210,195,160,0.8)"/>
|
||||||
|
<path d="M36 25 L34 37 L44 37 L46 25 Z" fill="rgba(190,175,140,0.85)"/>
|
||||||
|
<rect x="66" y="15" width="28" height="6" rx="1" fill="rgba(210,195,160,0.8)"/>
|
||||||
|
<rect x="88" y="12" width="2" height="3" fill="rgba(210,195,160,0.7)"/>
|
||||||
|
<rect x="30" y="9" width="22" height="5" rx="1" fill="rgba(190,175,140,0.7)"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ammo-row">
|
||||||
|
<span class="ammo-label-tag">AMMO</span>
|
||||||
|
<span id="ammo-current-val">30</span>
|
||||||
|
<span class="ammo-divider">/</span>
|
||||||
|
<span id="ammo-total-val">300</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="model-credit">
|
||||||
|
Scene model:
|
||||||
|
<a href="https://sketchfab.com/3d-models/horror-corridor-3-e03a192de67d442ebdf42f43a8a2d37a" target="_blank" rel="noopener noreferrer">Sketchfab</a>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="../control-hints.js"></script>
|
||||||
|
<script type="module" src="./shooting.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js";
|
||||||
|
import Stats from "three/examples/jsm/libs/stats.module.js";
|
||||||
|
|
||||||
|
import { createSceneSetup } from "./core/SceneSetup.js";
|
||||||
|
import { LocalPlayer } from "./player/LocalPlayer.js";
|
||||||
|
import { DecalSystem } from "./weapon/DecalSystem.js";
|
||||||
|
import { WeaponController, MODE } from "./weapon/WeaponController.js";
|
||||||
|
import { ShootingEffects } from "./weapon/effects.js";
|
||||||
|
import { HUD } from "./ui/HUD.js";
|
||||||
|
import { ZombieManager } from "./zombie/ZombieManager.js";
|
||||||
|
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
|
||||||
|
const initPos = new THREE.Vector3(33.21, 4.6, 2.6);
|
||||||
|
|
||||||
|
const NORMAL_MAX_CAM = 220;
|
||||||
|
const MOUSE_SENSITIVITY = 5;
|
||||||
|
const PLAYER_MODEL_URL = base + "./glb/swat.glb";
|
||||||
|
const FIRST_PERSON_PITCH_OFFSET_BY_MODEL = {
|
||||||
|
[base + "./glb/swat.glb"]: Math.PI * (16 / 180),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const container = document.querySelector("#container");
|
||||||
|
|
||||||
|
let tiles, localPlayer, weapon, zombieManager;
|
||||||
|
|
||||||
|
// ==================== 1. 场景基础 ====================
|
||||||
|
const { scene, renderer, camera, controls, bakeProbes } = createSceneSetup({ container });
|
||||||
|
|
||||||
|
// 添加音频监听器
|
||||||
|
const listener = new THREE.AudioListener();
|
||||||
|
camera.add(listener);
|
||||||
|
|
||||||
|
camera.position.copy(initPos);
|
||||||
|
camera.lookAt(initPos.x, initPos.y, initPos.z + 1);
|
||||||
|
controls.target.set(initPos.x, initPos.y, initPos.z + 1);
|
||||||
|
|
||||||
|
const 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);
|
||||||
|
|
||||||
|
const timer = new THREE.Timer();
|
||||||
|
let _prevGunEngaged = false;
|
||||||
|
renderer.setAnimationLoop(() => {
|
||||||
|
timer.update();
|
||||||
|
const elapsed = timer.getElapsed();
|
||||||
|
const dt = timer.getDelta();
|
||||||
|
|
||||||
|
if (localPlayer && weapon && zombieManager) {
|
||||||
|
const spineIK = localPlayer.spineIK;
|
||||||
|
const gunEngaged = weapon.isGunEngaged();
|
||||||
|
|
||||||
|
// 开火/瞄准时切 mouseMode 5(锁定朝向跟相机),其余情况用 1
|
||||||
|
if (gunEngaged !== _prevGunEngaged) {
|
||||||
|
localPlayer.setThirdMouseMode(gunEngaged ? 5 : 1);
|
||||||
|
_prevGunEngaged = gunEngaged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复脊椎骨骼到上一帧的干净动画姿态
|
||||||
|
if (gunEngaged) spineIK.restoreBones();
|
||||||
|
|
||||||
|
// 驱动动画与物理(传入 dt,使上半身 mixer 与主 mixer 同步)
|
||||||
|
localPlayer ? localPlayer.update(dt) : controls.update();
|
||||||
|
|
||||||
|
// 应用脊椎 IK 与朝向(在 player.update() 后、weapon.update() 前)
|
||||||
|
if (gunEngaged) {
|
||||||
|
if (localPlayer.getIsFirstPerson()) {
|
||||||
|
localPlayer.applyHipsCorrection();
|
||||||
|
spineIK.applyAim1P(camera, localPlayer.pitchTarget1P);
|
||||||
|
} else {
|
||||||
|
// console.log('应用 3P IK');
|
||||||
|
localPlayer.applyHipsCorrection();
|
||||||
|
spineIK.applyAim3P(camera, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新武器状态(特效、射线命中、连射节拍)——骨骼此时已含 IK
|
||||||
|
weapon.update(elapsed, dt);
|
||||||
|
|
||||||
|
// 丧尸 AI
|
||||||
|
zombieManager.update(dt, localPlayer.getPosition());
|
||||||
|
|
||||||
|
} else {
|
||||||
|
controls?.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
stats.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ==================== 2. 资源加载器 ====================
|
||||||
|
const gltfLoader = new GLTFLoader();
|
||||||
|
|
||||||
|
const draco = new DRACOLoader();
|
||||||
|
draco.setDecoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/draco/");
|
||||||
|
gltfLoader.setDRACOLoader(draco);
|
||||||
|
|
||||||
|
const ktx2 = new KTX2Loader();
|
||||||
|
ktx2.setTranscoderPath("https://unpkg.com/three@0.180.0/examples/jsm/libs/basis/");
|
||||||
|
ktx2.detectSupport(renderer);
|
||||||
|
gltfLoader.setKTX2Loader(ktx2);
|
||||||
|
|
||||||
|
// ==================== 3. 场景模型 ====================
|
||||||
|
const { scene: mapScene } = await gltfLoader.loadAsync(
|
||||||
|
base + "./glb/horror_corridor.glb"
|
||||||
|
);
|
||||||
|
mapScene.name = "sceneGLB";
|
||||||
|
mapScene.scale.set(0.09, 0.09, 0.09);
|
||||||
|
scene.add(mapScene);
|
||||||
|
scene.updateMatrixWorld(true);
|
||||||
|
bakeProbes();
|
||||||
|
|
||||||
|
document.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
|
||||||
|
const playerGltf = await gltfLoader.loadAsync(PLAYER_MODEL_URL);
|
||||||
|
|
||||||
|
// ==================== 4. 本地玩家 ====================
|
||||||
|
localPlayer = new LocalPlayer({ scene, camera, controls });
|
||||||
|
await localPlayer.init({
|
||||||
|
playerModelConfig: {
|
||||||
|
model: playerGltf.scene,
|
||||||
|
animations: playerGltf.animations,
|
||||||
|
scale: 0.01,
|
||||||
|
idleAnim: "idle1",
|
||||||
|
walkAnim: "walk",
|
||||||
|
runAnim: "run",
|
||||||
|
jumpAnim: "jump",
|
||||||
|
flyAnim: "flying",
|
||||||
|
flyIdleAnim: "flyidle",
|
||||||
|
headBoneName: "mixamorigHead",
|
||||||
|
firstPersonPitchOffset: FIRST_PERSON_PITCH_OFFSET_BY_MODEL[PLAYER_MODEL_URL] ?? 0,
|
||||||
|
rotateY: - Math.PI / 2,
|
||||||
|
},
|
||||||
|
initPos: initPos,
|
||||||
|
minCamDistance: 10,
|
||||||
|
maxCamDistance: NORMAL_MAX_CAM,
|
||||||
|
enableOverShoulderView: true,
|
||||||
|
mouseSensitivity: MOUSE_SENSITIVITY,
|
||||||
|
camLookAtHeightRatio: 0.9,
|
||||||
|
keyMap: { toggleFly: null },
|
||||||
|
colliders: [
|
||||||
|
{ motion: "static", shape: { kind: "mesh", source: mapScene } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
localPlayer.getPlayerModel()?.traverse((c) => {
|
||||||
|
if (c.isMesh) {
|
||||||
|
c.frustumCulled = false;
|
||||||
|
c.castShadow = true;
|
||||||
|
c.receiveShadow = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 5. HUD ====================
|
||||||
|
const weaponSlots = [
|
||||||
|
{ key: "1", mode: "primary", label: "Rifle" },
|
||||||
|
{ key: "4", mode: "normal", label: "Fists" },
|
||||||
|
];
|
||||||
|
const hud = new HUD(weaponSlots);
|
||||||
|
hud.build();
|
||||||
|
|
||||||
|
// ==================== 6. 特效 ====================
|
||||||
|
let effects = new ShootingEffects(scene, {
|
||||||
|
listener,
|
||||||
|
flashScale: 0.15,
|
||||||
|
smokeSize: 0.8
|
||||||
|
});
|
||||||
|
await effects.load(
|
||||||
|
base + "./img/muzzle_flash.png",
|
||||||
|
base + "./img/smoke.png",
|
||||||
|
base + "./audio/gun_shot.mp3",
|
||||||
|
base + "./audio/reload.mp3",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==================== 7. 弹痕系统 ====================
|
||||||
|
const decalSystem = new DecalSystem(scene, 60, 0.25);
|
||||||
|
await decalSystem.loadMaterials(["img/bullet_hole.png"], base);
|
||||||
|
|
||||||
|
// ==================== 8. 丧尸管理器 ====================
|
||||||
|
zombieManager = new ZombieManager(scene, {
|
||||||
|
loader: gltfLoader,
|
||||||
|
colliders: localPlayer.getColliderMeshes(),
|
||||||
|
modelUrl: base + "./glb/zombie.glb",
|
||||||
|
scale: 0.01,
|
||||||
|
walkAnim: "walking",
|
||||||
|
idleAnim: "Idle",
|
||||||
|
punchAnim: "punching",
|
||||||
|
runAnim: "running",
|
||||||
|
rotateY: Math.PI,
|
||||||
|
speed: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 9. 武器控制器 ====================
|
||||||
|
weapon = new WeaponController({
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
localPlayer,
|
||||||
|
decalSystem,
|
||||||
|
effects,
|
||||||
|
hud,
|
||||||
|
zombieManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
await weapon.load(gltfLoader, base);
|
||||||
|
weapon.setupAnimations();
|
||||||
|
weapon.bindInput();
|
||||||
|
weapon.switchMode(MODE.PRIMARY);
|
||||||
|
|
||||||
|
// 让 LocalPlayer 的 1P 俯仰驱动能感知武器状态
|
||||||
|
localPlayer.setGunEngagedGetter(() => weapon.isGunEngaged());
|
||||||
|
|
||||||
|
// 初始刷新武器槽高亮
|
||||||
|
hud.update(weapon.getMode());
|
||||||
|
|
||||||
|
// ==================== 10. 鼠标拾取 ====================
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const mouse = new THREE.Vector2();
|
||||||
|
|
||||||
|
const getMousePickingPoint = (event) => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
// 将鼠标位置归一化为设备坐标 (NDC)
|
||||||
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
// 检测场景中所有的物体(包含 3D Tiles 和玩家模型)
|
||||||
|
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
return intersects[0].point;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("mousedown", (e) => {
|
||||||
|
// 只有当鼠标没有被锁定(非第一人称控制状态)时才执行点击拾取
|
||||||
|
if (document.pointerLockElement !== renderer.domElement) {
|
||||||
|
const point = getMousePickingPoint(e);
|
||||||
|
if (point) {
|
||||||
|
console.log("鼠标点击的 3D 坐标:", point);
|
||||||
|
// 你可以在这里添加逻辑,比如在点击位置生成丧尸:
|
||||||
|
// zombieManager.spawnZombie(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 丧尸波次 ====================
|
||||||
|
await Promise.all([
|
||||||
|
zombieManager.startWave({ origin: new THREE.Vector3(2.012, 4.6, -0.78), count: 5, radius: 1 }),
|
||||||
|
zombieManager.startWave({ origin: new THREE.Vector3(-3.731, 4.6, -1.824), count: 2, radius: 1 }),
|
||||||
|
zombieManager.startWave({ origin: new THREE.Vector3(11.911, 4.6, -4.010), count: 2, radius: 1 }),
|
||||||
|
zombieManager.startWave({ origin: new THREE.Vector3(13.567, 4.6, 7.001), count: 2, radius: 1 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
window.hideLoader?.();
|
||||||
|
|
||||||
|
// ==================== Start Screen ====================
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const screen = document.getElementById("start-screen");
|
||||||
|
screen.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||||
|
screen.addEventListener("mouseup", (e) => e.stopPropagation());
|
||||||
|
screen.addEventListener("click", (e) => e.stopPropagation());
|
||||||
|
screen.style.display = "flex";
|
||||||
|
document.getElementById("start-btn").addEventListener("click", () => {
|
||||||
|
screen.remove();
|
||||||
|
document.body.style.cursor = "none";
|
||||||
|
document.body.requestPointerLock?.();
|
||||||
|
resolve();
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 窗口缩放 ====================
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
const cont = document.querySelector("#container");
|
||||||
|
if (!cont) return;
|
||||||
|
camera.aspect = cont.clientWidth / cont.clientHeight;
|
||||||
|
renderer.setSize(cont.clientWidth, cont.clientHeight);
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// HUD:准星 + 武器选择栏 + 弹药显示
|
||||||
|
// 不依赖任何 Three.js 对象,只操作 DOM
|
||||||
|
export class HUD {
|
||||||
|
constructor(weaponSlots) {
|
||||||
|
this._slots = weaponSlots; // [{ key, mode, label }]
|
||||||
|
|
||||||
|
this._crosshair = document.getElementById("crosshair");
|
||||||
|
this._weaponHud = document.getElementById("weapon-hud");
|
||||||
|
this._ammoHud = document.getElementById("ammo-hud");
|
||||||
|
this._ammoCurrent = document.getElementById("ammo-current-val");
|
||||||
|
this._ammoTotal = document.getElementById("ammo-total-val");
|
||||||
|
this._hitTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 初始化 ====================
|
||||||
|
|
||||||
|
build() {
|
||||||
|
this._weaponHud.innerHTML = "";
|
||||||
|
for (let i = this._slots.length - 1; i >= 0; i--) {
|
||||||
|
const slot = this._slots[i];
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "weapon-slot";
|
||||||
|
el.dataset.mode = slot.mode;
|
||||||
|
el.innerHTML = `<span class="slot-key">${slot.key}</span><span class="slot-name">${slot.label}</span>`;
|
||||||
|
this._weaponHud.appendChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 武器槽 ====================
|
||||||
|
|
||||||
|
update(currentMode) {
|
||||||
|
this._weaponHud.querySelectorAll(".weapon-slot").forEach((el) => {
|
||||||
|
el.classList.toggle("active", el.dataset.mode === currentMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 弹药 ====================
|
||||||
|
|
||||||
|
updateAmmo(current, max) {
|
||||||
|
if (this._ammoCurrent) this._ammoCurrent.textContent = current;
|
||||||
|
if (this._ammoTotal) this._ammoTotal.textContent = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAmmo() {
|
||||||
|
if (this._ammoHud) this._ammoHud.style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
hideAmmo() {
|
||||||
|
if (this._ammoHud) this._ammoHud.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 准星 ====================
|
||||||
|
|
||||||
|
showCrosshair() { this._crosshair.style.display = "block"; }
|
||||||
|
hideCrosshair() { this._crosshair.style.display = "none"; }
|
||||||
|
|
||||||
|
flashHit() {
|
||||||
|
if (!this._crosshair) return;
|
||||||
|
if (this._hitTimer) clearTimeout(this._hitTimer);
|
||||||
|
this._crosshair.style.backgroundColor = "red";
|
||||||
|
this._hitTimer = setTimeout(() => {
|
||||||
|
this._crosshair.style.backgroundColor = "";
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||