PlaceLayer nimmt jetzt ein optionales warmUp(root) entgegen und ruft es nach prepareModel, aber vor dem Einsetzen auf. main.js nutzt das, um Texturen hochzuladen und CSM-Shader zu übersetzen, solange der Ort noch nicht in der Szene ist — gemessen kostete das sonst rund 130 ms auf einen Schlag im ersten Bild, in dem der Hof erscheint. Schlägt das Vorwärmen fehl, wird nur gewarnt; der Ort wird trotzdem eingesetzt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
360 lines
19 KiB
JavaScript
360 lines
19 KiB
JavaScript
import { ACESFilmicToneMapping, BoxGeometry, 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 { playerController } from "three-player-controller";
|
|
import { ChunkStreamer } from "./world/streaming.js";
|
|
import { createMaterials } from "./world/materials.js";
|
|
import { createSky } from "./world/sky.js";
|
|
import { createSunShadows, prepareLitMaterials } from "./world/shadows.js";
|
|
import { PropLayer } from "./world/props.js";
|
|
import { PlaceLayer } from "./world/places.js";
|
|
import { chunkForWorld, threeToWorld } from "./world/coords.js";
|
|
import { createCharacterModule } from "./characters/index.js";
|
|
import { activeVehicleOrNull, playerIsGrounded, playerPosition } from "./player.js";
|
|
import { createMap } from "./ui/map.js";
|
|
import { headingFromDirection } from "./ui/mapMath.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);
|
|
|
|
// Nahwert 0.1 -> 0.5 (Diagnose "Flackernder Boden"): ein 24-Bit-Tiefenpuffer löst bei 150-300 m
|
|
// Kameraabstand (Downtown von oben, s. Diagnose-Report) nur 1,3-5 cm auf — in derselben
|
|
// Größenordnung wie die 1-2 cm, die Boden/Stadtfläche/Park/Strand/Straße in geometry.js
|
|
// auseinanderliegen. Fünffacher Nahwert = fünffache Tiefenauflösung auf jede Entfernung
|
|
// (Auflösung skaliert mit dem Nahwert), macht die verbleibenden Abstände wieder auflösbar,
|
|
// ohne selbst etwas an der Fern-/Sichtweite (far=2500) zu ändern. 0.5 m liegt deutlich unter
|
|
// Kapsel-/Kameraradius und Wandstärken (s. Ersichtsprüfung im Bericht) — Ego-Perspektive an
|
|
// einer Wand clippt damit nicht sichtbar.
|
|
const camera = new PerspectiveCamera(55, container.clientWidth / container.clientHeight, 0.5, 2500);
|
|
// Pflichtoption für player.init: der Controller übernimmt die Kamera, braucht aber controls.domElement als Eingabeziel.
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
|
|
// Himmel, Umgebungslicht und Sonne aus dem HDRI "Nowhere Road" (src/world/sky.js) statt Sky-Shader,
|
|
// AmbientLight und fest gesetzter Sonne. Das Foto hat harte Mittagssonne — mit seinen echten
|
|
// Verhältnissen blieben Schatten fast schwarz. Himmelslicht und Kuppel laufen deshalb fünffach, die
|
|
// Sonne mit ihrer gemessenen Stärke (2,5); nachjustierbar im Debug-Panel unter "Welt".
|
|
const SUN_STRENGTH = 1;
|
|
const SKY_LIGHT = 5;
|
|
const SKY_BRIGHTNESS = 5;
|
|
scene.fog = new Fog(0xffffff, 500, 2200); // Farbe setzt sky.setBrightness() passend zum Horizont
|
|
const sky = await createSky({ renderer, scene });
|
|
sky.setBrightness(SKY_BRIGHTNESS);
|
|
scene.environmentIntensity = SKY_LIGHT;
|
|
// Kaskaden-Schatten statt eines einzelnen DirectionalLight (src/world/shadows.js): Figur, Laternen
|
|
// und Palmen scharf, Gebäude bis 1 km. sun.intensity steuert alle Stufenlichter (Regler "Sonne").
|
|
const sun = createSunShadows({ scene, camera, sun: { ...sky.sun, intensity: sky.sun.intensity * SUN_STRENGTH } });
|
|
|
|
// 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());
|
|
|
|
// 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, Rasen am Südrand des Parks (Beschriftungs-Plan, Task 7). Auch initPos, damit der Streamer von Anfang an den richtigen
|
|
// Chunk anfragt statt erst nach dem späteren reset() umzuschwenken.
|
|
const SPAWN = { x: 1600, y: 3, z: -690 };
|
|
|
|
// 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(1560, 1, -658),
|
|
new Vector3(1590, 1, -659),
|
|
new Vector3(1630, 1, -661),
|
|
new Vector3(1655, 1, -663),
|
|
];
|
|
// 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));
|
|
}
|
|
|
|
createPanel({ player, characterModule, scene, sky, sun, manifest, requestTeleport });
|
|
|
|
// Minimap auf Taste M (docs/superpowers/specs/2026-09-13-minimap-design.md). Position über
|
|
// playerPosition — im Fahrzeug liefert die Kapsel nur den lokalen Sitzplatz-Offset. Die Richtung
|
|
// kommt aus der Kamera, die zu Fuß der Blickrichtung und im Fahrzeug dessen Richtung folgt.
|
|
// Sprünge laufen über requestTeleport und damit über dessen Wartewache bis zum fertigen Chunk.
|
|
const MAP_TELEPORT_HEIGHT = 3;
|
|
const mapCameraDirection = new Vector3();
|
|
const mapVehicleForward = new Vector3();
|
|
createMap({
|
|
getPosition: () => playerPosition(player),
|
|
getHeading: () => {
|
|
camera.getWorldDirection(mapCameraDirection);
|
|
return headingFromDirection(mapCameraDirection.x, mapCameraDirection.z);
|
|
},
|
|
getVehicles: () => {
|
|
const riding = activeVehicleOrNull(player);
|
|
return player.getAllVehicles().map((vehicle) => {
|
|
mapVehicleForward.copy(vehicle.forwardLocal).transformDirection(vehicle.vehicleGroup.matrixWorld);
|
|
return {
|
|
x: vehicle.vehicleGroup.position.x,
|
|
z: vehicle.vehicleGroup.position.z,
|
|
heading: headingFromDirection(mapVehicleForward.x, mapVehicleForward.z),
|
|
occupied: vehicle === riding,
|
|
};
|
|
});
|
|
},
|
|
onTeleport: ({ x, z }) => requestTeleport({ x, y: MAP_TELEPORT_HEIGHT, z }),
|
|
});
|
|
|
|
// 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();
|
|
|
|
// 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 });
|
|
// warmUp fürs Vorladen eines Orts (PlaceLayer unten): lädt seine Texturen schon hier auf die GPU und
|
|
// übersetzt seine CSM-Shader, statt das im ersten Bild nach dem Einsetzen zu tun — dort kostete genau
|
|
// das gemessen rund 130 ms auf einmal (docs/superpowers/specs/2026-09-14-hood-yard-design.md,
|
|
// "Fehlerfälle und Risiken").
|
|
async function warmUpPlace(root) {
|
|
prepareLitMaterials(root, sun.csm);
|
|
const textures = new Set();
|
|
root.traverse((object) => {
|
|
if (!object.isMesh) return;
|
|
for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
|
|
if (!material) continue;
|
|
for (const value of Object.values(material)) {
|
|
if (value?.isTexture) textures.add(value);
|
|
}
|
|
}
|
|
});
|
|
for (const texture of textures) renderer.initTexture(texture);
|
|
await renderer.compileAsync(root, camera, scene);
|
|
}
|
|
// Fest platzierte Modelle aus three-d-stage (src/world/places.js). Eigene Radien, damit ein Ort nur in
|
|
// der Szene ist, solange die Chunks unter ihm geladen sind.
|
|
const places = new PlaceLayer({ scene, player, warmUp: warmUpPlace });
|
|
player.setSkipCapsuleCollision(true);
|
|
let settled = false;
|
|
let warnedSlowSpawn = false;
|
|
const startedAt = performance.now();
|
|
let lastSafe = { ...SPAWN };
|
|
let vehiclesSpawned = false;
|
|
|
|
function tick(delta) {
|
|
// Befund A (Fix-Runde 3): playerPosition() statt player.playerCapsule.position an jeder
|
|
// der fünf Stellen, die "wo ist der Spieler" brauchen — sitzt er in einem Fahrzeug, hängt
|
|
// die Kapsel als Kind an dessen vehicleGroup und ihre .position ist nur noch der lokale
|
|
// Sitzplatz-Offset, nicht die Weltposition (Begründung in src/player.js). Ohne diese
|
|
// Umstellung hört das Streaming beim Fahren auf nachzuladen.
|
|
streamer.update(playerPosition(player));
|
|
places.update(playerPosition(player));
|
|
if (!settled) {
|
|
// Ein Sprung vor dem ersten Boden — Karte und Panel sind schon vor props.load() bedienbar —
|
|
// ersetzt den Spawn als Startpunkt, statt beim Einrasten stillschweigend verloren zu gehen.
|
|
const start = pendingTeleport ?? SPAWN;
|
|
// Vehicles existieren hier noch nicht (vehiclesSpawned kommt erst nach settled) —
|
|
// playerPosition() liefert also zwangsläufig die Kapsel, wie zuvor.
|
|
if (streamer.isReadyAt(playerPosition(player))) {
|
|
settled = true;
|
|
pendingTeleport = null;
|
|
player.setSkipCapsuleCollision(false);
|
|
player.reset(new Vector3(start.x, start.y, start.z));
|
|
} else {
|
|
player.reset(new Vector3(start.x, start.y, start.z));
|
|
if (!warnedSlowSpawn && performance.now() - startedAt > 15000) {
|
|
warnedSlowSpawn = true;
|
|
console.warn(`Chunk am Startpunkt (${start.x}, ${start.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).
|
|
// requestTeleport() ruft player.reset() synchron auf, noch bevor dieser tick() läuft, und
|
|
// reset() steigt bei aktivem Fahrzeug zuerst aus (controllerMode -> 0, s. PlayerController.
|
|
// reset()) — hier kann also nie ein Fahrzeug aktiv sein. playerPosition() ändert an diesem
|
|
// Aufruf inhaltlich nichts, macht die Stelle aber konsistent mit den übrigen vier.
|
|
if (pendingTeleport) {
|
|
if (streamer.isReadyAt(playerPosition(player))) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Befund A: p muss die Fahrzeugposition sein, sobald eines aktiv ist — sonst sieht das
|
|
// Auffangnetz ein abstürzendes Fahrzeug nie fallen (die Kapsel hängt ja am bedeutungslosen
|
|
// Sitzplatz-Offset fest, dessen y sich nie in die Nähe von -30 bewegt), und lastSafe würde
|
|
// mit genau dieser bedeutungslosen Zahl überschrieben.
|
|
const p = playerPosition(player);
|
|
// 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.
|
|
// playerIsGrounded() zieht dasselbe Prinzip fahrzeugbewusst durch: player.getIsOnGround()
|
|
// friert beim Fahren ein (controllerMode 1 durchläuft nie den Fußgänger-Codepfad, der ihn
|
|
// setzt, bliebe also auf "true" von kurz vor dem Einsteigen stehen) und würde lastSafe
|
|
// sonst jeden Frame blind fortschreiben, selbst wenn das Fahrzeug gerade über einer Klippe
|
|
// oder einer noch ungeladenen Lücke schwebt — exakt das, was Ruling 52 verhindern sollte.
|
|
if (p.y < -30) player.reset(new Vector3(lastSafe.x, lastSafe.y, lastSafe.z));
|
|
else if (playerIsGrounded(player)) 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);
|
|
materials.water.normalMap.offset.x += delta * 0.01;
|
|
// Nach player.update/tick steht die Kamera für diesen Frame fest; die Schattenstufen folgen
|
|
// ihrem Blick — beim Fahren wie zu Fuß, ohne eigene Positionsquelle.
|
|
sun.update();
|
|
renderer.render(scene, camera);
|
|
});
|
|
addEventListener("resize", () => {
|
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
camera.aspect = container.clientWidth / container.clientHeight;
|
|
camera.updateProjectionMatrix();
|
|
});
|