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(); });