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 #!/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. * Ausgabe: *_opt.glb im selben Verzeichnis.
* Voraussetzung: npm install (@gltf-transform/cli als Dev-Dep) * Voraussetzung: npm install (@gltf-transform/cli als Dev-Dep)
* *
* Aufruf: npm run optimize-models * Aufruf: npm run optimize-models
* Optional können MODEL_SOURCE_DIR und MODEL_OUTPUT_DIR gesetzt werden, um * 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'; import fs from 'fs';
@@ -15,24 +16,22 @@ import { fileURLToPath } from 'url';
import path from 'path'; import path from 'path';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultSourceDir = path.join(__dirname, '..', 'models-src', '3d', 'falukant', 'characters'); const modelsRoot = path.join(__dirname, '..', 'models-src', '3d', 'falukant');
const defaultOutputDir = path.join(__dirname, '..', 'public', 'models', '3d', 'falukant', 'characters'); const publicModelsRoot = path.join(__dirname, '..', 'public', 'models', '3d', 'falukant');
const sourceDir = path.resolve(process.env.MODEL_SOURCE_DIR || defaultSourceDir);
const outputDir = path.resolve(process.env.MODEL_OUTPUT_DIR || defaultOutputDir);
const cli = path.join(__dirname, '..', 'node_modules', '.bin', 'gltf-transform'); const cli = path.join(__dirname, '..', 'node_modules', '.bin', 'gltf-transform');
function listSourceModels() { function listSourceModels(sourceDir) {
return fs.readdirSync(sourceDir) return fs.readdirSync(sourceDir)
.filter((file) => file.endsWith('.glb')) .filter((file) => file.endsWith('.glb'))
.filter((file) => !file.endsWith('_opt.glb')) .filter((file) => !file.endsWith('_opt.glb'))
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
} }
function ensureOutputDir() { function ensureOutputDir(outputDir) {
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
} }
function removeStaleOptimizedModels() { function removeStaleOptimizedModels(outputDir) {
if (!fs.existsSync(outputDir)) { if (!fs.existsSync(outputDir)) {
return; return;
} }
@@ -43,43 +42,74 @@ function removeStaleOptimizedModels() {
} }
} }
console.log('Optimize Falukant character GLBs (Draco + texture 1024)\n'); function optimizeModelDirectory({ name, sourceDir, outputDir, required = false }) {
if (!fs.existsSync(sourceDir)) {
if (!fs.existsSync(sourceDir)) { if (required) {
console.error(`Source directory not found: ${sourceDir}`); console.error(`Source directory not found: ${sourceDir}`);
process.exit(1); process.exitCode = 1;
} }
return;
ensureOutputDir(); }
removeStaleOptimizedModels();
ensureOutputDir(outputDir);
const models = listSourceModels(); removeStaleOptimizedModels(outputDir);
if (models.length === 0) {
console.log('No source GLBs found.'); const models = listSourceModels(sourceDir);
process.exit(0); if (models.length === 0) {
} console.log(`No ${name} GLBs found.`);
return;
for (const f of models) { }
const input = path.join(sourceDir, f);
const out = f.replace(/\.glb$/, '_opt.glb'); console.log(`Optimize Falukant ${name} GLBs (Draco + texture 1024)\n`);
const output = path.join(outputDir, out);
for (const f of models) {
try { const input = path.join(sourceDir, f);
const result = spawnSync( const out = f.replace(/\.glb$/, '_opt.glb');
'node', const output = path.join(outputDir, out);
[cli, 'optimize', input, output, '--compress', 'draco', '--texture-size', '1024'],
{ try {
stdio: 'inherit', const result = spawnSync(
cwd: path.join(__dirname, '..'), 'node',
} [cli, 'optimize', input, output, '--compress', 'draco', '--texture-size', '1024'],
); {
if (result.status !== 0) { stdio: 'inherit',
throw new Error(`gltf-transform exited with ${result.status}`); 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> </p>
</section> </section>
<div class="existing-house"> <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"> <div class="status-panel surface-card">
<h3>{{ $t('falukant.house.statusreport') }}</h3> <h3>{{ $t('falukant.house.statusreport') }}</h3>
<div class="status-cards"> <div class="status-cards">
@@ -128,8 +133,12 @@
<h3>{{ $t('falukant.house.buyablehouses') }}</h3> <h3>{{ $t('falukant.house.buyablehouses') }}</h3>
<div class="houses-list"> <div class="houses-list">
<div v-for="house in buyableHouses" :key="house.id" class="house-item"> <div v-for="house in buyableHouses" :key="house.id" class="house-item">
<div :style="house.houseType ? houseStyle(house.houseType.position, 114) : {}" <House3D
class="house-preview"></div> v-if="house.houseType?.labelTr"
class="house-preview"
:model="houseModel(house.houseType)"
:label="$t(`falukant.house.type.${house.houseType.labelTr}`)"
/>
<div class="house-info"> <div class="house-info">
<h4>{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}</h4> <h4>{{ $t(`falukant.house.type.${house.houseType.labelTr}`) }}</h4>
<div class="buyable-house-stats"> <div class="buyable-house-stats">
@@ -155,13 +164,15 @@
<script> <script>
import StatusBar from '@/components/falukant/StatusBar.vue'; import StatusBar from '@/components/falukant/StatusBar.vue';
import House3D from '@/components/falukant/House3D.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import { showError, showSuccess, confirmAction } from '@/utils/feedback.js'; import { showError, showSuccess, confirmAction } from '@/utils/feedback.js';
import { getFalukantHouseModel } from '@/utils/falukantHouseModels.js';
export default { export default {
name: 'HouseView', name: 'HouseView',
components: { StatusBar }, components: { StatusBar, House3D },
data() { data() {
return { return {
userHouse: null, userHouse: null,
@@ -222,17 +233,8 @@ export default {
if (v >= 1) return this.$t('falukant.conditionBand.catastrophic'); if (v >= 1) return this.$t('falukant.conditionBand.catastrophic');
return this.$t('falukant.conditionBand.unknown'); return this.$t('falukant.conditionBand.unknown');
}, },
houseStyle(position, picSize) { houseModel(houseType) {
const columns = 3; return getFalukantHouseModel(houseType);
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`
};
}, },
formatPrice(value) { formatPrice(value) {
return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value); return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
@@ -456,9 +458,6 @@ h2 {
.house { .house {
width: 341px; width: 341px;
height: 341px; height: 341px;
background-repeat: no-repeat;
image-rendering: crisp-edges;
border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
} }
@@ -597,14 +596,7 @@ h2 {
.house-preview { .house-preview {
width: 100px; width: 100px;
height: 100px; height: 100px;
background-repeat: no-repeat;
image-rendering: crisp-edges;
border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
background-size: contain;
/* scale image to container */
background-position: center;
/* center sprite */
} }
.house-info { .house-info {

View File

@@ -29,7 +29,12 @@
<div v-if="falukantUser?.character && !falukantUser?.debtorsPrison?.inDebtorsPrison" class="imagecontainer"> <div v-if="falukantUser?.character && !falukantUser?.debtorsPrison?.inDebtorsPrison" class="imagecontainer">
<div v-if="showPortraitAvatar" :style="getAvatarStyle" class="avatar"></div> <div v-if="showPortraitAvatar" :style="getAvatarStyle" class="avatar"></div>
<div v-if="showCharacterFigure3d" class="house-with-character"> <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"> <div class="character-foreground">
<Character3D <Character3D
:gender="falukantUser.character.gender" :gender="falukantUser.character.gender"
@@ -365,6 +370,8 @@
<script> <script>
import StatusBar from '@/components/falukant/StatusBar.vue'; import StatusBar from '@/components/falukant/StatusBar.vue';
import Character3D from '@/components/Character3D.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 apiClient from '@/utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js'; import { showError, showSuccess } from '@/utils/feedback.js';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
@@ -426,6 +433,7 @@ export default {
components: { components: {
StatusBar, StatusBar,
Character3D, Character3D,
House3D,
}, },
data() { data() {
return { return {
@@ -463,25 +471,8 @@ export default {
height: `${height}px`, height: `${height}px`,
}; };
}, },
getHouseStyle() { houseModel() {
if (!this.falukantUser || !this.falukantUser.userHouse?.houseType) return {}; return getFalukantHouseModel(this.falukantUser?.userHouse?.houseType);
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',
};
}, },
moneyValue() { moneyValue() {
const m = this.falukantUser?.money; const m = this.falukantUser?.money;