Files
yourpart3/frontend/src/components/Character3D.vue

486 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="character-3d-shell">
<div v-show="!showFallback" ref="container" class="character-3d-container"></div>
<img
v-if="showFallback"
class="character-fallback"
:src="fallbackImageSrc"
:alt="`Character ${actualGender}`"
/>
</div>
</template>
<script>
import { markRaw } from 'vue';
import { getApiBaseURL } from '@/utils/axios.js';
/** Backend-Route: GET /api/models/3d/falukant/characters/:filename (Proxy mit Draco-Optimierung) */
const MODELS_API_PATH = '/api/models/3d/falukant/characters';
let threeRuntimePromise = null;
let threeLoadersPromise = null;
let threeModelRuntimePromise = null;
async function loadThreeRuntime() {
if (!threeRuntimePromise) {
threeRuntimePromise = import('@/utils/threeRuntime.js');
}
return threeRuntimePromise;
}
async function loadThreeLoaders() {
if (!threeLoadersPromise) {
threeLoadersPromise = import('@/utils/threeLoaders.js');
}
return threeLoadersPromise;
}
async function loadThreeModelRuntime() {
if (!threeModelRuntimePromise) {
threeModelRuntimePromise = import('@/utils/threeModelRuntime.js');
}
return threeModelRuntimePromise;
}
export default {
name: 'Character3D',
props: {
gender: {
type: String,
default: null,
validator: (value) => value === null || ['male', 'female'].includes(value)
},
age: {
type: Number,
default: null,
validator: (value) => value === null || (value >= 0 && value <= 120)
},
noBackground: {
type: Boolean,
default: false
},
lightweight: {
type: Boolean,
default: false
}
},
data() {
return {
randomGender: null,
randomAge: null,
scene: null,
camera: null,
renderer: null,
model: null,
animationId: null,
mixer: null,
clock: null,
baseYPosition: 0,
showFallback: false,
threeRuntime: null,
threeLoaders: null,
threeModelRuntime: null
};
},
computed: {
actualGender() {
if (this.gender) {
return this.gender;
}
// Zufällige Auswahl beim ersten Mount, dann persistent
if (this.randomGender === null) {
this.randomGender = Math.random() < 0.5 ? 'male' : 'female';
}
return this.randomGender;
},
actualAge() {
if (this.age !== null && this.age !== undefined) {
return this.age;
}
// Zufällige Auswahl beim ersten Mount, dann persistent
if (this.randomAge === null) {
// Zufällige Altersgruppe auswählen, damit verschiedene Altersbereiche dargestellt werden
const ageGroups = [
{ min: 0, max: 3 }, // toddler
{ min: 4, max: 7 }, // child
{ min: 8, max: 12 }, // preteen
{ min: 13, max: 17 }, // teen
{ min: 18, max: 65 } // adult
];
const selectedGroup = ageGroups[Math.floor(Math.random() * ageGroups.length)];
this.randomAge = Math.floor(Math.random() * (selectedGroup.max - selectedGroup.min + 1)) + selectedGroup.min;
}
return this.randomAge;
},
ageGroup() {
const age = this.actualAge;
if (age <= 3) return 'toddler';
if (age <= 7) return 'child';
if (age <= 12) return 'preteen';
if (age <= 17) return 'teen';
return 'adult';
},
modelPath() {
const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
return `${prefix}/${this.actualGender}_${this.ageGroup}.glb`;
},
exactAgeModelPath() {
// Pfad für genaues Alter (z.B. female_1y.glb für Alter 1)
const age = this.actualAge;
const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
return `${prefix}/${this.actualGender}_${age}y.glb`;
},
fallbackImageSrc() {
return this.actualGender === 'female'
? '/images/mascot/mascot_female.png'
: '/images/mascot/mascot_male.png';
}
},
watch: {
async actualGender() {
await this.loadModel();
},
async ageGroup() {
await this.loadModel();
}
},
async mounted() {
await this.init3D();
await this.loadModel();
this.animate();
},
beforeUnmount() {
this.cleanup();
},
methods: {
async ensureThreeRuntime() {
if (!this.threeRuntime) {
this.threeRuntime = markRaw(await loadThreeRuntime());
}
return this.threeRuntime;
},
async ensureThreeLoaders() {
if (!this.threeLoaders) {
this.threeLoaders = markRaw(await loadThreeLoaders());
}
return this.threeLoaders;
},
async ensureThreeModelRuntime() {
if (!this.threeModelRuntime) {
this.threeModelRuntime = markRaw(await loadThreeModelRuntime());
}
return this.threeModelRuntime;
},
async init3D() {
const container = this.$refs.container;
if (!container) return;
this.showFallback = false;
const runtime = await this.ensureThreeRuntime();
this.clock = markRaw(new runtime.Clock());
// Scene erstellen - markRaw verwenden, um Vue's Reactivity zu vermeiden
this.scene = markRaw(new runtime.Scene());
if (!this.noBackground) {
this.scene.background = new runtime.Color(0xf0f0f0);
await this.loadBackground();
}
// Camera erstellen
const aspect = container.clientWidth / container.clientHeight;
this.camera = markRaw(new runtime.PerspectiveCamera(50, aspect, 0.1, 1000));
this.camera.position.set(0, 1.5, 3);
this.camera.lookAt(0, 1, 0);
// Renderer erstellen
this.renderer = markRaw(new runtime.WebGLRenderer({ antialias: true, alpha: true }));
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(this.renderer.domElement);
// Verbesserte Beleuchtung für hellere Modelle
// Mehr ambient light für gleichmäßigere Ausleuchtung
const ambientLight = new runtime.AmbientLight(0xffffff, 1.0);
this.scene.add(ambientLight);
// Hauptlicht von vorne oben - stärker
const directionalLight = new runtime.DirectionalLight(0xffffff, 1.2);
directionalLight.position.set(5, 10, 5);
this.scene.add(directionalLight);
// Zusätzliches Licht von hinten - heller
const backLight = new runtime.DirectionalLight(0xffffff, 0.75);
backLight.position.set(-5, 5, -5);
this.scene.add(backLight);
// Zusätzliches Seitenlicht für mehr Tiefe
const sideLight = new runtime.DirectionalLight(0xffffff, 0.5);
sideLight.position.set(-5, 5, 5);
this.scene.add(sideLight);
// Resize Handler
window.addEventListener('resize', this.onWindowResize);
},
async loadBackground() {
const runtime = await this.ensureThreeRuntime();
// Optimierte Versionen (512×341, ~130 KB); Originale ~3 MB
const backgrounds = ['bg1_opt.png', 'bg2_opt.png'];
const randomBg = backgrounds[Math.floor(Math.random() * backgrounds.length)];
const bgPath = `/images/falukant/backgrounds/${randomBg}`;
const loader = new runtime.TextureLoader();
loader.load(
bgPath,
(texture) => {
// Hintergrund erfolgreich geladen
if (this.scene) {
this.scene.background = texture;
}
},
undefined,
(error) => {
console.warn('Fehler beim Laden des Hintergrunds:', error);
// Fallback auf Standardfarbe bei Fehler
if (this.scene) {
this.scene.background = new runtime.Color(0xf0f0f0);
}
}
);
},
async loadModel() {
if (!this.scene) return;
const modelRuntime = await this.ensureThreeModelRuntime();
const loaders = await this.ensureThreeLoaders();
// 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 dracoLoader = new loaders.DRACOLoader();
dracoLoader.setDecoderPath('/draco/gltf/');
const loader = new loaders.GLTFLoader();
loader.setDRACOLoader(dracoLoader);
const base = getApiBaseURL();
const prefix = base ? `${base}${MODELS_API_PATH}` : MODELS_API_PATH;
// Fallback-Hierarchie:
// Standard:
// 1. Exaktes Altersmodell
// 2. Altersbereich
// 3. Basis-Modell
// Lightweight:
// 1. Altersbereich
// 2. Basis-Modell
const exactAgePath = this.exactAgeModelPath;
const ageGroupPath = this.modelPath;
const fallbackPath = `${prefix}/${this.actualGender}.glb`;
let gltf;
try {
if (this.lightweight) {
try {
gltf = await loader.loadAsync(ageGroupPath);
} catch (ageGroupError) {
gltf = await loader.loadAsync(fallbackPath);
}
} else {
try {
gltf = await loader.loadAsync(exactAgePath);
} catch (exactAgeError) {
try {
gltf = await loader.loadAsync(ageGroupPath);
} catch (ageGroupError) {
gltf = await loader.loadAsync(fallbackPath);
}
}
}
} finally {
dracoLoader.dispose();
}
// Modell als raw markieren, um Vue's Reactivity zu vermeiden
this.model = markRaw(gltf.scene);
// Initiale Bounding Box für Größenberechnung (vor Skalierung)
const initialBox = new modelRuntime.Box3().setFromObject(this.model);
const initialSize = initialBox.getSize(new modelRuntime.Vector3());
// Skalierung basierend auf Alter
const age = this.actualAge;
let ageScale = 1;
if (age <= 3) {
ageScale = 0.4;
} else if (age <= 7) {
ageScale = 0.6;
} else if (age <= 12) {
ageScale = 0.75;
} else if (age <= 17) {
ageScale = 0.9;
} else {
ageScale = 1.0;
}
// Modell skalieren, damit es in die Szene passt
const maxDimension = Math.max(initialSize.x, initialSize.y, initialSize.z);
const targetHeight = 2; // Zielhöhe in 3D-Einheiten
const modelScale = (targetHeight / maxDimension) * ageScale;
this.model.scale.set(modelScale, modelScale, modelScale);
// Bounding Box NACH dem Skalieren neu berechnen
const scaledBox = new modelRuntime.Box3().setFromObject(this.model);
const scaledCenter = scaledBox.getCenter(new modelRuntime.Vector3());
// Modell zentrieren basierend auf der skalierten Bounding Box
// Position direkt setzen statt zu subtrahieren, um Proxy-Probleme zu vermeiden
const baseY = -scaledBox.min.y; // Auf Boden setzen (y=0 entspricht dem unteren Rand)
this.model.position.set(-scaledCenter.x, baseY, -scaledCenter.z);
this.baseYPosition = baseY; // Basisposition für Animation speichern
this.scene.add(this.model);
// Animationen laden falls vorhanden
if (gltf.animations && gltf.animations.length > 0) {
this.mixer = markRaw(new modelRuntime.AnimationMixer(this.model));
gltf.animations.forEach((clip) => {
this.mixer.clipAction(clip).play();
});
}
// Keine Rotation - Figuren bleiben statisch
if (this.model) {
this.model.rotation.y = 0;
}
} catch (error) {
console.error('Error loading 3D model:', error);
this.showFallback = true;
}
},
animate() {
this.animationId = requestAnimationFrame(this.animate);
if (!this.clock) {
return;
}
const delta = this.clock.getDelta();
// Animation-Mixer aktualisieren
if (this.mixer) {
this.mixer.update(delta);
}
// Keine Bewegung - Modelle bleiben statisch
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();
}
}
}
};
</script>
<style scoped>
.character-3d-shell {
width: 100%;
height: 100%;
min-height: 0;
position: relative;
}
.character-3d-container {
width: 100%;
height: 100%;
min-height: 0;
position: relative;
overflow: hidden;
}
.character-fallback {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center bottom;
}
</style>