Compare commits
27 Commits
falukant-3
...
cd739fb52e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd739fb52e | ||
|
|
9e845843d8 | ||
|
|
0cc280ed55 | ||
|
|
b3707d21b2 | ||
|
|
fbebd6c1c1 | ||
|
|
d7c2bda461 | ||
|
|
2bf949513b | ||
|
|
84619fb656 | ||
|
|
b600f16ecd | ||
|
|
9273066f61 | ||
|
|
7d59dbcf84 | ||
|
|
015d1ae95b | ||
|
|
e2cd6e0e5e | ||
|
|
ec113058d0 | ||
|
|
d2ac2bfdd8 | ||
|
|
d75fe18e6a | ||
|
|
479f222b54 | ||
|
|
013c536b47 | ||
|
|
3b983a0db5 | ||
|
|
5f9559ac8d | ||
|
|
f487e6d765 | ||
|
|
5e26422e9c | ||
|
|
64baebfaaa | ||
|
|
521dec24b2 | ||
|
|
36f0bd8eb9 | ||
|
|
d0a2b122b2 | ||
|
|
c80cc8ec86 |
@@ -162,6 +162,18 @@ class FalukantController {
|
|||||||
}
|
}
|
||||||
return this.service.getProductPriceInRegion(userId, productId, regionId);
|
return this.service.getProductPriceInRegion(userId, productId, regionId);
|
||||||
});
|
});
|
||||||
|
this.getProductPricesInRegionBatch = this._wrapWithUser((userId, req) => {
|
||||||
|
const productIds = req.query.productIds;
|
||||||
|
const regionId = parseInt(req.query.regionId, 10);
|
||||||
|
if (!productIds || Number.isNaN(regionId)) {
|
||||||
|
throw new Error('productIds (comma-separated) and regionId are required');
|
||||||
|
}
|
||||||
|
const productIdArray = productIds.split(',').map(id => parseInt(id.trim(), 10)).filter(id => !Number.isNaN(id));
|
||||||
|
if (productIdArray.length === 0) {
|
||||||
|
throw new Error('At least one valid productId is required');
|
||||||
|
}
|
||||||
|
return this.service.getProductPricesInRegionBatch(userId, productIdArray, regionId);
|
||||||
|
});
|
||||||
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
|
this.getProductPricesInCities = this._wrapWithUser((userId, req) => {
|
||||||
const productId = parseInt(req.query.productId, 10);
|
const productId = parseInt(req.query.productId, 10);
|
||||||
const currentPrice = parseFloat(req.query.currentPrice);
|
const currentPrice = parseFloat(req.query.currentPrice);
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ function createServer() {
|
|||||||
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
||||||
});
|
});
|
||||||
wss = new WebSocketServer({ server: httpsServer });
|
wss = new WebSocketServer({ server: httpsServer });
|
||||||
|
// Direkte Verbindung: lausche auf allen Interfaces (0.0.0.0)
|
||||||
httpsServer.listen(PORT, '0.0.0.0', () => {
|
httpsServer.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
|
console.log(`[Daemon] WSS (TLS) Server gestartet auf Port ${PORT}`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
wss = new WebSocketServer({ port: PORT });
|
// Direkte Verbindung: lausche auf allen Interfaces (0.0.0.0)
|
||||||
|
wss = new WebSocketServer({ port: PORT, host: '0.0.0.0' });
|
||||||
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
|
console.log(`[Daemon] WS (ohne TLS) Server startet auf Port ${PORT} ...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Rollback: Remove indexes for director proposals and character queries
|
||||||
|
-- Created: 2026-01-12
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_character_region_user_created;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_character_region_user;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_character_user_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_director_proposal_employer_character;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_director_character_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_director_employer_user_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_knowledge_character_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_relationship_character1_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_child_relation_father_id;
|
||||||
|
DROP INDEX IF EXISTS falukant_data.idx_child_relation_mother_id;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Migration: Add indexes for director proposals and character queries
|
||||||
|
-- Created: 2026-01-12
|
||||||
|
|
||||||
|
-- Index für schnelle Suche nach NPCs in einer Region (mit Altersbeschränkung)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_character_region_user_created
|
||||||
|
ON falukant_data.character (region_id, user_id, created_at)
|
||||||
|
WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
-- Index für schnelle Suche nach NPCs ohne Altersbeschränkung
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_character_region_user
|
||||||
|
ON falukant_data.character (region_id, user_id)
|
||||||
|
WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
-- Index für Character-Suche nach user_id (wichtig für getFamily, getDirectorForBranch)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_character_user_id
|
||||||
|
ON falukant_data.character (user_id);
|
||||||
|
|
||||||
|
-- Index für Director-Proposals
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_director_proposal_employer_character
|
||||||
|
ON falukant_data.director_proposal (employer_user_id, director_character_id);
|
||||||
|
|
||||||
|
-- Index für aktive Direktoren
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_director_character_id
|
||||||
|
ON falukant_data.director (director_character_id);
|
||||||
|
|
||||||
|
-- Index für Director-Suche nach employer_user_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_director_employer_user_id
|
||||||
|
ON falukant_data.director (employer_user_id);
|
||||||
|
|
||||||
|
-- Index für Knowledge-Berechnung
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_knowledge_character_id
|
||||||
|
ON falukant_data.knowledge (character_id);
|
||||||
|
|
||||||
|
-- Index für Relationships (getFamily)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relationship_character1_id
|
||||||
|
ON falukant_data.relationship (character1_id);
|
||||||
|
|
||||||
|
-- Index für ChildRelations (getFamily)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_child_relation_father_id
|
||||||
|
ON falukant_data.child_relation (father_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_child_relation_mother_id
|
||||||
|
ON falukant_data.child_relation (mother_id);
|
||||||
@@ -76,6 +76,7 @@ router.get('/politics/open', falukantController.getOpenPolitics);
|
|||||||
router.post('/politics/open', falukantController.applyForElections);
|
router.post('/politics/open', falukantController.applyForElections);
|
||||||
router.get('/cities', falukantController.getRegions);
|
router.get('/cities', falukantController.getRegions);
|
||||||
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
router.get('/products/price-in-region', falukantController.getProductPriceInRegion);
|
||||||
|
router.get('/products/prices-in-region-batch', falukantController.getProductPricesInRegionBatch);
|
||||||
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
router.get('/products/prices-in-cities', falukantController.getProductPricesInCities);
|
||||||
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
router.get('/branches/:branchId/taxes', falukantController.getBranchTaxes);
|
||||||
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||||
|
|||||||
@@ -1,19 +1,55 @@
|
|||||||
import './config/loadEnv.js'; // .env deterministisch laden
|
import './config/loadEnv.js'; // .env deterministisch laden
|
||||||
|
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import fs from 'fs';
|
||||||
import app from './app.js';
|
import app from './app.js';
|
||||||
import { setupWebSocket } from './utils/socket.js';
|
import { setupWebSocket } from './utils/socket.js';
|
||||||
import { syncDatabase } from './utils/syncDatabase.js';
|
import { syncDatabase } from './utils/syncDatabase.js';
|
||||||
|
|
||||||
const server = http.createServer(app);
|
// HTTP-Server für API (Port 2020, intern, über Apache-Proxy)
|
||||||
|
const API_PORT = Number.parseInt(process.env.PORT || '2020', 10);
|
||||||
|
const httpServer = http.createServer(app);
|
||||||
|
// Socket.io wird nur auf HTTPS-Server bereitgestellt, nicht auf HTTP-Server
|
||||||
|
// setupWebSocket(httpServer); // Entfernt: Socket.io nur über HTTPS
|
||||||
|
|
||||||
setupWebSocket(server);
|
// HTTPS-Server für Socket.io (Port 4443, direkt erreichbar)
|
||||||
|
let httpsServer = null;
|
||||||
|
const SOCKET_IO_PORT = Number.parseInt(process.env.SOCKET_IO_PORT || '4443', 10);
|
||||||
|
const USE_TLS = process.env.SOCKET_IO_TLS === '1';
|
||||||
|
const TLS_KEY_PATH = process.env.SOCKET_IO_TLS_KEY_PATH;
|
||||||
|
const TLS_CERT_PATH = process.env.SOCKET_IO_TLS_CERT_PATH;
|
||||||
|
const TLS_CA_PATH = process.env.SOCKET_IO_TLS_CA_PATH;
|
||||||
|
|
||||||
|
if (USE_TLS && TLS_KEY_PATH && TLS_CERT_PATH) {
|
||||||
|
try {
|
||||||
|
httpsServer = https.createServer({
|
||||||
|
key: fs.readFileSync(TLS_KEY_PATH),
|
||||||
|
cert: fs.readFileSync(TLS_CERT_PATH),
|
||||||
|
ca: TLS_CA_PATH ? fs.readFileSync(TLS_CA_PATH) : undefined,
|
||||||
|
}, app);
|
||||||
|
setupWebSocket(httpsServer);
|
||||||
|
console.log(`[Socket.io] HTTPS-Server für Socket.io konfiguriert auf Port ${SOCKET_IO_PORT}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Socket.io] Fehler beim Laden der TLS-Zertifikate:', err.message);
|
||||||
|
console.error('[Socket.io] Socket.io wird nicht verfügbar sein');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('[Socket.io] TLS nicht konfiguriert - Socket.io wird nicht verfügbar sein');
|
||||||
|
}
|
||||||
|
|
||||||
syncDatabase().then(() => {
|
syncDatabase().then(() => {
|
||||||
const port = process.env.PORT || 3001;
|
// API-Server auf Port 2020 (intern, nur localhost)
|
||||||
server.listen(port, () => {
|
httpServer.listen(API_PORT, '127.0.0.1', () => {
|
||||||
console.log('Server is running on port', port);
|
console.log(`[API] HTTP-Server läuft auf localhost:${API_PORT} (intern, über Apache-Proxy)`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Socket.io-Server auf Port 4443 (extern, direkt erreichbar)
|
||||||
|
if (httpsServer) {
|
||||||
|
httpsServer.listen(SOCKET_IO_PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[Socket.io] HTTPS-Server läuft auf Port ${SOCKET_IO_PORT} (direkt erreichbar)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to sync database:', err);
|
console.error('Failed to sync database:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
45
check-apache-websocket.sh
Executable file
45
check-apache-websocket.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== Apache WebSocket-Konfiguration prüfen ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe, welche Module aktiviert sind
|
||||||
|
echo "Aktivierte Apache-Module:"
|
||||||
|
apache2ctl -M 2>/dev/null | grep -E "(proxy|rewrite|ssl|headers)" || echo "Keine relevanten Module gefunden"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe, ob die benötigten Module aktiviert sind
|
||||||
|
REQUIRED_MODULES=("proxy" "proxy_http" "proxy_wstunnel" "rewrite" "ssl" "headers")
|
||||||
|
MISSING_MODULES=()
|
||||||
|
|
||||||
|
for module in "${REQUIRED_MODULES[@]}"; do
|
||||||
|
if ! apache2ctl -M 2>/dev/null | grep -q "${module}_module"; then
|
||||||
|
MISSING_MODULES+=("$module")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ${#MISSING_MODULES[@]} -eq 0 ]; then
|
||||||
|
echo "✅ Alle benötigten Module sind aktiviert"
|
||||||
|
else
|
||||||
|
echo "❌ Fehlende Module:"
|
||||||
|
for module in "${MISSING_MODULES[@]}"; do
|
||||||
|
echo " - $module"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Aktivieren mit:"
|
||||||
|
for module in "${MISSING_MODULES[@]}"; do
|
||||||
|
echo " sudo a2enmod $module"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Apache-Konfiguration testen ==="
|
||||||
|
if sudo apache2ctl configtest 2>&1; then
|
||||||
|
echo "✅ Apache-Konfiguration ist gültig"
|
||||||
|
else
|
||||||
|
echo "❌ Apache-Konfiguration hat Fehler"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Aktive VirtualHosts ==="
|
||||||
|
apache2ctl -S 2>/dev/null | grep -E "(443|4443|4551)" || echo "Keine relevanten VirtualHosts gefunden"
|
||||||
35
debug-websocket-headers.sh
Executable file
35
debug-websocket-headers.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== WebSocket-Header Debug ==="
|
||||||
|
echo ""
|
||||||
|
echo "Prüfe Apache-Logs für WebSocket-Upgrade-Header..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe die letzten 50 Zeilen des Access-Logs für /ws/ oder /socket.io/
|
||||||
|
echo "Access-Log Einträge für /ws/ und /socket.io/:"
|
||||||
|
sudo tail -50 /var/log/apache2/yourpart.access.log | grep -E "(/ws/|/socket.io/)" | tail -10
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Prüfe Error-Log für WebSocket-Fehler:"
|
||||||
|
sudo tail -50 /var/log/apache2/yourpart.error.log | grep -iE "(websocket|upgrade|proxy)" | tail -10
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Test mit curl ==="
|
||||||
|
echo ""
|
||||||
|
echo "Teste WebSocket-Upgrade für /ws/:"
|
||||||
|
curl -i -N \
|
||||||
|
-H "Connection: Upgrade" \
|
||||||
|
-H "Upgrade: websocket" \
|
||||||
|
-H "Sec-WebSocket-Version: 13" \
|
||||||
|
-H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \
|
||||||
|
https://www.your-part.de/ws/ 2>&1 | head -20
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Prüfe Apache-Konfiguration ==="
|
||||||
|
echo ""
|
||||||
|
echo "Aktive Rewrite-Regeln für WebSocket:"
|
||||||
|
sudo apache2ctl -S 2>/dev/null | grep -A 5 "your-part.de:443" || echo "VirtualHost nicht gefunden"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Prüfe, ob mod_proxy_wstunnel aktiviert ist:"
|
||||||
|
apache2ctl -M 2>/dev/null | grep proxy_wstunnel || echo "mod_proxy_wstunnel NICHT aktiviert!"
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
# 3D-Animationen im Falukant-Bereich
|
|
||||||
|
|
||||||
## Benötigte Dependencies
|
|
||||||
|
|
||||||
### Three.js (Empfohlen)
|
|
||||||
```bash
|
|
||||||
npm install three
|
|
||||||
npm install @types/three --save-dev # Für TypeScript-Support
|
|
||||||
```
|
|
||||||
|
|
||||||
**Alternative Optionen:**
|
|
||||||
- **Babylon.js**: Mächtiger, aber größer (~500KB vs ~600KB)
|
|
||||||
- **A-Frame**: WebVR-fokussiert, einfacher für VR/AR
|
|
||||||
- **React Three Fiber**: Falls React verwendet wird (hier Vue)
|
|
||||||
|
|
||||||
**Empfehlung: Three.js** - am weitesten verbreitet, beste Dokumentation, große Community
|
|
||||||
|
|
||||||
### Optional: Vue-Three.js Wrapper
|
|
||||||
```bash
|
|
||||||
npm install vue-threejs # Oder troika-three-text für Text-Rendering
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sinnvolle Seiten für 3D-Animationen
|
|
||||||
|
|
||||||
### 1. **OverviewView** (Hauptübersicht)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐⭐⭐
|
|
||||||
- **3D-Charakter-Modell**: Rotierendes 3D-Modell des eigenen Charakters
|
|
||||||
- **Statussymbole**: 3D-Icons für Geld, Gesundheit, Reputation (schwebend/rotierend)
|
|
||||||
- **Hintergrund**: Subtile 3D-Szene (z.B. mittelalterliche Stadt im Hintergrund)
|
|
||||||
|
|
||||||
### 2. **HouseView** (Haus)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐⭐⭐
|
|
||||||
- **3D-Haus-Modell**: Interaktives 3D-Modell des eigenen Hauses
|
|
||||||
- **Upgrade-Visualisierung**: Animation beim Haus-Upgrade
|
|
||||||
- **Zustand-Anzeige**: 3D-Visualisierung von Dach, Wänden, Boden, Fenstern
|
|
||||||
|
|
||||||
### 3. **BranchView** (Niederlassungen)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐⭐
|
|
||||||
- **3D-Fabrik/Gebäude**: 3D-Modell der Niederlassung
|
|
||||||
- **Produktions-Animation**: 3D-Animationen für laufende Produktionen
|
|
||||||
- **Transport-Visualisierung**: 3D-Wagen/Karren für Transporte
|
|
||||||
|
|
||||||
### 4. **FamilyView** (Familie)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐⭐
|
|
||||||
- **3D-Charaktere**: 3D-Modelle von Partner und Kindern
|
|
||||||
- **Beziehungs-Visualisierung**: 3D-Animationen für Beziehungsstatus
|
|
||||||
- **Geschenk-Animation**: 3D-Animation beim Verschenken
|
|
||||||
|
|
||||||
### 5. **HealthView** (Gesundheit)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐
|
|
||||||
- **3D-Körper-Modell**: 3D-Visualisierung des Gesundheitszustands
|
|
||||||
- **Aktivitäts-Animationen**: 3D-Animationen für Gesundheitsaktivitäten
|
|
||||||
|
|
||||||
### 6. **NobilityView** (Sozialstatus)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐
|
|
||||||
- **3D-Wappen**: Rotierendes 3D-Wappen
|
|
||||||
- **Insignien**: 3D-Krone, Schwert, etc. je nach Titel
|
|
||||||
|
|
||||||
### 7. **ChurchView** (Kirche)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐
|
|
||||||
- **3D-Kirche**: 3D-Modell der Kirche
|
|
||||||
- **Taufe-Animation**: 3D-Animation bei der Taufe
|
|
||||||
|
|
||||||
### 8. **BankView** (Bank)
|
|
||||||
**Sinnvoll:** ⭐⭐
|
|
||||||
- **3D-Bankgebäude**: 3D-Modell der Bank
|
|
||||||
- **Geld-Animation**: 3D-Münzen/Geldstapel
|
|
||||||
|
|
||||||
### 9. **UndergroundView** (Untergrund)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐⭐
|
|
||||||
- **3D-Dungeon**: 3D-Untergrund-Visualisierung
|
|
||||||
- **Aktivitäts-Animationen**: 3D-Animationen für Untergrund-Aktivitäten
|
|
||||||
|
|
||||||
### 10. **ReputationView** (Reputation)
|
|
||||||
**Sinnvoll:** ⭐⭐⭐
|
|
||||||
- **3D-Party-Szene**: 3D-Visualisierung von Festen
|
|
||||||
- **Reputation-Visualisierung**: 3D-Effekte für Reputationsänderungen
|
|
||||||
|
|
||||||
## Implementierungs-Strategie
|
|
||||||
|
|
||||||
### Phase 1: Basis-Setup
|
|
||||||
1. Three.js installieren
|
|
||||||
2. Basis-Komponente `ThreeScene.vue` erstellen
|
|
||||||
3. Erste einfache Animation (z.B. rotierender Würfel) auf OverviewView
|
|
||||||
|
|
||||||
### Phase 2: Charakter-Modell
|
|
||||||
1. 3D-Charakter-Modell erstellen/laden (GLTF/GLB)
|
|
||||||
2. Auf OverviewView integrieren
|
|
||||||
3. Interaktionen (Klick, Hover)
|
|
||||||
|
|
||||||
### Phase 3: Gebäude-Modelle
|
|
||||||
1. Haus-Modell für HouseView
|
|
||||||
2. Fabrik-Modell für BranchView
|
|
||||||
3. Kirche-Modell für ChurchView
|
|
||||||
|
|
||||||
### Phase 4: Animationen
|
|
||||||
1. Upgrade-Animationen
|
|
||||||
2. Status-Änderungs-Animationen
|
|
||||||
3. Interaktive Elemente
|
|
||||||
|
|
||||||
## Technische Überlegungen
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- **Lazy Loading**: 3D-Szenen nur laden, wenn Seite aktiv ist
|
|
||||||
- **Level of Detail (LOD)**: Einfache Modelle für schwächere Geräte
|
|
||||||
- **WebGL-Detection**: Fallback auf 2D, wenn WebGL nicht unterstützt wird
|
|
||||||
|
|
||||||
### Asset-Management
|
|
||||||
- **GLTF/GLB**: Kompaktes Format für 3D-Modelle
|
|
||||||
- **Texturen**: Optimiert für Web (WebP, komprimiert)
|
|
||||||
- **CDN**: Assets über CDN laden für bessere Performance
|
|
||||||
|
|
||||||
### Browser-Kompatibilität
|
|
||||||
- **WebGL 1.0**: Mindestanforderung (95%+ Browser)
|
|
||||||
- **WebGL 2.0**: Optional für bessere Features
|
|
||||||
- **Fallback**: 2D-Versionen für ältere Browser
|
|
||||||
|
|
||||||
## Beispiel-Struktur
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/src/
|
|
||||||
components/
|
|
||||||
falukant/
|
|
||||||
ThreeScene.vue # Basis-3D-Szene-Komponente
|
|
||||||
CharacterModel.vue # 3D-Charakter-Komponente
|
|
||||||
BuildingModel.vue # 3D-Gebäude-Komponente
|
|
||||||
assets/
|
|
||||||
3d/
|
|
||||||
models/
|
|
||||||
character.glb
|
|
||||||
house.glb
|
|
||||||
factory.glb
|
|
||||||
textures/
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nächste Schritte
|
|
||||||
|
|
||||||
1. **Three.js installieren**
|
|
||||||
2. **Basis-Komponente erstellen**
|
|
||||||
3. **Erste Animation auf OverviewView testen**
|
|
||||||
4. **3D-Modelle erstellen/beschaffen** (Blender, Sketchfab, etc.)
|
|
||||||
5. **Schrittweise auf weitere Seiten ausweiten**
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
# 3D-Assets Struktur für Falukant
|
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/public/
|
|
||||||
models/
|
|
||||||
3d/
|
|
||||||
falukant/
|
|
||||||
characters/
|
|
||||||
male.glb # Basis-Modell männlich
|
|
||||||
female.glb # Basis-Modell weiblich
|
|
||||||
male_child.glb # Männlich, Kind (0-9 Jahre)
|
|
||||||
male_teen.glb # Männlich, Teenager (10-17 Jahre)
|
|
||||||
male_adult.glb # Männlich, Erwachsen (18-39 Jahre)
|
|
||||||
male_middle.glb # Männlich, Mittelalter (40-59 Jahre)
|
|
||||||
male_elder.glb # Männlich, Älter (60+ Jahre)
|
|
||||||
female_child.glb # Weiblich, Kind
|
|
||||||
female_teen.glb # Weiblich, Teenager
|
|
||||||
female_adult.glb # Weiblich, Erwachsen
|
|
||||||
female_middle.glb # Weiblich, Mittelalter
|
|
||||||
female_elder.glb # Weiblich, Älter
|
|
||||||
buildings/
|
|
||||||
house/
|
|
||||||
house_small.glb # Kleines Haus
|
|
||||||
house_medium.glb # Mittleres Haus
|
|
||||||
house_large.glb # Großes Haus
|
|
||||||
factory/
|
|
||||||
factory_basic.glb # Basis-Fabrik
|
|
||||||
factory_advanced.glb # Erweiterte Fabrik
|
|
||||||
church/
|
|
||||||
church.glb # Kirche
|
|
||||||
bank/
|
|
||||||
bank.glb # Bank
|
|
||||||
objects/
|
|
||||||
weapons/
|
|
||||||
sword.glb
|
|
||||||
shield.glb
|
|
||||||
items/
|
|
||||||
coin.glb
|
|
||||||
gift.glb
|
|
||||||
effects/
|
|
||||||
particles/
|
|
||||||
money.glb # Geld-Effekt
|
|
||||||
health.glb # Gesundheits-Effekt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Namenskonventionen
|
|
||||||
|
|
||||||
### Charaktere
|
|
||||||
- Format: `{gender}[_{ageRange}].glb`
|
|
||||||
- Beispiele:
|
|
||||||
- `male.glb` - Basis-Modell männlich (Fallback)
|
|
||||||
- `female.glb` - Basis-Modell weiblich (Fallback)
|
|
||||||
- `male_adult.glb` - Männlich, Erwachsen
|
|
||||||
- `female_teen.glb` - Weiblich, Teenager
|
|
||||||
|
|
||||||
### Gebäude
|
|
||||||
- Format: `{buildingType}_{variant}.glb`
|
|
||||||
- Beispiele:
|
|
||||||
- `house_small.glb`
|
|
||||||
- `factory_basic.glb`
|
|
||||||
- `church.glb`
|
|
||||||
|
|
||||||
### Objekte
|
|
||||||
- Format: `{category}/{item}.glb`
|
|
||||||
- Beispiele:
|
|
||||||
- `weapons/sword.glb`
|
|
||||||
- `items/coin.glb`
|
|
||||||
|
|
||||||
## Altersbereiche
|
|
||||||
|
|
||||||
Die Altersbereiche werden automatisch bestimmt:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In CharacterModel3D.vue
|
|
||||||
getAgeRange(age) {
|
|
||||||
if (age < 10) return 'child';
|
|
||||||
if (age < 18) return 'teen';
|
|
||||||
if (age < 40) return 'adult';
|
|
||||||
if (age < 60) return 'middle';
|
|
||||||
return 'elder';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fallback-Verhalten:**
|
|
||||||
- Wenn kein spezifisches Modell für den Altersbereich existiert, wird das Basis-Modell (`male.glb` / `female.glb`) verwendet
|
|
||||||
- Dies ermöglicht schrittweise Erweiterung ohne Breaking Changes
|
|
||||||
|
|
||||||
## Dateigrößen-Empfehlungen
|
|
||||||
|
|
||||||
- **Charaktere**: 100KB - 500KB (komprimiert)
|
|
||||||
- **Gebäude**: 200KB - 1MB (komprimiert)
|
|
||||||
- **Objekte**: 10KB - 100KB (komprimiert)
|
|
||||||
|
|
||||||
## Optimierung
|
|
||||||
|
|
||||||
### Vor dem Hochladen:
|
|
||||||
1. **Blender** öffnen
|
|
||||||
2. **Decimate Modifier** anwenden (falls nötig)
|
|
||||||
3. **Texturen komprimieren** (WebP, max 1024x1024)
|
|
||||||
4. **GLB Export** mit:
|
|
||||||
- Compression aktiviert
|
|
||||||
- Texturen eingebettet
|
|
||||||
- Unnötige Animationen entfernt
|
|
||||||
|
|
||||||
### Komprimierung:
|
|
||||||
- Verwende `gltf-pipeline` oder `gltf-transform` für weitere Komprimierung
|
|
||||||
- Ziel: < 500KB pro Modell
|
|
||||||
|
|
||||||
## Verwendung im Code
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- CharacterModel3D.vue -->
|
|
||||||
<CharacterModel3D
|
|
||||||
:gender="character.gender"
|
|
||||||
:age="character.age"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Automatisch wird geladen: -->
|
|
||||||
<!-- /models/3d/falukant/characters/male_adult.glb -->
|
|
||||||
<!-- Falls nicht vorhanden: male.glb -->
|
|
||||||
```
|
|
||||||
|
|
||||||
## Erweiterte Struktur (Optional)
|
|
||||||
|
|
||||||
Für komplexere Szenarien:
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/public/
|
|
||||||
models/
|
|
||||||
3d/
|
|
||||||
falukant/
|
|
||||||
characters/
|
|
||||||
{gender}/
|
|
||||||
base/
|
|
||||||
{gender}.glb # Basis-Modell
|
|
||||||
ages/
|
|
||||||
{gender}_{ageRange}.glb
|
|
||||||
variants/
|
|
||||||
{gender}_{variant}.glb # Z.B. verschiedene Outfits
|
|
||||||
```
|
|
||||||
|
|
||||||
## Wartung
|
|
||||||
|
|
||||||
### Neue Modelle hinzufügen:
|
|
||||||
1. GLB-Datei in entsprechendes Verzeichnis kopieren
|
|
||||||
2. Namenskonvention beachten
|
|
||||||
3. Dateigröße prüfen (< 500KB empfohlen)
|
|
||||||
4. Im Browser testen
|
|
||||||
|
|
||||||
### Modelle aktualisieren:
|
|
||||||
1. Alte Datei ersetzen
|
|
||||||
2. Browser-Cache leeren (oder Versionierung verwenden)
|
|
||||||
3. Testen
|
|
||||||
|
|
||||||
### Versionierung (Optional):
|
|
||||||
```
|
|
||||||
characters/
|
|
||||||
v1/
|
|
||||||
male.glb
|
|
||||||
v2/
|
|
||||||
male.glb
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance-Tipps
|
|
||||||
|
|
||||||
1. **Lazy Loading**: Modelle nur laden, wenn benötigt
|
|
||||||
2. **Preloading**: Wichtige Modelle vorladen
|
|
||||||
3. **Caching**: Browser-Cache nutzen
|
|
||||||
4. **CDN**: Für Produktion CDN verwenden
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# 3D-Modell-Erstellung für Falukant
|
|
||||||
|
|
||||||
## KI-basierte Tools (Empfohlen)
|
|
||||||
|
|
||||||
### 1. **Rodin** ⭐⭐⭐⭐⭐
|
|
||||||
- **URL**: https://rodin.io/
|
|
||||||
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
|
|
||||||
- **Features**:
|
|
||||||
- Text-zu-3D (z.B. "medieval character", "house")
|
|
||||||
- Sehr gute Qualität
|
|
||||||
- Export als GLB/GLTF
|
|
||||||
- **Gut für**: Charaktere, Gebäude, Objekte
|
|
||||||
|
|
||||||
### 2. **Meshy** ⭐⭐⭐⭐⭐
|
|
||||||
- **URL**: https://www.meshy.ai/
|
|
||||||
- **Preis**: Kostenlos (mit Limits), ab $9/monat
|
|
||||||
- **Features**:
|
|
||||||
- Text-zu-3D
|
|
||||||
- Bild-zu-3D
|
|
||||||
- Textur-Generierung
|
|
||||||
- Export als GLB/OBJ/FBX
|
|
||||||
- **Gut für**: Alle Arten von Modellen
|
|
||||||
|
|
||||||
### 3. **Luma AI Genie** ⭐⭐⭐⭐
|
|
||||||
- **URL**: https://lumalabs.ai/genie
|
|
||||||
- **Preis**: Kostenlos (Beta)
|
|
||||||
- **Features**:
|
|
||||||
- Text-zu-3D
|
|
||||||
- Sehr schnell
|
|
||||||
- Export als GLB
|
|
||||||
- **Gut für**: Schnelle Prototypen
|
|
||||||
|
|
||||||
### 4. **CSM (Common Sense Machines)** ⭐⭐⭐⭐
|
|
||||||
- **URL**: https://csm.ai/
|
|
||||||
- **Preis**: Kostenlos (mit Limits)
|
|
||||||
- **Features**:
|
|
||||||
- Text-zu-3D
|
|
||||||
- Bild-zu-3D
|
|
||||||
- Export als GLB/USD
|
|
||||||
- **Gut für**: Verschiedene Objekte
|
|
||||||
|
|
||||||
### 5. **Tripo AI** ⭐⭐⭐⭐
|
|
||||||
- **URL**: https://www.tripo3d.ai/
|
|
||||||
- **Preis**: Kostenlos (mit Limits), Premium verfügbar
|
|
||||||
- **Features**:
|
|
||||||
- Text-zu-3D
|
|
||||||
- Bild-zu-3D
|
|
||||||
- Export als GLB/FBX/OBJ
|
|
||||||
- **Gut für**: Charaktere und Objekte
|
|
||||||
|
|
||||||
### 6. **Masterpiece Studio** ⭐⭐⭐
|
|
||||||
- **URL**: https://masterpiecestudio.com/
|
|
||||||
- **Preis**: Ab $9/monat
|
|
||||||
- **Features**:
|
|
||||||
- Text-zu-3D
|
|
||||||
- VR-Unterstützung
|
|
||||||
- Export als GLB/FBX
|
|
||||||
- **Gut für**: Professionelle Modelle
|
|
||||||
|
|
||||||
## Traditionelle Tools (Für Nachbearbeitung)
|
|
||||||
|
|
||||||
### 1. **Blender** (Kostenlos) ⭐⭐⭐⭐⭐
|
|
||||||
- **URL**: https://www.blender.org/
|
|
||||||
- **Features**:
|
|
||||||
- Vollständige 3D-Suite
|
|
||||||
- GLB/GLTF Export
|
|
||||||
- Optimierung von KI-generierten Modellen
|
|
||||||
- **Gut für**: Nachbearbeitung, Optimierung, Animationen
|
|
||||||
|
|
||||||
### 2. **Sketchfab** (Modelle kaufen/laden)
|
|
||||||
- **URL**: https://sketchfab.com/
|
|
||||||
- **Preis**: Kostenlos (CC0 Modelle), Premium Modelle kostenpflichtig
|
|
||||||
- **Features**:
|
|
||||||
- Millionen von 3D-Modellen
|
|
||||||
- Viele kostenlose CC0 Modelle
|
|
||||||
- GLB/GLTF Download
|
|
||||||
- **Gut für**: Vorgefertigte Modelle, Inspiration
|
|
||||||
|
|
||||||
## Empfohlener Workflow
|
|
||||||
|
|
||||||
### Für Falukant-Charaktere:
|
|
||||||
1. **Rodin** oder **Meshy** verwenden
|
|
||||||
2. Prompt: "medieval character, male/female, simple style, low poly, game ready"
|
|
||||||
3. Export als GLB
|
|
||||||
4. In **Blender** optimieren (falls nötig)
|
|
||||||
5. Texturen anpassen
|
|
||||||
|
|
||||||
### Für Gebäude:
|
|
||||||
1. **Meshy** oder **Tripo AI** verwenden
|
|
||||||
2. Prompt: "medieval house, simple, low poly, game ready, front view"
|
|
||||||
3. Export als GLB
|
|
||||||
4. In **Blender** optimieren
|
|
||||||
5. Mehrere Varianten erstellen (Haus, Fabrik, Kirche)
|
|
||||||
|
|
||||||
### Für Objekte:
|
|
||||||
1. **Sketchfab** durchsuchen (kostenlose CC0 Modelle)
|
|
||||||
2. Oder **Meshy** für spezifische Objekte
|
|
||||||
3. Export als GLB
|
|
||||||
4. Optimieren falls nötig
|
|
||||||
|
|
||||||
## Prompt-Beispiele für Falukant
|
|
||||||
|
|
||||||
### Charakter:
|
|
||||||
```
|
|
||||||
"medieval character, [male/female], simple low poly style,
|
|
||||||
game ready, neutral pose, front view, no background,
|
|
||||||
GLB format, optimized for web"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Haus:
|
|
||||||
```
|
|
||||||
"medieval house, simple low poly style, game ready,
|
|
||||||
front view, no background, GLB format, optimized for web"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fabrik:
|
|
||||||
```
|
|
||||||
"medieval factory building, simple low poly style,
|
|
||||||
game ready, front view, no background, GLB format"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Wappen:
|
|
||||||
```
|
|
||||||
"medieval coat of arms shield, simple low poly style,
|
|
||||||
game ready, front view, no background, GLB format"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optimierung für Web
|
|
||||||
|
|
||||||
### Nach der Erstellung:
|
|
||||||
1. **Blender** öffnen
|
|
||||||
2. **Decimate Modifier** anwenden (weniger Polygone)
|
|
||||||
3. **Texture** komprimieren (WebP, 512x512 oder 1024x1024)
|
|
||||||
4. **GLB Export** mit:
|
|
||||||
- Compression aktiviert
|
|
||||||
- Texturen eingebettet
|
|
||||||
- Normals und Tangents berechnet
|
|
||||||
|
|
||||||
### Größen-Richtlinien:
|
|
||||||
- **Charaktere**: 2000-5000 Polygone
|
|
||||||
- **Gebäude**: 1000-3000 Polygone
|
|
||||||
- **Objekte**: 100-1000 Polygone
|
|
||||||
- **Texturen**: 512x512 oder 1024x1024 (nicht größer)
|
|
||||||
|
|
||||||
## Kostenlose Alternativen
|
|
||||||
|
|
||||||
### Wenn KI-Tools Limits haben:
|
|
||||||
1. **Sketchfab** durchsuchen (CC0 Modelle)
|
|
||||||
2. **Poly Haven** (https://polyhaven.com/) - kostenlose Assets
|
|
||||||
3. **Kenney.nl** - kostenlose Game Assets
|
|
||||||
4. **OpenGameArt.org** - kostenlose Game Assets
|
|
||||||
|
|
||||||
## Nächste Schritte
|
|
||||||
|
|
||||||
1. **Rodin** oder **Meshy** testen
|
|
||||||
2. Ersten Charakter erstellen
|
|
||||||
3. Als GLB exportieren
|
|
||||||
4. In Three.js testen
|
|
||||||
5. Bei Bedarf optimieren
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
# Blender Rigging-Anleitung für Falukant-Charaktere
|
|
||||||
|
|
||||||
Diese Anleitung erklärt, wie du Bones/Gelenke zu deinen 3D-Modellen in Blender hinzufügst, damit sie animiert werden können.
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
|
|
||||||
- Blender (kostenlos, https://www.blender.org/)
|
|
||||||
- GLB-Modell von meshy.ai oder anderen Quellen
|
|
||||||
|
|
||||||
## Schritt-für-Schritt Anleitung
|
|
||||||
|
|
||||||
### 1. Modell in Blender importieren
|
|
||||||
|
|
||||||
1. Öffne Blender
|
|
||||||
2. Gehe zu `File` → `Import` → `glTF 2.0 (.glb/.gltf)`
|
|
||||||
3. Wähle dein Modell aus
|
|
||||||
4. Das Modell sollte jetzt in der Szene erscheinen
|
|
||||||
|
|
||||||
### 2. Modell vorbereiten
|
|
||||||
|
|
||||||
1. Stelle sicher, dass das Modell im **Object Mode** ist (Tab drücken, falls im Edit Mode)
|
|
||||||
2. Wähle das Modell aus (Linksklick)
|
|
||||||
3. Drücke `Alt + G` um die Position auf (0, 0, 0) zu setzen
|
|
||||||
4. Drücke `Alt + R` um die Rotation zurückzusetzen
|
|
||||||
5. Drücke `Alt + S` um die Skalierung auf 1 zu setzen
|
|
||||||
|
|
||||||
### 3. Rigging (Bones hinzufügen)
|
|
||||||
|
|
||||||
#### Option A: Automatisches Rigging mit Rigify (Empfohlen)
|
|
||||||
|
|
||||||
1. **Rigify aktivieren:**
|
|
||||||
- Gehe zu `Edit` → `Preferences` (oder `Blender` → `Preferences` auf Mac)
|
|
||||||
- Klicke auf den Tab **"Add-ons"** (links im Fenster)
|
|
||||||
- Im Suchfeld oben rechts tippe: **"rigify"** (ohne Anführungszeichen)
|
|
||||||
- Du solltest "Rigify: Auto-rigging system" sehen
|
|
||||||
- Aktiviere das **Häkchen** neben "Rigify"
|
|
||||||
- Das Add-on ist jetzt aktiviert
|
|
||||||
- Schließe das Preferences-Fenster
|
|
||||||
|
|
||||||
**Alternative Wege zu Preferences:**
|
|
||||||
- Windows/Linux: `Edit` → `Preferences`
|
|
||||||
- Mac: `Blender` → `Preferences`
|
|
||||||
- Oder: `Ctrl + ,` (Strg + Komma)
|
|
||||||
|
|
||||||
2. **Rigify-Rig hinzufügen:**
|
|
||||||
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken, falls im Edit Mode)
|
|
||||||
- Wähle das Modell aus (oder nichts, das Rig wird separat erstellt)
|
|
||||||
- Drücke `Shift + A` (Add Menu)
|
|
||||||
- Wähle **`Armature`** aus
|
|
||||||
- In der Liste siehst du jetzt **`Human (Meta-Rig)`** - klicke darauf
|
|
||||||
- Ein Basis-Rig wird in der Szene erstellt
|
|
||||||
|
|
||||||
**Falls "Human (Meta-Rig)" nicht erscheint:**
|
|
||||||
- Stelle sicher, dass Rigify aktiviert ist (siehe Schritt 1)
|
|
||||||
- Starte Blender neu, falls nötig
|
|
||||||
- Prüfe, ob du die neueste Blender-Version hast (Rigify ist ab Version 2.8+ verfügbar)
|
|
||||||
|
|
||||||
3. **Rig positionieren und anpassen:**
|
|
||||||
|
|
||||||
**Schritt 1: Rig zum Modell bewegen**
|
|
||||||
- Stelle sicher, dass du im **Object Mode** bist (Tab drücken)
|
|
||||||
- Wähle das **Armature** aus (nicht das Modell)
|
|
||||||
- Drücke `G` (Grab/Move) und bewege das Rig zum Modell
|
|
||||||
- Oder: Drücke `Alt + G` um die Position zurückzusetzen, dann `G` + `X`, `Y` oder `Z` für eine Achse
|
|
||||||
|
|
||||||
**Schritt 2: Rig skalieren (falls zu groß/klein)**
|
|
||||||
- Wähle das Armature aus
|
|
||||||
- Drücke `S` (Scale) und skaliere das Rig
|
|
||||||
- Oder: `S` + `X`, `Y` oder `Z` für eine Achse
|
|
||||||
- Tipp: Drücke `Shift + X` (oder Y/Z) um diese Achse auszuschließen
|
|
||||||
|
|
||||||
**Schritt 3: Einzelne Bones anpassen**
|
|
||||||
- Wähle das Armature aus
|
|
||||||
- Wechsle in den **Edit Mode** (Tab)
|
|
||||||
- Wähle einen Bone aus (Linksklick)
|
|
||||||
- Drücke `G` um ihn zu bewegen
|
|
||||||
- Drücke `E` um einen neuen Bone zu extrudieren
|
|
||||||
- Drücke `R` um einen Bone zu rotieren
|
|
||||||
- Drücke `S` um einen Bone zu skalieren
|
|
||||||
|
|
||||||
**Wichtige Bones zum Anpassen:**
|
|
||||||
- **Root/Spine** - Sollte in der Mitte des Körpers sein (Hüfthöhe)
|
|
||||||
- **Spine1/Spine2** - Entlang der Wirbelsäule
|
|
||||||
- **Neck/Head** - Am Hals und Kopf
|
|
||||||
- **Shoulders** - An den Schultern
|
|
||||||
- **Arms** - Entlang der Arme
|
|
||||||
- **Legs** - Entlang der Beine
|
|
||||||
|
|
||||||
**Tipp:** Nutze die Zahlenansicht (Numpad) um die Positionen genau zu sehen
|
|
||||||
|
|
||||||
4. **Rig generieren:**
|
|
||||||
- Wechsle zurück in den **Object Mode** (Tab drücken)
|
|
||||||
- Wähle das **Meta-Rig (Armature)** aus (nicht das Modell!) - sollte im Outliner blau markiert sein
|
|
||||||
|
|
||||||
**Methode 1: Rigify-Button in der Toolbar (Einfachste Methode)**
|
|
||||||
- Oben in der Toolbar siehst du den Button **"Rigify"** (neben "Object")
|
|
||||||
- Klicke auf **"Rigify"** → **"Generate Rig"**
|
|
||||||
- Ein vollständiges Rig wird erstellt (dies kann einen Moment dauern)
|
|
||||||
|
|
||||||
**Methode 2: Properties-Panel (Alternative)**
|
|
||||||
- Im **Properties-Panel** (rechts):
|
|
||||||
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
|
|
||||||
- Oder: Klicke auf das **Bone-Icon** (Armature Properties)
|
|
||||||
- Scrolle durch die Tabs, bis du **"Rigify"** oder **"Rigify Generation"** siehst
|
|
||||||
- In diesem Tab findest du den Button **"Generate Rig"**
|
|
||||||
- Klicke auf **"Generate Rig"**
|
|
||||||
|
|
||||||
**Wichtig:** Nach dem Generieren kannst du das Rig weiter anpassen, aber du musst es im **Pose Mode** tun (nicht Edit Mode)
|
|
||||||
|
|
||||||
**Die richtigen Tabs im Properties-Panel (von oben nach unten):**
|
|
||||||
- 📐 **Object Properties** (Würfel-Icon) - hier findest du Transform, etc.
|
|
||||||
- 🦴 **Armature Properties** (Bone-Icon) - hier findest du Armature-Einstellungen
|
|
||||||
- 🔧 **Modifier Properties** (Wrench-Icon) - hier sollte der **Rigify-Tab** sein!
|
|
||||||
- 🌍 **World Properties** (Globus-Icon) - NICHT hier suchen!
|
|
||||||
|
|
||||||
**Falls du den Rigify-Tab nicht siehst:**
|
|
||||||
- Stelle sicher, dass das **Meta-Rig** (nicht ein bereits generiertes Rig) ausgewählt ist
|
|
||||||
- Klicke auf das **Wrench-Icon** (Modifier Properties) in der linken Toolbar
|
|
||||||
- Der Rigify-Tab sollte dort erscheinen
|
|
||||||
|
|
||||||
#### Option B: Manuelles Rigging
|
|
||||||
|
|
||||||
1. **Armature erstellen:**
|
|
||||||
- Drücke `Shift + A` → `Armature`
|
|
||||||
- Ein Bone wird erstellt
|
|
||||||
|
|
||||||
2. **Bones hinzufügen:**
|
|
||||||
- Wechsle in den **Edit Mode** (Tab)
|
|
||||||
- Wähle den Root-Bone aus
|
|
||||||
- Drücke `E` um einen neuen Bone zu extrudieren
|
|
||||||
- Erstelle die wichtigsten Bones:
|
|
||||||
- **Spine/Spine1/Spine2** - Wirbelsäule
|
|
||||||
- **Neck/Head** - Hals und Kopf
|
|
||||||
- **LeftArm/LeftForeArm/LeftHand** - Linker Arm
|
|
||||||
- **RightArm/RightForeArm/RightHand** - Rechter Arm
|
|
||||||
- **LeftUpLeg/LeftLeg/LeftFoot** - Linkes Bein
|
|
||||||
- **RightUpLeg/RightLeg/RightFoot** - Rechtes Bein
|
|
||||||
|
|
||||||
3. **Bone-Namen vergeben:**
|
|
||||||
- Wähle jeden Bone aus
|
|
||||||
- Im Properties-Panel (rechts) unter "Bone" kannst du den Namen ändern
|
|
||||||
- **Wichtig:** Verwende diese Namen für die Animation:
|
|
||||||
- `LeftArm`, `RightArm`
|
|
||||||
- `LeftForeArm`, `RightForeArm`
|
|
||||||
- `LeftHand`, `RightHand`
|
|
||||||
- `LeftUpLeg`, `RightUpLeg`
|
|
||||||
- `LeftLeg`, `RightLeg`
|
|
||||||
- `LeftFoot`, `RightFoot`
|
|
||||||
- `Neck`, `Head`
|
|
||||||
- `Spine`, `Spine1`, `Spine2`
|
|
||||||
|
|
||||||
### 4. Modell an Bones binden (Skinning)
|
|
||||||
|
|
||||||
1. **Beide Objekte auswählen:**
|
|
||||||
- Wähle zuerst das **Mesh** aus
|
|
||||||
- Dann wähle das **Armature** aus (Shift + Linksklick)
|
|
||||||
- Drücke `Ctrl + P` → `With Automatic Weights`
|
|
||||||
- Blender berechnet automatisch, welche Vertices zu welchen Bones gehören
|
|
||||||
|
|
||||||
2. **Weights überprüfen:**
|
|
||||||
- Wähle das Mesh aus
|
|
||||||
- Wechsle in den **Weight Paint Mode** (Dropdown oben)
|
|
||||||
- Wähle einen Bone aus (rechts im Properties-Panel)
|
|
||||||
- Rot = vollständig gebunden, Blau = nicht gebunden
|
|
||||||
- Falls nötig, kannst du die Weights manuell anpassen
|
|
||||||
|
|
||||||
### 5. Test-Animation erstellen (Optional)
|
|
||||||
|
|
||||||
1. **Pose Mode aktivieren:**
|
|
||||||
- Wähle das **generierte Rig** aus (nicht das Meta-Rig!)
|
|
||||||
- Wechsle in den **Pose Mode** (Dropdown oben: "Object Mode" → "Pose Mode")
|
|
||||||
- Oder: `Ctrl + Tab` → "Pose Mode"
|
|
||||||
|
|
||||||
2. **Bone auswählen:**
|
|
||||||
- **Wichtig:** Arbeite im **3D-Viewport** (Hauptfenster), nicht nur im Outliner!
|
|
||||||
- **Rigify-Bone-Namen** (nach dem Generieren):
|
|
||||||
- Für **Knie beugen**: `Leg.L (IK)` oder `Leg.L (FK)` (nicht "Tweak"!)
|
|
||||||
- Für **Hand anheben**: `Arm.L (IK)` oder `Arm.L (FK)`
|
|
||||||
- Für **Fuß bewegen**: `Leg.L (IK)` (der Fuß-Controller)
|
|
||||||
- **IK** = Inverse Kinematics (einfacher, empfohlen für Anfänger)
|
|
||||||
- **FK** = Forward Kinematics (mehr Kontrolle)
|
|
||||||
- **Tweak** = Feinabstimmungen (für später, nicht für Hauptanimationen)
|
|
||||||
- Klicke auf einen **Bone** im **3D-Viewport** (nicht im Outliner!)
|
|
||||||
- Der Bone sollte orange/ausgewählt sein und im Viewport sichtbar sein
|
|
||||||
- **Tipp:** Nutze `X-Ray Mode` (Button oben im Viewport) um Bones besser zu sehen
|
|
||||||
- **Tipp:** Im Outliner kannst du Bones finden, aber die Animation machst du im Viewport
|
|
||||||
|
|
||||||
3. **Bone animieren:**
|
|
||||||
- Wähle z.B. `hand.L` (linke Hand) aus
|
|
||||||
- Drücke `R` (Rotate) und rotiere den Bone
|
|
||||||
- Oder: `R` + `Z` (um Z-Achse rotieren)
|
|
||||||
- Oder: `R` + `X` (um X-Achse rotieren)
|
|
||||||
- Bewege die Maus → Linksklick zum Bestätigen
|
|
||||||
- **Beispiel für Hand anheben:** `hand.L` → `R` → `Z` → nach oben bewegen
|
|
||||||
|
|
||||||
4. **Animation aufnehmen (Timeline):**
|
|
||||||
- Unten siehst du die **Timeline** (falls nicht sichtbar: `Shift + F12` oder `Window` → `Animation` → `Timeline`)
|
|
||||||
- Stelle den Frame auf **1** (Anfang)
|
|
||||||
- Wähle den Bone aus und positioniere ihn in der **Ausgangsposition**
|
|
||||||
- Drücke `I` (Insert Keyframe) → wähle **"Rotation"** (oder "Location" falls bewegt)
|
|
||||||
- Ein Keyframe wird erstellt (gelber Punkt in der Timeline)
|
|
||||||
- Stelle den Frame auf **30** (oder einen anderen Frame)
|
|
||||||
- Rotiere/Bewege den Bone in die **Zielposition** (z.B. Hand nach oben)
|
|
||||||
- Drücke wieder `I` → **"Rotation"** (oder "Location")
|
|
||||||
- Stelle den Frame auf **60** (Rückkehr zur Ausgangsposition)
|
|
||||||
- Rotiere den Bone zurück zur Ausgangsposition
|
|
||||||
- Drücke `I` → **"Rotation"**
|
|
||||||
- Drücke **Play** (Leertaste) um die Animation zu sehen
|
|
||||||
|
|
||||||
5. **Animation testen:**
|
|
||||||
- Die Animation sollte jetzt in einer Schleife abgespielt werden
|
|
||||||
- Du kannst weitere Keyframes hinzufügen (Frame 90, 120, etc.)
|
|
||||||
- **Tipp:** Nutze `Alt + A` um die Animation zu stoppen
|
|
||||||
|
|
||||||
### 6. Modell exportieren
|
|
||||||
|
|
||||||
1. **Beide Objekte auswählen:**
|
|
||||||
- Wähle das **Mesh** aus
|
|
||||||
- Shift + Linksklick auf das **generierte Rig** (nicht das Meta-Rig!)
|
|
||||||
|
|
||||||
2. **Exportieren:**
|
|
||||||
- Gehe zu `File` → `Export` → `glTF 2.0 (.glb/.gltf)`
|
|
||||||
- Wähle `.glb` Format
|
|
||||||
- Stelle sicher, dass folgende Optionen aktiviert sind:
|
|
||||||
- ✅ **Include** → **Selected Objects**
|
|
||||||
- ✅ **Transform** → **+Y Up**
|
|
||||||
- ✅ **Geometry** → **Apply Modifiers**
|
|
||||||
- ✅ **Animation** → **Bake Animation** (wichtig für Animationen!)
|
|
||||||
- ✅ **Animation** → **Always Sample Animations** (falls Animationen nicht korrekt exportiert werden)
|
|
||||||
- Klicke auf "Export glTF 2.0"
|
|
||||||
|
|
||||||
### 7. Modell testen
|
|
||||||
|
|
||||||
1. Kopiere die exportierte `.glb` Datei nach:
|
|
||||||
```
|
|
||||||
frontend/public/models/3d/falukant/characters/
|
|
||||||
```
|
|
||||||
2. Lade die Seite neu
|
|
||||||
3. Die Bones sollten jetzt automatisch erkannt und animiert werden
|
|
||||||
4. **Animationen testen:**
|
|
||||||
- Öffne die Browser-Konsole (F12)
|
|
||||||
- Du solltest sehen: `[ThreeScene] Found X animation(s)`
|
|
||||||
- Die Animationen sollten automatisch abgespielt werden
|
|
||||||
- Falls keine Animationen vorhanden sind, werden die Bones trotzdem mit Idle-Animationen bewegt
|
|
||||||
|
|
||||||
## Rig anpassen - Detaillierte Anleitung
|
|
||||||
|
|
||||||
### Rig nach dem Generieren anpassen
|
|
||||||
|
|
||||||
Wenn das Rigify-Rig generiert wurde, aber nicht perfekt passt:
|
|
||||||
|
|
||||||
1. **Pose Mode verwenden:**
|
|
||||||
- Wähle das generierte Armature aus
|
|
||||||
- Wechsle in den **Pose Mode** (Dropdown oben, oder Strg+Tab → Pose Mode)
|
|
||||||
- Hier kannst du die Bones bewegen, ohne die Struktur zu zerstören
|
|
||||||
|
|
||||||
2. **Rig neu generieren (falls nötig):**
|
|
||||||
- Falls das Rig komplett neu positioniert werden muss:
|
|
||||||
- Lösche das generierte Rig (X → Delete)
|
|
||||||
- Gehe zurück zum Meta-Rig
|
|
||||||
- Passe das Meta-Rig im Edit Mode an
|
|
||||||
- Generiere das Rig erneut
|
|
||||||
|
|
||||||
3. **Snap to Mesh (Hilfsmittel):**
|
|
||||||
- Im Edit Mode: `Shift + Tab` um Snap zu aktivieren
|
|
||||||
- Oder: Rechtsklick auf das Snap-Symbol (Magnet) oben
|
|
||||||
- Wähle "Face" oder "Vertex" als Snap-Target
|
|
||||||
- Jetzt werden Bones automatisch am Mesh ausgerichtet
|
|
||||||
|
|
||||||
### Häufige Probleme und Lösungen
|
|
||||||
|
|
||||||
**Problem: Rig ist zu groß/klein**
|
|
||||||
- Lösung: Im Object Mode das Armature auswählen und mit `S` skalieren
|
|
||||||
|
|
||||||
**Problem: Rig ist an falscher Position**
|
|
||||||
- Lösung: Im Object Mode mit `G` bewegen, oder `Alt + G` zurücksetzen
|
|
||||||
|
|
||||||
**Problem: Einzelne Bones passen nicht**
|
|
||||||
- Lösung: Im Edit Mode die Bones einzeln anpassen (`G` zum Bewegen)
|
|
||||||
|
|
||||||
**Problem: Nach dem Generieren passt es nicht mehr**
|
|
||||||
- Lösung: Passe das Meta-Rig an und generiere neu, oder verwende Pose Mode
|
|
||||||
|
|
||||||
## Tipps und Tricks
|
|
||||||
|
|
||||||
### Bone-Namen für automatische Erkennung
|
|
||||||
|
|
||||||
Die Komponente erkennt Bones anhand ihrer Namen. Verwende diese Keywords:
|
|
||||||
- `arm` - für Arme
|
|
||||||
- `hand` oder `wrist` - für Hände
|
|
||||||
- `leg` oder `knee` - für Beine
|
|
||||||
- `foot` oder `ankle` - für Füße
|
|
||||||
- `shoulder` - für Schultern
|
|
||||||
- `elbow` - für Ellbogen
|
|
||||||
|
|
||||||
### Einfacheres Rigging mit Mixamo
|
|
||||||
|
|
||||||
Alternativ kannst du:
|
|
||||||
1. Dein Modell auf [Mixamo](https://www.mixamo.com/) hochladen
|
|
||||||
2. Automatisches Rigging durchführen lassen
|
|
||||||
3. Das geriggte Modell herunterladen
|
|
||||||
4. In Blender importieren und anpassen
|
|
||||||
|
|
||||||
### Performance-Optimierung
|
|
||||||
|
|
||||||
- Verwende nicht zu viele Bones (max. 50-100 für Charaktere)
|
|
||||||
- Entferne unnötige Bones vor dem Export
|
|
||||||
- Teste die Animation im Browser, bevor du das finale Modell exportierst
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Bones werden nicht erkannt
|
|
||||||
|
|
||||||
- Prüfe die Bone-Namen (müssen `arm`, `hand`, `leg`, etc. enthalten)
|
|
||||||
- Stelle sicher, dass das Modell korrekt an die Bones gebunden ist
|
|
||||||
- Öffne die Browser-Konsole und prüfe die Logs: `[ThreeScene] Found X bones for animation`
|
|
||||||
|
|
||||||
### Modell verformt sich falsch
|
|
||||||
|
|
||||||
- Überprüfe die Weights im Weight Paint Mode
|
|
||||||
- Passe die Bone-Positionen an
|
|
||||||
- Stelle sicher, dass alle Vertices korrekt zugewiesen sind
|
|
||||||
|
|
||||||
### Export schlägt fehl
|
|
||||||
|
|
||||||
- Stelle sicher, dass beide Objekte (Mesh + Armature) ausgewählt sind
|
|
||||||
- Prüfe, ob das Modell im Object Mode ist
|
|
||||||
- Versuche es mit einem anderen Export-Format (.gltf statt .glb)
|
|
||||||
|
|
||||||
## Weitere Ressourcen
|
|
||||||
|
|
||||||
- [Blender Rigging Tutorial](https://www.youtube.com/results?search_query=blender+rigging+tutorial)
|
|
||||||
- [Mixamo Auto-Rigging](https://www.mixamo.com/)
|
|
||||||
- [Three.js GLTF Animation Guide](https://threejs.org/docs/#manual/en/introduction/Animation-system)
|
|
||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -21,7 +21,6 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"three": "^0.182.0",
|
|
||||||
"vue": "~3.4.31",
|
"vue": "~3.4.31",
|
||||||
"vue-i18n": "^10.0.0-beta.2",
|
"vue-i18n": "^10.0.0-beta.2",
|
||||||
"vue-multiselect": "^3.1.0",
|
"vue-multiselect": "^3.1.0",
|
||||||
@@ -2835,12 +2834,6 @@
|
|||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/three": {
|
|
||||||
"version": "0.182.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
|
||||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"three": "^0.182.0",
|
|
||||||
"vue": "~3.4.31",
|
"vue": "~3.4.31",
|
||||||
"vue-i18n": "^10.0.0-beta.2",
|
"vue-i18n": "^10.0.0-beta.2",
|
||||||
"vue-multiselect": "^3.1.0",
|
"vue-multiselect": "^3.1.0",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# 3D-Charakter-Modelle
|
|
||||||
|
|
||||||
## Verzeichnisstruktur
|
|
||||||
|
|
||||||
Dieses Verzeichnis enthält die 3D-Modelle für Falukant-Charaktere.
|
|
||||||
|
|
||||||
## Dateinamen-Konvention
|
|
||||||
|
|
||||||
### Basis-Modelle (Fallback)
|
|
||||||
- `male.glb` - Basis-Modell männlich
|
|
||||||
- `female.glb` - Basis-Modell weiblich
|
|
||||||
|
|
||||||
### Altersspezifische Modelle
|
|
||||||
- `male_toddler.glb` - Männlich, Kleinkind (0-3 Jahre)
|
|
||||||
- `male_child.glb` - Männlich, Kind (4-7 Jahre)
|
|
||||||
- `male_preteen.glb` - Männlich, Vor-Teenager (8-12 Jahre)
|
|
||||||
- `male_teen.glb` - Männlich, Teenager (13-17 Jahre)
|
|
||||||
- `male_adult.glb` - Männlich, Erwachsen (18+ Jahre)
|
|
||||||
- `female_toddler.glb` - Weiblich, Kleinkind (0-3 Jahre)
|
|
||||||
- `female_child.glb` - Weiblich, Kind (4-7 Jahre)
|
|
||||||
- `female_preteen.glb` - Weiblich, Vor-Teenager (8-12 Jahre)
|
|
||||||
- `female_teen.glb` - Weiblich, Teenager (13-17 Jahre)
|
|
||||||
- `female_adult.glb` - Weiblich, Erwachsen (18+ Jahre)
|
|
||||||
|
|
||||||
## Fallback-Verhalten
|
|
||||||
|
|
||||||
Wenn kein spezifisches Modell für den Altersbereich existiert, wird automatisch das Basis-Modell (`male.glb` / `female.glb`) verwendet.
|
|
||||||
|
|
||||||
## Dateigröße
|
|
||||||
|
|
||||||
- Empfohlen: < 500KB pro Modell
|
|
||||||
- Maximal: 1MB pro Modell
|
|
||||||
|
|
||||||
## Optimierung
|
|
||||||
|
|
||||||
Vor dem Hochladen:
|
|
||||||
1. In Blender öffnen
|
|
||||||
2. Decimate Modifier anwenden (falls nötig)
|
|
||||||
3. Texturen komprimieren (WebP, max 1024x1024)
|
|
||||||
4. GLB Export mit Compression aktiviert
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,225 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="character-model-3d">
|
|
||||||
<ThreeScene
|
|
||||||
v-if="currentModelPath"
|
|
||||||
:key="currentModelPath"
|
|
||||||
:modelPath="currentModelPath"
|
|
||||||
:autoRotate="autoRotate"
|
|
||||||
:rotationSpeed="rotationSpeed"
|
|
||||||
:cameraPosition="cameraPosition"
|
|
||||||
:backgroundColor="backgroundColor"
|
|
||||||
@model-loaded="onModelLoaded"
|
|
||||||
@model-error="onModelError"
|
|
||||||
@loading-progress="onLoadingProgress"
|
|
||||||
/>
|
|
||||||
<div v-if="loading" class="loading-overlay">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<p v-if="loadingProgress > 0">{{ Math.round(loadingProgress) }}%</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="error" class="error-overlay">
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import ThreeScene from './ThreeScene.vue';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CharacterModel3D',
|
|
||||||
components: {
|
|
||||||
ThreeScene
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
gender: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
validator: (value) => ['male', 'female'].includes(value)
|
|
||||||
},
|
|
||||||
age: {
|
|
||||||
type: Number,
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
autoRotate: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
rotationSpeed: {
|
|
||||||
type: Number,
|
|
||||||
default: 0.5
|
|
||||||
},
|
|
||||||
cameraPosition: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ x: 0, y: 1, z: 3 })
|
|
||||||
},
|
|
||||||
backgroundColor: {
|
|
||||||
type: String,
|
|
||||||
default: '#f0f0f0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
loadingProgress: 0,
|
|
||||||
error: null,
|
|
||||||
currentModelPath: null
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
baseModelPath() {
|
|
||||||
const basePath = '/models/3d/falukant/characters';
|
|
||||||
return `${basePath}/${this.gender}.glb`;
|
|
||||||
},
|
|
||||||
ageSpecificModelPath() {
|
|
||||||
const ageRange = this.getAgeRange(this.age);
|
|
||||||
if (!ageRange) return null;
|
|
||||||
|
|
||||||
const basePath = '/models/3d/falukant/characters';
|
|
||||||
return `${basePath}/${this.gender}_${ageRange}.glb`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
gender() {
|
|
||||||
this.findAndLoadModel();
|
|
||||||
},
|
|
||||||
age() {
|
|
||||||
this.findAndLoadModel();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.findAndLoadModel();
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getAgeRange(age) {
|
|
||||||
if (age === null || age === undefined) return null;
|
|
||||||
|
|
||||||
// Verfügbare Altersbereiche: toddler, child, preteen, teen, adult
|
|
||||||
// Alter ist in Tagen gespeichert (1 Tag = 1 Jahr)
|
|
||||||
if (age < 4) return 'toddler'; // 0-3 Jahre
|
|
||||||
if (age < 10) return 'child'; // 4-7 Jahre
|
|
||||||
if (age < 13) return 'preteen'; // 8-12 Jahre
|
|
||||||
if (age < 18) return 'teen'; // 13-17 Jahre
|
|
||||||
return 'adult'; // 18+ Jahre
|
|
||||||
},
|
|
||||||
|
|
||||||
async findAndLoadModel() {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
// Versuche zuerst altersspezifisches Modell, dann Basis-Modell
|
|
||||||
const pathsToTry = [];
|
|
||||||
if (this.ageSpecificModelPath) {
|
|
||||||
pathsToTry.push(this.ageSpecificModelPath);
|
|
||||||
}
|
|
||||||
pathsToTry.push(this.baseModelPath);
|
|
||||||
|
|
||||||
// Prüfe welche Datei existiert
|
|
||||||
for (const path of pathsToTry) {
|
|
||||||
const exists = await this.checkFileExists(path);
|
|
||||||
if (exists) {
|
|
||||||
this.currentModelPath = path;
|
|
||||||
console.log(`[CharacterModel3D] Using model: ${path}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Verwende Basis-Modell auch wenn Prüfung fehlschlägt
|
|
||||||
this.currentModelPath = this.baseModelPath;
|
|
||||||
console.warn(`[CharacterModel3D] Using fallback model: ${this.baseModelPath}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
async checkFileExists(path) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(path, { method: 'HEAD' });
|
|
||||||
if (!response.ok) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prüfe Content-Type - sollte nicht HTML sein
|
|
||||||
const contentType = response.headers.get('content-type') || '';
|
|
||||||
const isHTML = contentType.includes('text/html') || contentType.includes('text/plain');
|
|
||||||
|
|
||||||
if (isHTML) {
|
|
||||||
console.warn(`[CharacterModel3D] File ${path} returns HTML, probably doesn't exist`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GLB-Dateien können verschiedene Content-Types haben
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`[CharacterModel3D] Error checking file ${path}:`, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onModelLoaded(model) {
|
|
||||||
this.loading = false;
|
|
||||||
this.error = null;
|
|
||||||
this.$emit('model-loaded', model);
|
|
||||||
},
|
|
||||||
|
|
||||||
onModelError(error) {
|
|
||||||
// Wenn ein Fehler auftritt und wir noch nicht das Basis-Modell verwenden
|
|
||||||
if (this.currentModelPath !== this.baseModelPath) {
|
|
||||||
console.warn('[CharacterModel3D] Model failed, trying fallback...');
|
|
||||||
this.currentModelPath = this.baseModelPath;
|
|
||||||
// Der Watch-Handler wird das Modell neu laden
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = false;
|
|
||||||
this.error = 'Fehler beim Laden des 3D-Modells';
|
|
||||||
console.error('Character model error:', error);
|
|
||||||
this.$emit('model-error', error);
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoadingProgress(progress) {
|
|
||||||
this.loadingProgress = progress;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.character-model-3d {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-overlay,
|
|
||||||
.error-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border: 4px solid #f3f3f3;
|
|
||||||
border-top: 4px solid #F9A22C;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-overlay p {
|
|
||||||
color: #d32f2f;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -151,6 +151,19 @@ export default {
|
|||||||
|
|
||||||
// Extrahiere Parameter aus value und effects
|
// Extrahiere Parameter aus value und effects
|
||||||
params = this.extractParamsFromValue(value, n);
|
params = this.extractParamsFromValue(value, n);
|
||||||
|
|
||||||
|
// Wenn value eine einfache Zahl ist (z.B. für overproduction), als value-Parameter verwenden
|
||||||
|
if (typeof parsed.value === 'number') {
|
||||||
|
params.value = parsed.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weitere Parameter aus parsed extrahieren (z.B. branch_id)
|
||||||
|
if (parsed.branch_id !== undefined) {
|
||||||
|
params.branch_id = parsed.branch_id;
|
||||||
|
}
|
||||||
|
if (parsed.region_id !== undefined) {
|
||||||
|
params.region_id = parsed.region_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Bei Parse-Fehler: Alte Struktur unterstützen
|
// Bei Parse-Fehler: Alte Struktur unterstützen
|
||||||
@@ -172,9 +185,12 @@ export default {
|
|||||||
if (value && value.title && value.description) {
|
if (value && value.title && value.description) {
|
||||||
// Parameter aus effects extrahieren und formatieren
|
// Parameter aus effects extrahieren und formatieren
|
||||||
const formattedParams = this.formatParams(params);
|
const formattedParams = this.formatParams(params);
|
||||||
|
// Zuerst Description interpolieren (für {amount} etc.), dann Effects hinzufügen
|
||||||
|
let description = this.interpolateString(value.description, formattedParams);
|
||||||
|
description = this.formatDescriptionWithEffects(description, value.effects || [], formattedParams);
|
||||||
return {
|
return {
|
||||||
title: this.interpolateString(value.title, formattedParams),
|
title: this.interpolateString(value.title, formattedParams),
|
||||||
description: this.formatDescriptionWithEffects(value.description, value.effects || [], formattedParams)
|
description: description
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +228,10 @@ export default {
|
|||||||
const title = this.$t(titleKey, formattedParams);
|
const title = this.$t(titleKey, formattedParams);
|
||||||
let description = this.$t(descKey, formattedParams);
|
let description = this.$t(descKey, formattedParams);
|
||||||
|
|
||||||
|
// Stelle sicher, dass auch hier die Parameter interpoliert werden (für {amount} etc.)
|
||||||
|
// Vue i18n interpoliert bereits, aber wir müssen sicherstellen, dass formatParams korrekt formatiert
|
||||||
|
description = this.interpolateString(description, formattedParams);
|
||||||
|
|
||||||
// Füge Effect-Details hinzu, falls vorhanden
|
// Füge Effect-Details hinzu, falls vorhanden
|
||||||
if (value && value.effects) {
|
if (value && value.effects) {
|
||||||
description = this.formatDescriptionWithEffects(description, value.effects, formattedParams);
|
description = this.formatDescriptionWithEffects(description, value.effects, formattedParams);
|
||||||
@@ -234,15 +254,27 @@ export default {
|
|||||||
|
|
||||||
// Geldbeträge formatieren
|
// Geldbeträge formatieren
|
||||||
if (params.amount !== undefined && params.amount !== null) {
|
if (params.amount !== undefined && params.amount !== null) {
|
||||||
formatted.amount = this.formatMoney(params.amount);
|
formatted.amount = this.formatMoney(Number(params.amount));
|
||||||
}
|
}
|
||||||
if (params.absolute !== undefined && params.absolute !== null) {
|
if (params.absolute !== undefined && params.absolute !== null) {
|
||||||
formatted.amount = this.formatMoney(params.absolute);
|
formatted.amount = this.formatMoney(Number(params.absolute));
|
||||||
}
|
}
|
||||||
if (params.percent !== undefined && params.percent !== null) {
|
if (params.percent !== undefined && params.percent !== null) {
|
||||||
formatted.percent = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
|
formatted.percent = `${params.percent > 0 ? '+' : ''}${params.percent.toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Einfache Werte (z.B. für overproduction)
|
||||||
|
if (params.value !== undefined && params.value !== null) {
|
||||||
|
formatted.value = Number(params.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filiale-Information
|
||||||
|
if (params.branch_id !== undefined && params.branch_id !== null) {
|
||||||
|
formatted.branch_info = ` (Filiale #${params.branch_id})`;
|
||||||
|
} else {
|
||||||
|
formatted.branch_info = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Gesundheit formatieren
|
// Gesundheit formatieren
|
||||||
if (params.change !== undefined && params.change !== null) {
|
if (params.change !== undefined && params.change !== null) {
|
||||||
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
|
formatted.healthChange = params.change > 0 ? `+${params.change}` : `${params.change}`;
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="container" class="three-scene-container"></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { markRaw } from 'vue';
|
|
||||||
import * as THREE from 'three';
|
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
||||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
|
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ThreeScene',
|
|
||||||
props: {
|
|
||||||
modelPath: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
autoRotate: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
rotationSpeed: {
|
|
||||||
type: Number,
|
|
||||||
default: 0.5
|
|
||||||
},
|
|
||||||
cameraPosition: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ x: 0, y: 1, z: 3 })
|
|
||||||
},
|
|
||||||
backgroundColor: {
|
|
||||||
type: String,
|
|
||||||
default: '#f0f0f0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
scene: null,
|
|
||||||
camera: null,
|
|
||||||
renderer: null,
|
|
||||||
controls: null,
|
|
||||||
model: null,
|
|
||||||
animationId: null,
|
|
||||||
mixer: null,
|
|
||||||
clock: null,
|
|
||||||
animationStartTime: 0,
|
|
||||||
baseY: 0, // Basis-Y-Position für Bewegungsanimation
|
|
||||||
bones: [] // Gespeicherte Bones für manuelle Animation
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.initScene();
|
|
||||||
this.loadModel();
|
|
||||||
this.animate();
|
|
||||||
window.addEventListener('resize', this.onWindowResize);
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
window.removeEventListener('resize', this.onWindowResize);
|
|
||||||
if (this.animationId) {
|
|
||||||
cancelAnimationFrame(this.animationId);
|
|
||||||
}
|
|
||||||
if (this.mixer) {
|
|
||||||
this.mixer.stopAllAction();
|
|
||||||
}
|
|
||||||
if (this.renderer) {
|
|
||||||
this.renderer.dispose();
|
|
||||||
}
|
|
||||||
if (this.model) {
|
|
||||||
this.disposeModel(this.model);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
modelPath() {
|
|
||||||
if (this.model) {
|
|
||||||
this.disposeModel(this.model);
|
|
||||||
this.model = null;
|
|
||||||
}
|
|
||||||
this.loadModel();
|
|
||||||
},
|
|
||||||
autoRotate(newVal) {
|
|
||||||
if (this.controls) {
|
|
||||||
this.controls.autoRotate = newVal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
initScene() {
|
|
||||||
// Szene erstellen - markRaw verhindert Vue-Reaktivität
|
|
||||||
this.scene = markRaw(new THREE.Scene());
|
|
||||||
this.scene.background = new THREE.Color(this.backgroundColor);
|
|
||||||
|
|
||||||
// Kamera erstellen - markRaw verhindert Vue-Reaktivität
|
|
||||||
this.camera = markRaw(new THREE.PerspectiveCamera(
|
|
||||||
50,
|
|
||||||
this.$refs.container.clientWidth / this.$refs.container.clientHeight,
|
|
||||||
0.1,
|
|
||||||
1000
|
|
||||||
));
|
|
||||||
this.camera.position.set(
|
|
||||||
this.cameraPosition.x,
|
|
||||||
this.cameraPosition.y,
|
|
||||||
this.cameraPosition.z
|
|
||||||
);
|
|
||||||
|
|
||||||
// Renderer erstellen - markRaw verhindert Vue-Reaktivität
|
|
||||||
this.renderer = markRaw(new THREE.WebGLRenderer({
|
|
||||||
antialias: true,
|
|
||||||
alpha: true,
|
|
||||||
powerPreference: 'high-performance'
|
|
||||||
}));
|
|
||||||
this.renderer.setSize(
|
|
||||||
this.$refs.container.clientWidth,
|
|
||||||
this.$refs.container.clientHeight
|
|
||||||
);
|
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // Begrenzt für Performance
|
|
||||||
this.renderer.shadowMap.enabled = true;
|
|
||||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
||||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
||||||
this.renderer.toneMappingExposure = 1.2; // Leicht erhöhte Helligkeit
|
|
||||||
this.$refs.container.appendChild(this.renderer.domElement);
|
|
||||||
|
|
||||||
// Controls erstellen - markRaw verhindert Vue-Reaktivität
|
|
||||||
this.controls = markRaw(new OrbitControls(this.camera, this.renderer.domElement));
|
|
||||||
this.controls.enableDamping = true;
|
|
||||||
this.controls.dampingFactor = 0.05;
|
|
||||||
this.controls.autoRotate = false; // Rotation deaktiviert
|
|
||||||
this.controls.enableRotate = false; // Manuelle Rotation deaktiviert
|
|
||||||
this.controls.enableZoom = true;
|
|
||||||
this.controls.enablePan = false;
|
|
||||||
this.controls.minDistance = 2;
|
|
||||||
this.controls.maxDistance = 5;
|
|
||||||
|
|
||||||
// Clock für Animationen
|
|
||||||
this.clock = markRaw(new THREE.Clock());
|
|
||||||
|
|
||||||
// Verbesserte Beleuchtung
|
|
||||||
// Umgebungslicht - heller für bessere Sichtbarkeit
|
|
||||||
const ambientLight = markRaw(new THREE.AmbientLight(0xffffff, 1.0));
|
|
||||||
this.scene.add(ambientLight);
|
|
||||||
|
|
||||||
// Hauptlicht von vorne oben (Key Light)
|
|
||||||
const mainLight = markRaw(new THREE.DirectionalLight(0xffffff, 1.2));
|
|
||||||
mainLight.position.set(3, 8, 4);
|
|
||||||
mainLight.castShadow = true;
|
|
||||||
mainLight.shadow.mapSize.width = 2048;
|
|
||||||
mainLight.shadow.mapSize.height = 2048;
|
|
||||||
mainLight.shadow.camera.near = 0.5;
|
|
||||||
mainLight.shadow.camera.far = 50;
|
|
||||||
this.scene.add(mainLight);
|
|
||||||
|
|
||||||
// Fülllicht von links (Fill Light)
|
|
||||||
const fillLight = markRaw(new THREE.DirectionalLight(0xffffff, 0.6));
|
|
||||||
fillLight.position.set(-4, 5, 3);
|
|
||||||
this.scene.add(fillLight);
|
|
||||||
|
|
||||||
// Zusätzliches Licht von rechts (Rim Light)
|
|
||||||
const rimLight = markRaw(new THREE.DirectionalLight(0xffffff, 0.5));
|
|
||||||
rimLight.position.set(4, 3, -3);
|
|
||||||
this.scene.add(rimLight);
|
|
||||||
|
|
||||||
// Punktlicht von oben für zusätzliche Helligkeit
|
|
||||||
const pointLight = markRaw(new THREE.PointLight(0xffffff, 0.8, 20));
|
|
||||||
pointLight.position.set(0, 6, 0);
|
|
||||||
this.scene.add(pointLight);
|
|
||||||
},
|
|
||||||
|
|
||||||
loadModel() {
|
|
||||||
const loader = new GLTFLoader();
|
|
||||||
|
|
||||||
// Optional: DRACO-Loader für komprimierte Modelle
|
|
||||||
// const dracoLoader = new DRACOLoader();
|
|
||||||
// dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/');
|
|
||||||
// loader.setDRACOLoader(dracoLoader);
|
|
||||||
|
|
||||||
console.log('[ThreeScene] Loading model from:', this.modelPath);
|
|
||||||
console.log('[ThreeScene] Full URL:', window.location.origin + this.modelPath);
|
|
||||||
|
|
||||||
loader.load(
|
|
||||||
this.modelPath,
|
|
||||||
(gltf) => {
|
|
||||||
console.log('[ThreeScene] Model loaded successfully:', gltf);
|
|
||||||
|
|
||||||
// Altes Modell entfernen
|
|
||||||
if (this.model) {
|
|
||||||
this.scene.remove(this.model);
|
|
||||||
this.disposeModel(this.model);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modell als nicht-reaktiv markieren - verhindert Vue-Proxy-Konflikte
|
|
||||||
this.model = markRaw(gltf.scene);
|
|
||||||
|
|
||||||
// Modell zentrieren und skalieren
|
|
||||||
const box = new THREE.Box3().setFromObject(this.model);
|
|
||||||
const center = box.getCenter(new THREE.Vector3());
|
|
||||||
const size = box.getSize(new THREE.Vector3());
|
|
||||||
|
|
||||||
console.log('[ThreeScene] Model bounds:', { center, size });
|
|
||||||
|
|
||||||
// Modell zentrieren (X und Z)
|
|
||||||
this.model.position.x = -center.x;
|
|
||||||
this.model.position.z = -center.z;
|
|
||||||
|
|
||||||
// Modell skalieren (größer für bessere Sichtbarkeit)
|
|
||||||
const maxSize = Math.max(size.x, size.y, size.z);
|
|
||||||
const scale = maxSize > 0 ? 3.0 / maxSize : 1;
|
|
||||||
this.model.scale.multiplyScalar(scale);
|
|
||||||
|
|
||||||
// Modell auf Boden setzen und Basis-Y-Position speichern
|
|
||||||
this.baseY = -size.y * scale / 2;
|
|
||||||
this.model.position.y = this.baseY;
|
|
||||||
|
|
||||||
// Schatten aktivieren
|
|
||||||
this.model.traverse((child) => {
|
|
||||||
if (child.isMesh) {
|
|
||||||
child.castShadow = true;
|
|
||||||
child.receiveShadow = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.scene.add(this.model);
|
|
||||||
|
|
||||||
// Kamera auf Modell ausrichten
|
|
||||||
this.centerCameraOnModel();
|
|
||||||
|
|
||||||
// Bones für manuelle Animation finden
|
|
||||||
this.findAndStoreBones(this.model);
|
|
||||||
|
|
||||||
// Falls keine Bones gefunden, Hinweis in der Konsole
|
|
||||||
if (this.bones.length === 0) {
|
|
||||||
console.warn('[ThreeScene] No bones found in model. To enable limb animations, add bones in Blender. See docs/BLENDER_RIGGING_GUIDE.md');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animationen aus GLTF laden (falls vorhanden)
|
|
||||||
if (gltf.animations && gltf.animations.length > 0) {
|
|
||||||
console.log(`[ThreeScene] Found ${gltf.animations.length} animation(s):`, gltf.animations.map(a => a.name));
|
|
||||||
this.mixer = markRaw(new THREE.AnimationMixer(this.model));
|
|
||||||
gltf.animations.forEach((clip) => {
|
|
||||||
const action = this.mixer.clipAction(clip);
|
|
||||||
action.play();
|
|
||||||
console.log(`[ThreeScene] Playing animation: "${clip.name}" (duration: ${clip.duration.toFixed(2)}s)`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('[ThreeScene] No animations found in model');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animationStartTime = this.clock.getElapsedTime();
|
|
||||||
this.$emit('model-loaded', this.model);
|
|
||||||
},
|
|
||||||
(progress) => {
|
|
||||||
// Loading-Progress
|
|
||||||
if (progress.lengthComputable) {
|
|
||||||
const percent = (progress.loaded / progress.total) * 100;
|
|
||||||
this.$emit('loading-progress', percent);
|
|
||||||
} else {
|
|
||||||
// Fallback für nicht-computable progress
|
|
||||||
this.$emit('loading-progress', 50);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
console.error('[ThreeScene] Error loading model:', error);
|
|
||||||
console.error('[ThreeScene] Model path was:', this.modelPath);
|
|
||||||
console.error('[ThreeScene] Full URL:', window.location.origin + this.modelPath);
|
|
||||||
console.error('[ThreeScene] Error details:', {
|
|
||||||
message: error?.message,
|
|
||||||
stack: error?.stack,
|
|
||||||
type: error?.constructor?.name
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prüfe ob es ein 404-Fehler ist (JSON-Parse-Fehler deutet auf HTML-Fehlerseite hin)
|
|
||||||
if (error?.message && (error.message.includes('JSON') || error.message.includes('Unexpected'))) {
|
|
||||||
console.error('[ThreeScene] Possible 404 error - file not found or wrong path');
|
|
||||||
console.error('[ThreeScene] Please check:');
|
|
||||||
console.error(' 1. File exists at:', this.modelPath);
|
|
||||||
console.error(' 2. Vite dev server is running');
|
|
||||||
console.error(' 3. File is in public/ directory');
|
|
||||||
|
|
||||||
// Versuche die Datei direkt zu fetchen um den Fehler zu sehen
|
|
||||||
fetch(this.modelPath)
|
|
||||||
.then(response => {
|
|
||||||
console.error('[ThreeScene] Fetch response:', {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: Object.fromEntries(response.headers.entries())
|
|
||||||
});
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then(text => {
|
|
||||||
console.error('[ThreeScene] Response preview:', text.substring(0, 200));
|
|
||||||
})
|
|
||||||
.catch(fetchError => {
|
|
||||||
console.error('[ThreeScene] Fetch error:', fetchError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$emit('model-error', error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
disposeModel(model) {
|
|
||||||
model.traverse((child) => {
|
|
||||||
if (child.isMesh) {
|
|
||||||
if (child.geometry) child.geometry.dispose();
|
|
||||||
if (child.material) {
|
|
||||||
if (Array.isArray(child.material)) {
|
|
||||||
child.material.forEach((mat) => mat.dispose());
|
|
||||||
} else {
|
|
||||||
child.material.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
findAndStoreBones(object) {
|
|
||||||
this.bones = [];
|
|
||||||
|
|
||||||
object.traverse((child) => {
|
|
||||||
if (child.isBone || (child.type === 'Bone')) {
|
|
||||||
// Speichere Bones mit ihren Namen für einfachen Zugriff
|
|
||||||
const boneName = child.name.toLowerCase();
|
|
||||||
|
|
||||||
// Typische Bone-Namen für Gliedmaßen
|
|
||||||
if (boneName.includes('arm') ||
|
|
||||||
boneName.includes('hand') ||
|
|
||||||
boneName.includes('leg') ||
|
|
||||||
boneName.includes('foot') ||
|
|
||||||
boneName.includes('shoulder') ||
|
|
||||||
boneName.includes('elbow') ||
|
|
||||||
boneName.includes('knee') ||
|
|
||||||
boneName.includes('wrist') ||
|
|
||||||
boneName.includes('ankle')) {
|
|
||||||
this.bones.push({
|
|
||||||
bone: child,
|
|
||||||
name: boneName,
|
|
||||||
originalRotation: child.rotation.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[ThreeScene] Found ${this.bones.length} bones for animation`);
|
|
||||||
},
|
|
||||||
|
|
||||||
animateLimbs(time) {
|
|
||||||
// Sanfte Idle-Animation für Gliedmaßen
|
|
||||||
const animationSpeed = 1.5; // Geschwindigkeit
|
|
||||||
const maxRotation = 0.15; // Maximale Rotation in Radianten (ca. 8.6 Grad)
|
|
||||||
|
|
||||||
this.bones.forEach((boneData, index) => {
|
|
||||||
const bone = boneData.bone;
|
|
||||||
const boneName = boneData.name;
|
|
||||||
|
|
||||||
// Unterschiedliche Animationen basierend auf Bone-Typ
|
|
||||||
if (boneName.includes('arm') || boneName.includes('shoulder')) {
|
|
||||||
// Arme: Sanftes Vor- und Zurückschwingen
|
|
||||||
const phase = time * animationSpeed + (index * 0.5);
|
|
||||||
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.3;
|
|
||||||
bone.rotation.z = boneData.originalRotation.z + Math.cos(phase * 0.7) * maxRotation * 0.2;
|
|
||||||
} else if (boneName.includes('hand') || boneName.includes('wrist')) {
|
|
||||||
// Hände: Leichtes Wackeln
|
|
||||||
const phase = time * animationSpeed * 1.5 + (index * 0.3);
|
|
||||||
bone.rotation.y = boneData.originalRotation.y + Math.sin(phase) * maxRotation * 0.4;
|
|
||||||
} else if (boneName.includes('leg') || boneName.includes('knee')) {
|
|
||||||
// Beine: Leichtes Vor- und Zurückbewegen
|
|
||||||
const phase = time * animationSpeed * 0.8 + (index * 0.4);
|
|
||||||
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.2;
|
|
||||||
} else if (boneName.includes('foot') || boneName.includes('ankle')) {
|
|
||||||
// Füße: Minimales Wackeln
|
|
||||||
const phase = time * animationSpeed * 1.2 + (index * 0.2);
|
|
||||||
bone.rotation.x = boneData.originalRotation.x + Math.sin(phase) * maxRotation * 0.15;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
centerCameraOnModel() {
|
|
||||||
if (!this.model || !this.camera) return;
|
|
||||||
|
|
||||||
// Kamera-Position für gute Ansicht des zentrierten Modells
|
|
||||||
this.camera.position.set(0, this.baseY + 1, 3);
|
|
||||||
this.camera.lookAt(0, this.baseY + 0.5, 0);
|
|
||||||
|
|
||||||
if (this.controls) {
|
|
||||||
this.controls.target.set(0, this.baseY + 0.5, 0);
|
|
||||||
this.controls.update();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
animate() {
|
|
||||||
this.animationId = requestAnimationFrame(this.animate);
|
|
||||||
|
|
||||||
const delta = this.clock ? this.clock.getDelta() : 0;
|
|
||||||
|
|
||||||
// GLTF-Animationen aktualisieren (falls vorhanden)
|
|
||||||
if (this.mixer) {
|
|
||||||
this.mixer.update(delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gliedmaßen-Animationen
|
|
||||||
if (this.bones.length > 0) {
|
|
||||||
const time = this.clock ? this.clock.getElapsedTime() : 0;
|
|
||||||
this.animateLimbs(time);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.controls) {
|
|
||||||
this.controls.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.renderer && this.scene && this.camera) {
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onWindowResize() {
|
|
||||||
if (!this.$refs.container || !this.camera || !this.renderer) return;
|
|
||||||
|
|
||||||
const width = this.$refs.container.clientWidth;
|
|
||||||
const height = this.$refs.container.clientHeight;
|
|
||||||
|
|
||||||
this.camera.aspect = width / height;
|
|
||||||
this.camera.updateProjectionMatrix();
|
|
||||||
this.renderer.setSize(width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.three-scene-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.three-scene-container canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.",
|
"notify_election_created": "Es wurde eine neue Wahl ausgeschrieben.",
|
||||||
"production": {
|
"production": {
|
||||||
"overproduction": "Überproduktion: Deine Produktion liegt {value}% über dem Bedarf."
|
"overproduction": "Überproduktion: Deine Produktion liegt {value} Einheiten über dem Bedarf{branch_info}."
|
||||||
},
|
},
|
||||||
"transport": {
|
"transport": {
|
||||||
"waiting": "Transport wartet"
|
"waiting": "Transport wartet"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"notifications": {
|
"notifications": {
|
||||||
"notify_election_created": "A new election has been scheduled.",
|
"notify_election_created": "A new election has been scheduled.",
|
||||||
"production": {
|
"production": {
|
||||||
"overproduction": "Overproduction: your production is {value}% above demand."
|
"overproduction": "Overproduction: your production is {value} units above demand{branch_info}."
|
||||||
},
|
},
|
||||||
"transport": {
|
"transport": {
|
||||||
"waiting": "Transport waiting"
|
"waiting": "Transport waiting"
|
||||||
|
|||||||
@@ -188,18 +188,29 @@ const store = createStore({
|
|||||||
socketIoUrl = 'http://localhost:3001';
|
socketIoUrl = 'http://localhost:3001';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalisiere URL (Env-Variablen enthalten teils Pfade wie /api; Port kann absichtlich gesetzt sein, z.B. :4443)
|
// Direkte Verbindung zu Socket.io (ohne Apache-Proxy)
|
||||||
try {
|
// In Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
|
||||||
if (socketIoUrl) {
|
const hostname = window.location.hostname;
|
||||||
const parsed = new URL(socketIoUrl, window.location.origin);
|
const isProduction = hostname === 'www.your-part.de' || hostname.includes('your-part.de');
|
||||||
// Falls /api oder ähnliche Pfade enthalten sind → auf Origin reduzieren (inkl. Port!)
|
|
||||||
socketIoUrl = parsed.origin;
|
if (isProduction) {
|
||||||
}
|
// Produktion: direkte Verbindung zu Port 4443 (verschlüsselt)
|
||||||
} catch (e) {
|
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
||||||
// Wenn Parsing fehlschlägt: letzte Rettung ist der aktuelle Origin
|
socketIoUrl = `${protocol}//${hostname}:4443`;
|
||||||
try {
|
} else {
|
||||||
|
// Lokale Entwicklung: direkte Backend-Verbindung
|
||||||
|
if (!socketIoUrl && (import.meta.env.DEV || hostname === 'localhost' || hostname === '127.0.0.1')) {
|
||||||
|
socketIoUrl = 'http://localhost:3001';
|
||||||
|
} else if (socketIoUrl) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(socketIoUrl, window.location.origin);
|
||||||
|
socketIoUrl = parsed.origin;
|
||||||
|
} catch (e) {
|
||||||
|
socketIoUrl = window.location.origin;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
socketIoUrl = window.location.origin;
|
socketIoUrl = window.location.origin;
|
||||||
} catch (_) {}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = io(socketIoUrl, {
|
const socket = io(socketIoUrl, {
|
||||||
@@ -284,12 +295,13 @@ const store = createStore({
|
|||||||
|
|
||||||
// Wenn Umgebungsvariable nicht gesetzt ist oder leer, verwende Fallback-Logik
|
// Wenn Umgebungsvariable nicht gesetzt ist oder leer, verwende Fallback-Logik
|
||||||
if (!daemonUrl || (typeof daemonUrl === 'string' && daemonUrl.trim() === '')) {
|
if (!daemonUrl || (typeof daemonUrl === 'string' && daemonUrl.trim() === '')) {
|
||||||
// Fallback: direkte Verbindung zum Daemon-Port 4551 (ohne Apache-Proxy)
|
// Immer direkte Verbindung zum Daemon-Port 4551 (verschlüsselt)
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
daemonUrl = `${protocol}//${hostname}:4551/`;
|
daemonUrl = `${protocol}//${hostname}:4551/`;
|
||||||
console.log('[Daemon] Verwende Fallback basierend auf Hostname, Protokoll und Port 4551');
|
console.log('[Daemon] Verwende direkte Verbindung zu Port 4551');
|
||||||
} else {
|
} else {
|
||||||
console.log('[Daemon] Verwende Umgebungsvariable');
|
// Wenn Umgebungsvariable gesetzt ist, verwende sie direkt
|
||||||
|
console.log('[Daemon] Verwende Umgebungsvariable:', daemonUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Daemon] Finale Daemon-URL:', daemonUrl);
|
console.log('[Daemon] Finale Daemon-URL:', daemonUrl);
|
||||||
|
|||||||
@@ -468,14 +468,10 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
activeTab(newVal, oldVal) {
|
activeTab(newVal) {
|
||||||
// Nur neu laden, wenn der Tab wirklich gewechselt wurde und ein Branch ausgewählt ist
|
if (newVal === 'taxes') {
|
||||||
if (!this.selectedBranch || newVal === oldVal) return;
|
this.loadBranchTaxes();
|
||||||
|
}
|
||||||
// Alle Tabs neu laden, wenn gewechselt wird
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.refreshActiveTab();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
selectedBranch: {
|
selectedBranch: {
|
||||||
handler(newBranch) {
|
handler(newBranch) {
|
||||||
@@ -541,33 +537,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshActiveTab() {
|
|
||||||
// Lade die Daten für den aktiven Tab neu
|
|
||||||
switch (this.activeTab) {
|
|
||||||
case 'director':
|
|
||||||
this.$refs.directorInfo?.refresh();
|
|
||||||
break;
|
|
||||||
case 'inventory':
|
|
||||||
this.$refs.saleSection?.loadInventory();
|
|
||||||
this.$refs.saleSection?.loadTransports();
|
|
||||||
break;
|
|
||||||
case 'production':
|
|
||||||
this.$refs.productionSection?.loadProductions();
|
|
||||||
this.$refs.productionSection?.loadStorage();
|
|
||||||
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
|
||||||
break;
|
|
||||||
case 'taxes':
|
|
||||||
this.loadBranchTaxes();
|
|
||||||
break;
|
|
||||||
case 'storage':
|
|
||||||
this.$refs.storageSection?.loadStorageData();
|
|
||||||
break;
|
|
||||||
case 'transport':
|
|
||||||
this.loadVehicles();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async onBranchSelected(newBranch) {
|
async onBranchSelected(newBranch) {
|
||||||
this.selectedBranch = newBranch;
|
this.selectedBranch = newBranch;
|
||||||
// Branches neu laden, um das Wetter zu aktualisieren
|
// Branches neu laden, um das Wetter zu aktualisieren
|
||||||
@@ -580,8 +549,13 @@ export default {
|
|||||||
await this.loadVehicles();
|
await this.loadVehicles();
|
||||||
await this.loadProductPricesForCurrentBranch();
|
await this.loadProductPricesForCurrentBranch();
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
// Alle Tabs neu laden
|
this.$refs.directorInfo?.refresh();
|
||||||
this.refreshActiveTab();
|
this.$refs.saleSection?.loadInventory();
|
||||||
|
this.$refs.saleSection?.loadTransports();
|
||||||
|
this.$refs.productionSection?.loadProductions();
|
||||||
|
this.$refs.productionSection?.loadStorage();
|
||||||
|
this.$refs.storageSection?.loadStorageData();
|
||||||
|
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// load tax info for this branch
|
// load tax info for this branch
|
||||||
@@ -598,27 +572,49 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lade Preise für alle Produkte in der aktuellen Region
|
if (!this.products || this.products.length === 0) {
|
||||||
const prices = {};
|
this.productPricesCache = {};
|
||||||
for (const product of this.products) {
|
return;
|
||||||
try {
|
}
|
||||||
const { data } = await apiClient.get('/api/falukant/products/price-in-region', {
|
|
||||||
params: {
|
// OPTIMIERUNG: Lade alle Preise in einem Batch-Request
|
||||||
productId: product.id,
|
try {
|
||||||
regionId: this.selectedBranch.regionId
|
const productIds = this.products.map(p => p.id).join(',');
|
||||||
}
|
const { data } = await apiClient.get('/api/falukant/products/prices-in-region-batch', {
|
||||||
});
|
params: {
|
||||||
prices[product.id] = data.price;
|
productIds: productIds,
|
||||||
} catch (error) {
|
regionId: this.selectedBranch.regionId
|
||||||
console.error(`Error loading price for product ${product.id}:`, error);
|
}
|
||||||
// Fallback auf Standard-Berechnung
|
});
|
||||||
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
|
this.productPricesCache = data || {};
|
||||||
const maxPrice = product.sellCost;
|
} catch (error) {
|
||||||
const minPrice = maxPrice * 0.6;
|
console.error('Error loading prices in batch:', error);
|
||||||
prices[product.id] = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
|
// Fallback: Lade Preise einzeln (aber parallel)
|
||||||
}
|
const pricePromises = this.products.map(async (product) => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/falukant/products/price-in-region', {
|
||||||
|
params: {
|
||||||
|
productId: product.id,
|
||||||
|
regionId: this.selectedBranch.regionId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { productId: product.id, price: data.price };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error loading price for product ${product.id}:`, err);
|
||||||
|
// Fallback auf Standard-Berechnung
|
||||||
|
const knowledgeFactor = product.knowledges?.[0]?.knowledge || 0;
|
||||||
|
const maxPrice = product.sellCost;
|
||||||
|
const minPrice = maxPrice * 0.6;
|
||||||
|
return { productId: product.id, price: minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(pricePromises);
|
||||||
|
this.productPricesCache = {};
|
||||||
|
results.forEach(({ productId, price }) => {
|
||||||
|
this.productPricesCache[productId] = price;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.productPricesCache = prices;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
formatPercent(value) {
|
formatPercent(value) {
|
||||||
|
|||||||
@@ -274,12 +274,16 @@ export default {
|
|||||||
|
|
||||||
getEffect(gift) {
|
getEffect(gift) {
|
||||||
const relationship = this.relationships[0];
|
const relationship = this.relationships[0];
|
||||||
|
if (!relationship || !relationship.character2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const partner = relationship.character2;
|
const partner = relationship.character2;
|
||||||
const currentMoodId = partner.mood?.id ?? partner.mood_id;
|
const currentMoodId = partner.moodId;
|
||||||
const moodEntry = gift.moodsAffects.find(ma => ma.mood_id === currentMoodId);
|
const moodEntry = gift.moodsAffects.find(ma => ma.mood_id === currentMoodId);
|
||||||
const moodValue = moodEntry ? moodEntry.suitability : 0;
|
const moodValue = moodEntry ? moodEntry.suitability : 0;
|
||||||
let highestCharacterValue = 0;
|
let highestCharacterValue = 0;
|
||||||
for (const trait of partner.characterTrait) {
|
// traits ist ein Array von Trait-Objekten mit id und tr
|
||||||
|
for (const trait of partner.traits || []) {
|
||||||
const charEntry = gift.charactersAffects.find(ca => ca.trait_id === trait.id);
|
const charEntry = gift.charactersAffects.find(ca => ca.trait_id === trait.id);
|
||||||
if (charEntry && charEntry.suitability > highestCharacterValue) {
|
if (charEntry && charEntry.suitability > highestCharacterValue) {
|
||||||
highestCharacterValue = charEntry.suitability;
|
highestCharacterValue = charEntry.suitability;
|
||||||
|
|||||||
@@ -116,26 +116,15 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="falukantUser?.character" class="overview-visualization">
|
<div v-if="falukantUser?.character" class="imagecontainer">
|
||||||
<div class="character-3d-container">
|
<div :style="getAvatarStyle" class="avatar"></div>
|
||||||
<CharacterModel3D
|
<div :style="getHouseStyle" class="house"></div>
|
||||||
:gender="falukantUser.character.gender"
|
|
||||||
:age="falukantUser.character.age"
|
|
||||||
:autoRotate="true"
|
|
||||||
:rotationSpeed="0.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="imagecontainer">
|
|
||||||
<div :style="getAvatarStyle" class="avatar"></div>
|
|
||||||
<div :style="getHouseStyle" class="house"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||||
import CharacterModel3D from '@/components/falukant/CharacterModel3D.vue';
|
|
||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
import { mapState } from 'vuex';
|
import { mapState } from 'vuex';
|
||||||
|
|
||||||
@@ -180,7 +169,6 @@ export default {
|
|||||||
name: 'FalukantOverviewView',
|
name: 'FalukantOverviewView',
|
||||||
components: {
|
components: {
|
||||||
StatusBar,
|
StatusBar,
|
||||||
CharacterModel3D,
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -493,27 +481,4 @@ h2 {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-visualization {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
margin-top: 20px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.character-3d-container {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 300px;
|
|
||||||
max-width: 500px;
|
|
||||||
height: 400px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imagecontainer {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,13 +50,5 @@ export default defineConfig(({ mode }) => {
|
|||||||
assert: 'assert',
|
assert: 'assert',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
fs: {
|
|
||||||
// Erlaube Zugriff auf Dateien außerhalb des Projektverzeichnisses
|
|
||||||
strict: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Stelle sicher, dass GLB/GLTF-Dateien als Assets behandelt werden
|
|
||||||
assetsInclude: ['**/*.glb', '**/*.gltf']
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
43
test-websocket-config.sh
Executable file
43
test-websocket-config.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=== WebSocket-Konfiguration testen ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Prüfe Apache-Module
|
||||||
|
echo "1. Prüfe Apache-Module:"
|
||||||
|
REQUIRED_MODULES=("proxy" "proxy_http" "proxy_wstunnel" "rewrite" "ssl" "headers")
|
||||||
|
for module in "${REQUIRED_MODULES[@]}"; do
|
||||||
|
if apache2ctl -M 2>/dev/null | grep -q "${module}_module"; then
|
||||||
|
echo " ✅ $module ist aktiviert"
|
||||||
|
else
|
||||||
|
echo " ❌ $module ist NICHT aktiviert"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "2. Prüfe Apache-Konfiguration:"
|
||||||
|
if sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then
|
||||||
|
echo " ✅ Konfiguration ist gültig"
|
||||||
|
else
|
||||||
|
echo " ❌ Konfiguration hat Fehler:"
|
||||||
|
sudo apache2ctl configtest 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Prüfe aktive VirtualHosts:"
|
||||||
|
apache2ctl -S 2>/dev/null | grep -E "(443|4443|4551)" || echo " Keine relevanten VirtualHosts gefunden"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Prüfe Apache-Logs (letzte 20 Zeilen):"
|
||||||
|
echo " Error-Log:"
|
||||||
|
sudo tail -20 /var/log/apache2/yourpart.error.log 2>/dev/null || echo " Keine Fehler gefunden"
|
||||||
|
echo ""
|
||||||
|
echo " Access-Log (letzte 10 Zeilen mit /ws/ oder /socket.io/):"
|
||||||
|
sudo tail -100 /var/log/apache2/yourpart.access.log 2>/dev/null | grep -E "(/ws/|/socket.io/)" | tail -10 || echo " Keine relevanten Einträge gefunden"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "5. Teste WebSocket-Verbindungen:"
|
||||||
|
echo " Socket.io: wss://www.your-part.de/socket.io/"
|
||||||
|
echo " Daemon: wss://www.your-part.de/ws/"
|
||||||
|
echo ""
|
||||||
|
echo " Bitte im Browser testen und dann die Logs prüfen."
|
||||||
74
yourpart-https-alternative.conf
Normal file
74
yourpart-https-alternative.conf
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<IfModule mod_ssl.c>
|
||||||
|
<VirtualHost your-part.de:443>
|
||||||
|
ServerAdmin webmaster@your-part.de
|
||||||
|
ServerName your-part.de
|
||||||
|
ServerAlias www.your-part.de
|
||||||
|
|
||||||
|
DocumentRoot /opt/yourpart/frontend/dist
|
||||||
|
|
||||||
|
DirectoryIndex index.html
|
||||||
|
|
||||||
|
# Frontend statische Dateien
|
||||||
|
<Directory "/opt/yourpart/frontend/dist">
|
||||||
|
AllowOverride None
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
Require all granted
|
||||||
|
|
||||||
|
# Fallback für Vue Router
|
||||||
|
FallbackResource /index.html
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# www Redirect (muss zuerst kommen)
|
||||||
|
RewriteEngine on
|
||||||
|
RewriteCond %{SERVER_NAME} =your-part.de
|
||||||
|
RewriteRule ^ https://www.%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||||
|
|
||||||
|
# Proxy-Einstellungen
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyRequests Off
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
AllowEncodedSlashes NoDecode
|
||||||
|
|
||||||
|
# API-Requests an Backend weiterleiten
|
||||||
|
ProxyPass "/api/" "http://localhost:2020/api/"
|
||||||
|
ProxyPassReverse "/api/" "http://localhost:2020/api/"
|
||||||
|
|
||||||
|
# Socket.io: WebSocket und HTTP-Polling mit Location-Blöcken
|
||||||
|
<LocationMatch "^/socket.io/">
|
||||||
|
# WebSocket-Upgrade
|
||||||
|
RewriteEngine on
|
||||||
|
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||||
|
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||||
|
RewriteRule .* "ws://localhost:2020%{REQUEST_URI}" [P,L]
|
||||||
|
|
||||||
|
# HTTP-Fallback für Polling
|
||||||
|
ProxyPass "http://localhost:2020/socket.io/"
|
||||||
|
ProxyPassReverse "http://localhost:2020/socket.io/"
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
# Daemon: WebSocket mit Location-Block
|
||||||
|
<LocationMatch "^/ws/">
|
||||||
|
# WebSocket-Upgrade
|
||||||
|
RewriteEngine on
|
||||||
|
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||||
|
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||||
|
RewriteRule .* "ws://localhost:4551%{REQUEST_URI}" [P,L]
|
||||||
|
|
||||||
|
# HTTP-Fallback (sollte eigentlich nicht benötigt werden)
|
||||||
|
ProxyPass "http://localhost:4551/"
|
||||||
|
ProxyPassReverse "http://localhost:4551/"
|
||||||
|
</LocationMatch>
|
||||||
|
|
||||||
|
ErrorLog /var/log/apache2/yourpart.error.log
|
||||||
|
CustomLog /var/log/apache2/yourpart.access.log combined
|
||||||
|
|
||||||
|
HostnameLookups Off
|
||||||
|
UseCanonicalName Off
|
||||||
|
ServerSignature On
|
||||||
|
|
||||||
|
# SSL-Konfiguration
|
||||||
|
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||||
|
</VirtualHost>
|
||||||
|
</IfModule>
|
||||||
@@ -18,28 +18,23 @@
|
|||||||
FallbackResource /index.html
|
FallbackResource /index.html
|
||||||
</Directory>
|
</Directory>
|
||||||
|
|
||||||
# API-Requests an Backend weiterleiten
|
# Proxy-Einstellungen
|
||||||
ProxyPass "/api/" "http://localhost:2020/api/"
|
ProxyPreserveHost On
|
||||||
ProxyPassReverse "/api/" "http://localhost:2020/api/"
|
ProxyRequests Off
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
AllowEncodedSlashes NoDecode
|
||||||
|
|
||||||
# WebSocket-Requests an Backend weiterleiten
|
# www Redirect (muss zuerst kommen, aber nicht für API-Pfade)
|
||||||
ProxyPass "/socket.io/" "http://localhost:2020/socket.io/"
|
|
||||||
ProxyPassReverse "/socket.io/" "http://localhost:2020/socket.io/"
|
|
||||||
|
|
||||||
# WebSocket-Upgrade-Header für Socket.io
|
|
||||||
RewriteEngine on
|
RewriteEngine on
|
||||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
RewriteCond %{SERVER_NAME} =your-part.de
|
||||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
RewriteCond %{REQUEST_URI} !^/api/
|
||||||
RewriteRule ^/socket.io/(.*)$ "ws://localhost:2020/socket.io/$1" [P,L]
|
RewriteRule ^ https://www.%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
||||||
|
|
||||||
# WebSocket-Upgrade-Header für Daemon-Verbindungen
|
# API-Requests an Backend weiterleiten (Location-Block hat höhere Priorität)
|
||||||
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
<Location "/api/">
|
||||||
RewriteCond %{HTTP:Connection} upgrade [NC]
|
ProxyPass "http://localhost:2020/api/"
|
||||||
RewriteRule ^/ws/(.*)$ "ws://localhost:4551/$1" [P,L]
|
ProxyPassReverse "http://localhost:2020/api/"
|
||||||
|
</Location>
|
||||||
# WebSocket-Proxy für Daemon-Verbindungen mit benutzerdefiniertem Protokoll
|
|
||||||
ProxyPass "/ws/" "ws://localhost:4551/" upgrade=websocket
|
|
||||||
ProxyPassReverse "/ws/" "ws://localhost:4551/"
|
|
||||||
|
|
||||||
ErrorLog /var/log/apache2/yourpart.error.log
|
ErrorLog /var/log/apache2/yourpart.error.log
|
||||||
CustomLog /var/log/apache2/yourpart.access.log combined
|
CustomLog /var/log/apache2/yourpart.access.log combined
|
||||||
@@ -52,9 +47,5 @@
|
|||||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||||
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||||
|
|
||||||
# www Redirect
|
|
||||||
RewriteCond %{SERVER_NAME} =your-part.de
|
|
||||||
RewriteRule ^ https://www.%{SERVER_NAME}%{REQUEST_URI} [END,NE,R=permanent]
|
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|||||||
39
yourpart-websocket-fixed.conf
Normal file
39
yourpart-websocket-fixed.conf
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# /etc/apache2/sites-available/yourpart-websocket.conf
|
||||||
|
<IfModule mod_ssl.c>
|
||||||
|
<VirtualHost *:4551>
|
||||||
|
ServerName www.your-part.de
|
||||||
|
|
||||||
|
# SSL aktivieren
|
||||||
|
SSLEngine on
|
||||||
|
Protocols http/1.1
|
||||||
|
|
||||||
|
# SSL-Konfiguration
|
||||||
|
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||||
|
|
||||||
|
# Proxy-Einstellungen
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyRequests Off
|
||||||
|
AllowEncodedSlashes NoDecode
|
||||||
|
|
||||||
|
# WebSocket-Upgrade (muss VOR ProxyPass stehen)
|
||||||
|
# Apache lauscht auf Port 4551 (extern, verschlüsselt) und leitet an Daemon auf Port 4551 weiter (intern, unverschlüsselt)
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||||
|
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||||
|
RewriteRule ^/?(.*) "ws://localhost:4551/$1" [P,L]
|
||||||
|
|
||||||
|
# Fallback für normale HTTP-Requests (falls nötig)
|
||||||
|
ProxyPass / http://localhost:4551/
|
||||||
|
ProxyPassReverse / http://localhost:4551/
|
||||||
|
|
||||||
|
# CORS-Headers
|
||||||
|
Header always set Access-Control-Allow-Origin "https://www.your-part.de"
|
||||||
|
Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
|
||||||
|
Header always set Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"
|
||||||
|
|
||||||
|
ErrorLog /var/log/apache2/yourpart-websocket.error.log
|
||||||
|
CustomLog /var/log/apache2/yourpart-websocket.access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
</IfModule>
|
||||||
29
yourpart-ws-fixed.conf
Normal file
29
yourpart-ws-fixed.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<IfModule mod_ssl.c>
|
||||||
|
<VirtualHost *:4443>
|
||||||
|
ServerName www.your-part.de
|
||||||
|
|
||||||
|
SSLEngine on
|
||||||
|
Protocols http/1.1
|
||||||
|
|
||||||
|
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/www.your-part.de/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/www.your-part.de/privkey.pem
|
||||||
|
|
||||||
|
ProxyPreserveHost On
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
AllowEncodedSlashes NoDecode
|
||||||
|
|
||||||
|
# WebSocket-Upgrade für Socket.io (muss VOR ProxyPass stehen)
|
||||||
|
RewriteEngine on
|
||||||
|
RewriteCond %{HTTP:Upgrade} websocket [NC]
|
||||||
|
RewriteCond %{HTTP:Connection} upgrade [NC]
|
||||||
|
RewriteRule ^/socket.io/(.*)$ "ws://127.0.0.1:2020/socket.io/$1" [P,L]
|
||||||
|
|
||||||
|
# HTTP-Proxy für Socket.io (Fallback für Polling)
|
||||||
|
ProxyPass "/socket.io/" "http://127.0.0.1:2020/socket.io/" retry=0
|
||||||
|
ProxyPassReverse "/socket.io/" "http://127.0.0.1:2020/socket.io/"
|
||||||
|
|
||||||
|
ErrorLog /var/log/apache2/yourpart-ws.error.log
|
||||||
|
CustomLog /var/log/apache2/yourpart-ws.access.log combined
|
||||||
|
</VirtualHost>
|
||||||
|
</IfModule>
|
||||||
Reference in New Issue
Block a user