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

+15
View File
@@ -7,6 +7,21 @@
<style>
html, body { margin: 0; height: 100%; overflow: hidden; }
#container { width: 100%; height: 100%; }
/* Namensnennung (Task 11): dauerhaft unten links, über dem Canvas, aber nicht klickbar
(pointer-events: none), damit sie die Maussteuerung/den Pointer-Lock nicht stört. */
.attribution {
position: fixed;
left: 8px;
bottom: 8px;
max-width: min(92vw, 640px);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.55);
color: #f0f0f0;
font: 11px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
border-radius: 4px;
pointer-events: none;
z-index: 10;
}
</style>
</head>
<body>
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
Binary file not shown.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.20467473566532135,-4.759437705400649e-21,1.2439442348830942e-21,0.9788300395011902],"translation":[-1.5903435849255598e-27,0.0711912140250206,2.0489096641540527e-08]},{"children":[0],"name":"neck_01","rotation":[0.3088374435901642,8.337463586651673e-21,-5.849849968646638e-21,0.95111483335495],"translation":[-1.0141478263557337e-20,0.19815585017204285,2.60770320892334e-08]},{"name":"index_04_leaf_l","rotation":[-8.381903171539307e-09,1,-1.5432005174599583e-14,3.6517765238386346e-06],"translation":[9.076757123693824e-10,0.023510519415140152,-3.8285477899080433e-08]},{"children":[2],"name":"index_03_l","rotation":[1.0023382124302316e-09,7.603918037758783e-13,-0.00013328436762094498,1],"scale":[0.9999999403953552,1,1],"translation":[4.420144250616431e-10,0.027111778035759926,-3.670398029953503e-08]},{"children":[3],"name":"index_02_l","rotation":[1.8987577732332284e-08,1.4024364247688936e-07,-0.002140256343409419,0.9999977350234985],"scale":[0.9999999403953552,1,1],"translation":[8.094502845779061e-11,0.03171013668179512,-6.863018597869086e-09]},{"children":[4],"name":"index_01_l","rotation":[-0.0033091865479946136,0.7071018815040588,-0.003309202380478382,0.7070961594581604],"translation":[0.004199977032840252,0.08823654055595398,0.03620496019721031]},{"name":"middle_04_leaf_l","rotation":[2.0489096641540527e-08,1,6.335320587363621e-14,3.6517765238386346e-06],"translation":[2.193701220676303e-09,0.029620492830872536,1.1362611473941797e-07]},{"children":[6],"name":"middle_03_l","rotation":[2.420814304571195e-08,1.4173854798182894e-11,-0.0032189602497965097,0.9999948143959045],"scale":[0.9999998807907104,0.9999999403953552,1],"translation":[-4.32646629633382e-09,0.02931377850472927,3.287611605173879e-08]},{"children":[7],"name":"middle_02_l","rotation":[-2.949975197452659e-08,1.402399334438087e-07,0.0038676888216286898,0.9999924898147583],"translation":[1.6816557035781443e-09,0.03642695024609566,1.0210540324351314e-07]},{"children":[8],"name":"middle_01_l","rotation":[-0.007979890331625938,0.7070646286010742,-0.007979941554367542,0.7070589065551758],"scale":[1,0.9999998807907104,1],"translation":[0.001899929717183113,0.08634158223867416,0.010671664960682392]},{"name":"pinky_04_leaf_l","rotation":[8.381903171539307e-09,1,5.647658583201112e-14,3.65177675121231e-06],"translation":[-1.317857822868973e-09,0.020919334143400192,-1.727942162688123e-07]},{"children":[10],"name":"pinky_03_l","rotation":[-2.1800540039862426e-08,5.25581791629981e-12,0.0028988486155867577,0.9999958276748657],"scale":[1,1.0000001192092896,1],"translation":[-1.318767317570746e-09,0.026631725952029228,-8.721048061488545e-08]},{"children":[11],"name":"pinky_02_l","rotation":[1.03155857189563e-08,1.4017835781032773e-07,-0.001705002854578197,0.9999985694885254],"scale":[0.9999999403953552,0.9999999403953552,1],"translation":[-2.967681211885065e-09,0.03083186410367489,-8.672896001371555e-08]},{"children":[12],"name":"pinky_01_l","rotation":[-0.01044775452464819,0.7070325016975403,-0.010447824373841286,0.7070267200469971],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.003799965837970376,0.06994835287332535,-0.03399413824081421]},{"name":"ring_04_leaf_l","rotation":[2.7474015951156616e-08,1,1.0721373739979931e-13,3.65177675121231e-06],"translation":[9.5042196335271e-10,0.028301579877734184,-3.474425369631717e-08]},{"children":[14],"name":"ring_03_l","rotation":[2.6234243222234e-08,4.3393351412124304e-11,-0.0034884933847934008,0.9999939203262329],"translation":[7.703420124016702e-09,0.027600184082984924,-1.1280478418029816e-07]},{"children":[15],"name":"ring_02_l","rotation":[-1.8197205520209536e-08,1.4009778226409253e-07,0.002474082401022315,0.9999969601631165],"translation":[-9.877112461254e-10,0.035001225769519806,-1.5782489981575054e-07]},{"children":[16],"name":"ring_01_l","rotation":[0.0025809321086853743,0.7071049213409424,0.0025809479411691427,0.707099199295044],"scale":[0.9999999403953552,0.9999997615814209,0.9999999403953552],"translation":[0.0021999571472406387,0.08042963594198227,-0.013825255446135998]},{"name":"thumb_04_leaf_l","rotation":[-1.2782692238033633e-08,0.48539119958877563,2.0375746601075662e-07,0.8742970824241638],"scale":[0.9999999403953552,1,1],"translation":[-1.2670352589339018e-07,0.03127264976501465,3.073364496231079e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-0.0012816648231819272,5.811178561998531e-05,0.0002541383437346667,0.9999991655349731],"scale":[1,1,0.9999999403953552],"translation":[2.2436142899096012e-07,0.035620346665382385,5.122274160385132e-09]},{"children":[19],"name":"thumb_02_l","rotation":[0.0019128327257931232,0.00019216042710468173,-0.0004484721284825355,0.9999980330467224],"scale":[1,0.9999998211860657,0.9999999403953552],"translation":[1.453081495128572e-07,0.03582108020782471,-1.3038516044616Line truncated
Binary file not shown.
@@ -0,0 +1 @@
{"asset":{"generator":"Khronos glTF Blender I/O v4.3.47","version":"2.0"},"scene":0,"scenes":[{"name":"Scene","nodes":[68]}],"nodes":[{"name":"Head","rotation":[-0.13207007944583893,0,0,0.991240382194519],"translation":[0,0.08285234868526459,9.313225746154785e-09]},{"children":[0],"name":"neck_01","rotation":[0.25725916028022766,0,0,0.9663424491882324],"translation":[0,0.21509313583374023,1.862645149230957e-08]},{"name":"index_04_leaf_l","rotation":[-1.789221437675792e-09,0.9998229742050171,3.712300440383842e-07,0.018816031515598297],"scale":[0.9999999403953552,1,1],"translation":[-1.1732481652870774e-09,0.024110054597258568,-1.3058706826996058e-07]},{"children":[2],"name":"index_03_l","rotation":[1.9416402210481465e-06,-7.145895324356388e-06,-0.00018566079961601645,1],"scale":[1,1,1.0000001192092896],"translation":[-5.757101462222636e-10,0.027911342680454254,5.889705789741129e-08]},{"children":[3],"name":"index_02_l","rotation":[3.8516853351211466e-07,4.9490859964862466e-05,-0.0005330402054823935,0.9999998807907104],"scale":[0.9999999403953552,1.0000001192092896,1],"translation":[-8.440110832452774e-10,0.032612428069114685,1.2031887308694422e-07]},{"children":[4],"name":"index_01_l","rotation":[0.008505093865096569,0.7068116664886475,-0.01852414943277836,0.7071080803871155],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.007159475702792406,0.1174025684595108,0.040643345564603806]},{"name":"middle_04_leaf_l","rotation":[-1.5724443969133972e-08,0.9998255372047424,5.420716675530457e-08,0.018679797649383545],"scale":[1,0.9999997019767761,1],"translation":[2.2610038286074996e-09,0.030519673600792885,1.955777406692505e-07]},{"children":[6],"name":"middle_03_l","rotation":[-4.686012857746391e-07,-5.432930265669711e-05,-0.0014198303688317537,0.9999990463256836],"scale":[0.9999999403953552,0.9999998211860657,1],"translation":[-5.793481250293553e-10,0.030116640031337738,-6.39629433862865e-08]},{"children":[7],"name":"middle_02_l","rotation":[5.612571953861334e-07,7.84045914770104e-05,0.0020528144668787718,0.9999979138374329],"scale":[1,1,1.0000001192092896],"translation":[-4.098183126188815e-09,0.03752607852220535,-1.7083948478102684e-08]},{"children":[8],"name":"middle_01_l","rotation":[0.005067416001111269,0.7067627906799316,-0.02195817232131958,0.7070916295051575],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[0.004682549275457859,0.1154387816786766,0.015113381668925285]},{"name":"pinky_04_leaf_l","rotation":[-6.453817924523264e-09,0.9998291730880737,2.147510969052746e-07,0.018483733758330345],"translation":[2.8803697205148637e-09,0.021523315459489822,-3.95521055907011e-08]},{"children":[10],"name":"pinky_03_l","rotation":[3.3508222259115428e-06,-0.00013744289753958583,0.0004660093109123409,0.9999998807907104],"scale":[0.9999998807907104,0.9999999403953552,0.9999998807907104],"translation":[-4.249159246683121e-09,0.027430830523371696,1.7808633856475353e-07]},{"children":[11],"name":"pinky_02_l","rotation":[3.9539517615594377e-07,9.536039578961208e-05,-0.0015674401074647903,0.9999988079071045],"scale":[1,0.9999999403953552,0.9999999403953552],"translation":[1.1932570487260818e-08,0.03163102641701698,-1.7530692275613546e-07]},{"children":[12],"name":"pinky_01_l","rotation":[0.002601056592538953,0.7066821455955505,-0.024414697661995888,0.7071049809455872],"scale":[1.0000001192092896,1.000000238418579,1.0000001192092896],"translation":[0.0060377283953130245,0.09854141622781754,-0.03461737558245659]},{"name":"ring_04_leaf_l","rotation":[8.940817863845041e-09,0.9998185634613037,5.402291662903735e-07,0.019049758091568947],"translation":[-9.167706593871117e-09,0.02910165674984455,5.82986103836447e-09]},{"children":[14],"name":"ring_03_l","rotation":[3.849756012641592e-06,-0.00025297756656073034,-0.003387551987543702,0.9999942183494568],"translation":[1.204170985147357e-09,0.028300179168581963,5.35583239980042e-08]},{"children":[15],"name":"ring_02_l","rotation":[-4.73642239740002e-06,0.00021853116049896926,0.00239976542070508,0.9999971389770508],"scale":[0.9999999403953552,0.9999999403953552,0.9999999403953552],"translation":[7.916241884231567e-09,0.03600123152136803,-8.07840478955768e-08]},{"children":[16],"name":"ring_01_l","rotation":[0.015323746018111706,0.7069170475006104,-0.011713176034390926,0.7070334553718567],"scale":[1,0.9999997615814209,1],"translation":[0.004751814063638449,0.10940060019493103,-0.012370164506137371]},{"name":"thumb_04_leaf_l","rotation":[1.285916511051255e-07,0.38270875811576843,-4.782022244853579e-08,0.9238690137863159],"scale":[1,0.9999999403953552,1],"translation":[-1.0035000741481781e-07,0.034558095037937164,2.1187588572502136e-08]},{"children":[18],"name":"thumb_03_l","rotation":[-5.349284037947655e-08,-2.9730843380093575e-05,1.7080456018447876e-06,1],"translation":[-1.2048985809087753e-08,0.03549090400338173,-2.9336661100387573e-08]},{"children":[19],"name":"thumb_02_l","rotation":[6.149784894660115e-07,4.712928785011172e-06,-3.21120Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.
+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 }));
}
+122 -1
View File
@@ -7,6 +7,10 @@ import { playerController } from "three-player-controller";
import { ChunkStreamer } from "./world/streaming.js";
import { createMaterials } from "./world/materials.js";
import { PropLayer } from "./world/props.js";
import { chunkForWorld, threeToWorld } from "./world/coords.js";
import { createCharacterModule } from "./characters/index.js";
import { createPanel } from "./ui/panel.js";
import { buildAttribution, mountHud } from "./ui/hud.js";
const container = document.querySelector("#container");
const scene = new Scene();
@@ -31,6 +35,11 @@ scene.add(sun);
// die dort erzeugten Vertexfarben (Pastellton pro Gebäude) über der Stuckputz-Textur.
const materials = await createMaterials(renderer.capabilities.getMaxAnisotropy());
// Task 11: erster Laufzeit-Verbraucher von manifest.json (Task 6) — bisher las nur die
// Extraktions-Pipeline es. Trägt die Namensnennung fürs HUD und die Chunk-Liste fürs
// Kartenbild im Debug-Panel.
const manifest = await fetch("./data/vice-a/manifest.json").then((r) => r.json());
// Himmel und Nebel über der Szene; sunPosition zeigt in dieselbe Richtung wie das Sonnenlicht.
const sky = new Sky();
sky.scale.setScalar(45000);
@@ -50,6 +59,47 @@ scene.add(water);
// Chunk anfragt statt erst nach dem späteren reset() umzuschwenken.
const SPAWN = { x: 1699, y: 3, z: -743 };
// Task 11, Schritt 5: vier SUVs an echten Mittellinienpunkten aus chunk_10_7.json (derselbe
// Chunk wie SPAWN), auf der Straße verteilt und weit genug von SPAWN entfernt, um den Spieler
// beim Start nicht zu überlappen. y=1: kleine Fallhöhe bis zur Fahrbahn (y=0), die Federung
// übernimmt den Rest — wie schon bei SPAWN selbst braucht es keine exakte Bodenhöhe im Voraus.
const VEHICLE_SPAWNS = [
new Vector3(1656, 1, -739),
new Vector3(1676, 1, -742),
new Vector3(1726, 1, -746),
new Vector3(1745, 1, -729),
];
// Konfiguration unverändert aus spawnShowcaseVehicles/VEHICLE_TUNING in
// three-player-controller/example/showcase.js — dieselbe suv.glb, dieselbe Abstimmung.
const VEHICLE_SCALE = 0.5;
const VEHICLE_TUNING = {
followVehicleDirection: true,
debug: { showPhysicsBox: false, showWheelRays: false, showWheelTravel: false, showWheelSpheres: false },
chassis: { density: 1, linearDamping: 0.05, angularDamping: 0.5, clearance: 0.8, sizeScale: { x: 0.9, y: 0.55, z: 0.9 } },
suspension: {
restLength: 0.15, maxTravel: 0.15, stiffness: 25, compression: 2.1, relaxation: 2.5,
maxForce: 6000, frictionSlip: 8, sideFrictionStiffness: 1, rollInfluence: 0.12,
},
steering: {
maxSteerAngle: Math.PI / 5, steerTime: 0.45, steerReturnTimeSlow: 0.55,
steerReturnTimeFast: 0.4, highSpeedSteerScale: 0.3,
},
grip: {
maxG: 1.2, sideFrictionIdle: 1, sideFrictionFrontMin: 0.55, sideFrictionRearMin: 0.45,
handbrakeRearFriction: 0.35, handbrakeRearDriveScale: 0.65, handbrakeReleaseTime: 0.15,
wheelbaseRatio: 0.55,
},
power: { maxSpeed: 100, acceleration: 5, deceleration: 5 },
};
// Die Chunks unter den vier Spawnpunkten hält ChunkStreamer dauerhaft fest (this.pinned,
// siehe streaming.js): ein Fahrzeug ohne Boden fällt ins Nichts, sobald der Spieler sich weit
// genug entfernt und der normale Radius-Abbau die Kollision unter ihm abbaut.
const VEHICLE_PINNED_CHUNKS = [...new Set(VEHICLE_SPAWNS.map((v) => {
const { X, Y } = threeToWorld(v.x, v.z);
const { i, j } = chunkForWorld(X, Y);
return `${i}_${j}`;
}))];
const gltf = await new GLTFLoader().loadAsync("./models/ual.glb");
const player = new playerController();
await player.init({
@@ -63,6 +113,57 @@ await player.init({
},
});
// Figurenauswahl (Task 11): player.playerModel ist hier noch das frisch geladene ual.glb
// (Mannequin) — genau das braucht createCharacterModule als Quellskelett/-animation für
// retargetClips, bevor die erste Figur gewechselt wird.
const characterModule = createCharacterModule(player);
// Namensnennung dauerhaft unten links (Layout in index.html, .attribution).
mountHud(document.body, buildAttribution(manifest));
async function spawnVehicles() {
const suvGltf = await new GLTFLoader().loadAsync("./models/vehicles/suv.glb");
for (const spawnPos of VEHICLE_SPAWNS) {
const model = suvGltf.scene.clone(true);
await player.loadVehicleModel({
model,
scale: VEHICLE_SCALE,
position: spawnPos.clone(),
wheelsNames: ["Wheel_LF", "Wheel_RF", "Wheel_LR", "Wheel_RR"],
driverSeatPosition: new Vector3(-0.4, 1.0, 0.3),
driverSeatRotation: -Math.PI / 2,
modelRotation: -Math.PI / 2,
followVehicleDirection: VEHICLE_TUNING.followVehicleDirection,
debug: VEHICLE_TUNING.debug,
chassis: VEHICLE_TUNING.chassis,
suspension: VEHICLE_TUNING.suspension,
steering: VEHICLE_TUNING.steering,
grip: VEHICLE_TUNING.grip,
power: VEHICLE_TUNING.power,
});
const vehicle = player.getAllVehicles().at(-1);
vehicle?.vehicleGroup?.traverse((child) => {
if (!child.isMesh) return;
child.castShadow = true;
child.receiveShadow = true;
});
}
}
// Ein hängender Sprung wartet auf den Collider am Ziel, statt sofort auf normale Kollision zu
// schalten (Ruling 53 gilt hier genauso wie beim Ersteinstieg unten): ohne das könnte der
// Spieler bei einem Sprung auf ein weit entferntes, noch nicht gestreamtes Wahrzeichen durch
// den ungeladenen Boden fallen und vom Auffangnetz (p.y < -30) zurück an lastSafe geschickt
// werden — der Sprung sähe dann aus, als wäre er wirkungslos.
let pendingTeleport = null;
function requestTeleport(position) {
pendingTeleport = { x: position.x, y: position.y, z: position.z };
player.setSkipCapsuleCollision(true);
player.reset(new Vector3(position.x, position.y, position.z));
}
createPanel({ player, characterModule, scene, manifest, requestTeleport });
// Meeresboden: man watet hinein, statt endlos zu fallen. Einmalig, nicht je Chunk.
const seafloor = new Mesh(new BoxGeometry(4000, 1, 4000), materials.seafloor);
seafloor.position.set(500, -3.0, -1000); // Oberkante bei y = -2,5
@@ -94,12 +195,13 @@ function updateShadow(target) {
// Zeitschranke, die danach die Kollision ohne vorhandenen Boden einschaltet, würde in eine
// endlose Fall-Auffang-Schleife laufen, sobald der Chunk dauerhaft ausbleibt. Nach 15s gibt
// es nur noch eine einmalige Warnung, kein Verhaltenswechsel.
const streamer = new ChunkStreamer({ scene, player, materials, props });
const streamer = new ChunkStreamer({ scene, player, materials, props, pinned: VEHICLE_PINNED_CHUNKS });
player.setSkipCapsuleCollision(true);
let settled = false;
let warnedSlowSpawn = false;
const startedAt = performance.now();
let lastSafe = { ...SPAWN };
let vehiclesSpawned = false;
function tick(delta) {
streamer.update(player.playerCapsule.position);
@@ -117,6 +219,25 @@ function tick(delta) {
}
return;
}
// Erst spawnen, sobald der Boden unter allen vier Punkten wirklich da ist (Task 11,
// Schritt 5) — sonst fiele ein SUV durch einen Chunk, dessen Collider noch nicht fertig ist.
if (!vehiclesSpawned && VEHICLE_SPAWNS.every((v) => streamer.isReadyAt(v))) {
vehiclesSpawned = true;
void spawnVehicles();
}
// Sprungziel wird gehalten, bis sein Chunk bereit ist (s. Kommentar bei requestTeleport).
if (pendingTeleport) {
if (streamer.isReadyAt(player.playerCapsule.position)) {
player.setSkipCapsuleCollision(false);
pendingTeleport = null;
} else {
player.reset(new Vector3(pendingTeleport.x, pendingTeleport.y, pendingTeleport.z));
return;
}
}
const p = player.playerCapsule.position;
// Ruling 52: an ein echtes Bodensignal binden statt an die Höhe — sonst würde auch der
// Scheitelpunkt eines Sprungs, ein Dach oder ein Moment im freien Fall als "sicher" gelten.
+26
View File
@@ -0,0 +1,26 @@
// Namensnennung ist bei den hier verwendeten Assets teils Pflicht (CC BY), nicht Kosmetik:
// - Kartenkacheln und Wahrzeichenhöhen: schon in manifest.json (Task 6), wörtlich übernommen.
// - SUV: Boris731 (Sketchfab, "Low-poly cyberpunk SUV") unter CC BY — Namensnennung Pflicht.
// - Texturen: Poly Haven, CC0.
// - Palme: Nobiax (OpenGameArt), CC0 (public/models/palm/readme.txt: "absolutely free to
// use or to modify in any kind of work").
// - Figuren/Frisuren: Quaternius, "Universal Base Characters[Standard]"-Paket, CC0 1.0
// Universal (Public Domain Dedication) laut dessen License_Standard.txt — siehe
// task-11-report.md für den wörtlichen Fund.
// Jede Quelle bekommt ihren eigenen Satz mit ihrer eigenen Lizenz: keine Lizenz kürzen,
// keine zwei Lizenzen in einer Zeile vermischen, kein CC0 für die CC-BY-Quelle behaupten.
const MODEL_CREDITS = "SUV: Boris731 (Sketchfab, CC BY). Texturen: Poly Haven (CC0). " +
"Palme: Nobiax (OpenGameArt, CC0). Figuren: Quaternius (CC0).";
/** Reine Textzusammensetzung, ohne DOM — so ohne Browser testbar (siehe tests/hud.test.js). */
export function buildAttribution(manifest) {
return `${manifest.attribution} ${MODEL_CREDITS}`;
}
export function mountHud(root, attribution) {
const el = document.createElement("div");
el.className = "attribution";
el.textContent = attribution;
root.appendChild(el);
return el;
}
+210
View File
@@ -0,0 +1,210 @@
import { BufferGeometry, CanvasTexture, Float32BufferAttribute, LineBasicMaterial,
LineSegments, Mesh, MeshBasicMaterial, PlaneGeometry, Vector3 } from "three";
import GUI from "lil-gui";
import { CHARACTERS, HAIRSTYLES, HAIR_NONE, teleportTargets } from "../characters/index.js";
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "../world/coords.js";
// Wahrzeichen: rolux/gtamaplib-vc (Unlicense, s. manifest.json), aus tools/extract/data/
// skyline.json übernommen (nur gelesen, nicht verändert — die Datei selbst bleibt
// tools/extract vorbehalten). Alle 54 liegen innerhalb dieses Kartenausschnitts (SLICE).
const LANDMARKS = [
{ name: "Southeast Financial Center", xyz: [-461.31, -460.1, 248.34], height: 248.3 },
{ name: "Jade Ocean Condos", xyz: [1580.99, 2482.23, 202.46], height: 202.5 },
{ name: "Opera Tower", xyz: [-417.55, 864.58, 200.87], height: 200.9 },
{ name: "Quantum on the Bay", xyz: [-398.51, 1140.65, 194.78], height: 194.8 },
{ name: "Wells Fargo Center", xyz: [-655.02, -408.58, 186.88], height: 186.9 },
{ name: "Vizcayne North Condominium", xyz: [-528.49, 137.15, 177.17], height: 177.2 },
{ name: "Marina Blue", xyz: [-455.68, 414.97, 177.13], height: 177.1 },
{ name: "The Grand", xyz: [-262.47, 952.24, 176.64], height: 176.6 },
{ name: "1800 Club", xyz: [-349.78, 1034.41, 161.96], height: 162.0 },
{ name: "Stephen P. Clark Government Center", xyz: [-934.67, 19.08, 161.5], height: 161.5 },
{ name: "Miami Tower", xyz: [-770.52, -414.85, 161.07], height: 161.1 },
{ name: "Icon at South Beach", xyz: [1355.98, -13.76, 154.8], height: 154.8 },
{ name: "Citigroup Center", xyz: [-382.36, -480.26, 152.12], height: 152.1 },
{ name: "50 Biscayne Blvd", xyz: [-434.64, -246.37, 146.71], height: 146.7 },
{ name: "Marriott Miami Biscayne Bay", xyz: [-219.73, 859.14, 146.44], height: 146.4 },
{ name: "Portofino Tower", xyz: [1734.05, -205.8, 144.46], height: 144.5 },
{ name: "Green Diamond", xyz: [1772.72, 2120.85, 144.48], height: 144.5 },
{ name: "Blue Diamond", xyz: [1830.96, 2061.61, 143.59], height: 143.6 },
{ name: "Ten Museum Park", xyz: [-455.33, 468.57, 138.15], height: 138.1 },
{ name: "InterContinental Miami", xyz: [-255.84, -466.24, 133.46], height: 133.5 },
{ name: "Murano Grande", xyz: [1415.06, -58.77, 126.36], height: 126.4 },
{ name: "Loft Downtown II", xyz: [-539.52, 50.15, 126.04], height: 126.0 },
{ name: "The Crimson", xyz: [-458.38, 1238.37, 122.89], height: 122.9 },
{ name: "Trésor Tower", xyz: [1837.42, 1946.05, 119.24], height: 119.2 },
{ name: "New Wave Condominiums", xyz: [-341.11, 1419.48, 114.5], height: 114.5 },
{ name: "Pelican Harbor Radio Tower", xyz: [1101.8, 1869.0, 113.36], height: 113.4 },
{ name: "The Waverly South Beach", xyz: [1249.0, 602.57, 110.95], height: 110.9 },
{ name: "The Floridian", xyz: [1265.56, 409.0, 96.47], height: 96.5 },
{ name: "Sunset Harbour South Condo", xyz: [1441.32, 1669.71, 87.36], height: 87.4 },
{ name: "Flamingo South Beach", xyz: [1278.92, 950.05, 76.39], height: 76.4 },
{ name: "Bank of America Financial Center", xyz: [1693.67, 1372.34, 73.44], height: 73.4 },
{ name: "22 Biscayne Bay", xyz: [-350.41, 1354.52, 72.62], height: 72.6 },
{ name: "Capri South Beach", xyz: [1316.65, 1132.18, 57.54], height: 57.5 },
{ name: "1111 Lincoln Rd", xyz: [1482.94, 1340.65, 56.06], height: 56.1 },
{ name: "1000 Venetian Way", xyz: [307.83, 1129.73, 54.16], height: 54.2 },
{ name: "112 NE 41st St", xyz: [26.62, 2219.87, 53.35], height: 53.3 },
{ name: "The Tides South Beach", xyz: [1993.63, 888.23, 53.21], height: 53.2 },
{ name: "Old City Hall", xyz: [1681.71, 605.02, 51.6], height: 51.6 },
{ name: "St. Moritz Hotel", xyz: [1983.51, 1107.58, 49.35], height: 49.4 },
{ name: "W South Beach", xyz: [1906.2, 1590.71, 48.76], height: 48.8 },
{ name: "The Gates Hotel South Beach", xyz: [1863.97, 1612.19, 48.18], height: 48.2 },
{ name: "Venetian Isle Condominium", xyz: [55.22, 1191.41, 46.93], height: 46.9 },
{ name: "Kaseya Center", xyz: [-272.56, 433.4, 46.77], height: 46.8 },
{ name: "Uptown Lofts", xyz: [-349.03, 1316.42, 46.15], height: 46.2 },
{ name: "Knight Concert Hall", xyz: [-353.55, 687.21, 46.16], height: 46.2 },
{ name: "New World Center", xyz: [1653.41, 1432.42, 45.18], height: 45.2 },
{ name: "Cruise Terminal D", xyz: [664.47, -431.34, 34.79], height: 34.8 },
{ name: "419 NE 4th Ave", xyz: [-428.91, 1210.66, 34.08], height: 34.1 },
{ name: "Miami Beach Parking Garage", xyz: [1850.1, 682.39, 34.08], height: 34.1 },
{ name: "Hotel Victor", xyz: [1945.1, 733.09, 33.59], height: 33.6 },
{ name: "City View Parking Garage", xyz: [-172.17, 2060.76, 33.16], height: 33.2 },
{ name: "Jenny Hostel", xyz: [1911.18, 602.76, 30.98], height: 31.0 },
{ name: "Star Island Water Tower", xyz: [760.6, 480.35, 28.73], height: 28.7 },
{ name: "Hotel Breakwater", xyz: [1949.86, 563.75, 25.8], height: 25.8 },
];
// Sprungziel liegt so weit über dem Wahrzeichen, dass die Fallphysik es sicher von oben
// erreicht, statt in der Dachgeometrie zu stecken (s. requestTeleport in main.js).
const TELEPORT_MARGIN = 15;
const MAP_TEXTURE_SIZE = 768;
const MAP_LAND_COLOR = "#cfae7d";
/** Cyanfarbenes Gitter entlang aller Chunk-Grenzen der Slice; rein aus SLICE/CHUNK_SIZE, ohne Fetch. */
function buildChunkGrid() {
const y = 0.15; // knapp über den Straßen-Decals (0.03), damit das Gitter nicht im Z-Fighting verschwindet
const positions = [];
for (let i = 0; i <= CHUNKS_PER_SIDE; i++) {
const x = SLICE.x0 + i * CHUNK_SIZE;
positions.push(x, y, -SLICE.y0, x, y, -SLICE.y1);
}
for (let j = 0; j <= CHUNKS_PER_SIDE; j++) {
const z = -(SLICE.y1 - j * CHUNK_SIZE);
positions.push(SLICE.x0, y, z, SLICE.x1, y, z);
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
const grid = new LineSegments(geometry, new LineBasicMaterial({ color: 0x00e5ff }));
grid.visible = false;
return grid;
}
/** Füllt einen Polygonzug (Außenring + Löcher) auf dem Canvas; evenodd spart die Löcher aus. */
function fillPolygon(ctx, rings, origin, scale) {
ctx.beginPath();
for (const ring of rings) {
ring.forEach(([lx, lz], idx) => {
const cx = (origin[0] + lx - SLICE.x0) * scale;
const cy = (origin[1] + lz + SLICE.y1) * scale;
if (idx === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
});
ctx.closePath();
}
ctx.fill("evenodd");
}
/**
* Kartenbild als Ebene auf dem Boden: Landflächen aller Chunks (aus public/data/vice-a, wie
* ChunkStreamer selbst sie lädt) als Silhouette auf transparentem Grund, damit das Meer
* darunter durchscheint. Fehlt ein einzelner Chunk (Netzfehler, 404), bleibt die Karte dort
* schlicht leer statt ganz abzubrechen — es ist ein Debug-Hilfsmittel, kein Spielinhalt.
*/
async function buildMapMesh(manifest, baseUrl) {
const width = SLICE.x1 - SLICE.x0, depth = SLICE.y1 - SLICE.y0;
const scale = MAP_TEXTURE_SIZE / width;
const canvas = document.createElement("canvas");
canvas.width = MAP_TEXTURE_SIZE;
canvas.height = Math.round(MAP_TEXTURE_SIZE * (depth / width));
const ctx = canvas.getContext("2d");
ctx.fillStyle = MAP_LAND_COLOR;
for (const [i, j] of manifest.chunks) {
let chunk;
try {
const res = await fetch(`${baseUrl}/chunk_${i}_${j}.json`);
if (!res.ok) continue;
chunk = await res.json();
} catch {
continue;
}
for (const polygon of chunk.land ?? []) fillPolygon(ctx, polygon, chunk.origin, scale);
}
const texture = new CanvasTexture(canvas);
texture.needsUpdate = true;
const mesh = new Mesh(
new PlaneGeometry(width, depth),
new MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false }),
);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set((SLICE.x0 + SLICE.x1) / 2, 0.05, -(SLICE.y0 + SLICE.y1) / 2);
mesh.visible = false;
return mesh;
}
/**
* Baut das lil-gui-Debug-Panel (Task 11, Schritt 6): Figur, Frisur, Höhe, Hitbox anzeigen,
* Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel) und Sprung zu
* Wahrzeichen. requestTeleport(position) kommt aus main.js, weil nur main.js weiß, wie ein
* Sprung sicher wird (Collider am Ziel abwarten, s. dortiger Kommentar zu pendingTeleport) —
* ohne das würde ein Sprung auf ein noch nicht gestreamtes Wahrzeichen den Spieler ins
* Leere fallen und vom Auffangnetz zurückschicken lassen.
*/
export function createPanel({ player, characterModule, scene, manifest, baseUrl = "./data/vice-a", requestTeleport }) {
const targets = teleportTargets(LANDMARKS);
const chunkGrid = buildChunkGrid();
scene.add(chunkGrid);
let mapMesh = null, mapPromise = null, mapWanted = false;
function setMapImageVisible(visible) {
mapWanted = visible;
if (mapMesh) { mapMesh.visible = mapWanted; return; }
if (!mapPromise) {
mapPromise = buildMapMesh(manifest, baseUrl).then((mesh) => {
mapMesh = mesh;
mapMesh.visible = mapWanted;
scene.add(mapMesh);
}).catch((err) => {
console.warn("[panel] Kartenbild konnte nicht gebaut werden:", err);
});
}
}
const params = {
character: characterModule.state.currentCharacterName,
hair: characterModule.state.currentHairName,
height: characterModule.state.currentHeightFactor,
showHitbox: false,
showChunkGrid: false,
showMapImage: false,
fogDistance: scene.fog?.far ?? 2200,
landmark: targets[0]?.name ?? "",
};
const gui = new GUI({ title: "Debug" });
const characterFolder = gui.addFolder("Figur");
characterFolder.add(params, "character", CHARACTERS.map((c) => c.name)).name("Figur")
.onChange((value) => { void characterModule.switchCharacter(value); });
characterFolder.add(params, "hair", [HAIR_NONE, ...HAIRSTYLES]).name("Frisur")
.onChange((value) => { void characterModule.switchHair(value); });
characterFolder.add(params, "height", 0.7, 1.3, 0.01).name("Höhe")
.onChange((value) => characterModule.applyHeight(value));
characterFolder.add(params, "showHitbox").name("Hitbox anzeigen")
.onChange((value) => player.setPlayerCapsuleDebug?.(value));
const worldFolder = gui.addFolder("Welt");
worldFolder.add(params, "showChunkGrid").name("Chunk-Grenzen anzeigen")
.onChange((value) => { chunkGrid.visible = value; });
worldFolder.add(params, "showMapImage").name("Kartenbild auf dem Boden")
.onChange((value) => setMapImageVisible(value));
worldFolder.add(params, "fogDistance", 400, 3000, 50).name("Sichtweite (Nebel)")
.onChange((value) => { if (scene.fog) scene.fog.far = value; });
const teleportFolder = gui.addFolder("Sprung zu Wahrzeichen");
teleportFolder.add(params, "landmark", targets.map((t) => t.name)).name("Wahrzeichen")
.onChange((value) => {
const target = targets.find((t) => t.name === value);
if (!target) return;
requestTeleport(new Vector3(target.x, target.height + TELEPORT_MARGIN, target.z));
});
return gui;
}
+6 -1
View File
@@ -32,11 +32,15 @@ function dist(k, position) {
}
export class ChunkStreamer {
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a" }) {
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) {
Object.assign(this, { scene, player, materials, props, baseUrl });
this.active = new Map(); // key -> { group, handle }
this.pending = new Set();
this.queue = [];
// Chunks, die #unload nie abbaut (Task 11, Schritt 5): die vier Fahrzeug-Spawnpunkte
// liegen darauf, und ein Fahrzeug ohne Boden fällt ins Nichts, sobald der Spieler
// sich weit genug entfernt und der normale Radius-Abbau eingreift.
this.pinned = new Set(pinned);
}
update(position) {
@@ -110,6 +114,7 @@ export class ChunkStreamer {
}
#unload(k) {
if (this.pinned.has(k)) return;
const entry = this.active.get(k);
if (!entry) return;
// Ruling 51: collider-lose Chunks (offenes Wasser) haben kein handle abzumelden.
+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