Merge branch 'main' into leonida/strassen

Bringt den Stand von main (Animationen, Showroom) in den Straßenzweig,
damit die zusammengeführte Fassung geprüft werden kann, bevor main auf
diesen Stand vorgezogen wird. main ist in einem zweiten Arbeitsverzeichnis
ausgecheckt und kann hier nicht ausgecheckt werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-18 12:09:42 +02:00
commit 4a91e84bfc
23 files changed
+4583

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
// Ein Dummy der Animations-Testseite: geklontes Mannequin, Mocap-Clip in Dauerschleife, dazu eine feste
// Fingerhaltung und wahlweise eine Waffe im Handknochen
// (docs/superpowers/specs/2026-09-17-animationen-design.md, "Dummies").
import { AnimationClip, AnimationMixer, LoopRepeat, MathUtils, QuaternionKeyframeTrack } from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
/** Fingerknochen des Mannequins ohne die Blattknochen. Die Mocap-Daten haben keine Finger. */
export const FINGER_BONE = /^(thumb|index|middle|ring|pinky)_0[1-3]_[lr]$/;
/**
* Macht aus einem Clip des Mannequins (Pistol_Idle_Loop für die Waffenhand, Idle_Loop für leere Hände) eine feste
* Fingerhaltung: nur die Drehungen der Fingerknochen, eingefroren auf ihren ersten Keyframe. Läuft als zweite
* Aktion auf demselben Mixer — sie berührt keinen Knochen, den der Mocap-Clip bewegt.
*/
export function fingerPoseClip(source, name = `${source.name}_Finger`) {
const tracks = source.tracks
.filter((track) => track.name.endsWith(".quaternion")
&& FINGER_BONE.test(track.name.slice(0, track.name.lastIndexOf("."))))
.map((track) => {
const first = [...track.values.slice(0, 4)];
return new QuaternionKeyframeTrack(track.name, [0, 1], [...first, ...first]);
});
if (tracks.length === 0) throw new Error(`${source.name}: keine Fingerspuren`);
return new AnimationClip(name, 1, tracks);
}
/** Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad (XYZ). */
export function applyGrip(object, grip) {
object.position.fromArray(grip.position);
object.rotation.set(...grip.rotation.map((degrees) => MathUtils.degToRad(degrees)), "XYZ");
}
/** Baut einen Dummy. template ist das geladene Mannequin, weapon die geladene Waffenszene (wird geklont). */
export function createDummy({ template, clip, fingers, weapon = null, hand = "r", grip = null }) {
const root = cloneSkeleton(template);
root.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = true;
object.receiveShadow = true;
// Für ein Skinned Mesh ist die Hülle der Ruhepose ohnehin die falsche Grundlage, sobald Knochen die
// Figur bewegen — besonders sichtbar beim Bogenschützen, der bis 3 m von seinem Platz wandert. Deshalb
// für alle Dummies aus, nicht nur für ihn.
object.frustumCulled = false;
});
const mixer = new AnimationMixer(root);
mixer.clipAction(clip).setLoop(LoopRepeat, Infinity).play();
mixer.clipAction(fingers).play();
let held = null;
if (weapon) {
if (!grip) throw new Error("Dummy mit Waffe ohne Griff");
const boneName = hand === "l" ? "hand_l" : "hand_r";
const bone = root.getObjectByName(boneName);
if (!bone) throw new Error(`Dummy ohne Knochen ${boneName}`);
held = weapon.clone(true);
held.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = true;
object.receiveShadow = true;
});
applyGrip(held, grip);
bone.add(held);
}
return { root, mixer, clip, weapon: held };
}
+31
View File
@@ -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;
}
+71
View File
@@ -0,0 +1,71 @@
// Aufstellung der Animations-Testseite (docs/superpowers/specs/2026-09-17-animationen-design.md, "Aufstellung"):
// welcher Dummy welchen Clip spielt, welche Waffe er in welcher Hand hält und wo er steht. Alle Maße in
// Mannequin-Metern — src/showroom/main.js hängt die Aufstellung an eine Gruppe mit dem Maßstab der Spielfigur.
/**
* Griff = Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad in der Reihenfolge XYZ. Die Werte
* sind im Browser mit dem Panel eingepasst (Plan-Task 9); das Pack verspricht für alle fünf denselben Griffpunkt.
*/
export const WEAPONS = {
pistol: { label: "Pistole", url: "./models/weapons/pistol.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
ak47: { label: "AK47", url: "./models/weapons/ak47.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
m4: { label: "M4", url: "./models/weapons/m4.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
shotgun870: { label: "870 Shotgun", url: "./models/weapons/shotgun870.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, 90] } },
// Das Pistolenmaß passt auch hier (Griffpunkt, Position), aber die L96 sitzt im Modell um 180° um ihre
// Querachse gedreht (Länge und Höhe ihrer Bounding-Box liegen seitenverkehrt zu AK47/M4/Shotgun) — deshalb
// -90° statt 90° in Z.
l96: { label: "L96", url: "./models/weapons/l96.glb", grip: { position: [0, 0.09, 0], rotation: [0, 0, -90] } },
};
export const MOTIONS_URL = "./models/motions/weapons-mocap.glb";
/** Reihe 1 steht bei z = 0 mit acht Plätzen, Reihe 2 bei z = −ROW_DEPTH. Die Spielfigur steht VIEW_DISTANCE davor. */
export const ROW_SPACING = 3.5;
export const ROW_DEPTH = 8;
export const FIRST_SLOT_X = -12.25;
export const BOW_X = 8.75;
export const LABEL_HEIGHT = 2.2;
export const VIEW_DISTANCE = 8;
/**
* Reihe 1 (Plätze 1–8) hält die Waffen, Reihe 2 (Plätze 9–13) die Clips ohne passende Waffe. Die Gewehr-Clips
* laufen doppelt, damit auch Shotgun und L96 zu sehen sind — eine eigene Animation gibt es für sie nicht.
*/
export const DUMMIES = [
{ clip: "idle_loadGun-Hand_tk01", label: "Pistole nachladen · Take 1", weapon: "pistol", hand: "r" },
{ clip: "idle_loadGun-Hand_tk02", label: "Pistole nachladen · Take 2", weapon: "pistol", hand: "r" },
{ clip: "taunt_shootSelfInHead_tk01", label: "Taunt: Schuss in den Kopf · Take 1", weapon: "pistol", hand: "r" },
{ clip: "taunt_shootSelfInHead_tk02", label: "Taunt: Schuss in den Kopf · Take 2", weapon: "pistol", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk04", label: "Gewehr nachladen · Take 4", weapon: "ak47", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk05", label: "Gewehr nachladen · Take 5", weapon: "m4", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk04", label: "Gewehr nachladen · Take 4", weapon: "shotgun870", hand: "r" },
{ clip: "idle_loadGun-Rifle_tk05", label: "Gewehr nachladen · Take 5", weapon: "l96", hand: "r" },
{ clip: "stabTwist_Knife_tk01", label: "Messerstich mit Drehung · Take 1", weapon: null, hand: null },
{ clip: "stabTwist_Knife_tk02", label: "Messerstich mit Drehung · Take 2", weapon: null, hand: null },
{ clip: "leftHandThrow_grenade_tk01", label: "Granate werfen (links) · Take 1", weapon: null, hand: null },
{ clip: "leftHandThrow_grenade_tk02", label: "Granate werfen (links) · Take 2", weapon: null, hand: null },
{ clip: "hunter_bowArrow_tk01", label: "Bogenschütze · Take 1", weapon: null, hand: null },
];
/** Platz eines Dummys in Mannequin-Metern. Der Bogenschütze steht abseits, er wandert bis 3,13 m. */
export function slotPosition(index, dummies = DUMMIES) {
if (index < 8) return { x: FIRST_SLOT_X + ROW_SPACING * index, z: 0 };
if (dummies[index]?.clip.startsWith("hunter_bowArrow")) return { x: BOW_X, z: -ROW_DEPTH };
return { x: FIRST_SLOT_X + ROW_SPACING * (index - 8), z: -ROW_DEPTH };
}
/**
* Lage der Aufstellung: VIEW_DISTANCE Mannequin-Meter vor der Figur in Blickrichtung, +Z zurück zu ihr gedreht.
* position ist die Figurenposition (x, z genügen), view die bereits normalisierte waagrechte Blickrichtung.
*/
export function stageTransform(position, view, scale) {
return {
x: position.x + view.x * VIEW_DISTANCE * scale,
z: position.z + view.z * VIEW_DISTANCE * scale,
rotationY: Math.atan2(-view.x, -view.z),
};
}
/** Text des Schilds: Clip oben, Waffe darunter. */
export function labelLines(dummy) {
return [dummy.label, dummy.weapon ? WEAPONS[dummy.weapon].label : "ohne Waffe"];
}
+176
View File
@@ -0,0 +1,176 @@
// 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, stageTransform, 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 } }],
});
// Wie in createCharacterModule (src/characters/index.js): Der Controller setzt beim Laden selbst keinen
// Schatten, und dieses Startmodell durchläuft applyCharacter nie — ohne diese Schleife bliebe die Spielfigur
// als einzige Figur der Seite ohne Schatten.
player.playerModel.traverse((object) => {
if (!object.isMesh) return;
object.castShadow = true;
object.receiveShadow = true;
});
// 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]));
// Wie beim Clip-Lookup der Dummies unten: findByName gibt bei einem fehlenden Clip still null zurück, das
// bräche erst tief in fingerPoseClip mit einem unklaren TypeError ab.
function requireAnimation(name) {
const clip = AnimationClip.findByName(ual.animations, name);
if (!clip) throw new Error(`ual.glb ohne Clip ${name}`);
return clip;
}
const gripFingers = fingerPoseClip(requireAnimation("Pistol_Idle_Loop"), "Finger_Waffe");
const looseFingers = fingerPoseClip(requireAnimation("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 { x, z, rotationY } = stageTransform(player.playerCapsule.position, view, figureScale);
stage.position.set(x, 0, z);
stage.rotation.y = rotationY;
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 };
+46
View File
@@ -0,0 +1,46 @@
// 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);
// lil-gui zeigt bei einem Objekt { Beschriftung: Wert } die Schlüssel statt der IDs im Dropdown an.
const weaponOptions = Object.fromEntries(ids.map((id) => [WEAPONS[id].label, id]));
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", weaponOptions).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;
}