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,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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user