feat(showroom): Testseite mit Dummies, Schildern und Panel
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
12a77051d6
commit
da4d078cab
6 files changed
+270
No files matched your search
@@ -20,6 +20,13 @@
|
|||||||
],
|
],
|
||||||
"port": 5173,
|
"port": 5173,
|
||||||
"url": "http://localhost:5173"
|
"url": "http://localhost:5173"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "animationen",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||||
|
"port": 5180,
|
||||||
|
"url": "http://localhost:5180"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Leonida · Animationen</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; overflow: hidden; }
|
||||||
|
#container { width: 100%; height: 100%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container"></div>
|
||||||
|
<script type="module" src="/src/showroom/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Schild über einem Platz der Animations-Testseite: zwei Zeilen auf dunklem Grund, immer zur Kamera gedreht.
|
||||||
|
import { CanvasTexture, SRGBColorSpace, Sprite, SpriteMaterial } from "three";
|
||||||
|
|
||||||
|
const WIDTH = 512;
|
||||||
|
const HEIGHT = 128;
|
||||||
|
|
||||||
|
/** breite in Mannequin-Metern; die Höhe folgt dem Seitenverhältnis der Zeichenfläche. */
|
||||||
|
export function createLabel([title, subtitle], width = 2.4) {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = WIDTH;
|
||||||
|
canvas.height = HEIGHT;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "rgba(0, 0, 0, 0.6)";
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(0, 0, WIDTH, HEIGHT, 24);
|
||||||
|
context.fill();
|
||||||
|
context.textAlign = "center";
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.font = "600 40px system-ui, sans-serif";
|
||||||
|
context.fillText(title, WIDTH / 2, 56, WIDTH - 32);
|
||||||
|
context.fillStyle = "#ffd27a";
|
||||||
|
context.font = "400 34px system-ui, sans-serif";
|
||||||
|
context.fillText(subtitle, WIDTH / 2, 102, WIDTH - 32);
|
||||||
|
|
||||||
|
const texture = new CanvasTexture(canvas);
|
||||||
|
texture.colorSpace = SRGBColorSpace;
|
||||||
|
// toneMapped: false, sonst zieht ACESFilmicToneMapping die Schrift grau.
|
||||||
|
const sprite = new Sprite(new SpriteMaterial({ map: texture, depthWrite: false, toneMapped: false }));
|
||||||
|
sprite.scale.set(width, (width * HEIGHT) / WIDTH, 1);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Animations-Testseite (docs/superpowers/specs/2026-09-17-animationen-design.md): 13 Mannequin-Dummies spielen die
|
||||||
|
// Mocap-Clips aus weapons-mocap.glb in Dauerschleife, acht davon mit Waffe in der Hand. Die Spielfigur läuft wie
|
||||||
|
// im Spiel. Keine Stadt, kein Streaming, keine Fahrzeuge.
|
||||||
|
import { ACESFilmicToneMapping, AnimationClip, Fog, Group, Mesh, MeshStandardMaterial, PerspectiveCamera,
|
||||||
|
PlaneGeometry, RepeatWrapping, Scene, SRGBColorSpace, TextureLoader, Timer, Vector3,
|
||||||
|
WebGLRenderer } from "three";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
||||||
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
|
import { playerController } from "three-player-controller";
|
||||||
|
import { PLAYER_MODEL_BASE } from "../characters/index.js";
|
||||||
|
import { createSky } from "../world/sky.js";
|
||||||
|
import { createSunShadows } from "../world/shadows.js";
|
||||||
|
import { createDummy, fingerPoseClip } from "./dummies.js";
|
||||||
|
import { createLabel } from "./labels.js";
|
||||||
|
import { DUMMIES, labelLines, LABEL_HEIGHT, MOTIONS_URL, slotPosition, VIEW_DISTANCE, WEAPONS } from "./lineup.js";
|
||||||
|
import { createShowroomPanel } from "./panel.js";
|
||||||
|
|
||||||
|
// Dieselben Werte wie in src/main.js (dort lokale Konstanten): Sonne in gemessener Stärke, Himmelslicht und
|
||||||
|
// Kuppelhelligkeit fünffach.
|
||||||
|
const SUN_STRENGTH = 1;
|
||||||
|
const SKY_LIGHT = 5;
|
||||||
|
const SKY_BRIGHTNESS = 5;
|
||||||
|
// Poly Haven misst concrete_pavement mit 1,8 × 1,8 m. Die Ebene ist groß genug für jeden Figurenmaßstab.
|
||||||
|
const GROUND_TILE = 1.8;
|
||||||
|
const GROUND_SIZE = 200;
|
||||||
|
|
||||||
|
const container = document.querySelector("#container");
|
||||||
|
const scene = new Scene();
|
||||||
|
const renderer = new WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
renderer.toneMapping = ACESFilmicToneMapping;
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Nahwert 0,1 statt 0,5 wie im Spiel: Hier steht nur eine Ebene, es gibt keine übereinanderliegenden Flächen,
|
||||||
|
// die im Tiefenpuffer flackern könnten, dafür geht die Kamera näher an die Dummies heran.
|
||||||
|
const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500);
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
|
||||||
|
scene.fog = new Fog(0xffffff, 500, 2200); // Farbe setzt sky.setBrightness() passend zum Horizont
|
||||||
|
const sky = await createSky({ renderer, scene });
|
||||||
|
sky.setBrightness(SKY_BRIGHTNESS);
|
||||||
|
scene.environmentIntensity = SKY_LIGHT;
|
||||||
|
const sun = createSunShadows({ scene, camera, sun: { ...sky.sun, intensity: sky.sun.intensity * SUN_STRENGTH } });
|
||||||
|
|
||||||
|
const textureLoader = new TextureLoader();
|
||||||
|
const groundTextures = await Promise.all(
|
||||||
|
["Diffuse", "nor_gl", "arm"].map((map) => textureLoader.loadAsync(`./textures/concrete_pavement_${map}.jpg`)));
|
||||||
|
for (const texture of groundTextures) {
|
||||||
|
texture.wrapS = texture.wrapT = RepeatWrapping;
|
||||||
|
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
|
||||||
|
}
|
||||||
|
const [groundColor, groundNormal, groundArm] = groundTextures;
|
||||||
|
groundColor.colorSpace = SRGBColorSpace;
|
||||||
|
const ground = new Mesh(new PlaneGeometry(GROUND_SIZE, GROUND_SIZE), new MeshStandardMaterial({
|
||||||
|
map: groundColor, normalMap: groundNormal, aoMap: groundArm, roughnessMap: groundArm, metalness: 0,
|
||||||
|
}));
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
ground.receiveShadow = true;
|
||||||
|
scene.add(ground);
|
||||||
|
ground.updateMatrixWorld(true);
|
||||||
|
|
||||||
|
const gltfLoader = new GLTFLoader();
|
||||||
|
gltfLoader.setMeshoptDecoder(MeshoptDecoder); // die Waffen sind meshopt-komprimiert
|
||||||
|
const weaponIds = Object.keys(WEAPONS);
|
||||||
|
const [ual, motions, ...weaponFiles] = await Promise.all([
|
||||||
|
gltfLoader.loadAsync("./models/ual.glb"),
|
||||||
|
gltfLoader.loadAsync(MOTIONS_URL),
|
||||||
|
...weaponIds.map((id) => gltfLoader.loadAsync(WEAPONS[id].url)),
|
||||||
|
]);
|
||||||
|
// Vor player.init klonen: Der Controller skaliert ual.scene an Ort und Stelle auf seine Kapselhöhe.
|
||||||
|
const template = cloneSkeleton(ual.scene);
|
||||||
|
|
||||||
|
const player = new playerController();
|
||||||
|
await player.init({
|
||||||
|
scene, camera, controls,
|
||||||
|
initPos: new Vector3(0, 1, 0),
|
||||||
|
playerModelConfig: { ...PLAYER_MODEL_BASE, model: ual.scene, animations: ual.animations },
|
||||||
|
colliders: [{ motion: "static", shape: { kind: "mesh", source: ground } }],
|
||||||
|
});
|
||||||
|
// Der Controller skaliert das Modell selbst (playerCapsuleHeight × scale). Ausgelesen statt nachgerechnet:
|
||||||
|
// daran hängen Dummies, Waffen, Schilder und alle Abstände.
|
||||||
|
const figureScale = player.playerModel.getWorldScale(new Vector3()).x;
|
||||||
|
for (const texture of groundTextures) texture.repeat.setScalar(GROUND_SIZE / (GROUND_TILE * figureScale));
|
||||||
|
|
||||||
|
const stage = new Group();
|
||||||
|
stage.scale.setScalar(figureScale);
|
||||||
|
scene.add(stage);
|
||||||
|
|
||||||
|
const clips = new Map(motions.animations.map((clip) => [clip.name, clip]));
|
||||||
|
const gripFingers = fingerPoseClip(AnimationClip.findByName(ual.animations, "Pistol_Idle_Loop"), "Finger_Waffe");
|
||||||
|
const looseFingers = fingerPoseClip(AnimationClip.findByName(ual.animations, "Idle_Loop"), "Finger_locker");
|
||||||
|
const weaponScenes = new Map(weaponIds.map((id, index) => [id, weaponFiles[index].scene]));
|
||||||
|
const weaponObjects = new Map();
|
||||||
|
const dummies = DUMMIES.map((entry, index) => {
|
||||||
|
const clip = clips.get(entry.clip);
|
||||||
|
if (!clip) throw new Error(`weapons-mocap.glb ohne Clip ${entry.clip}`);
|
||||||
|
const dummy = createDummy({
|
||||||
|
template,
|
||||||
|
clip,
|
||||||
|
fingers: entry.weapon ? gripFingers : looseFingers,
|
||||||
|
weapon: entry.weapon ? weaponScenes.get(entry.weapon) : null,
|
||||||
|
hand: entry.hand ?? "r",
|
||||||
|
grip: entry.weapon ? WEAPONS[entry.weapon].grip : null,
|
||||||
|
});
|
||||||
|
const { x, z } = slotPosition(index);
|
||||||
|
dummy.root.position.set(x, 0, z);
|
||||||
|
stage.add(dummy.root);
|
||||||
|
const label = createLabel(labelLines(entry));
|
||||||
|
label.position.set(x, LABEL_HEIGHT, z);
|
||||||
|
stage.add(label);
|
||||||
|
if (dummy.weapon) weaponObjects.set(entry.weapon, [...(weaponObjects.get(entry.weapon) ?? []), dummy.weapon]);
|
||||||
|
return dummy;
|
||||||
|
});
|
||||||
|
|
||||||
|
const playback = { tempo: 1, paused: false };
|
||||||
|
createShowroomPanel({ playback, weaponObjects });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt die Aufstellung vor die Spielfigur. Wohin die Kamera anfangs schaut, bestimmt der Controller
|
||||||
|
* (rotateY der Kapsel, daraus die Kameraposition in CameraSystem.ts) — deshalb wird die Aufstellung nach dem
|
||||||
|
* ersten Bild an den Blick gehängt statt die Figur zu versetzen. stage +Z zeigt zurück zur Figur, die Dummies
|
||||||
|
* schauen sie also an.
|
||||||
|
*/
|
||||||
|
function placeStage() {
|
||||||
|
const view = camera.getWorldDirection(new Vector3());
|
||||||
|
view.y = 0;
|
||||||
|
if (view.lengthSq() < 1e-6) return false;
|
||||||
|
view.normalize();
|
||||||
|
const position = player.playerCapsule.position;
|
||||||
|
stage.position.set(
|
||||||
|
position.x + view.x * VIEW_DISTANCE * figureScale, 0, position.z + view.z * VIEW_DISTANCE * figureScale);
|
||||||
|
stage.rotation.y = Math.atan2(-view.x, -view.z);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = new Timer();
|
||||||
|
timer.connect(document);
|
||||||
|
let staged = false;
|
||||||
|
function loop(time) {
|
||||||
|
timer.update(time);
|
||||||
|
const delta = Math.min(timer.getDelta(), 1 / 40);
|
||||||
|
player.update(delta);
|
||||||
|
if (!staged) staged = placeStage();
|
||||||
|
if (!playback.paused) {
|
||||||
|
for (const dummy of dummies) dummy.mixer.update(delta * playback.tempo);
|
||||||
|
}
|
||||||
|
sun.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
renderer.setAnimationLoop(loop);
|
||||||
|
addEventListener("resize", () => {
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
camera.aspect = container.clientWidth / container.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Für die Prüfung im Browser (Griff einpassen, Schleifennaht, Bildzeit). Die Seite ist ein Prüfstand, kein Spiel.
|
||||||
|
window.__showroom = { renderer, scene, camera, player, stage, dummies, playback, figureScale, loop };
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Panel der Animations-Testseite: Tempo und Pause für alle Dummies, dazu der Griff je Waffe zum Einpassen im
|
||||||
|
// Browser (docs/superpowers/specs/2026-09-17-animationen-design.md, "Panel").
|
||||||
|
import GUI from "lil-gui";
|
||||||
|
import { applyGrip } from "./dummies.js";
|
||||||
|
import { WEAPONS } from "./lineup.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* playback = { tempo, paused } liest der Bildtakt in main.js. weaponObjects ist id -> alle Kopien dieser Waffe an
|
||||||
|
* den Dummies; ein verschobener Griff wirkt dadurch sofort auf alle.
|
||||||
|
*/
|
||||||
|
export function createShowroomPanel({ playback, weaponObjects }) {
|
||||||
|
const gui = new GUI({ title: "Animationen" });
|
||||||
|
gui.add(playback, "tempo", 0, 2, 0.05).name("Tempo");
|
||||||
|
gui.add(playback, "paused").name("Pause");
|
||||||
|
|
||||||
|
const folder = gui.addFolder("Griff");
|
||||||
|
const ids = Object.keys(WEAPONS);
|
||||||
|
const edit = { weapon: ids[0], x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0 };
|
||||||
|
const sliders = [];
|
||||||
|
const load = () => {
|
||||||
|
const { position, rotation } = WEAPONS[edit.weapon].grip;
|
||||||
|
[edit.x, edit.y, edit.z] = position;
|
||||||
|
[edit.rx, edit.ry, edit.rz] = rotation;
|
||||||
|
for (const slider of sliders) slider.updateDisplay();
|
||||||
|
};
|
||||||
|
const apply = () => {
|
||||||
|
const grip = WEAPONS[edit.weapon].grip;
|
||||||
|
grip.position = [edit.x, edit.y, edit.z];
|
||||||
|
grip.rotation = [edit.rx, edit.ry, edit.rz];
|
||||||
|
for (const object of weaponObjects.get(edit.weapon) ?? []) applyGrip(object, grip);
|
||||||
|
};
|
||||||
|
folder.add(edit, "weapon", ids).name("Waffe").onChange(load);
|
||||||
|
for (const axis of ["x", "y", "z"]) {
|
||||||
|
sliders.push(folder.add(edit, axis, -0.3, 0.3, 0.001).name(`Position ${axis} (m)`).onChange(apply));
|
||||||
|
}
|
||||||
|
for (const axis of ["rx", "ry", "rz"]) {
|
||||||
|
sliders.push(folder.add(edit, axis, -180, 180, 1).name(`Drehung ${axis.slice(1)} (Grad)`).onChange(apply));
|
||||||
|
}
|
||||||
|
folder.add({
|
||||||
|
ausgeben: () => console.log(`${edit.weapon}:`, JSON.stringify(WEAPONS[edit.weapon].grip)),
|
||||||
|
}, "ausgeben").name("Werte ausgeben");
|
||||||
|
load();
|
||||||
|
return gui;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { configDefaults } from "vitest/config";
|
import { configDefaults } from "vitest/config";
|
||||||
|
|
||||||
@@ -6,6 +7,15 @@ export default defineConfig({
|
|||||||
resolve: { dedupe: ["three", "three-mesh-bvh"] },
|
resolve: { dedupe: ["three", "three-mesh-bvh"] },
|
||||||
// Der BVH-Worker wird dynamisch aus three-mesh-bvh/src/workers/ geladen und darf nicht vorgebündelt werden.
|
// Der BVH-Worker wird dynamisch aus three-mesh-bvh/src/workers/ geladen und darf nicht vorgebündelt werden.
|
||||||
optimizeDeps: { exclude: ["three-mesh-bvh", "three-player-controller"] },
|
optimizeDeps: { exclude: ["three-mesh-bvh", "three-player-controller"] },
|
||||||
|
// Zwei Seiten: das Spiel und die Animations-Testseite. Ohne diese Liste baut vite nur index.html.
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||||
|
animationen: fileURLToPath(new URL("./animationen.html", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: { host: true },
|
server: { host: true },
|
||||||
// Parallele Sitzungen legen ihre Arbeitsbäume unter .claude/worktrees/ in diesem Repository
|
// Parallele Sitzungen legen ihre Arbeitsbäume unter .claude/worktrees/ in diesem Repository
|
||||||
// an. Ohne diesen Ausschluss sammelt vitest deren Testdateien mit ein — das verdoppelt die
|
// an. Ohne diesen Ausschluss sammelt vitest deren Testdateien mit ein — das verdoppelt die
|
||||||
|
|||||||
Reference in new issue
Block a user