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:
Torsten Schulz (local)
2026-01-09 13:29:32 +01:00
parent 3722bcf8c8
commit 5ddb099f5a
24 changed files with 1567 additions and 3 deletions

View File

@@ -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",

View File

@@ -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",

View 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

Binary file not shown.

View 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>

View 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>

View File

@@ -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>

View File

@@ -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']
};
});