feat(falukant): render houses with 3D models
Some checks failed
Deploy to production / deploy (push) Failing after 1m17s

This commit is contained in:
Torsten Schulz (local)
2026-09-25 07:44:53 +02:00
parent 4e20111331
commit d15c56a7b3
5 changed files with 274 additions and 90 deletions

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env node
/**
* Optimiert alle Falukant-Charakter-GLBs mit Draco + Textur-Optimierung.
* Optimiert Falukant-Charakter- und Haus-GLBs mit Draco + Textur-Optimierung.
* Ausgabe: *_opt.glb im selben Verzeichnis.
* Voraussetzung: npm install (@gltf-transform/cli als Dev-Dep)
*
* Aufruf: npm run optimize-models
* Optional können MODEL_SOURCE_DIR und MODEL_OUTPUT_DIR gesetzt werden, um
* Modelle aus dem separaten YourPart3Assets-Repository zu verarbeiten.
* genau einen Modellordner aus dem separaten YourPart3Assets-Repository zu
* verarbeiten.
*/
import fs from 'fs';
@@ -15,24 +16,22 @@ import { fileURLToPath } from 'url';
import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultSourceDir = path.join(__dirname, '..', 'models-src', '3d', 'falukant', 'characters');
const defaultOutputDir = path.join(__dirname, '..', 'public', 'models', '3d', 'falukant', 'characters');
const sourceDir = path.resolve(process.env.MODEL_SOURCE_DIR || defaultSourceDir);
const outputDir = path.resolve(process.env.MODEL_OUTPUT_DIR || defaultOutputDir);
const modelsRoot = path.join(__dirname, '..', 'models-src', '3d', 'falukant');
const publicModelsRoot = path.join(__dirname, '..', 'public', 'models', '3d', 'falukant');
const cli = path.join(__dirname, '..', 'node_modules', '.bin', 'gltf-transform');
function listSourceModels() {
function listSourceModels(sourceDir) {
return fs.readdirSync(sourceDir)
.filter((file) => file.endsWith('.glb'))
.filter((file) => !file.endsWith('_opt.glb'))
.sort((a, b) => a.localeCompare(b));
}
function ensureOutputDir() {
function ensureOutputDir(outputDir) {
fs.mkdirSync(outputDir, { recursive: true });
}
function removeStaleOptimizedModels() {
function removeStaleOptimizedModels(outputDir) {
if (!fs.existsSync(outputDir)) {
return;
}
@@ -43,43 +42,74 @@ function removeStaleOptimizedModels() {
}
}
console.log('Optimize Falukant character GLBs (Draco + texture 1024)\n');
if (!fs.existsSync(sourceDir)) {
console.error(`Source directory not found: ${sourceDir}`);
process.exit(1);
}
ensureOutputDir();
removeStaleOptimizedModels();
const models = listSourceModels();
if (models.length === 0) {
console.log('No source GLBs found.');
process.exit(0);
}
for (const f of models) {
const input = path.join(sourceDir, f);
const out = f.replace(/\.glb$/, '_opt.glb');
const output = path.join(outputDir, out);
try {
const result = spawnSync(
'node',
[cli, 'optimize', input, output, '--compress', 'draco', '--texture-size', '1024'],
{
stdio: 'inherit',
cwd: path.join(__dirname, '..'),
}
);
if (result.status !== 0) {
throw new Error(`gltf-transform exited with ${result.status}`);
function optimizeModelDirectory({ name, sourceDir, outputDir, required = false }) {
if (!fs.existsSync(sourceDir)) {
if (required) {
console.error(`Source directory not found: ${sourceDir}`);
process.exitCode = 1;
}
return;
}
ensureOutputDir(outputDir);
removeStaleOptimizedModels(outputDir);
const models = listSourceModels(sourceDir);
if (models.length === 0) {
console.log(`No ${name} GLBs found.`);
return;
}
console.log(`Optimize Falukant ${name} GLBs (Draco + texture 1024)\n`);
for (const f of models) {
const input = path.join(sourceDir, f);
const out = f.replace(/\.glb$/, '_opt.glb');
const output = path.join(outputDir, out);
try {
const result = spawnSync(
'node',
[cli, 'optimize', input, output, '--compress', 'draco', '--texture-size', '1024'],
{
stdio: 'inherit',
cwd: path.join(__dirname, '..'),
}
);
if (result.status !== 0) {
throw new Error(`gltf-transform exited with ${result.status}`);
}
} catch (e) {
console.error(`Failed: ${f}`);
process.exitCode = 1;
}
} catch (e) {
console.error(`Failed: ${f}`);
process.exitCode = 1;
}
}
console.log(`\nDone. Optimized models written to ${outputDir}`);
const customSourceDir = process.env.MODEL_SOURCE_DIR;
const customOutputDir = process.env.MODEL_OUTPUT_DIR;
if (Boolean(customSourceDir) !== Boolean(customOutputDir)) {
console.error('MODEL_SOURCE_DIR and MODEL_OUTPUT_DIR must be set together.');
process.exit(1);
}
if (customSourceDir) {
optimizeModelDirectory({
name: 'custom',
sourceDir: path.resolve(customSourceDir),
outputDir: path.resolve(customOutputDir),
required: true,
});
} else {
optimizeModelDirectory({
name: 'character',
sourceDir: path.join(modelsRoot, 'characters'),
outputDir: path.join(publicModelsRoot, 'characters'),
required: true,
});
optimizeModelDirectory({
name: 'house',
sourceDir: path.join(modelsRoot, 'houses'),
outputDir: path.join(publicModelsRoot, 'houses'),
});
}

View File

@@ -0,0 +1,156 @@
<template>
<div ref="container" class="house-3d" role="img" :aria-label="label || 'Haus'">
<span v-if="loadFailed" class="house-3d__unavailable">3D-Modell nicht verfügbar</span>
</div>
</template>
<script>
import { markRaw } from 'vue';
const MODEL_BASE_PATH = '/models/3d/falukant/houses';
export default {
name: 'House3D',
props: {
model: { type: String, required: true },
label: { type: String, default: '' },
},
data() {
return {
scene: null,
camera: null,
renderer: null,
renderedModel: null,
resizeObserver: null,
loadFailed: false,
};
},
watch: {
model() {
this.loadModel();
},
},
async mounted() {
await this.init();
await this.loadModel();
},
beforeUnmount() {
this.dispose();
},
methods: {
async init() {
const container = this.$refs.container;
if (!container || import.meta.env.VITE_DISABLE_3D === 'true') return;
const runtime = await import('@/utils/threeRuntime.js');
this.scene = markRaw(new runtime.Scene());
this.scene.background = new runtime.Color(0xf7f1e6);
this.camera = markRaw(new runtime.PerspectiveCamera(36, 1, 0.1, 100));
this.renderer = markRaw(new runtime.WebGLRenderer({ antialias: true, alpha: false }));
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
container.appendChild(this.renderer.domElement);
this.scene.add(new runtime.HemisphereLight(0xfff8e8, 0x665b50, 2.2));
const keyLight = new runtime.DirectionalLight(0xffffff, 2.8);
keyLight.position.set(4, 7, 5);
this.scene.add(keyLight);
const fillLight = new runtime.DirectionalLight(0xffead0, 1.2);
fillLight.position.set(-4, 3, -4);
this.scene.add(fillLight);
this.resizeObserver = new ResizeObserver(() => this.render());
this.resizeObserver.observe(container);
},
async loadModel() {
if (!this.scene || !this.renderer || !this.model) return;
this.loadFailed = false;
this.removeModel();
try {
const [loaders, modelRuntime] = await Promise.all([
import('@/utils/threeLoaders.js'),
import('@/utils/threeModelRuntime.js'),
]);
const 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();
this.renderedModel = markRaw(gltf.scene);
const box = new modelRuntime.Box3().setFromObject(this.renderedModel);
const size = box.getSize(new modelRuntime.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z);
if (!Number.isFinite(maxDimension) || maxDimension <= 0) throw new Error('Empty house model');
const targetSize = 2.6;
const scale = targetSize / maxDimension;
this.renderedModel.scale.setScalar(scale);
const scaledBox = new modelRuntime.Box3().setFromObject(this.renderedModel);
const center = scaledBox.getCenter(new modelRuntime.Vector3());
this.renderedModel.position.set(-center.x, -scaledBox.min.y, -center.z);
this.scene.add(this.renderedModel);
this.render();
} catch (error) {
console.error(`Unable to load 3D house model '${this.model}'`, error);
this.loadFailed = true;
}
},
render() {
const container = this.$refs.container;
if (!container || !this.renderer || !this.camera || !this.scene) return;
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
this.camera.aspect = width / height;
this.camera.position.set(0, 1.5, 4.1);
this.camera.lookAt(0, 1.05, 0);
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height, false);
this.renderer.render(this.scene, this.camera);
},
removeModel() {
if (!this.renderedModel) return;
this.scene.remove(this.renderedModel);
this.renderedModel.traverse((object) => {
object.geometry?.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material];
materials.filter(Boolean).forEach((material) => material.dispose());
});
this.renderedModel = null;
},
dispose() {
this.resizeObserver?.disconnect();
this.removeModel();
if (this.renderer) {
this.$refs.container?.removeChild(this.renderer.domElement);
this.renderer.dispose();
}
this.scene?.clear();
},
},
};
</script>
<style scoped>
.house-3d {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: inherit;
background: #f7f1e6;
}
.house-3d__unavailable {
display: grid;
width: 100%;
height: 100%;
place-items: center;
padding: 12px;
color: var(--color-text-secondary);
font-size: 0.82rem;
text-align: center;
}
</style>

View File

@@ -0,0 +1,15 @@
const HOUSE_MODELS = Object.freeze({
'Unter der Brücke': 'under_bridge',
'Strohhütte': 'straw_hut',
Holzhaus: 'wooden_house',
Hinterhofzimmer: 'backyard_room',
'Kleines Familienhaus': 'family_house',
Stadthaus: 'townhouse',
Villa: 'villa',
Herrenhaus: 'mansion',
Schloss: 'castle',
});
export function getFalukantHouseModel(houseType) {
return HOUSE_MODELS[houseType?.labelTr] || 'under_bridge';
}

View File

@@ -19,7 +19,12 @@
</p>
</section>
<div class="existing-house">
<div :style="houseType ? houseStyle(houseType.position, 341) : {}" class="house"></div>
<House3D
v-if="houseType?.labelTr"
class="house"
:model="houseModel(houseType)"
:label="$t(`falukant.house.type.${houseType.labelTr}`)"
/>
<div class="status-panel surface-card">
<h3>{{ $t('falukant.house.statusreport') }}</h3>
<div class="status-cards">
@@ -128,8 +133,12 @@
<h3>{{ $t('falukant.house.buyablehouses') }}</h3>
<div class="houses-list">
<div v-for="house in buyableHouses" :key="house.id" class="house-item">
<div :style="house.houseType ? houseStyle(house.houseType.position, 114) : {}"
class="house-preview"></div>
<House3D
v-if="house.houseType?.labelTr"
class="house-preview"
:model="houseModel(house.houseType)"
:label="$t(`falukant.house.type.${house.houseType.labelTr}`)"
/>
<div class="house-info">
<h4>{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}</h4>
<div class="buyable-house-stats">
@@ -155,13 +164,15 @@
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import House3D from '@/components/falukant/House3D.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
import { showError, showSuccess, confirmAction } from '@/utils/feedback.js';
import { getFalukantHouseModel } from '@/utils/falukantHouseModels.js';
export default {
name: 'HouseView',
components: { StatusBar },
components: { StatusBar, House3D },
data() {
return {
userHouse: null,
@@ -222,17 +233,8 @@ export default {
if (v >= 1) return this.$t('falukant.conditionBand.catastrophic');
return this.$t('falukant.conditionBand.unknown');
},
houseStyle(position, picSize) {
const columns = 3;
const size = picSize;
const index = position - 1;
const x = (index % columns) * size;
const y = Math.floor(index / columns) * size;
return {
backgroundImage: 'url("/images/falukant/houses.png")',
backgroundPosition: `-${x}px -${y}px`,
backgroundSize: `${columns * size}px auto`
};
houseModel(houseType) {
return getFalukantHouseModel(houseType);
},
formatPrice(value) {
return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
@@ -456,9 +458,6 @@ h2 {
.house {
width: 341px;
height: 341px;
background-repeat: no-repeat;
image-rendering: crisp-edges;
border: 1px solid var(--color-border);
border-radius: 4px;
}
@@ -597,14 +596,7 @@ h2 {
.house-preview {
width: 100px;
height: 100px;
background-repeat: no-repeat;
image-rendering: crisp-edges;
border: 1px solid var(--color-border);
border-radius: 4px;
background-size: contain;
/* scale image to container */
background-position: center;
/* center sprite */
}
.house-info {

View File

@@ -29,7 +29,12 @@
<div v-if="falukantUser?.character && !falukantUser?.debtorsPrison?.inDebtorsPrison" class="imagecontainer">
<div v-if="showPortraitAvatar" :style="getAvatarStyle" class="avatar"></div>
<div v-if="showCharacterFigure3d" class="house-with-character">
<div :style="getHouseStyle" class="house"></div>
<House3D
v-if="falukantUser.userHouse?.houseType"
class="house"
:model="houseModel"
:label="$t(`falukant.house.type.${falukantUser.userHouse.houseType.labelTr}`)"
/>
<div class="character-foreground">
<Character3D
:gender="falukantUser.character.gender"
@@ -365,6 +370,8 @@
<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';
import { showError, showSuccess } from '@/utils/feedback.js';
import { mapState } from 'vuex';
@@ -426,6 +433,7 @@ export default {
components: {
StatusBar,
Character3D,
House3D,
},
data() {
return {
@@ -463,25 +471,8 @@ export default {
height: `${height}px`,
};
},
getHouseStyle() {
if (!this.falukantUser || !this.falukantUser.userHouse?.houseType) return {};
const imageUrl = '/images/falukant/houses.png';
const pos = this.falukantUser.userHouse.houseType.position;
const index = pos - 1;
const columns = 3;
const spriteSize = 300;
const x = (index % columns) * spriteSize;
const y = Math.floor(index / columns) * spriteSize;
return {
backgroundImage: `url(${imageUrl})`,
backgroundPosition: `-${x}px -${y}px`,
backgroundSize: `${columns * spriteSize}px auto`,
width: `300px`,
height: `300px`,
border: '1px solid #ccc',
borderRadius: '4px',
imageRendering: 'crisp-edges',
};
houseModel() {
return getFalukantHouseModel(this.falukantUser?.userHouse?.houseType);
},
moneyValue() {
const m = this.falukantUser?.money;