Files
gta6/tests/characters.test.js
T
knsoandClaude Opus 5 f76e97b69d feat(world): HDRI-Himmel „Nowhere Road" mit Umgebungslicht und scharfen Kaskaden-Schatten
- Himmel: 4k-HDRI von Poly Haven (CC0, tools/assets/hdri.mjs) statt Sky-Shader. Kuppel zeigt das Foto
  ab 12°, darunter weiches Band und Dunst in Nebelfarbe; Umgebungslicht und Spiegelungen per PMREM;
  Sonne aus dem Bild gekappt und als Licht übernommen (77°, Stärke 2,5)
- Schatten: CSM mit vier Stufen (25/100/350/1000 m, je 2048²), Schutznetz für beleuchtete Materialien,
  Normal-Bias 1,5 Texel je Stufe gegen Streifen auf doppelseitigen Materialien (Figur, Autos)
- Startfigur wirft Schatten, Palmen und Laternen empfangen Schatten, Wasser als ruhige See
- Debug-Panel: Sonne, Himmelslicht, Himmelshelligkeit; Namensnennung um das HDRI ergänzt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:38:44 +02:00

82 lines
4.2 KiB
JavaScript

import { describe, expect, it } from "vitest";
import { AnimationClip, BoxGeometry, Group, Mesh, MeshStandardMaterial, Quaternion, QuaternionKeyframeTrack,
VectorKeyframeTrack } from "three";
import { CHARACTERS, createCharacterModule, retargetClips, teleportTargets } from "../src/characters/index.js";
const rest = (q) => new Map([["arm", { position: { x: 0, y: 1, z: 0 }, quaternion: q }]]);
describe("retargetClips", () => {
it("wirft konstante Positionsspuren weg", () => {
const clip = new AnimationClip("a", 1, [
new VectorKeyframeTrack("arm.position", [0, 1], [0, 1, 0, 0, 1, 0]),
new QuaternionKeyframeTrack("arm.quaternion", [0], [0, 0, 0, 1]),
]);
const out = retargetClips([clip], rest(new Quaternion()), rest(new Quaternion()));
expect(out[0].tracks.map((t) => t.name)).toEqual(["arm.quaternion"]);
});
it("überträgt Rotationen relativ zur Ruhepose", () => {
const src = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.3);
const dst = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.9);
const clip = new AnimationClip("a", 1, [
new QuaternionKeyframeTrack("arm.quaternion", [0], [src.x, src.y, src.z, src.w]),
]);
const out = retargetClips([clip], rest(src), rest(dst));
const v = out[0].tracks[0].values;
expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(dst)).toBeLessThan(1e-6);
});
// Fix-Runde 1, Ruling 67: ual.glb hängt die Arme, der Quaternius-Rig steht in T (70° an
// upperarm_l, s. task-11-report.md) — die Ruhepose-Differenz ist damit selbst schon fast
// eine ganze Pose, nicht die paar Grad Bauartunterschied, für die "Delta" gedacht ist. Im
// absoluten Modus wird die animierte Rotation unverändert übernommen (q_neu = q_anim),
// ohne jede Ruhepose-Referenz — das Gegenstück zu "relativ zur Ruhepose" oben.
it("übernimmt Rotationen im absoluten Modus unverändert (Ruling 67)", () => {
const src = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.3);
const dst = new Quaternion().setFromAxisAngle({ x: 0, y: 1, z: 0 }, 0.9);
const clip = new AnimationClip("a", 1, [
new QuaternionKeyframeTrack("arm.quaternion", [0], [src.x, src.y, src.z, src.w]),
]);
const out = retargetClips([clip], rest(src), rest(dst), { rotation: "absolute" });
const v = out[0].tracks[0].values;
expect(new Quaternion(v[0], v[1], v[2], v[3]).angleTo(src)).toBeLessThan(1e-6);
});
});
// Fix-Runde 1, Ruling 67: durchprobiert statt vermutet — Delta hielt Superhero Male/Female
// bei Idle_Loop UND Walk_Loop in praktisch voller T-Pose (Screenshots r67-03..r67-04 im
// Bericht), Absolute zeigte für beide einen sauberen Gang ohne Schulterverdrehung
// (r67-05..r67-10), und für Mannequin sind beide Verfahren ohnehin identisch (sourceRest ===
// targetRest, da ual.glb sich selbst liefert). Ein Verfahren trägt für alle drei — dieser Test
// hält das Messergebnis fest, damit ein künftiger Umbau nicht versehentlich zu Delta zurückfällt.
describe("CHARACTERS", () => {
it("verwendet absolute Rotation für alle drei Figuren (gemessen, nicht vermutet — Ruling 67)", () => {
for (const c of CHARACTERS) expect(c.retarget).toBe("absolute");
});
});
describe("teleportTargets", () => {
it("sortiert Wahrzeichen nach Höhe", () => {
const targets = teleportTargets([
{ name: "klein", xyz: [0, 0, 0], height: 30 },
{ name: "groß", xyz: [10, 20, 0], height: 200 },
]);
expect(targets[0].name).toBe("groß");
expect(targets[0].z).toBe(-20);
});
});
describe("createCharacterModule", () => {
// Befund (Schatten-Spec): enableModelShadows lief nur in applyCharacter — das Startmodell
// (ual.glb, Mannequin) warf bis zum ersten Figurenwechsel keinen Schatten.
it("schaltet Schatten schon für das Startmodell ein", () => {
const body = new Mesh(new BoxGeometry(), new MeshStandardMaterial());
const playerModel = new Group().add(body);
createCharacterModule({ playerModel, playerModelConfig: { animations: [] } });
expect(body.castShadow).toBe(true);
expect(body.receiveShadow).toBe(true);
});
});