fix(world): Chunk-Streaming, Auffangnetz und Schatten folgen dem Fahrzeug
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e179794967
commit
b273bb09cc
3 files changed
+193
-6
No files matched your search
+29
-6
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user