Add model viewer with automated screenshot script
This commit is contained in:
@@ -19,6 +19,22 @@ npm run dev # Dev-Server mit HMR
|
||||
- `Makefile`: `make web`/`make dev`/`make build` (Container-Build via Podman).
|
||||
- UI-Sprache: Deutsch (Skill-Namen, Level-up-Karten); Code-Kommentare bisher gemischt, Git-Messages englisch.
|
||||
|
||||
## Modell-Viewer (visuelle Modell-Checks)
|
||||
|
||||
- `web/viewer.html` = eigener Vite-Entry (auch im Build): rendert ein Gegner-/Boss-Modell
|
||||
isoliert auf Grid-Bühne mit hellem Licht, Turntable-Kamera (Auto-Fit per Box3) und
|
||||
Mini-UI zum Umschalten. Modelle laufen gegen ein Stub-`Game` (Stub-Spieler als grüner
|
||||
Blob 8 m entfernt als Blick-/Angriffsziel).
|
||||
- URL-Params: `?m=<id>` (ids wie `dragon`, `krake`, `spider`, … siehe Registry in
|
||||
`viewer.ts`), `&still=1` = statische Pose (nur `updateAnimation`, kein `update`).
|
||||
- Automatisierte Screenshots: `scripts/model_shot.sh <id> [shots=4] [intervall=1.5] [still]` —
|
||||
startet eigenen Dev-Server auf Port 3100, öffnet Viewer-Fenster (Titel
|
||||
„Wackelpeter Modell-Viewer"), minimiert die Konsole per KWin, macht `shots`
|
||||
Turntable-Screenshots nach `/tmp/modelshot_*_crop.png`, blendet die Konsole wieder
|
||||
ein, schließt das Viewer-Fenster und stoppt den 3100er-Server. Pfade kommen auf stdout.
|
||||
- Workflow für Modell-Überarbeitung: Code editieren → `scripts/model_shot.sh <id>` →
|
||||
Crops lesen → iterieren. Keine Cheats, Klicks oder Bosskämpfe nötig.
|
||||
|
||||
## Architektur (web/src)
|
||||
|
||||
| Datei | Inhalt |
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Visuelle Modell-Checks fuer Wackelpeter (Bazzite/KDE Wayland).
|
||||
#
|
||||
# Öffnet den Modell-Viewer (web/viewer.html) in einem eigenen Firefox-Fenster,
|
||||
# macht Turntable-Screenshots und räumt danach auf (Fenster zu, Konsole wieder
|
||||
# sichtbar, eigener Dev-Server gestoppt). Die Pfade der Crops gehen auf stdout.
|
||||
#
|
||||
# Usage: scripts/model_shot.sh <modell-id> [shots=4] [intervall-sek=1.5] [still]
|
||||
# modell-id: spider ghost frog bat bee slime brute crab turret shaman archer
|
||||
# bomber wisp turtle spore golem krake minotaurus spiderqueen
|
||||
# frostgiant necromancer dragon gnome mazeguard
|
||||
# still: beliebiges 4. Argument -> statische Pose (&still=1)
|
||||
#
|
||||
# Voraussetzung: maximiertes Firefox-Fenster auf 3440x1440 (feste Crop-Box).
|
||||
set -u
|
||||
|
||||
MODEL="${1:?Modell-ID erforderlich, z.B. dragon}"
|
||||
SHOTS="${2:-4}"
|
||||
INTERVAL="${3:-1.5}"
|
||||
STILL="${4:-}"
|
||||
PORT=3100
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
|
||||
URL="http://localhost:$PORT/viewer.html?m=$MODEL"
|
||||
[ -n "$STILL" ] && URL="$URL&still=1"
|
||||
|
||||
# --- eigener Dev-Server auf 3100 (nicht den Nutzer-Server auf 3000 anfassen) ---
|
||||
started_server=0
|
||||
if ! ss -tln | grep -q ":$PORT "; then
|
||||
(cd web && npx vite --port "$PORT" --strictPort > /tmp/viewer-vite.log 2>&1 &)
|
||||
started_server=1
|
||||
for _ in $(seq 1 40); do
|
||||
ss -tln | grep -q ":$PORT " && break
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
|
||||
# --- KWin-Scripte (Fensterverwaltung ohne Fokus-Raten) ---
|
||||
kwin_run() {
|
||||
local name="$1" file="/tmp/qwen_kwin_$1.js"
|
||||
printf '%s\n' "$2" > "$file"
|
||||
qdbus org.kde.KWin /Scripting unloadScript "$name" > /dev/null 2>&1
|
||||
qdbus org.kde.KWin /Scripting loadScript "$file" "$name" > /dev/null
|
||||
qdbus org.kde.KWin /Scripting start > /dev/null
|
||||
}
|
||||
|
||||
kwin_run viewer_min 'for (const c of workspace.windowList()) { if (c.resourceClass.toLowerCase().includes("konsole")) c.minimized = true; }'
|
||||
|
||||
flatpak run org.mozilla.firefox --new-window "$URL" > /dev/null 2>&1 &
|
||||
sleep 6
|
||||
|
||||
# --- Screenshots + feste Crop-Box (Mitte des maximierten Fensters) ---
|
||||
for i in $(seq 1 "$SHOTS"); do
|
||||
# Viewer-Fenster vor jedem Shot nach vorn holen (sonst kapert ein anderes
|
||||
# Fenster den Desktop-Screenshot)
|
||||
kwin_run viewer_raise 'for (const c of workspace.windowList()) { if (c.caption.includes("Modell-Viewer")) { c.minimized = false; workspace.activeWindow = c; } }'
|
||||
sleep 0.4
|
||||
spectacle -b -n -o "/tmp/modelshot_$i.png"
|
||||
magick "/tmp/modelshot_$i.png" -crop 2200x1200+600+100 +repage "/tmp/modelshot_${i}_crop.png"
|
||||
echo "/tmp/modelshot_${i}_crop.png"
|
||||
[ "$i" -lt "$SHOTS" ] && sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
# --- Aufräumen: Konsole einblenden, Viewer-Fenster schließen ---
|
||||
kwin_run viewer_restore 'for (const c of workspace.windowList()) { const cls = c.resourceClass.toLowerCase(); if (cls.includes("konsole")) c.minimized = false; if (c.caption.includes("Modell-Viewer")) c.closeWindow(); }'
|
||||
|
||||
if [ "$started_server" = 1 ]; then
|
||||
PID=$(ss -tlnp | grep ":$PORT " | grep -oP 'pid=\K[0-9]+' | head -1)
|
||||
[ -n "${PID:-}" ] && kill "$PID" 2> /dev/null
|
||||
fi
|
||||
@@ -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();
|
||||
@@ -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=<id> Modell · &still=1 statische Pose</div>
|
||||
</div>
|
||||
<script type="module" src="/src/viewer.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,12 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'index.html',
|
||||
viewer: 'viewer.html',
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
|
||||
Reference in New Issue
Block a user