feat(falukant): render houses with 3D models
Some checks failed
Deploy to production / deploy (push) Failing after 1m17s
Some checks failed
Deploy to production / deploy (push) Failing after 1m17s
This commit is contained in:
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
156
frontend/src/components/falukant/House3D.vue
Normal file
156
frontend/src/components/falukant/House3D.vue
Normal 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>
|
||||
15
frontend/src/utils/falukantHouseModels.js
Normal file
15
frontend/src/utils/falukantHouseModels.js
Normal 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';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user