fix(falukant): combine overview house and character scene
All checks were successful
Deploy to production / deploy (push) Successful in 2m2s

This commit is contained in:
Torsten Schulz (local)
2026-09-30 14:50:07 +02:00
parent c02bc07265
commit 73b83727f8
2 changed files with 73 additions and 27 deletions

View File

@@ -8,12 +8,20 @@
import { markRaw } from 'vue';
const MODEL_BASE_PATH = '/models/3d/falukant/houses';
const CHARACTER_MODELS_API_PATH = '/api/models/3d/falukant/characters';
const CHARACTER_MODEL_AGES = Object.freeze([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 20, 25, 30, 40, 50, 60]);
const CHARACTER_MODEL_ASSET_REVISION = '20260930-2';
export default {
name: 'House3D',
props: {
model: { type: String, required: true },
label: { type: String, default: '' },
// When supplied, the character is loaded into the same Three.js scene as
// the house. This keeps their depth relationship intact while rotating or
// zooming the overview scene.
characterGender: { type: String, default: null },
characterAge: { type: Number, default: null },
// List previews must not keep a WebGL context while they are outside the
// scroll viewport. A page with several houses otherwise exhausts the
// browser's per-site context limit after navigating around Falukant.
@@ -25,6 +33,7 @@ export default {
camera: null,
renderer: null,
renderedModel: null,
renderedCharacter: null,
controls: null,
onControlsChange: null,
resizeObserver: null,
@@ -38,6 +47,12 @@ export default {
model() {
if (this.isVisible) this.loadModel();
},
characterGender() {
if (this.isVisible) this.loadModel();
},
characterAge() {
if (this.isVisible) this.loadModel();
},
},
async mounted() {
if (!this.lazy) {
@@ -110,18 +125,18 @@ export default {
const lifecycleToken = this.lifecycleToken;
this.loadFailed = false;
this.removeModel();
let dracoLoader = null;
try {
const [loaders, modelRuntime] = await Promise.all([
import('@/utils/threeLoaders.js'),
import('@/utils/threeModelRuntime.js'),
]);
const dracoLoader = new loaders.DRACOLoader();
dracoLoader = new loaders.DRACOLoader();
dracoLoader.setDecoderPath('/draco/gltf/');
const loader = new loaders.GLTFLoader();
loader.setDRACOLoader(dracoLoader);
const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`);
dracoLoader.dispose();
if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) {
this.disposeObject(gltf.scene);
@@ -141,13 +156,62 @@ export default {
const center = scaledBox.getCenter(new modelRuntime.Vector3());
this.renderedModel.position.set(-center.x, -scaledBox.min.y, -center.z);
this.scene.add(this.renderedModel);
await this.loadForegroundCharacter(loader, modelRuntime, lifecycleToken);
this.controls?.update();
this.render();
} catch (error) {
console.error(`Unable to load 3D house model '${this.model}'`, error);
this.loadFailed = true;
} finally {
dracoLoader?.dispose();
}
},
async loadForegroundCharacter(loader, modelRuntime, lifecycleToken) {
const gender = this.characterGender === 'female' ? 'female' : this.characterGender === 'male' ? 'male' : null;
const requestedAge = Number(this.characterAge);
if (!gender || !Number.isFinite(requestedAge)) return;
const candidateAges = CHARACTER_MODEL_AGES
.filter((age) => age <= requestedAge)
.sort((left, right) => right - left);
let gltf = null;
for (const age of candidateAges) {
try {
gltf = await loader.loadAsync(
`${CHARACTER_MODELS_API_PATH}/${gender}_${age}y_opt.glb?v=${CHARACTER_MODEL_ASSET_REVISION}`
);
break;
} catch (_) {
// The next lower concrete age is an intentional fallback.
}
}
if (!gltf) return;
if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) {
this.disposeObject(gltf.scene);
return;
}
this.renderedCharacter = markRaw(gltf.scene);
const box = new modelRuntime.Box3().setFromObject(this.renderedCharacter);
const size = box.getSize(new modelRuntime.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z);
if (!Number.isFinite(maxDimension) || maxDimension <= 0) {
this.disposeObject(this.renderedCharacter);
this.renderedCharacter = null;
return;
}
const ageScale = requestedAge <= 3 ? 0.6 : requestedAge <= 7 ? 0.8 : requestedAge <= 12 ? 0.9 : 0.98;
this.renderedCharacter.scale.setScalar((1.7 / maxDimension) * ageScale);
const scaledBox = new modelRuntime.Box3().setFromObject(this.renderedCharacter);
const center = scaledBox.getCenter(new modelRuntime.Vector3());
// The house model is centered at z=0. Place the figure a little in
// front of it, rather than overlaying an unrelated canvas.
this.renderedCharacter.position.set(-center.x, -scaledBox.min.y, 0.62 - center.z);
this.scene.add(this.renderedCharacter);
},
render() {
const container = this.$refs.container;
if (!container || !this.renderer || !this.camera || !this.scene) return;
@@ -159,10 +223,12 @@ export default {
this.renderer.render(this.scene, this.camera);
},
removeModel() {
if (!this.renderedModel) return;
this.scene.remove(this.renderedModel);
this.disposeObject(this.renderedModel);
[this.renderedModel, this.renderedCharacter].filter(Boolean).forEach((model) => {
this.scene?.remove(model);
this.disposeObject(model);
});
this.renderedModel = null;
this.renderedCharacter = null;
},
disposeObject(object3d) {
object3d?.traverse((object) => {

View File

@@ -34,14 +34,9 @@
class="house"
:model="houseModel"
:label="$t(`falukant.house.type.${falukantUser.userHouse.houseType.labelTr}`)"
:character-gender="falukantUser.character.gender"
:character-age="falukantUser.character.age"
/>
<div class="character-foreground">
<Character3D
:gender="falukantUser.character.gender"
:age="falukantUser.character.age"
:no-background="true"
/>
</div>
</div>
</div>
@@ -369,7 +364,6 @@
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import Character3D from '@/components/Character3D.vue';
import House3D from '@/components/falukant/House3D.vue';
import { getFalukantHouseModel } from '@/utils/falukantHouseModels.js';
import apiClient from '@/utils/axios.js';
@@ -432,7 +426,6 @@ export default {
name: 'FalukantOverviewView',
components: {
StatusBar,
Character3D,
House3D,
},
data() {
@@ -1392,23 +1385,10 @@ export default {
}
.house {
position: absolute;
inset: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-repeat: no-repeat;
image-rendering: crisp-edges;
z-index: 1;
}
.character-foreground {
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
width: 55%;
height: 55%;
z-index: 2;
}
.heir-selection-container {