fix(falukant): combine overview house and character scene
All checks were successful
Deploy to production / deploy (push) Successful in 2m2s
All checks were successful
Deploy to production / deploy (push) Successful in 2m2s
This commit is contained in:
@@ -8,12 +8,20 @@
|
|||||||
import { markRaw } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
const MODEL_BASE_PATH = '/models/3d/falukant/houses';
|
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 {
|
export default {
|
||||||
name: 'House3D',
|
name: 'House3D',
|
||||||
props: {
|
props: {
|
||||||
model: { type: String, required: true },
|
model: { type: String, required: true },
|
||||||
label: { type: String, default: '' },
|
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
|
// List previews must not keep a WebGL context while they are outside the
|
||||||
// scroll viewport. A page with several houses otherwise exhausts the
|
// scroll viewport. A page with several houses otherwise exhausts the
|
||||||
// browser's per-site context limit after navigating around Falukant.
|
// browser's per-site context limit after navigating around Falukant.
|
||||||
@@ -25,6 +33,7 @@ export default {
|
|||||||
camera: null,
|
camera: null,
|
||||||
renderer: null,
|
renderer: null,
|
||||||
renderedModel: null,
|
renderedModel: null,
|
||||||
|
renderedCharacter: null,
|
||||||
controls: null,
|
controls: null,
|
||||||
onControlsChange: null,
|
onControlsChange: null,
|
||||||
resizeObserver: null,
|
resizeObserver: null,
|
||||||
@@ -38,6 +47,12 @@ export default {
|
|||||||
model() {
|
model() {
|
||||||
if (this.isVisible) this.loadModel();
|
if (this.isVisible) this.loadModel();
|
||||||
},
|
},
|
||||||
|
characterGender() {
|
||||||
|
if (this.isVisible) this.loadModel();
|
||||||
|
},
|
||||||
|
characterAge() {
|
||||||
|
if (this.isVisible) this.loadModel();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
if (!this.lazy) {
|
if (!this.lazy) {
|
||||||
@@ -110,18 +125,18 @@ export default {
|
|||||||
const lifecycleToken = this.lifecycleToken;
|
const lifecycleToken = this.lifecycleToken;
|
||||||
this.loadFailed = false;
|
this.loadFailed = false;
|
||||||
this.removeModel();
|
this.removeModel();
|
||||||
|
let dracoLoader = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [loaders, modelRuntime] = await Promise.all([
|
const [loaders, modelRuntime] = await Promise.all([
|
||||||
import('@/utils/threeLoaders.js'),
|
import('@/utils/threeLoaders.js'),
|
||||||
import('@/utils/threeModelRuntime.js'),
|
import('@/utils/threeModelRuntime.js'),
|
||||||
]);
|
]);
|
||||||
const dracoLoader = new loaders.DRACOLoader();
|
dracoLoader = new loaders.DRACOLoader();
|
||||||
dracoLoader.setDecoderPath('/draco/gltf/');
|
dracoLoader.setDecoderPath('/draco/gltf/');
|
||||||
const loader = new loaders.GLTFLoader();
|
const loader = new loaders.GLTFLoader();
|
||||||
loader.setDRACOLoader(dracoLoader);
|
loader.setDRACOLoader(dracoLoader);
|
||||||
const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`);
|
const gltf = await loader.loadAsync(`${MODEL_BASE_PATH}/${this.model}_opt.glb`);
|
||||||
dracoLoader.dispose();
|
|
||||||
|
|
||||||
if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) {
|
if (!this.isVisible || lifecycleToken !== this.lifecycleToken || !this.scene) {
|
||||||
this.disposeObject(gltf.scene);
|
this.disposeObject(gltf.scene);
|
||||||
@@ -141,13 +156,62 @@ export default {
|
|||||||
const center = scaledBox.getCenter(new modelRuntime.Vector3());
|
const center = scaledBox.getCenter(new modelRuntime.Vector3());
|
||||||
this.renderedModel.position.set(-center.x, -scaledBox.min.y, -center.z);
|
this.renderedModel.position.set(-center.x, -scaledBox.min.y, -center.z);
|
||||||
this.scene.add(this.renderedModel);
|
this.scene.add(this.renderedModel);
|
||||||
|
|
||||||
|
await this.loadForegroundCharacter(loader, modelRuntime, lifecycleToken);
|
||||||
this.controls?.update();
|
this.controls?.update();
|
||||||
this.render();
|
this.render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Unable to load 3D house model '${this.model}'`, error);
|
console.error(`Unable to load 3D house model '${this.model}'`, error);
|
||||||
this.loadFailed = true;
|
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() {
|
render() {
|
||||||
const container = this.$refs.container;
|
const container = this.$refs.container;
|
||||||
if (!container || !this.renderer || !this.camera || !this.scene) return;
|
if (!container || !this.renderer || !this.camera || !this.scene) return;
|
||||||
@@ -159,10 +223,12 @@ export default {
|
|||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
},
|
},
|
||||||
removeModel() {
|
removeModel() {
|
||||||
if (!this.renderedModel) return;
|
[this.renderedModel, this.renderedCharacter].filter(Boolean).forEach((model) => {
|
||||||
this.scene.remove(this.renderedModel);
|
this.scene?.remove(model);
|
||||||
this.disposeObject(this.renderedModel);
|
this.disposeObject(model);
|
||||||
|
});
|
||||||
this.renderedModel = null;
|
this.renderedModel = null;
|
||||||
|
this.renderedCharacter = null;
|
||||||
},
|
},
|
||||||
disposeObject(object3d) {
|
disposeObject(object3d) {
|
||||||
object3d?.traverse((object) => {
|
object3d?.traverse((object) => {
|
||||||
|
|||||||
@@ -34,14 +34,9 @@
|
|||||||
class="house"
|
class="house"
|
||||||
:model="houseModel"
|
:model="houseModel"
|
||||||
:label="$t(`falukant.house.type.${falukantUser.userHouse.houseType.labelTr}`)"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -369,7 +364,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import StatusBar from '@/components/falukant/StatusBar.vue';
|
import StatusBar from '@/components/falukant/StatusBar.vue';
|
||||||
import Character3D from '@/components/Character3D.vue';
|
|
||||||
import House3D from '@/components/falukant/House3D.vue';
|
import House3D from '@/components/falukant/House3D.vue';
|
||||||
import { getFalukantHouseModel } from '@/utils/falukantHouseModels.js';
|
import { getFalukantHouseModel } from '@/utils/falukantHouseModels.js';
|
||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
@@ -432,7 +426,6 @@ export default {
|
|||||||
name: 'FalukantOverviewView',
|
name: 'FalukantOverviewView',
|
||||||
components: {
|
components: {
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Character3D,
|
|
||||||
House3D,
|
House3D,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -1392,23 +1385,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.house {
|
.house {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
image-rendering: crisp-edges;
|
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 {
|
.heir-selection-container {
|
||||||
|
|||||||
Reference in New Issue
Block a user