feat: Füge Debug-Overlay und Alters-/Geschlechtsanpassungen für 3D-Charaktere hinzu
All checks were successful
Deploy to production / deploy (push) Successful in 1m55s

This commit is contained in:
Torsten Schulz (local)
2026-05-21 09:55:11 +02:00
parent 3df7abe628
commit ad0ccd0281
11 changed files with 364 additions and 24 deletions

View File

@@ -7,6 +7,16 @@
:src="fallbackImageSrc"
:alt="`Character ${actualGender}`"
/>
<div v-if="isDev" class="character-debug-overlay">
<div><strong>3D Debug</strong></div>
<div>model: {{ debugInfo.modelType || '—' }}</div>
<div>path: {{ debugInfo.modelPath || '—' }}</div>
<div>age: {{ debugInfo.actualAge || '—' }} ({{ debugInfo.ageGroup || '—' }})</div>
<div>camera: <span v-if="debugInfo.camera">{{ debugInfo.camera.x.toFixed(2) }}, {{ debugInfo.camera.y.toFixed(2) }}, {{ debugInfo.camera.z.toFixed(2) }}</span><span v-else></span></div>
<div>camDist: {{ debugInfo.cameraDistance || '—' }}</div>
<div>lightweight: {{ debugInfo.lightweight }}</div>
<div>lazy: {{ debugInfo.lazy }}</div>
</div>
</div>
</template>
@@ -16,6 +26,46 @@ 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';
// Optional: zusätzliche Namenskonventionen, die vor dem Standardpfad ausprobiert werden.
// Hier werden mögliche Alternativ-Dateinamen aufgeführt, die für bestimmte Altersstufen
// (z.B. weibliche Modelle für 1-4 Jahre) existieren könnten. Trage hier die tatsächlichen
// Dateinamen ein, falls dein Backend solche Varianten enthält.
const AGE_ALTERNATIVE_SUFFIXES = [
'_alt.glb',
'_special.glb',
'_v2.glb'
];
// Per-age camera offsets applied after auto-fit. Values are additive (meters).
// Set `y` to raise/lower the camera, `z` to move it closer/further.
// The mapping supports per-age overrides; if an exact age key is missing the
// gender `default` entry will be used. Tune these numbers visually.
const AGE_CAMERA_OVERRIDES = {
female: {
default: { y: 0.35, z: 0.35 },
'1': { y: 0.0, z: 0.0 },
'2': { y: 0.0, z: 0.0 },
'5': { y: 0.0, z: 0.0 }
},
male: {
default: { y: 0.25, z: 0.25 }
}
};
// Per-age model position overrides (y in meters). Positive raises the model,
// negative moves it down. Defaults move most characters slightly down to match
// the visual baseline; specific ages (e.g. female 1/2/5) keep no offset.
const AGE_MODEL_POSITION_OVERRIDES = {
female: {
default: { y: -1 },
'1': { y: 0 },
'2': { y: 0 },
'5': { y: 0 }
},
male: {
default: { y: -1 }
}
};
let threeRuntimePromise = null;
let threeLoadersPromise = null;
let threeModelRuntimePromise = null;
@@ -89,8 +139,23 @@ export default {
,resizeObserver: null
,intersectionObserver: null
,isVisible: false
,debugInfo: {
modelPath: null,
modelType: null,
camera: null,
cameraDistance: null,
ageGroup: null,
actualAge: null,
lightweight: false,
lazy: false
}
};
},
computed: {
isDev() {
return !import.meta.env.PROD;
}
},
computed: {
actualGender() {
if (this.gender) {
@@ -157,6 +222,9 @@ export default {
},
async mounted() {
const container = this.$refs.container;
// expose runtime props for debug overlay
this.debugInfo.lightweight = !!this.lightweight;
this.debugInfo.lazy = !!this.lazy;
if (this.lazy && typeof window !== 'undefined' && 'IntersectionObserver' in window && container) {
// Defer initialization until the element is in viewport
this.intersectionObserver = new window.IntersectionObserver((entries) => {
@@ -375,25 +443,49 @@ export default {
const ageGroupPath = this.modelPath;
const fallbackPath = `${prefix}/${this.actualGender}.glb`;
// Build a list of candidate paths. For every age and gender we first try
// possible alternate filenames for the exact-age model (e.g. female_1y_alt.glb),
// then the exact-age file, then the age-group file, and finally the base gender
// fallback. This makes the age-specific model the primary source and the
// age-group only a later fallback.
const candidates = [];
const modelAge = this.actualAge;
// Try exact-age variants with configured suffixes first (works for any gender/age)
for (const sfx of AGE_ALTERNATIVE_SUFFIXES) {
candidates.push(`${prefix}/${this.actualGender}_${modelAge}y${sfx}`);
}
// Standard preference order: exact age, age group, fallback
candidates.push(exactAgePath);
candidates.push(ageGroupPath);
candidates.push(fallbackPath);
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) {
let chosenPath = null;
// Try candidates in order until one loads
for (const candidate of candidates) {
try {
gltf = await loader.loadAsync(ageGroupPath);
} catch (ageGroupError) {
gltf = await loader.loadAsync(fallbackPath);
chosenPath = candidate;
gltf = await loader.loadAsync(candidate);
// loaded successfully
break;
} catch (err) {
// try next candidate
}
}
}
if (!gltf) {
throw new Error('No model candidate could be loaded');
}
// store for debug overlay
this.debugInfo.modelPath = chosenPath;
// determine modelType
if (chosenPath === exactAgePath) this.debugInfo.modelType = 'exactAge';
else if (chosenPath === ageGroupPath) this.debugInfo.modelType = 'ageGroup';
else if (chosenPath === fallbackPath) this.debugInfo.modelType = 'fallback';
else this.debugInfo.modelType = 'alternate';
} finally {
dracoLoader.dispose();
}
@@ -435,7 +527,20 @@ export default {
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
// Apply per-age/gender model position overrides (vertical shift)
try {
const genderMap = AGE_MODEL_POSITION_OVERRIDES[this.actualGender] || {};
const specific = genderMap[String(this.actualAge)];
const def = genderMap.default;
const used = specific || def;
if (used && typeof used.y === 'number') {
this.model.position.y += used.y;
this.debugInfo.modelOffset = { y: used.y };
}
} catch (e) {
// ignore position override errors
}
this.scene.add(this.model);
// Auto-Kamera-Anpassung: Abstand so setzen, dass die modell-Höhe gut ins Bild passt
@@ -446,11 +551,33 @@ export default {
const desiredHeight = Math.max(scaledSize.y, scaledSize.z) * 1.15; // etwas Margin
let distance = desiredHeight / (2 * Math.tan(fovRad / 2));
if (!isFinite(distance) || distance <= 0) distance = 3;
const camY = scaledCenter.y + Math.max(0.5, scaledSize.y * 0.15);
const camZ = distance + 0.6; // kleiner Offset, damit etwas Raum vor dem Modell ist
let camY = scaledCenter.y + Math.max(0.5, scaledSize.y * 0.15);
let camZ = distance + 0.6; // kleiner Offset, damit etwas Raum vor dem Modell ist
// Apply per-age/gender camera overrides if configured
try {
const genderMap = AGE_CAMERA_OVERRIDES[this.actualGender] || {};
const specific = genderMap[String(this.actualAge)];
const def = genderMap.default;
const used = specific || def;
if (used) {
camY += (used.y || 0);
camZ += (used.z || 0);
// expose applied offsets for easier debugging
this.debugInfo.cameraOffset = { y: (used.y || 0), z: (used.z || 0) };
}
} catch (e) {
// ignore override errors
}
this.camera.position.set(0, camY, camZ);
this.camera.lookAt(scaledCenter.x, scaledCenter.y, scaledCenter.z);
this.onWindowResize();
// debug info
this.debugInfo.camera = { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z };
this.debugInfo.cameraDistance = camZ;
this.debugInfo.ageGroup = this.ageGroup;
this.debugInfo.actualAge = this.actualAge;
}
} catch (e) {
// ignore camera auto-fit errors
@@ -582,4 +709,19 @@ export default {
object-fit: contain;
object-position: center bottom;
}
.character-debug-overlay {
position: absolute;
right: 6px;
top: 6px;
background: rgba(0,0,0,0.6);
color: #fff;
font-size: 11px;
padding: 6px 8px;
border-radius: 6px;
line-height: 1.2;
z-index: 1000;
pointer-events: none;
}
.character-debug-overlay div { opacity: 0.95 }
</style>