feat(falukant): add character visual representation and settings
All checks were successful
Deploy to production / deploy (push) Successful in 2m53s

- Introduced FalukantCharacterVisual component to display character portraits and 3D figures based on user settings.
- Updated DirectorInfo, ChurchView, PoliticsView, and other components to utilize the new character visual component.
- Added visual settings management for users, allowing selection between portrait styles and display modes (portraits, 3D, or both).
- Implemented age rules for engagement and marriage in the backend, ensuring compliance with specified age limits.
- Created a new settings view for Falukant to manage visual preferences.
- Added new images for medieval character portraits.
- Updated translations for new settings and features in multiple languages.
This commit is contained in:
Torsten Schulz (local)
2026-07-24 10:55:19 +02:00
parent c13339f771
commit 16703f467f
36 changed files with 811 additions and 49 deletions

View File

@@ -80,6 +80,7 @@
:branchId="selectedBranch.id"
:vehicles="vehicles"
:branches="branches"
:visual-settings="visualSettings"
ref="directorInfo"
@transportCreated="handleTransportCreated"
/>
@@ -386,6 +387,10 @@ import BuyVehicleDialog from '@/dialogues/falukant/BuyVehicleDialog.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
import { showError, showSuccess, showApiError } from '@/utils/feedback.js';
import {
FALUKANT_VISUAL_DEFAULTS,
loadFalukantVisualSettings,
} from '@/utils/falukantVisualSettings.js';
const CERTIFICATE_PRODUCT_LEVELS = [
{ level: 1, products: ['fish', 'meat', 'leather', 'wood', 'stone', 'milk', 'cheese', 'bread', 'wheat', 'grain', 'carrot'] },
@@ -476,6 +481,7 @@ export default {
inDebtorsPrison: false
},
pendingBranchRefresh: null,
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
};
},
@@ -528,6 +534,7 @@ export default {
},
async mounted() {
this.visualSettings = await loadFalukantVisualSettings(this.user?.id);
await this.loadBranches();
const branchId = this.$route.params.branchId;

View File

@@ -41,10 +41,23 @@
<tr v-for="person in baptismList" :key="person.id">
<td>{{ $t(`falukant.church.baptism.gender.${person.gender}`) }}</td>
<td>
<input type="text" v-model="person.proposedFirstName" />
<button @click="newName(person)">
{{ $t('falukant.church.baptism.table.newName') }}
</button>
<div class="church-person-cell">
<FalukantCharacterVisual
:gender="person.gender"
:age="person.age"
:visual-settings="visualSettings"
:portrait-scale="0.32"
:figure-width="68"
:figure-height="88"
:compact="true"
/>
<div class="church-person-cell__content">
<input type="text" v-model="person.proposedFirstName" />
<button @click="newName(person)">
{{ $t('falukant.church.baptism.table.newName') }}
</button>
</div>
</div>
</td>
<td>{{ person.age }}</td>
<td>
@@ -76,8 +89,21 @@
<td>{{ pos.region.name }}</td>
<td>
<span v-if="pos.character">
{{ $t(`falukant.titles.${pos.character.gender}.${pos.character.title || 'noncivil'}`) }}
{{ pos.character.name }}
<div class="church-person-cell">
<FalukantCharacterVisual
:gender="pos.character.gender"
:age="pos.character.age"
:visual-settings="visualSettings"
:portrait-scale="0.32"
:figure-width="68"
:figure-height="88"
:compact="true"
/>
<div class="church-person-cell__content">
{{ $t(`falukant.titles.${pos.character.gender}.${pos.character.title || 'noncivil'}`) }}
{{ pos.character.name }}
</div>
</div>
</span>
<span v-else></span>
</td>
@@ -187,7 +213,12 @@ import MessageDialog from '@/dialogues/standard/MessageDialog.vue'
import ErrorDialog from '@/dialogues/standard/ErrorDialog.vue'
import apiClient from '@/utils/axios.js'
import SimpleTabs from '@/components/SimpleTabs.vue'
import FalukantCharacterVisual from '@/components/falukant/FalukantCharacterVisual.vue'
import { mapState } from 'vuex'
import {
FALUKANT_VISUAL_DEFAULTS,
loadFalukantVisualSettings,
} from '@/utils/falukantVisualSettings.js'
export default {
name: 'ChurchView',
@@ -196,6 +227,7 @@ export default {
MessageDialog,
ErrorDialog,
SimpleTabs,
FalukantCharacterVisual,
},
data() {
return {
@@ -215,7 +247,8 @@ export default {
current: false,
available: false,
applications: false
}
},
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
}
},
computed: {
@@ -236,6 +269,7 @@ export default {
}
},
async mounted() {
this.visualSettings = await loadFalukantVisualSettings(this.user?.id);
await this.loadNotBaptisedChildren();
await this.loadOwnCharacterId();
await Promise.all([
@@ -495,6 +529,21 @@ export default {
}
</script>
<style scoped>
.church-person-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.church-person-cell__content {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>
<style scoped>
h2 {
padding-top: 20px;

View File

@@ -149,10 +149,11 @@
</div>
<div class="character-media character-media--spouse">
<div
v-if="showPortraitAvatar"
class="character-avatar character-avatar--spouse"
:style="getCharacterAvatarStyle(relationships[0].character2, 0.72)"
></div>
<div class="character-3d-frame character-3d-frame--spouse">
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--spouse">
<Character3D
:gender="relationships[0].character2.gender"
:age="relationships[0].character2.age"
@@ -328,10 +329,11 @@
</div>
<div class="character-media character-media--compact child-character-media">
<div
v-if="showPortraitAvatar"
class="character-avatar character-avatar--compact"
:style="getCharacterAvatarStyle(child, 0.5)"
></div>
<div class="character-3d-frame character-3d-frame--compact">
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--compact">
<Character3D
:gender="child.gender"
:age="child.age"
@@ -406,10 +408,11 @@
<div class="lover-card__header">
<div class="character-media character-media--compact lover-character-media">
<div
v-if="showPortraitAvatar"
class="character-avatar character-avatar--compact"
:style="getCharacterAvatarStyle(lover, 0.5)"
></div>
<div class="character-3d-frame character-3d-frame--compact">
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--compact">
<Character3D
:gender="lover.gender"
:age="lover.age"
@@ -581,6 +584,13 @@ import Character3D from '@/components/Character3D.vue'
import apiClient from '@/utils/axios.js'
import { confirmAction, showError, showInfo, showSuccess } from '@/utils/feedback.js'
import { mapState } from 'vuex'
import {
FALUKANT_VISUAL_DEFAULTS,
loadFalukantVisualSettings,
resolveFalukantPortraitAtlas,
showFalukant3dFigures,
showFalukantPortraits,
} from '@/utils/falukantVisualSettings.js'
const WOOING_PROGRESS_TARGET = 70
const MARRIAGE_GIFT_COSTS = {
@@ -662,11 +672,18 @@ export default {
selectedChild: null,
pendingFamilyRefresh: null,
familyTab: 'partner',
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
}
},
computed: {
...mapState(['socket', 'daemonSocket', 'user']),
isDev() { return !import.meta.env.PROD },
showPortraitAvatar() {
return showFalukantPortraits(this.visualSettings.figureMode)
},
showCharacterFigure3d() {
return showFalukant3dFigures(this.visualSettings.figureMode)
},
marriageGiftCosts() {
return MARRIAGE_GIFT_COSTS;
},
@@ -712,6 +729,7 @@ export default {
}
},
async mounted() {
this.visualSettings = await loadFalukantVisualSettings(this.user?.id);
await this.loadOwnCharacter();
await this.loadFamilyData();
await this.loadGifts();
@@ -1068,7 +1086,7 @@ export default {
const position = genderData.positions?.[ageGroup] || { x: 0, y: 0 };
const safeScale = Number(scale) > 0 ? Number(scale) : 1;
return {
backgroundImage: `url(/images/falukant/avatar/${gender}.png)`,
backgroundImage: `url(${resolveFalukantPortraitAtlas(gender, this.visualSettings.portraitStyle)})`,
backgroundPosition: `-${Math.round(position.x * safeScale)}px -${Math.round(position.y * safeScale)}px`,
backgroundSize: `${Math.round(1792 * safeScale)}px ${Math.round(1024 * safeScale)}px`,
width: `${Math.round(genderData.width * safeScale)}px`,

View File

@@ -27,8 +27,8 @@
</section>
<div v-if="falukantUser?.character && !falukantUser?.debtorsPrison?.inDebtorsPrison" class="imagecontainer">
<div :style="getAvatarStyle" class="avatar"></div>
<div class="house-with-character">
<div v-if="showPortraitAvatar" :style="getAvatarStyle" class="avatar"></div>
<div v-if="showCharacterFigure3d" class="house-with-character">
<div :style="getHouseStyle" class="house"></div>
<div class="character-foreground">
<Character3D
@@ -336,6 +336,13 @@ import Character3D from '@/components/Character3D.vue';
import apiClient from '@/utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
import { mapState } from 'vuex';
import {
FALUKANT_VISUAL_DEFAULTS,
loadFalukantVisualSettings,
resolveFalukantPortraitAtlas,
showFalukant3dFigures,
showFalukantPortraits,
} from '@/utils/falukantVisualSettings.js';
const AVATAR_POSITIONS = {
male: {
@@ -396,14 +403,21 @@ export default {
potentialHeirs: [],
loadingHeirs: false,
pendingOverviewRefresh: null,
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
};
},
computed: {
...mapState(['socket', 'daemonSocket', 'user']),
showPortraitAvatar() {
return showFalukantPortraits(this.visualSettings.figureMode);
},
showCharacterFigure3d() {
return showFalukant3dFigures(this.visualSettings.figureMode);
},
getAvatarStyle() {
if (!this.falukantUser || !this.falukantUser.character) return {};
const { gender, age } = this.falukantUser.character;
const imageUrl = `/images/falukant/avatar/${gender}.png`;
const imageUrl = resolveFalukantPortraitAtlas(gender, this.visualSettings.portraitStyle);
const ageGroup = this.getAgeGroup(age);
const genderData = AVATAR_POSITIONS[gender] || {};
const position = genderData.positions?.[ageGroup] || { x: 0, y: 0 };
@@ -560,6 +574,7 @@ export default {
},
},
async mounted() {
this.visualSettings = await loadFalukantVisualSettings(this.user?.id);
await this.fetchFalukantUser();
if (!this.falukantUser?.character) {
await this.fetchPotentialHeirs();

View File

@@ -16,11 +16,22 @@
<span>{{ pos.region.name }}</span>
</div>
<div class="politics-card__meta">
<div v-if="pos.character" class="politics-card__visual">
<FalukantCharacterVisual
:gender="pos.character.gender"
:age="pos.character.age"
:visual-settings="visualSettings"
:portrait-scale="0.36"
:figure-width="78"
:figure-height="100"
:compact="true"
/>
</div>
<div class="politics-card__meta-row">
<span class="politics-card__meta-label">{{ $t('falukant.politics.current.holder') }}:</span>
<span class="politics-card__meta-value">
<template v-if="pos.character">
{{ pos.character.definedFirstName.name }} {{ pos.character.definedLastName.name }}
{{ formatPoliticalCharacterName(pos.character) }}
</template>
<template v-else></template>
</span>
@@ -272,16 +283,22 @@
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import FalukantCharacterVisual from '@/components/falukant/FalukantCharacterVisual.vue';
import SimpleTabs from '@/components/SimpleTabs.vue';
import Multiselect from 'vue-multiselect';
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
import { mapState } from 'vuex';
import {
FALUKANT_VISUAL_DEFAULTS,
loadFalukantVisualSettings,
} from '@/utils/falukantVisualSettings.js';
const debugLog = () => {};
export default {
name: 'PoliticsView',
components: { StatusBar, SimpleTabs, Multiselect },
components: { StatusBar, FalukantCharacterVisual, SimpleTabs, Multiselect },
data() {
return {
activeTab: 'current',
@@ -314,10 +331,12 @@ export default {
openPolitics: false,
elections: false,
powers: false
}
},
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
};
},
computed: {
...mapState(['user']),
hasAnyPowers() {
const p = this.myPowers;
if (!p) return false;
@@ -339,11 +358,17 @@ export default {
return this.elections.some(e => !e.voted);
}
},
mounted() {
this.loadOwnCharacterId();
this.loadCurrentPositions();
async mounted() {
this.visualSettings = await loadFalukantVisualSettings(this.user?.id);
await this.loadOwnCharacterId();
await this.loadCurrentPositions();
},
methods: {
formatPoliticalCharacterName(character) {
const firstName = character?.definedFirstName?.name || '';
const lastName = character?.definedLastName?.name || '';
return `${firstName} ${lastName}`.trim();
},
politicsBenefitItems(pos) {
const raw = pos?.benefit;
if (!Array.isArray(raw) || !raw.length) {
@@ -719,6 +744,12 @@ export default {
};
</script>
<style scoped>
.politics-card__visual {
margin-bottom: 0.75rem;
}
</style>
<style scoped>
.politics-view {
max-width: var(--content-max-width);

View File

@@ -0,0 +1,17 @@
<template>
<div>
<h2>{{ $t("settings.falukant.title") }}</h2>
<SettingsWidget :settingsType="'falukant'" />
</div>
</template>
<script>
import SettingsWidget from '@/components/SettingsWidget.vue';
export default {
name: 'FalukantSettingsView',
components: {
SettingsWidget,
}
}
</script>