Add 3D character model integration and update dependencies
- Introduced a new CharacterModel3D component for rendering 3D character models in OverviewView. - Updated package.json and package-lock.json to include 'three' library for 3D graphics support. - Enhanced Vite configuration to allow access to external files and ensure proper handling of GLB/GLTF assets. - Improved layout and styling in OverviewView for better visualization of character and avatar.
This commit is contained in:
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "^0.182.0",
|
||||
"vue": "~3.4.31",
|
||||
"vue-i18n": "^10.0.0-beta.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
@@ -2834,6 +2835,12 @@
|
||||
"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": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"dotenv": "^16.4.5",
|
||||
"mitt": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"three": "^0.182.0",
|
||||
"vue": "~3.4.31",
|
||||
"vue-i18n": "^10.0.0-beta.2",
|
||||
"vue-multiselect": "^3.1.0",
|
||||
|
||||
40
frontend/public/models/3d/falukant/characters/README.md
Normal file
40
frontend/public/models/3d/falukant/characters/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 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
|
||||
BIN
frontend/public/models/3d/falukant/characters/female.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_adult.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_adult.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_child.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_child.glb
Normal file
Binary file not shown.
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_preteen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_preteen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_teen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_teen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/female_toddler.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/female_toddler.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_adult.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_adult.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_child.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_child.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_preteen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_preteen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_teen.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_teen.glb
Normal file
Binary file not shown.
BIN
frontend/public/models/3d/falukant/characters/male_toddler.glb
Normal file
BIN
frontend/public/models/3d/falukant/characters/male_toddler.glb
Normal file
Binary file not shown.
225
frontend/src/components/falukant/CharacterModel3D.vue
Normal file
225
frontend/src/components/falukant/CharacterModel3D.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<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>
|
||||
441
frontend/src/components/falukant/ThreeScene.vue
Normal file
441
frontend/src/components/falukant/ThreeScene.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<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>
|
||||
@@ -116,15 +116,26 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="falukantUser?.character" class="imagecontainer">
|
||||
<div :style="getAvatarStyle" class="avatar"></div>
|
||||
<div :style="getHouseStyle" class="house"></div>
|
||||
<div v-if="falukantUser?.character" class="overview-visualization">
|
||||
<div class="character-3d-container">
|
||||
<CharacterModel3D
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||
import CharacterModel3D from '@/components/falukant/CharacterModel3D.vue';
|
||||
import apiClient from '@/utils/axios.js';
|
||||
import { mapState } from 'vuex';
|
||||
|
||||
@@ -169,6 +180,7 @@ export default {
|
||||
name: 'FalukantOverviewView',
|
||||
components: {
|
||||
StatusBar,
|
||||
CharacterModel3D,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -481,4 +493,27 @@ h2 {
|
||||
padding: 20px;
|
||||
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>
|
||||
|
||||
@@ -50,5 +50,13 @@ export default defineConfig(({ mode }) => {
|
||||
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']
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user