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

+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.