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:
1 parent
02d0f36580
commit
d30aae68a1
42 files changed
+13143
-4
No files matched your search
@@ -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 }));
|
||||
}
|
||||
Reference in new issue
Block a user