Add 3D character rendering to Character3D component

- Integrated Three.js for 3D character visualization based on user gender and age.
- Simplified the character structure by removing outdated HTML elements and replacing them with a dynamic 3D model loader.
- Implemented model loading with fallback options and added animation capabilities for enhanced visual appeal.
- Updated CSS for the character container to ensure proper rendering and responsiveness.
This commit is contained in:
Torsten Schulz (local)
2026-01-22 11:53:40 +01:00
parent 33aa2ddd45
commit 41106ae306
17 changed files with 281 additions and 266 deletions

View File

@@ -21,6 +21,7 @@
"dotenv": "^16.4.5",
"mitt": "^3.0.1",
"socket.io-client": "^4.8.1",
"three": "^0.169.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

@@ -1,31 +1,11 @@
<template>
<div class="character-3d-container" :class="[`gender-${actualGender}`, `age-${ageGroup}`]">
<div class="character-3d" :style="characterStyle">
<!-- Kopf -->
<div class="head">
<div class="face">
<div class="eye left"></div>
<div class="eye right"></div>
<div class="mouth"></div>
</div>
<!-- Haare -->
<div class="hair" :class="actualGender"></div>
</div>
<!-- Körper -->
<div class="body" :class="actualGender">
<div class="chest"></div>
</div>
<!-- Arme -->
<div class="arm left"></div>
<div class="arm right"></div>
<!-- Beine -->
<div class="leg left"></div>
<div class="leg right"></div>
</div>
</div>
<div ref="container" class="character-3d-container"></div>
</template>
<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
export default {
name: 'Character3D',
props: {
@@ -43,7 +23,14 @@ export default {
data() {
return {
randomGender: null,
randomAge: null
randomAge: null,
scene: null,
camera: null,
renderer: null,
model: null,
animationId: null,
mixer: null,
clock: new THREE.Clock()
};
},
computed: {
@@ -71,37 +58,237 @@ export default {
ageGroup() {
const age = this.actualAge;
if (age <= 3) return 'toddler';
if (age <= 12) return 'child';
if (age <= 7) return 'child';
if (age <= 12) return 'preteen';
if (age <= 17) return 'teen';
if (age <= 30) return 'young-adult';
if (age <= 50) return 'adult';
if (age <= 70) return 'senior';
return 'elderly';
return 'adult';
},
characterStyle() {
const age = this.actualAge;
let scale = 1;
modelPath() {
const gender = this.actualGender;
const ageGroup = this.ageGroup;
// Skalierung basierend auf Alter
if (age <= 3) {
scale = 0.4; // Kleinkind
} else if (age <= 12) {
scale = 0.6; // Kind
} else if (age <= 17) {
scale = 0.85; // Teenager
} else if (age <= 30) {
scale = 1.0; // Junger Erwachsener
} else if (age <= 50) {
scale = 0.95; // Erwachsener
} else if (age <= 70) {
scale = 0.9; // Senior
} else {
scale = 0.85; // Älterer Senior
// Versuche zuerst altersspezifisches Modell
const specificModel = `/models/3d/falukant/characters/${gender}_${ageGroup}.glb`;
// Fallback auf Basis-Modell
return specificModel;
}
},
watch: {
actualGender() {
this.loadModel();
},
ageGroup() {
this.loadModel();
}
},
mounted() {
this.init3D();
this.loadModel();
this.animate();
},
beforeUnmount() {
this.cleanup();
},
methods: {
init3D() {
const container = this.$refs.container;
if (!container) return;
// Scene erstellen
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0);
// Camera erstellen
const aspect = container.clientWidth / container.clientHeight;
this.camera = new THREE.PerspectiveCamera(50, aspect, 0.1, 1000);
this.camera.position.set(0, 1.5, 3);
this.camera.lookAt(0, 1, 0);
// Renderer erstellen
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(this.renderer.domElement);
// Licht hinzufügen
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 10, 5);
this.scene.add(directionalLight);
// Zusätzliches Licht von hinten
const backLight = new THREE.DirectionalLight(0xffffff, 0.3);
backLight.position.set(-5, 0, -5);
this.scene.add(backLight);
// Resize Handler
window.addEventListener('resize', this.onWindowResize);
},
async loadModel() {
if (!this.scene) return;
// Altes Modell entfernen
if (this.model) {
this.scene.remove(this.model);
// Cleanup des alten Modells
this.model.traverse((object) => {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose());
} else {
object.material.dispose();
}
}
});
this.model = null;
}
if (this.mixer) {
this.mixer = null;
}
try {
const loader = new GLTFLoader();
const modelPath = this.modelPath;
// Versuche zuerst spezifisches Modell, dann Fallback
let gltf;
try {
gltf = await loader.loadAsync(modelPath);
} catch (error) {
console.warn(`Could not load ${modelPath}, trying fallback model`);
// Fallback auf Basis-Modell
const fallbackPath = `/models/3d/falukant/characters/${this.actualGender}.glb`;
gltf = await loader.loadAsync(fallbackPath);
}
this.model = 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());
// Skalierung basierend auf Alter
const age = this.actualAge;
let scale = 1;
if (age <= 3) {
scale = 0.4;
} else if (age <= 7) {
scale = 0.6;
} else if (age <= 12) {
scale = 0.75;
} else if (age <= 17) {
scale = 0.9;
} else {
scale = 1.0;
}
// Modell skalieren, damit es in die Szene passt
const maxDimension = Math.max(size.x, size.y, size.z);
const targetHeight = 2; // Zielhöhe in 3D-Einheiten
const modelScale = (targetHeight / maxDimension) * scale;
this.model.scale.set(modelScale, modelScale, modelScale);
// Modell zentrieren
this.model.position.sub(center.multiplyScalar(modelScale));
this.model.position.y = 0; // Auf Boden setzen
this.scene.add(this.model);
// Animationen laden falls vorhanden
if (gltf.animations && gltf.animations.length > 0) {
this.mixer = new THREE.AnimationMixer(this.model);
gltf.animations.forEach((clip) => {
this.mixer.clipAction(clip).play();
});
}
// Sanfte Rotation-Animation
if (this.model) {
this.model.rotation.y = Math.random() * Math.PI * 2;
}
} catch (error) {
console.error('Error loading 3D model:', error);
}
},
animate() {
this.animationId = requestAnimationFrame(this.animate);
const delta = this.clock.getDelta();
// Animation-Mixer aktualisieren
if (this.mixer) {
this.mixer.update(delta);
}
if (this.model) {
// Sanfte Rotation
this.model.rotation.y += 0.005;
// Sanftes Auf und Ab
this.model.position.y = Math.sin(Date.now() * 0.001) * 0.1;
}
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
},
onWindowResize() {
const container = this.$refs.container;
if (!container || !this.camera || !this.renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
},
cleanup() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
window.removeEventListener('resize', this.onWindowResize);
if (this.mixer) {
this.mixer = null;
}
if (this.model) {
this.model.traverse((object) => {
if (object.geometry) object.geometry.dispose();
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(m => m.dispose());
} else {
object.material.dispose();
}
}
});
this.model = null;
}
if (this.renderer) {
const container = this.$refs.container;
if (container && this.renderer.domElement) {
container.removeChild(this.renderer.domElement);
}
this.renderer.dispose();
}
if (this.scene) {
this.scene.clear();
}
return {
transform: `scale(${scale})`
};
}
}
};
@@ -111,220 +298,7 @@ export default {
.character-3d-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
perspective: 1000px;
perspective-origin: center center;
}
.character-3d {
position: relative;
width: 80%;
height: 80%;
transform-style: preserve-3d;
animation: float 3s ease-in-out infinite;
transform-origin: center bottom;
}
/* Altersbasierte Anpassungen */
.age-toddler .head {
width: 35%;
height: 30%;
}
.age-child .head {
width: 32%;
height: 27%;
}
.age-teen .head {
width: 30%;
height: 25%;
}
.age-elderly .head {
width: 28%;
height: 23%;
}
.age-elderly .hair {
background: linear-gradient(135deg, #d3d3d3 0%, #a9a9a9 100%) !important;
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotateY(0deg);
}
50% {
transform: translateY(-10px) rotateY(5deg);
}
}
/* Kopf */
.head {
position: absolute;
width: 30%;
height: 25%;
left: 50%;
top: 0;
transform: translateX(-50%);
background: linear-gradient(135deg, #ffdbac 0%, #f4c2a1 100%);
border-radius: 50% 50% 45% 45%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform-style: preserve-3d;
}
.face {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.eye {
position: absolute;
width: 8%;
height: 8%;
background: #000;
border-radius: 50%;
top: 35%;
}
.eye.left {
left: 30%;
}
.eye.right {
right: 30%;
}
.mouth {
position: absolute;
width: 20%;
height: 8%;
left: 50%;
top: 60%;
transform: translateX(-50%);
border: 2px solid #000;
border-top: none;
border-radius: 0 0 50% 50%;
}
/* Haare */
.hair {
position: absolute;
top: -15%;
left: 50%;
transform: translateX(-50%);
width: 120%;
height: 60%;
border-radius: 50% 50% 0 0;
}
.hair.male {
background: linear-gradient(135deg, #2c1810 0%, #1a0f08 100%);
clip-path: polygon(20% 0%, 80% 0%, 100% 50%, 0% 50%);
}
.hair.female {
background: linear-gradient(135deg, #ffd700 0%, #ffb347 100%);
border-radius: 50% 50% 30% 30%;
box-shadow: 0 -2px 10px rgba(255, 215, 0, 0.3);
}
/* Körper */
.body {
position: absolute;
width: 35%;
height: 40%;
left: 50%;
top: 25%;
transform: translateX(-50%);
border-radius: 20% 20% 10% 10%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.body.male {
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
}
.body.female {
background: linear-gradient(135deg, #e24a90 0%, #c73a7a 100%);
}
.chest {
position: absolute;
width: 60%;
height: 40%;
left: 50%;
top: 20%;
transform: translateX(-50%);
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
}
/* Arme */
.arm {
position: absolute;
width: 12%;
height: 35%;
background: linear-gradient(135deg, #ffdbac 0%, #f4c2a1 100%);
border-radius: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.arm.left {
left: 15%;
top: 28%;
transform: rotate(-20deg);
}
.arm.right {
right: 15%;
top: 28%;
transform: rotate(20deg);
}
/* Beine */
.leg {
position: absolute;
width: 15%;
height: 30%;
border-radius: 10px 10px 5px 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.leg.left {
left: 35%;
top: 65%;
}
.leg.right {
right: 35%;
top: 65%;
}
.gender-male .leg {
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
}
.gender-female .leg {
background: linear-gradient(135deg, #8b4caf 0%, #6b3a8f 100%);
}
/* 3D-Effekt mit Schatten */
.character-3d::before {
content: '';
position: absolute;
width: 60%;
height: 10%;
left: 50%;
bottom: -5%;
transform: translateX(-50%);
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.3) 0%, transparent 70%);
border-radius: 50%;
z-index: -1;
overflow: hidden;
}
</style>