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).
|
- `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.
|
- 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)
|
## Architektur (web/src)
|
||||||
|
|
||||||
| Datei | Inhalt |
|
| 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: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
assetsDir: 'assets',
|
assetsDir: 'assets',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: 'index.html',
|
||||||
|
viewer: 'viewer.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user