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
All checks were successful
Deploy to production / deploy (push) Successful in 1m55s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user