feat: Figurenauswahl, Autos, Debug-Panel und Namensnennung

Task 11: Figuren (Mannequin/Superhero Male/Female) mit auf ual.glb
retargeteten Animationen, Frisuren, vier SUVs auf angehefteten Chunks,
ein lil-gui-Debug-Panel (Figur/Frisur/Höhe/Hitbox/Chunk-Grenzen/
Kartenbild/Nebel/Wahrzeichen-Sprung) und die verpflichtende
Namensnennung fürs HUD (Kartenkacheln, Wahrzeichen, SUV, Texturen,
Palme, Figuren — jede Quelle mit ihrer eigenen Lizenz).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-13 03:04:49 +02:00
1 parent 02d0f36580
commit d30aae68a1
42 files changed
+13143 -4

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
import { AnimationClip, Box3, Quaternion, QuaternionKeyframeTrack, Vector3,
VectorKeyframeTrack } from "three";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
// Übernommen aus three-player-controller/example/showcase.js (drei Figuren teilen sich
// dasselbe 65-Bone-Skelett; Animation kommt immer von ual.glb, siehe retargetClips unten).
// Pfade an dieses Projekts public/-Layout angepasst (dort ./glb/…, hier ./models/…).
export const CHARACTER_DIR = "./models/characters/";
export const HAIR_NONE = "None";
export const CHARACTERS = [
{ name: "Mannequin", url: "./models/ual.glb", heightFactor: 1 },
{ name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1 },
// Erwachsene Frauen sind im Schnitt ca. 93% der Körpergröße von Männern; die im Modell
// eingebauten 97,5% Unterschied fallen kaum auf (Kommentar aus showcase.js übernommen).
{ name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93 },
];
export const HAIRSTYLES = [
"Hair_Buzzed",
"Hair_BuzzedFemale",
"Hair_SimpleParted",
"Hair_Long",
"Hair_Buns",
"Hair_Beard",
];
// Nur zur Breitenmessung (measureTorsoRadiusRatio): T-Pose-Arme sollen die Breite nicht verfälschen.
const HITBOX_TORSO_BONES = ["pelvis", "spine_01", "spine_02", "spine_03"];
// Controller-Standard playerCapsuleHeight / playerCapsuleRadius = 180 / 30.
const CAPSULE_HEIGHT_TO_RADIUS = 6;
// Modellunabhängige Spielerkonfiguration, gemeinsam für Ersteinrichtung (main.js) und
// Figurenwechsel (applyCharacter) — identisch zu PLAYER_MODEL_BASE in showcase.js, weil
// alle drei Figuren von genau demselben ual.glb animiert werden.
export const PLAYER_MODEL_BASE = {
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",
drivingAnim: "Driving_Loop",
headBoneName: "Head",
firstPersonCameraOffset: [0, 0.15, 0.12],
rotateY: -Math.PI / 2,
speed: 100,
runSpeed: 600,
};
// Records: Bone-Name -> { position, quaternion } zum Zeitpunkt des Ladens (vor dem ersten
// mixer-Update aufgenommen). Unverändert aus showcase.js.
export function collectRestPose(root) {
const rest = new Map();
root?.traverse((obj) => {
if (obj.isBone) rest.set(obj.name, { position: obj.position.clone(), quaternion: obj.quaternion.clone() });
});
return rest;
}
// Ändert sich die Spur über die Zeit? Unverändert aus showcase.js.
export function trackVaries(track, stride) {
const v = track.values;
for (let i = stride; i < v.length; i++) {
if (Math.abs(v[i] - v[i % stride]) > 1e-6) return true;
}
return false;
}
// Wiederverwendete Skalare für retargetClips, um pro Aufruf keine neuen Quaternion-Objekte
// anzulegen (unverändert aus showcase.js, dort retargetTmp genannt).
const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quaternion() };
/**
* Passt die Animationen von ual.glb an ein anderes Körperschema an. Der Blender-Export legt
* allen 65 Bones Translation-/Scale-Spuren an; direktes Abspielen würde dem Zielmodell die
* Bone-Positionen des Mannequins aufzwingen und Schulterbreite/Gliedmaßenlängen verzerren.
* Deshalb bleiben nur Rotationen erhalten, konstante Verschiebung und alle Scale-Spuren
* fallen weg; die einzige echt bewegte Spur (pelvis-Verschiebung) wird auf die Ziel-Ruhepose
* umgerechnet.
*
* Anders als in showcase.js (wo sourceRest ein Modul-Global war) nimmt diese Fassung die
* Quellruhepose als expliziten dritten Parameter — sonst ließe sich die Funktion nicht ohne
* versteckten globalen Zustand testen/exportieren. Die Rechnung selbst (q_new = R_target ·
* R_source⁻¹ · q_anim, konstante Verschiebung und Scale verworfen) ist unverändert: am realen
* Modell zeigte sich, dass ein direktes Übernehmen der absoluten Rotation die Schultern des
* weiblichen Modells sichtbar verdreht (siehe task-11-brief.md) — deshalb hier nicht vereinfachen.
*/
export function retargetClips(clips, sourceRest, targetRest) {
if (!sourceRest) return clips;
return clips.map((clip) => {
const tracks = [];
for (const track of clip.tracks) {
const dot = track.name.lastIndexOf(".");
const bone = track.name.slice(0, dot);
const prop = track.name.slice(dot + 1);
const from = sourceRest.get(bone);
const to = targetRest.get(bone);
if (prop === "quaternion") {
if (!from || !to) { tracks.push(track); continue; }
// Die beiden Skelette unterscheiden sich in der Ruheausrichtung (Schlüsselbein
// 9,5°, Nacken 23°) — eine direkt übernommene absolute Rotation würde das
// Zielskelett verdrehen, daher wird nur die Differenz zur Ruhepose übertragen.
retargetTmp.corr.copy(to.quaternion).multiply(retargetTmp.inv.copy(from.quaternion).invert());
if (1 - Math.abs(retargetTmp.corr.w) < 1e-9) { tracks.push(track); continue; }
const q = Float32Array.from(track.values);
for (let i = 0; i + 3 < q.length; i += 4) {
retargetTmp.q.set(q[i], q[i + 1], q[i + 2], q[i + 3]).premultiply(retargetTmp.corr);
q[i] = retargetTmp.q.x; q[i + 1] = retargetTmp.q.y;
q[i + 2] = retargetTmp.q.z; q[i + 3] = retargetTmp.q.w;
}
tracks.push(new QuaternionKeyframeTrack(track.name, track.times, q));
continue;
}
if (prop !== "position") continue; // scale wird immer verworfen
if (!trackVaries(track, 3)) continue; // konstante Verschiebung ist Ruhepose, verwerfen
if (!from || !to) { tracks.push(track); continue; }
const values = Float32Array.from(track.values);
for (let i = 0; i + 2 < values.length; i += 3) {
values[i] += to.position.x - from.position.x;
values[i + 1] += to.position.y - from.position.y;
values[i + 2] += to.position.z - from.position.z;
}
tracks.push(new VectorKeyframeTrack(track.name, track.times, values));
}
return new AnimationClip(clip.name, clip.duration, tracks);
});
}
/**
* Auf ein Modell angepasste Animationen, je Figur zwischengespeichert. `cache` und die
* Quelldaten (playerAnimations/sourceRestPose) kommen explizit herein statt aus Modul-Globalen
* (showcase.js hielt sie als `let` auf Skriptebene — hier ist die Funktion ein benannter
* Export und braucht darum einen expliziten, von außen kontrollierten Zustand).
*/
export function clipsForModel(name, model, { playerAnimations, sourceRestPose, cache }) {
if (!cache.has(name)) {
cache.set(name, retargetClips(playerAnimations, sourceRestPose, collectRestPose(model)));
}
return cache.get(name);
}
/** Erstes SkinnedMesh unter root, oder null. Unverändert aus showcase.js. */
function findSkinnedMesh(root) {
let found = null;
root?.traverse((obj) => { if (!found && obj.isSkinnedMesh) found = obj; });
return found;
}
function enableModelShadows(root) {
root?.traverse((obj) => {
if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; }
});
}
/**
* Misst die tatsächliche halbe Rumpfbreite und rechnet sie in ein capsuleRadiusRatio um, das
* zur Netzform passt. Das Modell steht in T-Pose; ein direkter Bounding-Box-Griff würde die
* ausgestreckten Arme mit einrechnen, daher nur Vertices mit dominantem Rumpf-Bone-Gewicht.
* Unverändert aus showcase.js.
*/
export function measureTorsoRadiusRatio(root) {
const mesh = findSkinnedMesh(root);
const pos = mesh?.geometry?.attributes?.position;
const skinIndex = mesh?.geometry?.attributes?.skinIndex;
const skinWeight = mesh?.geometry?.attributes?.skinWeight;
if (!mesh || !pos || !skinIndex || !skinWeight) return 1;
const torso = new Set();
mesh.skeleton?.bones.forEach((bone, i) => {
if (HITBOX_TORSO_BONES.includes(bone.name)) torso.add(i);
});
if (!torso.size) return 1;
let halfWidth = 0;
for (let v = 0; v < pos.count; v++) {
let dominant = 0;
let best = -1;
for (let k = 0; k < 4; k++) {
const w = skinWeight.getComponent(v, k);
if (w > best) { best = w; dominant = skinIndex.getComponent(v, k); }
}
if (torso.has(dominant)) halfWidth = Math.max(halfWidth, Math.abs(pos.getX(v)));
}
if (halfWidth <= 0) return 1;
root.updateMatrixWorld(true);
const height = new Box3().setFromObject(root).getSize(new Vector3()).y;
if (!(height > 0)) return 1;
return CAPSULE_HEIGHT_TO_RADIUS * (halfWidth / height);
}
function loadGltfCached(state, url) {
if (!state.gltfCache.has(url)) state.gltfCache.set(url, state.gltfLoader.loadAsync(url));
return state.gltfCache.get(url);
}
/**
* Wechselt das Figurenmodell; die Animationen kommen immer von ual.glb (state.playerAnimations).
* Unverändert aus showcase.js außer dem expliziten state-Objekt statt Modul-Globalen (siehe
* clipsForModel) und dem Wegfall der showcase-eigenen Zonen-/Armjustierfunktionen, die es in
* diesem Projekt nicht gibt (kein schrumpfender Korridor, keine Arm-Feinjustierung im Panel).
*/
export async function applyCharacter(state, name) {
const entry = CHARACTERS.find((c) => c.name === name) ?? CHARACTERS[0];
const gltf = await loadGltfCached(state, entry.url);
// Jedes Mal eine frische Kopie: der Controller skaliert das Modell direkt, dieselbe
// Instanz wiederzuverwenden würde die Skalierung aufsummieren.
const model = cloneSkeleton(gltf.scene);
const radiusRatio = measureTorsoRadiusRatio(model);
state.currentHeightFactor = state.characterHeights.get(entry.name) ?? entry.heightFactor ?? 1;
await state.player.switchPlayerModel({
...PLAYER_MODEL_BASE,
model,
animations: clipsForModel(entry.name, model, state),
scale: PLAYER_MODEL_BASE.scale * state.currentHeightFactor,
capsuleRadiusRatio: radiusRatio,
});
state.currentCharacterName = entry.name;
enableModelShadows(state.player.playerModel);
state.currentHairMesh = null; // altes Haar verschwindet mit dem alten Modell
await applyHair(state, state.currentHairName);
}
/**
* Wechselt die Frisur; Figur und Frisur teilen dasselbe Skelett mit identischer Bone-
* Reihenfolge und lauter Einheitsmatrizen als Bind-Matrix, daher direkt bindbar. Unverändert
* aus showcase.js außer dem expliziten state-Objekt (siehe applyCharacter).
*/
export async function applyHair(state, name) {
state.currentHairName = name;
if (state.currentHairMesh) {
state.currentHairMesh.removeFromParent();
state.currentHairMesh = null;
}
if (name === HAIR_NONE) return;
const gltf = await loadGltfCached(state, `${CHARACTER_DIR}${name}.gltf`);
const hair = findSkinnedMesh(cloneSkeleton(gltf.scene));
const target = findSkinnedMesh(state.player.playerModel);
if (!hair || !target) {
console.warn("[characters] Frisur oder Figur ohne SkinnedMesh:", name);
return;
}
hair.name = name;
hair.position.set(0, 0, 0);
hair.quaternion.identity();
hair.scale.set(1, 1, 1);
target.parent.add(hair);
hair.bind(target.skeleton, hair.bindMatrix);
// Bounding-Box wird nach dem Neu-Binden ungültig; Frustum-Culling aus, sonst verschwindet
// die Frisur fälschlich, sobald ihre alte Box aus dem Sichtfeld wandert.
hair.frustumCulled = false;
enableModelShadows(hair);
state.currentHairMesh = hair;
}
/**
* Baut den {switchCharacter, switchHair, applyHeight}-Zustand für einen bereits initialisierten
* Player (Task 1). playerAnimations/sourceRestPose werden hier einmalig eingefroren: zum
* Zeitpunkt des Aufrufs trägt player.playerModel noch das ursprüngliche ual.glb (Mannequin),
* das gleichzeitig das Quellskelett für retargetClips ist.
*/
export function createCharacterModule(player) {
const state = {
player,
playerAnimations: player.playerModelConfig?.animations ?? [],
sourceRestPose: collectRestPose(player.playerModel),
gltfLoader: new GLTFLoader(),
gltfCache: new Map(),
cache: new Map(),
characterHeights: new Map(CHARACTERS.map((c) => [c.name, c.heightFactor])),
currentCharacterName: CHARACTERS[0].name,
currentHairName: HAIR_NONE,
currentHairMesh: null,
currentHeightFactor: 1,
busy: false,
};
async function switchCharacter(name) {
if (state.busy || name === state.currentCharacterName) return;
state.busy = true;
try { await applyCharacter(state, name); }
catch (err) { console.error("[characters] Figurenwechsel fehlgeschlagen:", err); }
finally { state.busy = false; }
}
async function switchHair(name) {
if (state.busy || name === state.currentHairName) return;
state.busy = true;
try { await applyHair(state, name); }
catch (err) { console.error("[characters] Frisurenwechsel fehlgeschlagen:", err); }
finally { state.busy = false; }
}
/** Höhenfaktor der aktuellen Figur (0.7–1.3 im Panel), skaliert den Spieler direkt. */
function applyHeight(factor) {
state.currentHeightFactor = factor;
state.characterHeights.set(state.currentCharacterName, factor);
player.setPlayerScale(PLAYER_MODEL_BASE.scale * factor);
}
return { switchCharacter, switchHair, applyHeight, state };
}
/**
* Wahrzeichen absteigend nach Höhe sortiert, mit three-Weltkoordinaten statt gtadb-XY
* (worldToThree: x bleibt, z = -Y, s. src/world/coords.js).
*/
export function teleportTargets(landmarks) {
return [...landmarks]
.sort((a, b) => b.height - a.height)
.map((l) => ({ name: l.name, x: l.xyz[0], z: -l.xyz[1], height: l.height }));
}