From b273bb09ccf63bb63df5765104e82d70b081d050 Mon Sep 17 00:00:00 2001 From: KNSO Date: Sun, 13 Sep 2026 05:13:54 +0200 Subject: [PATCH] fix(world): Chunk-Streaming, Auffangnetz und Schatten folgen dem Fahrzeug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playerCapsule.position ist beim Fahren nicht die Weltposition, sondern der lokale Sitzplatz-Offset (syncMountedPlayer hängt die Kapsel an vehicleGroup und überschreibt ihre .position mit driverSeatPosition * scale). main.js trieb Streaming, isReadyAt, das Auffangnetz, lastSafe und den Schattenwurf trotzdem ausnahmslos damit an — die Welt hörte beim Fahren auf nachzuladen. src/player.js bündelt "wo ist der Spieler gerade" in playerPosition()/ playerIsGrounded(): Fahrzeugposition (vehicleGroup.position) und Radkontakt, wenn eines aktiv ist, sonst Kapsel und das vorhandene getIsOnGround(). Zusätzlich über getControllerMode() abgesichert, nicht nur getActiveVehicle() — Letzteres wird beim Aussteigen vom Controller nie zurückgesetzt (nur destroy() tut das), im Browser nachgewiesen: nach einem erzwungenen Ausstieg lieferte getActiveVehicle() weiterhin das verlassene Fahrzeug, obwohl controllerMode schon wieder 0 war. Im Browser mit von Hand angetriebenem tick()/player.update() verifiziert (die automatisierte Vorschau drosselt requestAnimationFrame auf ~2 Hz): Fahrzeug 227 Einheiten bewegt, playerPosition() folgte exakt, vier neue Chunks luden nach (Netzwerk-Log bestätigt), sichtbar andere Umgebung nach der Fahrt. Co-Authored-By: Claude Sonnet 5 --- src/main.js | 35 +++++++++++--- src/player.js | 51 +++++++++++++++++++ tests/player.test.js | 113 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 src/player.js create mode 100644 tests/player.test.js diff --git a/src/main.js b/src/main.js index 89e56ba..8028523 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ 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 { playerIsGrounded, playerPosition } from "./player.js"; import { createPanel } from "./ui/panel.js"; import { buildAttribution, mountHud } from "./ui/hud.js"; @@ -214,9 +215,16 @@ let lastSafe = { ...SPAWN }; let vehiclesSpawned = false; function tick(delta) { - streamer.update(player.playerCapsule.position); + // Befund A (Fix-Runde 3): playerPosition() statt player.playerCapsule.position an jeder + // der sechs 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)); if (!settled) { - if (streamer.isReadyAt(player.playerCapsule.position)) { + // 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; player.setSkipCapsuleCollision(false); player.reset(new Vector3(SPAWN.x, SPAWN.y, SPAWN.z)); @@ -238,8 +246,12 @@ function tick(delta) { } // 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 fünf. if (pendingTeleport) { - if (streamer.isReadyAt(player.playerCapsule.position)) { + if (streamer.isReadyAt(playerPosition(player))) { player.setSkipCapsuleCollision(false); pendingTeleport = null; } else { @@ -254,11 +266,20 @@ function tick(delta) { } } - const p = player.playerCapsule.position; + // 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 (player.getIsOnGround()) lastSafe = { x: p.x, y: p.y + 1, z: p.z }; + else if (playerIsGrounded(player)) lastSafe = { x: p.x, y: p.y + 1, z: p.z }; } const timer = new Timer(); @@ -268,7 +289,9 @@ renderer.setAnimationLoop((t) => { const delta = Math.min(timer.getDelta(), 1 / 40); player.update(delta); tick(delta); - updateShadow(player.playerCapsule.position); + // Befund A: Schatten folgen der aktuellen Position (Fahrzeug beim Fahren, sonst Kapsel) — + // sonst bliebe die Schattenkamera beim Fahren auf dem Einstiegspunkt stehen. + updateShadow(playerPosition(player)); materials.water.normalMap.offset.x += delta * 0.01; renderer.render(scene, camera); }); diff --git a/src/player.js b/src/player.js new file mode 100644 index 0000000..12f4e5f --- /dev/null +++ b/src/player.js @@ -0,0 +1,51 @@ +/** + * Einzige Quelle für "wo ist der Spieler gerade" (Befund A, Fix-Runde 3). main.js trieb + * Streaming, isReadyAt, Auffangnetz, lastSafe und Schattenwurf an sechs Stellen ausnahmslos + * mit player.playerCapsule.position an - das ist falsch, sobald ein Fahrzeug aktiv ist: + * PlayerController.syncMountedPlayer() hängt die Kapsel beim Einsteigen als Kind an + * vehicle.vehicleGroup und überschreibt cap.position mit driverSeatPosition * scale, einer + * festen, kleinen lokalen Zahl nahe dem Ursprung - NICHT der Weltposition. playerCapsule. + * position bleibt also beim Fahren nicht etwa nur stehen, es springt auf einen bedeutungslosen + * Sitzplatz-Offset. Jede Stelle, die "wo ist der Spieler" braucht, muss über playerPosition() + * laufen, sonst hört das Streaming beim Fahren auf nachzuladen. + */ +/** + * getActiveVehicle() allein reicht nicht: VehicleSystem löscht this.active beim Aussteigen + * NICHT (weder exit() noch das stopActive(), das reset() bei aktivem Fahrzeug aufruft, setzen + * es zurück - nur destroy() tut das, beim kompletten Abbau aller Fahrzeuge). getActiveVehicle() + * bleibt also auf dem zuletzt benutzten Fahrzeug stehen, lange nachdem man ausgestiegen ist. + * getControllerMode() === 1 ist das einzige verlässliche "fährt gerade wirklich"-Signal (von + * enter()/exit() korrekt gesetzt) - per Hand im Browser nachgewiesen: nach reset() bei aktivem + * Fahrzeug lieferte getActiveVehicle() weiterhin das verlassene Fahrzeug, obwohl die Kapsel + * schon zurück auf dem Spawnpunkt stand und controllerMode schon wieder 0 war. + */ +function activeVehicleOrNull(player) { + return player.getControllerMode() === 1 ? player.getActiveVehicle() : null; +} + +export function playerPosition(player) { + const vehicle = activeVehicleOrNull(player); + return vehicle ? vehicle.vehicleGroup.position : player.playerCapsule.position; +} + +/** + * Echtes Bodensignal für lastSafe, fahrzeugbewusst (Ruling 52 gilt auch im Auto). Zu Fuß + * das vorhandene, raycast-gestützte getIsOnGround(). Im Fahrzeug NICHT dasselbe + * getIsOnGround(): controllerMode 1 (Fahrzeug) läuft nie durch CharacterMovement, das + * einzige, was playerIsOnGround setzt - der Wert bliebe beim Fahren auf dem Zustand von + * kurz vor dem Einsteigen eingefroren (nahezu immer "true", man steigt ja stehend ein) und + * würde lastSafe jeden Frame unbesehen überschreiben, auch wenn das Fahrzeug gerade über + * einer Klippe oder einer noch nicht geladenen Lücke schwebt - exakt das Verhalten, das + * Ruling 52 ("an ein echtes Bodensignal binden statt an die Höhe") verhindern sollte. + * Stattdessen derselbe Radkontakt-Test, den VehicleSystem.finishPhysics() intern für + * "hasWheelContact" benutzt: mindestens ein Rad mit Bodenkontakt. + */ +export function playerIsGrounded(player) { + const vehicle = activeVehicleOrNull(player); + if (!vehicle) return player.getIsOnGround(); + const wheels = vehicle.vehicleController; + for (let i = 0; i < wheels.numWheels(); i++) { + if (wheels.wheelIsInContact(i)) return true; + } + return false; +} diff --git a/tests/player.test.js b/tests/player.test.js new file mode 100644 index 0000000..51e1eb3 --- /dev/null +++ b/tests/player.test.js @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { playerIsGrounded, playerPosition } from "../src/player.js"; + +// Befund A (Fix-Runde 3): main.js trieb Streaming, isReadyAt, Auffangnetz, lastSafe und +// Schattenwurf ausnahmslos mit player.playerCapsule.position an. Sitzt der Spieler in +// einem Fahrzeug, hängt PlayerController.syncMountedPlayer() die Kapsel als Kind an +// vehicle.vehicleGroup und überschreibt ihre .position mit dem lokalen Sitzplatz-Offset +// (driverSeatPosition * scale) - eine feste, kleine Zahl nahe dem Ursprung, NICHT die +// Weltposition. playerPosition() ist die eine Stelle, die "wo ist der Spieler gerade" +// entscheidet: Fahrzeugposition (vehicleGroup.position, Weltkoordinaten seit +// syncVehicleVisual() sie direkt aus dem Chassis-Rigidbody setzt), wenn eines aktiv ist, +// sonst die Kapsel. +function makePlayer({ + activeVehicle = null, + // Default spiegelt den Normalfall (ein übergebenes Fahrzeug heißt "fährt gerade"); der + // Stale-Reference-Test unten übersteuert das gezielt auf 0. + controllerMode = activeVehicle ? 1 : 0, + capsulePosition = { x: 0, y: 0, z: 0 }, + onGround = false, +} = {}) { + return { + getActiveVehicle: () => activeVehicle, + getControllerMode: () => controllerMode, + playerCapsule: { position: capsulePosition }, + getIsOnGround: () => onGround, + }; +} + +function makeVehicle({ position, wheelsInContact = [] } = {}) { + return { + vehicleGroup: { position }, + vehicleController: { + numWheels: () => wheelsInContact.length, + wheelIsInContact: (i) => wheelsInContact[i], + }, + }; +} + +describe("playerPosition", () => { + it("liefert die Kapselposition, wenn kein Fahrzeug aktiv ist (zu Fuß)", () => { + const capsulePosition = { x: 12, y: 3, z: -7 }; + const player = makePlayer({ activeVehicle: null, capsulePosition }); + expect(playerPosition(player)).toBe(capsulePosition); + }); + + it("liefert die Fahrzeug-Weltposition, wenn ein Fahrzeug aktiv ist, nicht den Sitzplatz-Offset der Kapsel", () => { + // capsulePosition steht hier bewusst auf dem winzigen, ortsfremden Sitzplatz-Offset + // (wie syncMountedPlayer ihn setzt), vehiclePosition auf der echten Weltposition weit + // entfernt davon - der Test muss fehlschlagen, wenn playerPosition() versehentlich + // weiterhin die Kapsel zurückgibt. + const vehiclePosition = { x: 1656, y: 1, z: -739 }; + const player = makePlayer({ + activeVehicle: makeVehicle({ position: vehiclePosition }), + capsulePosition: { x: -0.2, y: 0.5, z: 0.15 }, + }); + expect(playerPosition(player)).toBe(vehiclePosition); + }); + + // Im Browser nachgewiesen (s. Bericht): VehicleSystem löscht this.active beim Aussteigen + // NICHT — weder exit() noch das stopActive(), das reset() bei aktivem Fahrzeug aufruft, + // setzen es zurück; nur destroy() (kompletter Abbau aller Fahrzeuge) tut das. Nach einem + // ganz normalen Ausstieg (oder einem erzwungenen reset()/Teleport aus dem Fahrzeug heraus) + // liefert getActiveVehicle() also weiterhin das zuletzt benutzte, längst verlassene + // Fahrzeug — ohne diesen Test hätte playerPosition() nach dem Aussteigen für immer die + // Position des verlassenen Fahrzeugs zurückgegeben statt der (dann wieder gültigen) + // Kapselposition. + it("ignoriert ein verlassenes Fahrzeug: getControllerMode() ist die verlässliche Quelle, nicht nur getActiveVehicle()", () => { + const capsulePosition = { x: 1699, y: 3, z: -743 }; + const player = makePlayer({ + activeVehicle: makeVehicle({ position: { x: 1448, y: 0.67, z: -742 } }), + controllerMode: 0, // ausgestiegen — getActiveVehicle() bleibt trotzdem gesetzt + capsulePosition, + }); + expect(playerPosition(player)).toBe(capsulePosition); + }); +}); + +describe("playerIsGrounded", () => { + it("liefert getIsOnGround(), wenn kein Fahrzeug aktiv ist (zu Fuß, Ruling 52)", () => { + expect(playerIsGrounded(makePlayer({ onGround: true }))).toBe(true); + expect(playerIsGrounded(makePlayer({ onGround: false }))).toBe(false); + }); + + it("ignoriert das eingefrorene getIsOnGround() im Fahrzeug und prüft stattdessen Radkontakt", () => { + // setOnGround() läuft nur im Fußgänger-Codepfad (CharacterMovement), controllerMode 1 + // (Fahrzeug) aktualisiert playerIsOnGround nie - es bliebe auf "true" von vor dem + // Einsteigen eingefroren. Ein Fahrzeug ganz ohne Radkontakt (frei fallend/über einer + // Klippe) muss trotzdem als nicht geerdet gelten, sonst überschreibt lastSafe sich + // jeden Frame blind, genau das Problem, das Ruling 52 ursprünglich verhindern sollte. + const player = makePlayer({ + onGround: true, + activeVehicle: makeVehicle({ position: { x: 0, y: 0, z: 0 }, wheelsInContact: [false, false, false, false] }), + }); + expect(playerIsGrounded(player)).toBe(false); + }); + + it("gilt im Fahrzeug als geerdet, sobald mindestens ein Rad Bodenkontakt hat", () => { + const player = makePlayer({ + onGround: false, + activeVehicle: makeVehicle({ position: { x: 0, y: 0, z: 0 }, wheelsInContact: [false, true, false, false] }), + }); + expect(playerIsGrounded(player)).toBe(true); + }); + + it("fällt nach dem Aussteigen auf getIsOnGround() zurück, auch wenn getActiveVehicle() noch das verlassene Fahrzeug liefert", () => { + const player = makePlayer({ + onGround: true, + controllerMode: 0, + activeVehicle: makeVehicle({ position: { x: 0, y: 0, z: 0 }, wheelsInContact: [] }), + }); + expect(playerIsGrounded(player)).toBe(true); + }); +});