diff --git a/AGENTS.md b/AGENTS.md index 348a833..6850272 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=` (ids wie `dragon`, `krake`, `spider`, … siehe Registry in + `viewer.ts`), `&still=1` = statische Pose (nur `updateAnimation`, kein `update`). +- Automatisierte Screenshots: `scripts/model_shot.sh [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 ` → + Crops lesen → iterieren. Keine Cheats, Klicks oder Bosskämpfe nötig. + ## Architektur (web/src) | Datei | Inhalt | diff --git a/scripts/model_shot.sh b/scripts/model_shot.sh new file mode 100755 index 0000000..5cdacde --- /dev/null +++ b/scripts/model_shot.sh @@ -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 [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 diff --git a/web/src/viewer.ts b/web/src/viewer.ts new file mode 100644 index 0000000..9bf9a88 --- /dev/null +++ b/web/src/viewer.ts @@ -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= Modell waehlen +// viewer.html?m=&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 }[] = [ + { 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(); diff --git a/web/viewer.html b/web/viewer.html new file mode 100644 index 0000000..23e7d91 --- /dev/null +++ b/web/viewer.html @@ -0,0 +1,36 @@ + + + + + + Wackelpeter Modell-Viewer + + + +
+

+
?m=<id> Modell · &still=1 statische Pose
+
+ + + diff --git a/web/vite.config.ts b/web/vite.config.ts index 636cc5d..3725df4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ build: { outDir: 'dist', assetsDir: 'assets', + rollupOptions: { + input: { + main: 'index.html', + viewer: 'viewer.html', + }, + }, }, server: { host: true,