feat: Figurenauswahl, Autos, Debug-Panel und Namensnennung
Task 11: Figuren (Mannequin/Superhero Male/Female) mit auf ual.glb retargeteten Animationen, Frisuren, vier SUVs auf angehefteten Chunks, ein lil-gui-Debug-Panel (Figur/Frisur/Höhe/Hitbox/Chunk-Grenzen/ Kartenbild/Nebel/Wahrzeichen-Sprung) und die verpflichtende Namensnennung fürs HUD (Kartenkacheln, Wahrzeichen, SUV, Texturen, Palme, Figuren — jede Quelle mit ihrer eigenen Lizenz). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
02d0f36580
commit
d30aae68a1
42 files changed
+13143
-4
No files matched your search
@@ -0,0 +1,324 @@
|
||||
import { AnimationClip, Box3, Quaternion, QuaternionKeyframeTrack, Vector3,
|
||||
VectorKeyframeTrack } from "three";
|
||||
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
|
||||
// Übernommen aus three-player-controller/example/showcase.js (drei Figuren teilen sich
|
||||
// dasselbe 65-Bone-Skelett; Animation kommt immer von ual.glb, siehe retargetClips unten).
|
||||
// Pfade an dieses Projekts public/-Layout angepasst (dort ./glb/…, hier ./models/…).
|
||||
export const CHARACTER_DIR = "./models/characters/";
|
||||
export const HAIR_NONE = "None";
|
||||
export const CHARACTERS = [
|
||||
{ name: "Mannequin", url: "./models/ual.glb", heightFactor: 1 },
|
||||
{ name: "Superhero Male", url: `${CHARACTER_DIR}Superhero_Male_FullBody.gltf`, heightFactor: 1 },
|
||||
// Erwachsene Frauen sind im Schnitt ca. 93% der Körpergröße von Männern; die im Modell
|
||||
// eingebauten 97,5% Unterschied fallen kaum auf (Kommentar aus showcase.js übernommen).
|
||||
{ name: "Superhero Female", url: `${CHARACTER_DIR}Superhero_Female_FullBody.gltf`, heightFactor: 0.93 },
|
||||
];
|
||||
export const HAIRSTYLES = [
|
||||
"Hair_Buzzed",
|
||||
"Hair_BuzzedFemale",
|
||||
"Hair_SimpleParted",
|
||||
"Hair_Long",
|
||||
"Hair_Buns",
|
||||
"Hair_Beard",
|
||||
];
|
||||
// Nur zur Breitenmessung (measureTorsoRadiusRatio): T-Pose-Arme sollen die Breite nicht verfälschen.
|
||||
const HITBOX_TORSO_BONES = ["pelvis", "spine_01", "spine_02", "spine_03"];
|
||||
// Controller-Standard playerCapsuleHeight / playerCapsuleRadius = 180 / 30.
|
||||
const CAPSULE_HEIGHT_TO_RADIUS = 6;
|
||||
|
||||
// Modellunabhängige Spielerkonfiguration, gemeinsam für Ersteinrichtung (main.js) und
|
||||
// Figurenwechsel (applyCharacter) — identisch zu PLAYER_MODEL_BASE in showcase.js, weil
|
||||
// alle drei Figuren von genau demselben ual.glb animiert werden.
|
||||
export const PLAYER_MODEL_BASE = {
|
||||
scale: 0.005,
|
||||
idleAnim: "Idle_Loop",
|
||||
walkAnim: "Walk_Loop",
|
||||
runAnim: "Sprint_Loop",
|
||||
jumpAnim: ["Jump_Start", "Jump_Loop", "Jump_Land"],
|
||||
flyAnim: "fly",
|
||||
flyIdleAnim: "flyIdle",
|
||||
flyHoverForwardAnim: "flyHoverForward",
|
||||
flyHoverBackAnim: "flyHoverBack",
|
||||
flyHoverLeftAnim: "flyHoverLeft",
|
||||
flyHoverRightAnim: "flyHoverRight",
|
||||
flyHoverUpAnim: "flyHoverUp",
|
||||
flyHoverDownAnim: "flyHoverDown",
|
||||
drivingAnim: "Driving_Loop",
|
||||
headBoneName: "Head",
|
||||
firstPersonCameraOffset: [0, 0.15, 0.12],
|
||||
rotateY: -Math.PI / 2,
|
||||
speed: 100,
|
||||
runSpeed: 600,
|
||||
};
|
||||
|
||||
// Records: Bone-Name -> { position, quaternion } zum Zeitpunkt des Ladens (vor dem ersten
|
||||
// mixer-Update aufgenommen). Unverändert aus showcase.js.
|
||||
export function collectRestPose(root) {
|
||||
const rest = new Map();
|
||||
root?.traverse((obj) => {
|
||||
if (obj.isBone) rest.set(obj.name, { position: obj.position.clone(), quaternion: obj.quaternion.clone() });
|
||||
});
|
||||
return rest;
|
||||
}
|
||||
|
||||
// Ändert sich die Spur über die Zeit? Unverändert aus showcase.js.
|
||||
export function trackVaries(track, stride) {
|
||||
const v = track.values;
|
||||
for (let i = stride; i < v.length; i++) {
|
||||
if (Math.abs(v[i] - v[i % stride]) > 1e-6) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wiederverwendete Skalare für retargetClips, um pro Aufruf keine neuen Quaternion-Objekte
|
||||
// anzulegen (unverändert aus showcase.js, dort retargetTmp genannt).
|
||||
const retargetTmp = { corr: new Quaternion(), inv: new Quaternion(), q: new Quaternion() };
|
||||
|
||||
/**
|
||||
* Passt die Animationen von ual.glb an ein anderes Körperschema an. Der Blender-Export legt
|
||||
* allen 65 Bones Translation-/Scale-Spuren an; direktes Abspielen würde dem Zielmodell die
|
||||
* Bone-Positionen des Mannequins aufzwingen und Schulterbreite/Gliedmaßenlängen verzerren.
|
||||
* Deshalb bleiben nur Rotationen erhalten, konstante Verschiebung und alle Scale-Spuren
|
||||
* fallen weg; die einzige echt bewegte Spur (pelvis-Verschiebung) wird auf die Ziel-Ruhepose
|
||||
* umgerechnet.
|
||||
*
|
||||
* Anders als in showcase.js (wo sourceRest ein Modul-Global war) nimmt diese Fassung die
|
||||
* Quellruhepose als expliziten dritten Parameter — sonst ließe sich die Funktion nicht ohne
|
||||
* versteckten globalen Zustand testen/exportieren. Die Rechnung selbst (q_new = R_target ·
|
||||
* R_source⁻¹ · q_anim, konstante Verschiebung und Scale verworfen) ist unverändert: am realen
|
||||
* Modell zeigte sich, dass ein direktes Übernehmen der absoluten Rotation die Schultern des
|
||||
* weiblichen Modells sichtbar verdreht (siehe task-11-brief.md) — deshalb hier nicht vereinfachen.
|
||||
*/
|
||||
export function retargetClips(clips, sourceRest, targetRest) {
|
||||
if (!sourceRest) return clips;
|
||||
return clips.map((clip) => {
|
||||
const tracks = [];
|
||||
for (const track of clip.tracks) {
|
||||
const dot = track.name.lastIndexOf(".");
|
||||
const bone = track.name.slice(0, dot);
|
||||
const prop = track.name.slice(dot + 1);
|
||||
|
||||
const from = sourceRest.get(bone);
|
||||
const to = targetRest.get(bone);
|
||||
|
||||
if (prop === "quaternion") {
|
||||
if (!from || !to) { tracks.push(track); continue; }
|
||||
// Die beiden Skelette unterscheiden sich in der Ruheausrichtung (Schlüsselbein
|
||||
// 9,5°, Nacken 23°) — eine direkt übernommene absolute Rotation würde das
|
||||
// Zielskelett verdrehen, daher wird nur die Differenz zur Ruhepose übertragen.
|
||||
retargetTmp.corr.copy(to.quaternion).multiply(retargetTmp.inv.copy(from.quaternion).invert());
|
||||
if (1 - Math.abs(retargetTmp.corr.w) < 1e-9) { tracks.push(track); continue; }
|
||||
const q = Float32Array.from(track.values);
|
||||
for (let i = 0; i + 3 < q.length; i += 4) {
|
||||
retargetTmp.q.set(q[i], q[i + 1], q[i + 2], q[i + 3]).premultiply(retargetTmp.corr);
|
||||
q[i] = retargetTmp.q.x; q[i + 1] = retargetTmp.q.y;
|
||||
q[i + 2] = retargetTmp.q.z; q[i + 3] = retargetTmp.q.w;
|
||||
}
|
||||
tracks.push(new QuaternionKeyframeTrack(track.name, track.times, q));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prop !== "position") continue; // scale wird immer verworfen
|
||||
if (!trackVaries(track, 3)) continue; // konstante Verschiebung ist Ruhepose, verwerfen
|
||||
if (!from || !to) { tracks.push(track); continue; }
|
||||
|
||||
const values = Float32Array.from(track.values);
|
||||
for (let i = 0; i + 2 < values.length; i += 3) {
|
||||
values[i] += to.position.x - from.position.x;
|
||||
values[i + 1] += to.position.y - from.position.y;
|
||||
values[i + 2] += to.position.z - from.position.z;
|
||||
}
|
||||
tracks.push(new VectorKeyframeTrack(track.name, track.times, values));
|
||||
}
|
||||
return new AnimationClip(clip.name, clip.duration, tracks);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Auf ein Modell angepasste Animationen, je Figur zwischengespeichert. `cache` und die
|
||||
* Quelldaten (playerAnimations/sourceRestPose) kommen explizit herein statt aus Modul-Globalen
|
||||
* (showcase.js hielt sie als `let` auf Skriptebene — hier ist die Funktion ein benannter
|
||||
* Export und braucht darum einen expliziten, von außen kontrollierten Zustand).
|
||||
*/
|
||||
export function clipsForModel(name, model, { playerAnimations, sourceRestPose, cache }) {
|
||||
if (!cache.has(name)) {
|
||||
cache.set(name, retargetClips(playerAnimations, sourceRestPose, collectRestPose(model)));
|
||||
}
|
||||
return cache.get(name);
|
||||
}
|
||||
|
||||
/** Erstes SkinnedMesh unter root, oder null. Unverändert aus showcase.js. */
|
||||
function findSkinnedMesh(root) {
|
||||
let found = null;
|
||||
root?.traverse((obj) => { if (!found && obj.isSkinnedMesh) found = obj; });
|
||||
return found;
|
||||
}
|
||||
|
||||
function enableModelShadows(root) {
|
||||
root?.traverse((obj) => {
|
||||
if (obj.isMesh) { obj.castShadow = true; obj.receiveShadow = true; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Misst die tatsächliche halbe Rumpfbreite und rechnet sie in ein capsuleRadiusRatio um, das
|
||||
* zur Netzform passt. Das Modell steht in T-Pose; ein direkter Bounding-Box-Griff würde die
|
||||
* ausgestreckten Arme mit einrechnen, daher nur Vertices mit dominantem Rumpf-Bone-Gewicht.
|
||||
* Unverändert aus showcase.js.
|
||||
*/
|
||||
export function measureTorsoRadiusRatio(root) {
|
||||
const mesh = findSkinnedMesh(root);
|
||||
const pos = mesh?.geometry?.attributes?.position;
|
||||
const skinIndex = mesh?.geometry?.attributes?.skinIndex;
|
||||
const skinWeight = mesh?.geometry?.attributes?.skinWeight;
|
||||
if (!mesh || !pos || !skinIndex || !skinWeight) return 1;
|
||||
|
||||
const torso = new Set();
|
||||
mesh.skeleton?.bones.forEach((bone, i) => {
|
||||
if (HITBOX_TORSO_BONES.includes(bone.name)) torso.add(i);
|
||||
});
|
||||
if (!torso.size) return 1;
|
||||
|
||||
let halfWidth = 0;
|
||||
for (let v = 0; v < pos.count; v++) {
|
||||
let dominant = 0;
|
||||
let best = -1;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const w = skinWeight.getComponent(v, k);
|
||||
if (w > best) { best = w; dominant = skinIndex.getComponent(v, k); }
|
||||
}
|
||||
if (torso.has(dominant)) halfWidth = Math.max(halfWidth, Math.abs(pos.getX(v)));
|
||||
}
|
||||
if (halfWidth <= 0) return 1;
|
||||
|
||||
root.updateMatrixWorld(true);
|
||||
const height = new Box3().setFromObject(root).getSize(new Vector3()).y;
|
||||
if (!(height > 0)) return 1;
|
||||
return CAPSULE_HEIGHT_TO_RADIUS * (halfWidth / height);
|
||||
}
|
||||
|
||||
function loadGltfCached(state, url) {
|
||||
if (!state.gltfCache.has(url)) state.gltfCache.set(url, state.gltfLoader.loadAsync(url));
|
||||
return state.gltfCache.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt das Figurenmodell; die Animationen kommen immer von ual.glb (state.playerAnimations).
|
||||
* Unverändert aus showcase.js außer dem expliziten state-Objekt statt Modul-Globalen (siehe
|
||||
* clipsForModel) und dem Wegfall der showcase-eigenen Zonen-/Armjustierfunktionen, die es in
|
||||
* diesem Projekt nicht gibt (kein schrumpfender Korridor, keine Arm-Feinjustierung im Panel).
|
||||
*/
|
||||
export async function applyCharacter(state, name) {
|
||||
const entry = CHARACTERS.find((c) => c.name === name) ?? CHARACTERS[0];
|
||||
const gltf = await loadGltfCached(state, entry.url);
|
||||
// Jedes Mal eine frische Kopie: der Controller skaliert das Modell direkt, dieselbe
|
||||
// Instanz wiederzuverwenden würde die Skalierung aufsummieren.
|
||||
const model = cloneSkeleton(gltf.scene);
|
||||
const radiusRatio = measureTorsoRadiusRatio(model);
|
||||
state.currentHeightFactor = state.characterHeights.get(entry.name) ?? entry.heightFactor ?? 1;
|
||||
await state.player.switchPlayerModel({
|
||||
...PLAYER_MODEL_BASE,
|
||||
model,
|
||||
animations: clipsForModel(entry.name, model, state),
|
||||
scale: PLAYER_MODEL_BASE.scale * state.currentHeightFactor,
|
||||
capsuleRadiusRatio: radiusRatio,
|
||||
});
|
||||
state.currentCharacterName = entry.name;
|
||||
enableModelShadows(state.player.playerModel);
|
||||
state.currentHairMesh = null; // altes Haar verschwindet mit dem alten Modell
|
||||
await applyHair(state, state.currentHairName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wechselt die Frisur; Figur und Frisur teilen dasselbe Skelett mit identischer Bone-
|
||||
* Reihenfolge und lauter Einheitsmatrizen als Bind-Matrix, daher direkt bindbar. Unverändert
|
||||
* aus showcase.js außer dem expliziten state-Objekt (siehe applyCharacter).
|
||||
*/
|
||||
export async function applyHair(state, name) {
|
||||
state.currentHairName = name;
|
||||
if (state.currentHairMesh) {
|
||||
state.currentHairMesh.removeFromParent();
|
||||
state.currentHairMesh = null;
|
||||
}
|
||||
if (name === HAIR_NONE) return;
|
||||
|
||||
const gltf = await loadGltfCached(state, `${CHARACTER_DIR}${name}.gltf`);
|
||||
const hair = findSkinnedMesh(cloneSkeleton(gltf.scene));
|
||||
const target = findSkinnedMesh(state.player.playerModel);
|
||||
if (!hair || !target) {
|
||||
console.warn("[characters] Frisur oder Figur ohne SkinnedMesh:", name);
|
||||
return;
|
||||
}
|
||||
|
||||
hair.name = name;
|
||||
hair.position.set(0, 0, 0);
|
||||
hair.quaternion.identity();
|
||||
hair.scale.set(1, 1, 1);
|
||||
target.parent.add(hair);
|
||||
hair.bind(target.skeleton, hair.bindMatrix);
|
||||
// Bounding-Box wird nach dem Neu-Binden ungültig; Frustum-Culling aus, sonst verschwindet
|
||||
// die Frisur fälschlich, sobald ihre alte Box aus dem Sichtfeld wandert.
|
||||
hair.frustumCulled = false;
|
||||
enableModelShadows(hair);
|
||||
state.currentHairMesh = hair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den {switchCharacter, switchHair, applyHeight}-Zustand für einen bereits initialisierten
|
||||
* Player (Task 1). playerAnimations/sourceRestPose werden hier einmalig eingefroren: zum
|
||||
* Zeitpunkt des Aufrufs trägt player.playerModel noch das ursprüngliche ual.glb (Mannequin),
|
||||
* das gleichzeitig das Quellskelett für retargetClips ist.
|
||||
*/
|
||||
export function createCharacterModule(player) {
|
||||
const state = {
|
||||
player,
|
||||
playerAnimations: player.playerModelConfig?.animations ?? [],
|
||||
sourceRestPose: collectRestPose(player.playerModel),
|
||||
gltfLoader: new GLTFLoader(),
|
||||
gltfCache: new Map(),
|
||||
cache: new Map(),
|
||||
characterHeights: new Map(CHARACTERS.map((c) => [c.name, c.heightFactor])),
|
||||
currentCharacterName: CHARACTERS[0].name,
|
||||
currentHairName: HAIR_NONE,
|
||||
currentHairMesh: null,
|
||||
currentHeightFactor: 1,
|
||||
busy: false,
|
||||
};
|
||||
|
||||
async function switchCharacter(name) {
|
||||
if (state.busy || name === state.currentCharacterName) return;
|
||||
state.busy = true;
|
||||
try { await applyCharacter(state, name); }
|
||||
catch (err) { console.error("[characters] Figurenwechsel fehlgeschlagen:", err); }
|
||||
finally { state.busy = false; }
|
||||
}
|
||||
|
||||
async function switchHair(name) {
|
||||
if (state.busy || name === state.currentHairName) return;
|
||||
state.busy = true;
|
||||
try { await applyHair(state, name); }
|
||||
catch (err) { console.error("[characters] Frisurenwechsel fehlgeschlagen:", err); }
|
||||
finally { state.busy = false; }
|
||||
}
|
||||
|
||||
/** Höhenfaktor der aktuellen Figur (0.7–1.3 im Panel), skaliert den Spieler direkt. */
|
||||
function applyHeight(factor) {
|
||||
state.currentHeightFactor = factor;
|
||||
state.characterHeights.set(state.currentCharacterName, factor);
|
||||
player.setPlayerScale(PLAYER_MODEL_BASE.scale * factor);
|
||||
}
|
||||
|
||||
return { switchCharacter, switchHair, applyHeight, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahrzeichen absteigend nach Höhe sortiert, mit three-Weltkoordinaten statt gtadb-XY
|
||||
* (worldToThree: x bleibt, z = -Y, s. src/world/coords.js).
|
||||
*/
|
||||
export function teleportTargets(landmarks) {
|
||||
return [...landmarks]
|
||||
.sort((a, b) => b.height - a.height)
|
||||
.map((l) => ({ name: l.name, x: l.xyz[0], z: -l.xyz[1], height: l.height }));
|
||||
}
|
||||
+122
-1
@@ -7,6 +7,10 @@ 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();
|
||||
@@ -31,6 +35,11 @@ scene.add(sun);
|
||||
// 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);
|
||||
@@ -50,6 +59,47 @@ scene.add(water);
|
||||
// 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({
|
||||
@@ -63,6 +113,57 @@ await player.init({
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
let pendingTeleport = null;
|
||||
function requestTeleport(position) {
|
||||
pendingTeleport = { x: position.x, y: position.y, z: position.z };
|
||||
player.setSkipCapsuleCollision(true);
|
||||
player.reset(new Vector3(position.x, position.y, position.z));
|
||||
}
|
||||
|
||||
createPanel({ player, characterModule, scene, manifest, requestTeleport });
|
||||
|
||||
// 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
|
||||
@@ -94,12 +195,13 @@ function updateShadow(target) {
|
||||
// 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 });
|
||||
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);
|
||||
@@ -117,6 +219,25 @@ function tick(delta) {
|
||||
}
|
||||
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));
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Namensnennung ist bei den hier verwendeten Assets teils Pflicht (CC BY), nicht Kosmetik:
|
||||
// - Kartenkacheln und Wahrzeichenhöhen: schon in manifest.json (Task 6), wörtlich übernommen.
|
||||
// - SUV: Boris731 (Sketchfab, "Low-poly cyberpunk SUV") unter CC BY — Namensnennung Pflicht.
|
||||
// - Texturen: Poly Haven, CC0.
|
||||
// - Palme: Nobiax (OpenGameArt), CC0 (public/models/palm/readme.txt: "absolutely free to
|
||||
// use or to modify in any kind of work").
|
||||
// - Figuren/Frisuren: Quaternius, "Universal Base Characters[Standard]"-Paket, CC0 1.0
|
||||
// Universal (Public Domain Dedication) laut dessen License_Standard.txt — siehe
|
||||
// task-11-report.md für den wörtlichen Fund.
|
||||
// Jede Quelle bekommt ihren eigenen Satz mit ihrer eigenen Lizenz: keine Lizenz kürzen,
|
||||
// keine zwei Lizenzen in einer Zeile vermischen, kein CC0 für die CC-BY-Quelle behaupten.
|
||||
const MODEL_CREDITS = "SUV: Boris731 (Sketchfab, CC BY). Texturen: Poly Haven (CC0). " +
|
||||
"Palme: Nobiax (OpenGameArt, CC0). Figuren: Quaternius (CC0).";
|
||||
|
||||
/** Reine Textzusammensetzung, ohne DOM — so ohne Browser testbar (siehe tests/hud.test.js). */
|
||||
export function buildAttribution(manifest) {
|
||||
return `${manifest.attribution} ${MODEL_CREDITS}`;
|
||||
}
|
||||
|
||||
export function mountHud(root, attribution) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "attribution";
|
||||
el.textContent = attribution;
|
||||
root.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { BufferGeometry, CanvasTexture, Float32BufferAttribute, LineBasicMaterial,
|
||||
LineSegments, Mesh, MeshBasicMaterial, PlaneGeometry, Vector3 } from "three";
|
||||
import GUI from "lil-gui";
|
||||
import { CHARACTERS, HAIRSTYLES, HAIR_NONE, teleportTargets } from "../characters/index.js";
|
||||
import { CHUNKS_PER_SIDE, CHUNK_SIZE, SLICE } from "../world/coords.js";
|
||||
|
||||
// Wahrzeichen: rolux/gtamaplib-vc (Unlicense, s. manifest.json), aus tools/extract/data/
|
||||
// skyline.json übernommen (nur gelesen, nicht verändert — die Datei selbst bleibt
|
||||
// tools/extract vorbehalten). Alle 54 liegen innerhalb dieses Kartenausschnitts (SLICE).
|
||||
const LANDMARKS = [
|
||||
{ name: "Southeast Financial Center", xyz: [-461.31, -460.1, 248.34], height: 248.3 },
|
||||
{ name: "Jade Ocean Condos", xyz: [1580.99, 2482.23, 202.46], height: 202.5 },
|
||||
{ name: "Opera Tower", xyz: [-417.55, 864.58, 200.87], height: 200.9 },
|
||||
{ name: "Quantum on the Bay", xyz: [-398.51, 1140.65, 194.78], height: 194.8 },
|
||||
{ name: "Wells Fargo Center", xyz: [-655.02, -408.58, 186.88], height: 186.9 },
|
||||
{ name: "Vizcayne North Condominium", xyz: [-528.49, 137.15, 177.17], height: 177.2 },
|
||||
{ name: "Marina Blue", xyz: [-455.68, 414.97, 177.13], height: 177.1 },
|
||||
{ name: "The Grand", xyz: [-262.47, 952.24, 176.64], height: 176.6 },
|
||||
{ name: "1800 Club", xyz: [-349.78, 1034.41, 161.96], height: 162.0 },
|
||||
{ name: "Stephen P. Clark Government Center", xyz: [-934.67, 19.08, 161.5], height: 161.5 },
|
||||
{ name: "Miami Tower", xyz: [-770.52, -414.85, 161.07], height: 161.1 },
|
||||
{ name: "Icon at South Beach", xyz: [1355.98, -13.76, 154.8], height: 154.8 },
|
||||
{ name: "Citigroup Center", xyz: [-382.36, -480.26, 152.12], height: 152.1 },
|
||||
{ name: "50 Biscayne Blvd", xyz: [-434.64, -246.37, 146.71], height: 146.7 },
|
||||
{ name: "Marriott Miami Biscayne Bay", xyz: [-219.73, 859.14, 146.44], height: 146.4 },
|
||||
{ name: "Portofino Tower", xyz: [1734.05, -205.8, 144.46], height: 144.5 },
|
||||
{ name: "Green Diamond", xyz: [1772.72, 2120.85, 144.48], height: 144.5 },
|
||||
{ name: "Blue Diamond", xyz: [1830.96, 2061.61, 143.59], height: 143.6 },
|
||||
{ name: "Ten Museum Park", xyz: [-455.33, 468.57, 138.15], height: 138.1 },
|
||||
{ name: "InterContinental Miami", xyz: [-255.84, -466.24, 133.46], height: 133.5 },
|
||||
{ name: "Murano Grande", xyz: [1415.06, -58.77, 126.36], height: 126.4 },
|
||||
{ name: "Loft Downtown II", xyz: [-539.52, 50.15, 126.04], height: 126.0 },
|
||||
{ name: "The Crimson", xyz: [-458.38, 1238.37, 122.89], height: 122.9 },
|
||||
{ name: "Trésor Tower", xyz: [1837.42, 1946.05, 119.24], height: 119.2 },
|
||||
{ name: "New Wave Condominiums", xyz: [-341.11, 1419.48, 114.5], height: 114.5 },
|
||||
{ name: "Pelican Harbor Radio Tower", xyz: [1101.8, 1869.0, 113.36], height: 113.4 },
|
||||
{ name: "The Waverly South Beach", xyz: [1249.0, 602.57, 110.95], height: 110.9 },
|
||||
{ name: "The Floridian", xyz: [1265.56, 409.0, 96.47], height: 96.5 },
|
||||
{ name: "Sunset Harbour South Condo", xyz: [1441.32, 1669.71, 87.36], height: 87.4 },
|
||||
{ name: "Flamingo South Beach", xyz: [1278.92, 950.05, 76.39], height: 76.4 },
|
||||
{ name: "Bank of America Financial Center", xyz: [1693.67, 1372.34, 73.44], height: 73.4 },
|
||||
{ name: "22 Biscayne Bay", xyz: [-350.41, 1354.52, 72.62], height: 72.6 },
|
||||
{ name: "Capri South Beach", xyz: [1316.65, 1132.18, 57.54], height: 57.5 },
|
||||
{ name: "1111 Lincoln Rd", xyz: [1482.94, 1340.65, 56.06], height: 56.1 },
|
||||
{ name: "1000 Venetian Way", xyz: [307.83, 1129.73, 54.16], height: 54.2 },
|
||||
{ name: "112 NE 41st St", xyz: [26.62, 2219.87, 53.35], height: 53.3 },
|
||||
{ name: "The Tides South Beach", xyz: [1993.63, 888.23, 53.21], height: 53.2 },
|
||||
{ name: "Old City Hall", xyz: [1681.71, 605.02, 51.6], height: 51.6 },
|
||||
{ name: "St. Moritz Hotel", xyz: [1983.51, 1107.58, 49.35], height: 49.4 },
|
||||
{ name: "W South Beach", xyz: [1906.2, 1590.71, 48.76], height: 48.8 },
|
||||
{ name: "The Gates Hotel South Beach", xyz: [1863.97, 1612.19, 48.18], height: 48.2 },
|
||||
{ name: "Venetian Isle Condominium", xyz: [55.22, 1191.41, 46.93], height: 46.9 },
|
||||
{ name: "Kaseya Center", xyz: [-272.56, 433.4, 46.77], height: 46.8 },
|
||||
{ name: "Uptown Lofts", xyz: [-349.03, 1316.42, 46.15], height: 46.2 },
|
||||
{ name: "Knight Concert Hall", xyz: [-353.55, 687.21, 46.16], height: 46.2 },
|
||||
{ name: "New World Center", xyz: [1653.41, 1432.42, 45.18], height: 45.2 },
|
||||
{ name: "Cruise Terminal D", xyz: [664.47, -431.34, 34.79], height: 34.8 },
|
||||
{ name: "419 NE 4th Ave", xyz: [-428.91, 1210.66, 34.08], height: 34.1 },
|
||||
{ name: "Miami Beach Parking Garage", xyz: [1850.1, 682.39, 34.08], height: 34.1 },
|
||||
{ name: "Hotel Victor", xyz: [1945.1, 733.09, 33.59], height: 33.6 },
|
||||
{ name: "City View Parking Garage", xyz: [-172.17, 2060.76, 33.16], height: 33.2 },
|
||||
{ name: "Jenny Hostel", xyz: [1911.18, 602.76, 30.98], height: 31.0 },
|
||||
{ name: "Star Island Water Tower", xyz: [760.6, 480.35, 28.73], height: 28.7 },
|
||||
{ name: "Hotel Breakwater", xyz: [1949.86, 563.75, 25.8], height: 25.8 },
|
||||
];
|
||||
// Sprungziel liegt so weit über dem Wahrzeichen, dass die Fallphysik es sicher von oben
|
||||
// erreicht, statt in der Dachgeometrie zu stecken (s. requestTeleport in main.js).
|
||||
const TELEPORT_MARGIN = 15;
|
||||
const MAP_TEXTURE_SIZE = 768;
|
||||
const MAP_LAND_COLOR = "#cfae7d";
|
||||
|
||||
/** Cyanfarbenes Gitter entlang aller Chunk-Grenzen der Slice; rein aus SLICE/CHUNK_SIZE, ohne Fetch. */
|
||||
function buildChunkGrid() {
|
||||
const y = 0.15; // knapp über den Straßen-Decals (0.03), damit das Gitter nicht im Z-Fighting verschwindet
|
||||
const positions = [];
|
||||
for (let i = 0; i <= CHUNKS_PER_SIDE; i++) {
|
||||
const x = SLICE.x0 + i * CHUNK_SIZE;
|
||||
positions.push(x, y, -SLICE.y0, x, y, -SLICE.y1);
|
||||
}
|
||||
for (let j = 0; j <= CHUNKS_PER_SIDE; j++) {
|
||||
const z = -(SLICE.y1 - j * CHUNK_SIZE);
|
||||
positions.push(SLICE.x0, y, z, SLICE.x1, y, z);
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const grid = new LineSegments(geometry, new LineBasicMaterial({ color: 0x00e5ff }));
|
||||
grid.visible = false;
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** Füllt einen Polygonzug (Außenring + Löcher) auf dem Canvas; evenodd spart die Löcher aus. */
|
||||
function fillPolygon(ctx, rings, origin, scale) {
|
||||
ctx.beginPath();
|
||||
for (const ring of rings) {
|
||||
ring.forEach(([lx, lz], idx) => {
|
||||
const cx = (origin[0] + lx - SLICE.x0) * scale;
|
||||
const cy = (origin[1] + lz + SLICE.y1) * scale;
|
||||
if (idx === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
|
||||
});
|
||||
ctx.closePath();
|
||||
}
|
||||
ctx.fill("evenodd");
|
||||
}
|
||||
|
||||
/**
|
||||
* Kartenbild als Ebene auf dem Boden: Landflächen aller Chunks (aus public/data/vice-a, wie
|
||||
* ChunkStreamer selbst sie lädt) als Silhouette auf transparentem Grund, damit das Meer
|
||||
* darunter durchscheint. Fehlt ein einzelner Chunk (Netzfehler, 404), bleibt die Karte dort
|
||||
* schlicht leer statt ganz abzubrechen — es ist ein Debug-Hilfsmittel, kein Spielinhalt.
|
||||
*/
|
||||
async function buildMapMesh(manifest, baseUrl) {
|
||||
const width = SLICE.x1 - SLICE.x0, depth = SLICE.y1 - SLICE.y0;
|
||||
const scale = MAP_TEXTURE_SIZE / width;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = MAP_TEXTURE_SIZE;
|
||||
canvas.height = Math.round(MAP_TEXTURE_SIZE * (depth / width));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = MAP_LAND_COLOR;
|
||||
for (const [i, j] of manifest.chunks) {
|
||||
let chunk;
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/chunk_${i}_${j}.json`);
|
||||
if (!res.ok) continue;
|
||||
chunk = await res.json();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const polygon of chunk.land ?? []) fillPolygon(ctx, polygon, chunk.origin, scale);
|
||||
}
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
const mesh = new Mesh(
|
||||
new PlaneGeometry(width, depth),
|
||||
new MeshBasicMaterial({ map: texture, transparent: true, depthWrite: false }),
|
||||
);
|
||||
mesh.rotation.x = -Math.PI / 2;
|
||||
mesh.position.set((SLICE.x0 + SLICE.x1) / 2, 0.05, -(SLICE.y0 + SLICE.y1) / 2);
|
||||
mesh.visible = false;
|
||||
return mesh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut das lil-gui-Debug-Panel (Task 11, Schritt 6): Figur, Frisur, Höhe, Hitbox anzeigen,
|
||||
* Chunk-Grenzen anzeigen, Kartenbild auf dem Boden, Sichtweite (Nebel) und Sprung zu
|
||||
* Wahrzeichen. requestTeleport(position) kommt aus main.js, weil nur main.js weiß, wie ein
|
||||
* Sprung sicher wird (Collider am Ziel abwarten, s. dortiger Kommentar zu pendingTeleport) —
|
||||
* ohne das würde ein Sprung auf ein noch nicht gestreamtes Wahrzeichen den Spieler ins
|
||||
* Leere fallen und vom Auffangnetz zurückschicken lassen.
|
||||
*/
|
||||
export function createPanel({ player, characterModule, scene, manifest, baseUrl = "./data/vice-a", requestTeleport }) {
|
||||
const targets = teleportTargets(LANDMARKS);
|
||||
const chunkGrid = buildChunkGrid();
|
||||
scene.add(chunkGrid);
|
||||
|
||||
let mapMesh = null, mapPromise = null, mapWanted = false;
|
||||
function setMapImageVisible(visible) {
|
||||
mapWanted = visible;
|
||||
if (mapMesh) { mapMesh.visible = mapWanted; return; }
|
||||
if (!mapPromise) {
|
||||
mapPromise = buildMapMesh(manifest, baseUrl).then((mesh) => {
|
||||
mapMesh = mesh;
|
||||
mapMesh.visible = mapWanted;
|
||||
scene.add(mapMesh);
|
||||
}).catch((err) => {
|
||||
console.warn("[panel] Kartenbild konnte nicht gebaut werden:", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
character: characterModule.state.currentCharacterName,
|
||||
hair: characterModule.state.currentHairName,
|
||||
height: characterModule.state.currentHeightFactor,
|
||||
showHitbox: false,
|
||||
showChunkGrid: false,
|
||||
showMapImage: false,
|
||||
fogDistance: scene.fog?.far ?? 2200,
|
||||
landmark: targets[0]?.name ?? "",
|
||||
};
|
||||
|
||||
const gui = new GUI({ title: "Debug" });
|
||||
|
||||
const characterFolder = gui.addFolder("Figur");
|
||||
characterFolder.add(params, "character", CHARACTERS.map((c) => c.name)).name("Figur")
|
||||
.onChange((value) => { void characterModule.switchCharacter(value); });
|
||||
characterFolder.add(params, "hair", [HAIR_NONE, ...HAIRSTYLES]).name("Frisur")
|
||||
.onChange((value) => { void characterModule.switchHair(value); });
|
||||
characterFolder.add(params, "height", 0.7, 1.3, 0.01).name("Höhe")
|
||||
.onChange((value) => characterModule.applyHeight(value));
|
||||
characterFolder.add(params, "showHitbox").name("Hitbox anzeigen")
|
||||
.onChange((value) => player.setPlayerCapsuleDebug?.(value));
|
||||
|
||||
const worldFolder = gui.addFolder("Welt");
|
||||
worldFolder.add(params, "showChunkGrid").name("Chunk-Grenzen anzeigen")
|
||||
.onChange((value) => { chunkGrid.visible = value; });
|
||||
worldFolder.add(params, "showMapImage").name("Kartenbild auf dem Boden")
|
||||
.onChange((value) => setMapImageVisible(value));
|
||||
worldFolder.add(params, "fogDistance", 400, 3000, 50).name("Sichtweite (Nebel)")
|
||||
.onChange((value) => { if (scene.fog) scene.fog.far = value; });
|
||||
|
||||
const teleportFolder = gui.addFolder("Sprung zu Wahrzeichen");
|
||||
teleportFolder.add(params, "landmark", targets.map((t) => t.name)).name("Wahrzeichen")
|
||||
.onChange((value) => {
|
||||
const target = targets.find((t) => t.name === value);
|
||||
if (!target) return;
|
||||
requestTeleport(new Vector3(target.x, target.height + TELEPORT_MARGIN, target.z));
|
||||
});
|
||||
|
||||
return gui;
|
||||
}
|
||||
@@ -32,11 +32,15 @@ function dist(k, position) {
|
||||
}
|
||||
|
||||
export class ChunkStreamer {
|
||||
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a" }) {
|
||||
constructor({ scene, player, materials, props, baseUrl = "./data/vice-a", pinned = [] }) {
|
||||
Object.assign(this, { scene, player, materials, props, baseUrl });
|
||||
this.active = new Map(); // key -> { group, handle }
|
||||
this.pending = new Set();
|
||||
this.queue = [];
|
||||
// Chunks, die #unload nie abbaut (Task 11, Schritt 5): die vier Fahrzeug-Spawnpunkte
|
||||
// liegen darauf, und ein Fahrzeug ohne Boden fällt ins Nichts, sobald der Spieler
|
||||
// sich weit genug entfernt und der normale Radius-Abbau eingreift.
|
||||
this.pinned = new Set(pinned);
|
||||
}
|
||||
|
||||
update(position) {
|
||||
@@ -110,6 +114,7 @@ export class ChunkStreamer {
|
||||
}
|
||||
|
||||
#unload(k) {
|
||||
if (this.pinned.has(k)) return;
|
||||
const entry = this.active.get(k);
|
||||
if (!entry) return;
|
||||
// Ruling 51: collider-lose Chunks (offenes Wasser) haben kein handle abzumelden.
|
||||
|
||||
Reference in new issue
Block a user