486 lines
14 KiB
Vue
486 lines
14 KiB
Vue
<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>
|