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,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;
|
||||
}
|
||||
Reference in new issue
Block a user