Files
gta6/src/main.js
T
knsoandClaude Opus 5 0ece90e2ec fix(ui): Höhe-Regler nachführen, Teleport-Warnfrist, SUV-Abstimmung
Der Höhe-Regler las den Höhenfaktor nur beim Bau des Panels und zeigte
nach einem Figurenwechsel weiter den alten Wert; beim nächsten Ziehen
überschrieb er die korrekt gesetzte Größe mit einem Sprung (Ruling 68).

Der Teleport hielt den Spieler notfalls unbegrenzt still. Er bekommt
dieselbe einmalige Warnung nach 15 s wie der Spawn, mit Frist je Sprung
statt modulweit (Ruling 69).

Der SUV bekommt metalness und roughness zurück, die der Kommentar
darüber als unverändert zusicherte (Ruling 70).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 04:14:01 +02:00

301 lines
14 KiB
JavaScript

import { ACESFilmicToneMapping, AmbientLight, BoxGeometry, DirectionalLight, Fog, Mesh,
PerspectiveCamera, PlaneGeometry, Scene, Timer, Vector3, WebGLRenderer } from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { Sky } from "three/examples/jsm/objects/Sky.js";
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();
const renderer = new WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.toneMapping = ACESFilmicToneMapping;
renderer.shadowMap.enabled = true;
container.appendChild(renderer.domElement);
const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.1, 2500);
// Pflichtoption für player.init: der Controller übernimmt die Kamera, braucht aber controls.domElement als Eingabeziel.
const controls = new OrbitControls(camera, renderer.domElement);
scene.add(new AmbientLight(0xffffff, 1.5));
const sun = new DirectionalLight(0xffffff, 2);
sun.position.set(60, 120, 40);
sun.castShadow = true;
scene.add(sun);
// Ein Material je Schicht aus buildChunkGeometry() (Task 7), jetzt mit echten Poly-Haven-
// Texturen statt Flächenfarben (Task 9, src/world/materials.js). buildings trägt zusätzlich
// 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);
scene.add(sky);
sky.material.uniforms.turbidity.value = 8;
sky.material.uniforms.rayleigh.value = 1.6;
sky.material.uniforms.sunPosition.value.copy(sun.position.clone().normalize());
scene.fog = new Fog(0xbcd7ea, 500, 2200);
// Meer rund um den Ausschnitt: eine einzige große Ebene unter/um die Chunks, keine je Chunk.
const water = new Mesh(new PlaneGeometry(4000, 4000), materials.water);
water.rotation.x = -Math.PI / 2;
water.position.set(500, -0.6, -1000);
scene.add(water);
// Vice Beach; Task 8, Schritt 5. Auch initPos, damit der Streamer von Anfang an den richtigen
// 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({
scene, camera, controls,
initPos: new Vector3(SPAWN.x, SPAWN.y, SPAWN.z),
playerModelConfig: {
model: gltf.scene, animations: gltf.animations, scale: 0.005,
idleAnim: "Idle_Loop", walkAnim: "Walk_Loop", runAnim: "Sprint_Loop",
jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
headBoneName: "Head", rotateY: -Math.PI / 2, speed: 100, runSpeed: 600,
},
});
// 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;
const mats = Array.isArray(child.material) ? child.material : [child.material];
for (const mat of mats) {
if (!mat) continue;
mat.metalness = 0.8;
mat.roughness = 0.0;
}
});
}
}
// 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.
// startedAt/warned sitzen auf dem pendingTeleport-Objekt selbst statt in eigenen module-level
// Variablen (Ruling 69, Gegenstück zu warnedSlowSpawn/startedAt oben): jeder neue Sprung
// bekommt so seine eigene, frisch scharfgestellte Warnfrist, statt von einer vorherigen,
// bereits ausgelösten Warnung stumm mitgenommen zu werden.
let pendingTeleport = null;
function requestTeleport(position) {
pendingTeleport = { x: position.x, y: position.y, z: position.z, startedAt: performance.now(), warned: false };
player.setSkipCapsuleCollision(true);
player.reset(new Vector3(position.x, position.y, position.z));
}
const gui = createPanel({ player, characterModule, scene, manifest, requestTeleport });
// TEMP: Fix-Runde 2 Screenshot-Beleg, vor Abschluss entfernen.
window.__shotWithReadout = () => {
renderer.render(scene, camera);
const params = gui.folders[0].controllers[0].object;
const canvas = document.createElement("canvas");
canvas.width = renderer.domElement.width;
canvas.height = renderer.domElement.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(renderer.domElement, 0, 0);
const scale = canvas.width / 800;
ctx.font = `${28 * scale}px monospace`;
const text = `Figur: ${params.character} Höhe: ${params.height}`;
const pad = 14 * scale;
const w = ctx.measureText(text).width + pad * 2;
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(0, 0, w, 44 * scale);
ctx.fillStyle = "#fff";
ctx.textBaseline = "middle";
ctx.fillText(text, pad, 22 * scale);
return canvas.toDataURL("image/png");
};
// 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
scene.add(seafloor);
seafloor.updateMatrixWorld(true);
player.addCollider({ motion: "static", shape: { kind: "mesh", source: seafloor } });
// Requisiten (Palmen, Laternen) — ein Ladevorgang für alle Chunks, siehe Task 10.
const props = new PropLayer();
await props.load();
// Schattenkamera folgt dem Spieler, statt eine feste Box zu benutzen.
function updateShadow(target) {
sun.position.set(target.x + 120, 240, target.z + 80);
sun.target.position.copy(target);
sun.target.updateMatrixWorld();
sun.shadow.camera.left = -150; sun.shadow.camera.right = 150;
sun.shadow.camera.top = 150; sun.shadow.camera.bottom = -150;
sun.shadow.camera.far = 600;
sun.shadow.camera.updateProjectionMatrix();
}
// Chunk-Collider treffen erst nach und nach ein (Fetch + asynchroner BVH-Bau, siehe
// streaming.js). setSkipCapsuleCollision(true) schaltet nur das horizontale Herausdrücken
// ab; applyGravity läuft im Controller unabhängig davon weiter (CharacterMovement.ts), die
// Kapsel würde also binnen Sekunden frei fallen, wenn man sie nur "schweben" ließe. Solange
// der Chunk unter dem Spawn nicht bereit ist, hält tick() sie deshalb aktiv auf Spawnhöhe
// fest (Ruling 53) statt sie fallen und vom Auffangnetz auffangen zu lassen — eine feste
// 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, 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);
if (!settled) {
if (streamer.isReadyAt(player.playerCapsule.position)) {
settled = true;
player.setSkipCapsuleCollision(false);
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
} else {
player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z));
if (!warnedSlowSpawn && performance.now() - startedAt > 15000) {
warnedSlowSpawn = true;
console.warn(`Chunk am Spawn (${SPAWN.x}, ${SPAWN.z}) ist nach 15s immer noch nicht bereit.`);
}
}
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));
// Ruling 69: gleiche Begründung wie bei warnedSlowSpawn oben — stilles Festhalten
// wäre nicht diagnostizierbar, sollte der Chunk am Sprungziel nie fertig werden.
if (!pendingTeleport.warned && performance.now() - pendingTeleport.startedAt > 15000) {
pendingTeleport.warned = true;
console.warn(`Chunk am Sprungziel (${pendingTeleport.x}, ${pendingTeleport.z}) ist nach 15s immer noch nicht bereit.`);
}
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.
if (p.y < -30) player.reset(new Vector3(lastSafe.x, lastSafe.y, lastSafe.z));
else if (player.getIsOnGround()) lastSafe = { x: p.x, y: p.y + 1, z: p.z };
}
const timer = new Timer();
timer.connect(document);
renderer.setAnimationLoop((t) => {
timer.update(t);
const delta = Math.min(timer.getDelta(), 1 / 40);
player.update(delta);
tick(delta);
updateShadow(player.playerCapsule.position);
materials.water.normalMap.offset.x += delta * 0.01;
renderer.render(scene, camera);
});
addEventListener("resize", () => {
renderer.setSize(container.clientWidth, container.clientHeight);
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
});