diff --git a/.claude/launch.json b/.claude/launch.json index 9037340..1709f09 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -20,6 +20,13 @@ ], "port": 5173, "url": "http://localhost:5173" + }, + { + "name": "animationen", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"], + "port": 5180, + "url": "http://localhost:5180" } ] } diff --git a/animationen.html b/animationen.html new file mode 100644 index 0000000..49817ff --- /dev/null +++ b/animationen.html @@ -0,0 +1,16 @@ + + + + + + Leonida · Animationen + + + +
+ + + diff --git a/src/showroom/labels.js b/src/showroom/labels.js new file mode 100644 index 0000000..45341c7 --- /dev/null +++ b/src/showroom/labels.js @@ -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; +} diff --git a/src/showroom/main.js b/src/showroom/main.js new file mode 100644 index 0000000..b69305a --- /dev/null +++ b/src/showroom/main.js @@ -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 }; diff --git a/src/showroom/panel.js b/src/showroom/panel.js new file mode 100644 index 0000000..e03c28b --- /dev/null +++ b/src/showroom/panel.js @@ -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; +} diff --git a/vite.config.js b/vite.config.js index a0abc4e..9ffdcd1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; import { configDefaults } from "vitest/config"; @@ -6,6 +7,15 @@ export default defineConfig({ resolve: { dedupe: ["three", "three-mesh-bvh"] }, // 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"] }, + // 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 }, // 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