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

+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { AnimationClip, Quaternion, QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
import { 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);
});
});
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);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildAttribution } from "../src/ui/hud.js";
// Die Namensnennung ist Pflicht (CC BY, s. task-11-brief.md), keine Kosmetik — deshalb wird
// die Textzusammensetzung hier ohne Browser geprüft statt nur im laufenden HUD betrachtet.
describe("buildAttribution", () => {
const manifest = {
attribution: "Kartenkacheln: GTA VI Map (CC BY 4.0) — https://map.gtadb.org, YANIS v15. " +
"Wahrzeichen: rolux/gtamaplib-vc (Unlicense).",
};
it("behält den Manifest-Text wörtlich bei", () => {
expect(buildAttribution(manifest)).toContain(manifest.attribution);
});
it("ergänzt alle vier Modellquellen mit ihrer jeweiligen Lizenz", () => {
const text = buildAttribution(manifest);
expect(text).toContain("Boris731");
expect(text).toContain("Poly Haven");
expect(text).toContain("Nobiax");
expect(text).toContain("Quaternius");
expect(text).toMatch(/Boris731[^.]*CC BY/);
expect(text).toMatch(/Poly Haven[^.]*CC0/);
expect(text).toMatch(/Nobiax[^.]*CC0/);
expect(text).toMatch(/Quaternius[^.]*CC0/);
});
// Ruling (task-11-brief.md): keine Lizenz kürzen, keine zwei Lizenzen in einer Zeile
// vermischen, kein CC0 für etwas behaupten, das CC BY ist — der SUV-Satz muss darum
// isoliert bleiben und darf nicht in denselben Satz wie ein CC0-Eintrag rutschen.
it("vermischt die SUV-Lizenz (CC BY) nicht mit einer CC0-Angabe im selben Satz", () => {
const text = buildAttribution(manifest);
const suvSentence = text.split(".").find((s) => s.includes("Boris731"));
expect(suvSentence).toBeTruthy();
expect(suvSentence).not.toContain("CC0");
});
});
+27 -2
View File
@@ -36,7 +36,7 @@ const emptyChunk = { chunk: [4, 6], origin: [0, -1000], land: [], surfaces: [],
// Zentrum von Chunk 4_6 in three-Koordinaten: SLICE.x0 + 4.5*CHUNK_SIZE, -(SLICE.y1 - 6.5*CHUNK_SIZE).
const emptyChunkCenter = { x: 125, z: -875 };
function makeStreamer(chunkData, props) {
function makeStreamer(chunkData, props, pinned) {
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => chunkData })));
const player = {
addCollider: vi.fn(() => ({ id: 42 })),
@@ -47,7 +47,7 @@ function makeStreamer(chunkData, props) {
collisionWorld: { get: vi.fn(() => ({ ready: false })) },
};
const scene = { add: vi.fn(), remove: vi.fn() };
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props }), player, scene };
return { streamer: new ChunkStreamer({ scene, player, materials: {}, props, pinned }), player, scene };
}
// #load() ist privat und läuft nebenläufig; zwei Mikrotask-Runden reichen, um
@@ -127,6 +127,31 @@ describe("ChunkStreamer", () => {
expect(ownDispose).toHaveBeenCalledTimes(1);
expect(sharedDispose).not.toHaveBeenCalled();
});
// Task 11, Schritt 5: die vier Fahrzeug-Chunks werden festgehalten, weil ein Fahrzeug
// ohne Boden ins Nichts fällt — #unload muss sie überspringen, statt sie beim
// gewöhnlichen Entfernen-jenseits-des-Radius-Durchlauf trotzdem abzubauen. Mit echter
// Landgeometrie (nicht emptyChunk), damit ein echtes Collider-Handle entsteht und die
// removeCollider-Zusicherung unten etwas prüft, statt nach Ruling 51 trivial wahr zu sein.
it("entlädt einen angehefteten Chunk nicht, auch jenseits des Entladeradius (Task 11)", async () => {
const landChunk = {
chunk: [4, 6], origin: [0, -1000],
land: [[[[0, 0], [50, 0], [50, 50], [0, 50]]]],
surfaces: [], roads: [], buildings: [],
};
const { streamer, player } = makeStreamer(landChunk, undefined, ["4_6"]);
streamer.update(emptyChunkCenter);
await flush();
expect(streamer.active.has("4_6")).toBe(true);
expect(player.addCollider).toHaveBeenCalledTimes(1); // sonst prüft removeCollider unten nichts
// Weit weg -> jenseits des Entladeradius (600); ohne pinned wäre "4_6" nun weg
// (siehe Test oben, gleiches Setup ohne pinned).
streamer.update({ x: emptyChunkCenter.x + 5000, z: emptyChunkCenter.z });
expect(streamer.active.has("4_6")).toBe(true);
expect(player.removeCollider).not.toHaveBeenCalled();
});
});
// Fix-Runde 2, Befund C / Ruling 64: der Zweig `if (this.props)` in #load hatte keine