Add model viewer with automated screenshot script

This commit is contained in:
2026-08-21 11:25:50 +02:00
parent d1ba3fc9c3
commit b05835df74
5 changed files with 384 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
// Modell-Viewer: rendert Gegner-/Boss-Modelle isoliert auf einer Bühne mit
// heller Beleuchtung und Turntable-Kamera — fuer visuelle Modell-Checks ohne
// Spiel-UI, Cheats oder Bosskaempfe. Siehe AGENTS.md "Modell-Viewer".
//
// URL: viewer.html?m=<id> Modell waehlen
// viewer.html?m=<id>&still=1 statische Pose (nur updateAnimation, kein update)
import * as THREE from 'three';
import type { Game } from './Game';
import type { Enemy } from './Enemy';
import { Spider, initSpiderModel } from './Spider';
import { Ghost } from './Ghost';
import { MazeGuard } from './MazeGuard';
import { Frog } from './Frog';
import { Bat } from './Bat';
import { BeeSwarm } from './BeeSwarm';
import { Slime } from './Slime';
import { Brute } from './Brute';
import { Golem } from './Golem';
import { Crab } from './Crab';
import { Turret } from './Turret';
import { Shaman } from './Shaman';
import { Archer } from './Archer';
import { Bomber } from './Bomber';
import { Wisp } from './Wisp';
import { Turtle } from './Turtle';
import { SporeMushroom } from './SporeMushroom';
import { Krake } from './Krake';
import { Minotaurus } from './Minotaurus';
import { SpiderQueen } from './SpiderQueen';
import { FrostGiant } from './FrostGiant';
import { Necromancer } from './Necromancer';
import { Dragon } from './Dragon';
import { Gnome } from './Gnome';
const VIEWER_MODELS: { id: string; create: () => Enemy | Promise<Enemy> }[] = [
{ id: 'spider', create: async () => { await initSpiderModel(); return new Spider(); } },
{ id: 'ghost', create: () => new Ghost() },
{ id: 'frog', create: () => new Frog() },
{ id: 'bat', create: () => new Bat() },
{ id: 'bee', create: () => new BeeSwarm() },
{ id: 'slime', create: () => new Slime() },
{ id: 'brute', create: () => new Brute() },
{ id: 'crab', create: () => new Crab() },
{ id: 'turret', create: () => new Turret() },
{ id: 'shaman', create: () => new Shaman() },
{ id: 'archer', create: () => new Archer() },
{ id: 'bomber', create: () => new Bomber() },
{ id: 'wisp', create: () => new Wisp() },
{ id: 'turtle', create: () => new Turtle() },
{ id: 'spore', create: () => new SporeMushroom() },
{ id: 'golem', create: () => new Golem() },
{ id: 'krake', create: () => new Krake() },
{ id: 'minotaurus', create: () => new Minotaurus() },
{ id: 'spiderqueen', create: async () => { await initSpiderModel(); return new SpiderQueen(); } },
{ id: 'frostgiant', create: () => new FrostGiant() },
{ id: 'necromancer', create: () => new Necromancer() },
{ id: 'dragon', create: () => new Dragon() },
{ id: 'gnome', create: () => new Gnome(250, 90) },
{ id: 'mazeguard', create: () => new MazeGuard() },
];
// --- Buehne ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a2030);
const camera = new THREE.PerspectiveCamera(
50, window.innerWidth / window.innerHeight, 0.1, 300
);
// Bewusst heller als das Spiel, damit Formen gut sichtbar sind
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(8, 14, 6);
dirLight.castShadow = true;
dirLight.shadow.mapSize.set(2048, 2048);
dirLight.shadow.camera.left = -16;
dirLight.shadow.camera.right = 16;
dirLight.shadow.camera.top = 16;
dirLight.shadow.camera.bottom = -16;
scene.add(dirLight);
const ground = new THREE.Mesh(
new THREE.CircleGeometry(40, 48),
new THREE.MeshLambertMaterial({ color: 0x2a3a2a })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);
const grid = new THREE.GridHelper(80, 80, 0x44608a, 0x334455);
grid.position.y = 0.02;
scene.add(grid);
// --- Stub-Spieler (8 m entfernt: Blick-/Angriffsziel) ---
// Nur ein flacher Boden-Ring als Markierung — eine Kugel wuerde die Sicht
// auf Modelle verdecken, wenn Mobs hineingelaufen kommen.
const stubPlayer = {
body: new THREE.Group(),
health: 1000,
pullTime: 0,
pullDir: new THREE.Vector3(),
stunTime: 0,
rootTime: 0,
rootCooldown: 0,
slowTimer: 0,
damage: () => {},
};
stubPlayer.body.position.set(0, 0, 8);
const playerMarker = new THREE.Mesh(
new THREE.RingGeometry(0.5, 0.8, 24),
new THREE.MeshBasicMaterial({ color: 0x44aa44, side: THREE.DoubleSide })
);
playerMarker.rotation.x = -Math.PI / 2;
playerMarker.position.y = 0.05;
stubPlayer.body.add(playerMarker);
scene.add(stubPlayer.body);
// --- Stub-Game: deckt die game.*-Oberflaeche aller Gegner ab (per grep ermittelt) ---
const stubGame = {
scene,
player: stubPlayer,
enemies: [],
traps: [],
gasClouds: [],
fireballs: [],
slashEffects: [],
maze: null,
groundPoint: stubPlayer.body.position,
clampToArena: (v: THREE.Vector3) => v,
triggerCameraShake: () => {},
triggerInkVignette: () => {},
spawnDamageText: () => {},
spawnEnemyProjectile: () => {},
spawnEnemyInstance: () => {},
removeEnemy: () => {},
onDamageDealt: () => {},
onPlayerHurt: () => {},
destroyProjectilesInArc: () => {},
} as unknown as Game;
// --- Modell-Wechsel + Turntable ---
const params = new URLSearchParams(location.search);
const still = params.has('still');
let current: Enemy | null = null;
let orbitAngle = 0;
let fitRadius = 8;
let fitHeight = 2;
let fitCenterY = 1;
let fitTimer = 0;
let frame = 0;
const nameEl = document.getElementById('name')!;
const uiEl = document.getElementById('ui')!;
function refit() {
if (!current) return;
// Auto-Fit nur ueber Modell-Meshes: grosse Effekt-Geometrien (Heil-Ring,
// Telegrafen) wuerden sonst die Kamera-Distanz dominieren
const box = new THREE.Box3();
const tmp = new THREE.Box3();
current.body.updateMatrixWorld(true);
current.body.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
if (!obj.geometry.boundingSphere) obj.geometry.computeBoundingSphere();
if ((obj.geometry.boundingSphere?.radius ?? 0) < 5) {
tmp.setFromObject(obj);
box.union(tmp);
}
}
});
if (box.isEmpty()) return;
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z, 2);
fitRadius = maxDim * 1.15 + 3;
// deutlich erhoehte Kamera, damit Fluegel/Flaechen nicht kantenzeitig wirken
fitHeight = center.y + maxDim * 1.0;
fitCenterY = center.y;
}
async function show(id: string) {
const entry = VIEWER_MODELS.find((m) => m.id === id) ?? VIEWER_MODELS[0];
if (current) {
try {
current.dispose();
} catch {
// dispose ist nur Kosmetik
}
scene.remove(current.body);
current = null;
}
current = await entry.create();
scene.add(current.body);
refit();
nameEl.textContent = current.displayName || entry.id;
history.replaceState(null, '', `?m=${entry.id}${still ? '&still=1' : ''}`);
for (const btn of uiEl.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.id === entry.id);
}
}
for (const m of VIEWER_MODELS) {
const btn = document.createElement('button');
btn.textContent = m.id;
btn.dataset.id = m.id;
btn.addEventListener('click', () => show(m.id));
uiEl.appendChild(btn);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// --- Loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
if (current) {
if (!still) current.update(dt, stubGame);
current.updateAnimation(dt);
// Auto-Fit alle 30 Frames nachjustieren (States aendern die Ausdehnung)
fitTimer += dt;
if (frame % 30 === 0 || fitTimer > 2) {
fitTimer = 0;
refit();
}
orbitAngle += dt * 0.35;
const c = current.body.position;
camera.position.set(
c.x + Math.sin(orbitAngle) * fitRadius,
fitHeight,
c.z + Math.cos(orbitAngle) * fitRadius
);
camera.lookAt(c.x, fitCenterY * 0.85, c.z);
}
frame++;
renderer.render(scene, camera);
}
show(params.get('m') ?? 'dragon');
animate();
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wackelpeter Modell-Viewer</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #1a2030; }
canvas { display: block; }
#ui {
position: absolute; top: 10px; left: 10px; max-width: 320px;
display: flex; flex-wrap: wrap; gap: 4px; align-content: flex-start;
font-family: monospace; pointer-events: none;
}
#name {
width: 100%; color: #ffd54a; font-size: 16px; margin: 0 0 6px;
text-shadow: 0 1px 2px #000;
}
#ui button {
pointer-events: auto; cursor: pointer; font-family: monospace; font-size: 11px;
color: #dfe8ff; background: #2a3550cc; border: 1px solid #4a5a80;
padding: 3px 8px; border-radius: 3px;
}
#ui button:hover { background: #3a4a70; }
#ui button.active { background: #6a5a20; border-color: #ffd54a; }
#hint { width: 100%; color: #8899bb; font-size: 11px; margin-top: 6px; }
</style>
</head>
<body>
<div id="ui">
<h1 id="name"></h1>
<div id="hint">?m=&lt;id&gt; Modell · &amp;still=1 statische Pose</div>
</div>
<script type="module" src="/src/viewer.ts"></script>
</body>
</html>
+6
View File
@@ -5,6 +5,12 @@ export default defineConfig({
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
input: {
main: 'index.html',
viewer: 'viewer.html',
},
},
},
server: {
host: true,