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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -68,6 +68,7 @@ const TITLE_MAP = {
'Flirt settings': 'sectionBar.titles.flirtSettings',
'Account settings': 'sectionBar.titles.accountSettings',
'Language assistant settings': 'sectionBar.titles.languageAssistantSettings',
'Falukant settings': 'sectionBar.titles.falukantSettings',
Interests: 'sectionBar.titles.interests',
AdminInterests: 'sectionBar.titles.adminInterests',
AdminUsers: 'sectionBar.titles.adminUsers',

View File

@@ -4,51 +4,51 @@
<tr v-for="setting in settings" :key="setting.id">
<td>
<InputStringWidget v-if="setting.datatype == 'string'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value=setting.value
:disabled="setting.immutable && setting.value ? true : false"
:list="languagesList()" @input="handleInput(setting.id, $event)" />
<DateInputWidget v-else-if="setting.datatype == 'date'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value=setting.value
:disabled="setting.immutable && setting.value ? true : false"
@input="handleInput(setting.id, $event)" />
<SelectDropdownWidget v-else-if="setting.datatype == 'singleselect'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value=setting.value
:disabled="setting.immutable && setting.value ? true : false"
:list="getSettingOptions(setting.name, setting.options)"
@input="handleInput(setting.id, $event)" />
<InputNumberWidget v-else-if="setting.datatype == 'int'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value="convertToInt(setting.value)"
:disabled="setting.immutable && setting.value ? true : false"
min="0" max="200" @input="handleInput(setting.id, $event)" />
<FloatInputWidget v-else-if="setting.datatype == 'float'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value="convertToFloat(setting.value)"
:disabled="setting.immutable && setting.value ? true : false"
@input="handleInput(setting.id, $event)" />
<CheckboxWidget v-else-if="setting.datatype == 'bool'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value="convertToBool(setting.value)"
:disabled="setting.immutable && setting.value ? true : false"
@input="handleInput(setting.id, $event)" />
<MultiselectWidget v-else-if="setting.datatype == 'multiselect'"
:labelTr="`settings.personal.label.${setting.name}`"
:tooltipTr="setting.immutable ? $t('settings.immutable.tooltip') : `settings.personal.tooltip.${setting.name}`"
:labelTr="settingLabelTr(setting.name)"
:tooltipTr="settingTooltipTr(setting.name, setting.immutable)"
:value="setting.value"
:disabled="setting.immutable && setting.value ? true : false"
:list="getSettingOptions(setting.name, setting.options)"
@@ -144,10 +144,39 @@ export default {
return options.map((option) => {
return {
value: option.id,
captionTr: `settings.personal.${fieldName}.${option.value}`
captionTr: this.settingOptionTr(fieldName, option.value)
}
});
},
settingLabelTr(fieldName) {
return this.resolveSettingTr([
`settings.${this.settingsType}.label.${fieldName}`,
`settings.personal.label.${fieldName}`
]);
},
settingTooltipTr(fieldName, immutable) {
if (immutable) {
return this.$t('settings.immutable.tooltip');
}
return this.resolveSettingTr([
`settings.${this.settingsType}.tooltip.${fieldName}`,
`settings.personal.tooltip.${fieldName}`
]);
},
settingOptionTr(fieldName, optionValue) {
return this.resolveSettingTr([
`settings.${this.settingsType}.${fieldName}.${optionValue}`,
`settings.personal.${fieldName}.${optionValue}`
]);
},
resolveSettingTr(candidates) {
for (const key of candidates) {
if (this.$te(key)) {
return key;
}
}
return candidates[0];
},
async handleInput(settingId, value) {
if (['object', 'array'].includes(typeof value)) {
return;
@@ -232,7 +261,7 @@ export default {
}
const fieldNames = immutableFields.map(field =>
this.$t(`settings.personal.label.${field.name}`)
this.$t(this.settingLabelTr(field.name))
).join(', ');
return this.$t('settings.immutable.supportMessage.specific', { fields: fieldNames });

View File

@@ -9,6 +9,16 @@
<div v-else class="director-info-container">
<!-- Linke Seite: Stammdaten & Wissen (aus /falukant/directors) -->
<div class="director-main">
<div class="director-visual" v-if="director.character">
<FalukantCharacterVisual
:gender="director.character.gender"
:age="director.character.age"
:visual-settings="visualSettings"
:portrait-scale="0.58"
:figure-width="124"
:figure-height="156"
/>
</div>
<h3 class="director-name">
{{ $t('falukant.titles.' + director.character.gender + '.' + director.character.nobleTitle.labelTr) }}
{{ director.character.definedFirstName.name }} {{ director.character.definedLastName.name }}
@@ -192,7 +202,9 @@
<script>
import apiClient from '@/utils/axios.js';
import NewDirectorDialog from '@/dialogues/falukant/NewDirectorDialog.vue';
import FalukantCharacterVisual from '@/components/falukant/FalukantCharacterVisual.vue';
import { showError, showInfo, showSuccess } from '@/utils/feedback.js';
import { FALUKANT_VISUAL_DEFAULTS } from '@/utils/falukantVisualSettings.js';
export default {
name: "DirectorInfo",
@@ -200,9 +212,11 @@ export default {
branchId: { type: Number, required: true },
vehicles: { type: Array, default: () => [] },
branches: { type: Array, default: () => [] },
visualSettings: { type: Object, default: () => ({ ...FALUKANT_VISUAL_DEFAULTS }) },
},
components: {
NewDirectorDialog
NewDirectorDialog,
FalukantCharacterVisual,
},
data() {
return {
@@ -484,6 +498,10 @@ export default {
flex: 2;
}
.director-visual {
margin-bottom: 0.75rem;
}
.director-actions {
flex: 1;
display: flex;

View File

@@ -0,0 +1,189 @@
<template>
<div v-if="shouldRender" class="falukant-character-visual" :class="{ 'is-compact': compact }">
<div
v-if="showPortrait"
class="falukant-character-visual__portrait"
:style="portraitStyle"
:aria-label="`${gender || 'male'} portrait`"
/>
<div v-if="showFigure" class="falukant-character-visual__figure" :style="figureStyle">
<Character3D
:gender="normalizedGender"
:age="normalizedAge"
:lightweight="lightweight"
:noBackground="noBackground"
:lazy="lazy"
/>
</div>
</div>
</template>
<script>
import Character3D from '@/components/Character3D.vue';
import {
FALUKANT_VISUAL_DEFAULTS,
resolveFalukantPortraitAtlas,
showFalukant3dFigures,
showFalukantPortraits,
} from '@/utils/falukantVisualSettings.js';
const AVATAR_POSITIONS = {
male: {
width: 195,
height: 300,
positions: {
'0-1': { x: 161, y: 28 },
'2-3': { x: 802, y: 28 },
'4-6': { x: 1014, y: 28 },
'7-10': { x: 800, y: 368 },
'11-13': { x: 373, y: 368 },
'14-16': { x: 1441, y: 28 },
'17-20': { x: 1441, y: 368 },
'21-30': { x: 1014, y: 368 },
'31-45': { x: 1227, y: 368 },
'45-55': { x: 803, y: 687 },
'55+': { x: 1441, y: 687 },
},
},
female: {
width: 223,
height: 298,
positions: {
'0-1': { x: 302, y: 66 },
'2-3': { x: 792, y: 66 },
'4-6': { x: 62, y: 66 },
'7-10': { x: 1034, y: 66 },
'11-13': { x: 1278, y: 66 },
'14-16': { x: 303, y: 392 },
'17-20': { x: 1525, y: 392 },
'21-30': { x: 1278, y: 392 },
'31-45': { x: 547, y: 718 },
'45-55': { x: 1034, y: 718 },
'55+': { x: 1525, y: 718 },
},
},
};
export default {
name: 'FalukantCharacterVisual',
components: {
Character3D,
},
props: {
gender: {
type: String,
default: 'male',
},
age: {
type: Number,
default: null,
},
visualSettings: {
type: Object,
default: () => ({ ...FALUKANT_VISUAL_DEFAULTS }),
},
portraitScale: {
type: Number,
default: 0.42,
},
figureWidth: {
type: Number,
default: 92,
},
figureHeight: {
type: Number,
default: 118,
},
compact: {
type: Boolean,
default: false,
},
lightweight: {
type: Boolean,
default: true,
},
noBackground: {
type: Boolean,
default: true,
},
lazy: {
type: Boolean,
default: true,
},
},
computed: {
normalizedGender() {
return String(this.gender || 'male').toLowerCase() === 'female' ? 'female' : 'male';
},
normalizedAge() {
return Number.isFinite(this.age) ? this.age : null;
},
showPortrait() {
return showFalukantPortraits(this.visualSettings?.figureMode);
},
showFigure() {
return showFalukant3dFigures(this.visualSettings?.figureMode);
},
shouldRender() {
return this.showPortrait || this.showFigure;
},
figureStyle() {
return {
width: `${this.figureWidth}px`,
height: `${this.figureHeight}px`,
};
},
portraitStyle() {
const genderData = AVATAR_POSITIONS[this.normalizedGender] || AVATAR_POSITIONS.male;
const position = genderData.positions?.[this.ageGroup] || { x: 0, y: 0 };
const safeScale = Number(this.portraitScale) > 0 ? Number(this.portraitScale) : 1;
return {
backgroundImage: `url(${resolveFalukantPortraitAtlas(this.normalizedGender, 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`,
height: `${Math.round(genderData.height * safeScale)}px`,
};
},
ageGroup() {
const age = this.normalizedAge;
if (age == null || age <= 1) return '0-1';
if (age <= 3) return '2-3';
if (age <= 6) return '4-6';
if (age <= 10) return '7-10';
if (age <= 13) return '11-13';
if (age <= 16) return '14-16';
if (age <= 20) return '17-20';
if (age <= 30) return '21-30';
if (age <= 45) return '31-45';
if (age <= 55) return '45-55';
return '55+';
},
},
};
</script>
<style scoped>
.falukant-character-visual {
display: inline-flex;
align-items: flex-end;
gap: 0.45rem;
}
.falukant-character-visual__portrait {
background-repeat: no-repeat;
background-color: rgba(255, 250, 242, 0.9);
border: 1px solid rgba(116, 85, 49, 0.22);
border-radius: 0.7rem;
box-shadow: 0 8px 18px rgba(46, 28, 14, 0.08);
overflow: hidden;
}
.falukant-character-visual__figure {
flex: 0 0 auto;
}
.is-compact {
gap: 0.35rem;
}
</style>

View File

@@ -173,6 +173,7 @@
"flirtSettings": "Flirt",
"accountSettings": "Account",
"languageAssistantSettings": "Katabang sa pinulongan",
"falukantSettings": "Falukant",
"interests": "Mga interes",
"adminInterests": "Pagdumala sa interes",
"adminUsers": "Mga user",

View File

@@ -138,6 +138,26 @@
"view": {
"title": "Panagway"
},
"falukant": {
"title": "Falukant",
"label": {
"falukantPortraitStyle": "Estilo sa portrait",
"falukantFigureMode": "Paagi sa pagpakita"
},
"tooltip": {
"falukantPortraitStyle": "Pilia ang estilo sa mga portrait sa Falukant.",
"falukantFigureMode": "Pilia kung portraits, 3D nga mga hulagway, o pareho ang ipakita."
},
"falukantPortraitStyle": {
"classic": "Klasiko",
"medieval": "Medyebal"
},
"falukantFigureMode": {
"portraits": "Portraits lang",
"3d": "3D nga mga hulagway lang",
"both": "Portraits ug 3D nga mga hulagway"
}
},
"sexuality": {
"title": "Sekswalidad"
},

View File

@@ -173,6 +173,7 @@
"flirtSettings": "Flirt",
"accountSettings": "Account",
"languageAssistantSettings": "Sprachassistent",
"falukantSettings": "Falukant",
"interests": "Interessen",
"adminInterests": "Interessenverwaltung",
"adminUsers": "Benutzer",

View File

@@ -138,6 +138,26 @@
"view": {
"title": "Aussehen"
},
"falukant": {
"title": "Falukant",
"label": {
"falukantPortraitStyle": "Portrait-Stil",
"falukantFigureMode": "Darstellung"
},
"tooltip": {
"falukantPortraitStyle": "Wähle den Stil der Falukant-Portraits.",
"falukantFigureMode": "Lege fest, ob Portraits, 3D-Figuren oder beides angezeigt werden sollen."
},
"falukantPortraitStyle": {
"classic": "Klassisch",
"medieval": "Mittelalterlich"
},
"falukantFigureMode": {
"portraits": "Nur Portraits",
"3d": "Nur 3D-Figuren",
"both": "Portraits und 3D-Figuren"
}
},
"sexuality": {
"title": "Sexualität"
},

View File

@@ -173,6 +173,7 @@
"flirtSettings": "Flirt",
"accountSettings": "Account",
"languageAssistantSettings": "Language assistant",
"falukantSettings": "Falukant",
"interests": "Interests",
"adminInterests": "Interest administration",
"adminUsers": "Users",

View File

@@ -138,6 +138,26 @@
"view": {
"title": "Appearance"
},
"falukant": {
"title": "Falukant",
"label": {
"falukantPortraitStyle": "Portrait style",
"falukantFigureMode": "Display mode"
},
"tooltip": {
"falukantPortraitStyle": "Choose the style for Falukant portraits.",
"falukantFigureMode": "Choose whether Falukant should show portraits, 3D characters, or both."
},
"falukantPortraitStyle": {
"classic": "Classic",
"medieval": "Medieval"
},
"falukantFigureMode": {
"portraits": "Portraits only",
"3d": "3D characters only",
"both": "Portraits and 3D characters"
}
},
"sexuality": {
"title": "Sexuality"
},

View File

@@ -173,6 +173,7 @@
"flirtSettings": "Coqueteo",
"accountSettings": "Cuenta",
"languageAssistantSettings": "Asistente de idiomas",
"falukantSettings": "Falukant",
"interests": "Intereses",
"adminInterests": "Administración de intereses",
"adminUsers": "Usuarios",

View File

@@ -138,6 +138,26 @@
"view": {
"title": "Apariencia"
},
"falukant": {
"title": "Falukant",
"label": {
"falukantPortraitStyle": "Estilo del retrato",
"falukantFigureMode": "Modo de visualización"
},
"tooltip": {
"falukantPortraitStyle": "Elige el estilo de los retratos de Falukant.",
"falukantFigureMode": "Elige si Falukant debe mostrar retratos, figuras 3D o ambos."
},
"falukantPortraitStyle": {
"classic": "Clásico",
"medieval": "Medieval"
},
"falukantFigureMode": {
"portraits": "Solo retratos",
"3d": "Solo figuras 3D",
"both": "Retratos y figuras 3D"
}
},
"sexuality": {
"title": "Sexualidad"
},

View File

@@ -173,6 +173,7 @@
"flirtSettings": "flirter",
"accountSettings": "compte",
"languageAssistantSettings": "Assistant vocal",
"falukantSettings": "Falukant",
"interests": "Interessen",
"adminInterests": "Gestion des intérêts",
"adminUsers": "Benutzer",

View File

@@ -138,6 +138,26 @@
"view": {
"title": "Regarder"
},
"falukant": {
"title": "Falukant",
"label": {
"falukantPortraitStyle": "Style de portrait",
"falukantFigureMode": "Mode d'affichage"
},
"tooltip": {
"falukantPortraitStyle": "Choisissez le style des portraits Falukant.",
"falukantFigureMode": "Choisissez si Falukant doit afficher des portraits, des personnages 3D ou les deux."
},
"falukantPortraitStyle": {
"classic": "Classique",
"medieval": "Médiéval"
},
"falukantFigureMode": {
"portraits": "Portraits uniquement",
"3d": "Personnages 3D uniquement",
"both": "Portraits et personnages 3D"
}
},
"sexuality": {
"title": "sexualité"
},

View File

@@ -5,6 +5,7 @@ const SexualitySettingsView = () => import('../views/settings/SexualityView.vue'
const AccountSettingsView = () => import('../views/settings/AccountView.vue');
const InterestsView = () => import('../views/settings/InterestsView.vue');
const LanguageAssistantView = () => import('../views/settings/LanguageAssistantView.vue');
const FalukantSettingsView = () => import('../views/settings/FalukantView.vue');
const settingsRoutes = [
{
@@ -49,6 +50,12 @@ const settingsRoutes = [
component: LanguageAssistantView,
meta: { requiresAuth: true }
},
{
path: '/settings/falukant',
name: 'Falukant settings',
component: FalukantSettingsView,
meta: { requiresAuth: true }
},
];
export default settingsRoutes;

View File

@@ -0,0 +1,60 @@
import apiClient from '@/utils/axios.js';
export const FALUKANT_VISUAL_DEFAULTS = Object.freeze({
portraitStyle: 'classic',
figureMode: 'both',
});
export function showFalukantPortraits(figureMode) {
return figureMode === 'portraits' || figureMode === 'both';
}
export function showFalukant3dFigures(figureMode) {
return figureMode === '3d' || figureMode === 'both';
}
export function resolveFalukantPortraitAtlas(genderRaw, portraitStyle) {
const gender = String(genderRaw || 'male').toLowerCase() === 'female' ? 'female' : 'male';
if (portraitStyle === 'medieval') {
// Falukant liefert hier noch keine Haarfarbe; bis dahin geschlechtsbasierter Atlas-Fallback.
return gender === 'female'
? '/images/falukant/avatar/female00-medieval.png'
: '/images/falukant/avatar/male02-medieval.png';
}
return `/images/falukant/avatar/${gender}.png`;
}
export async function loadFalukantVisualSettings(userId) {
if (!userId) {
return { ...FALUKANT_VISUAL_DEFAULTS };
}
try {
const { data } = await apiClient.post('/api/settings/filter', {
userid: userId,
type: 'falukant'
});
const values = { ...FALUKANT_VISUAL_DEFAULTS };
for (const setting of Array.isArray(data) ? data : []) {
const selected = Array.isArray(setting.options)
? setting.options.find((option) => String(option.id) === String(setting.value))
: null;
const normalizedValue = selected?.value || null;
if (setting.name === 'falukantPortraitStyle' && normalizedValue) {
values.portraitStyle = normalizedValue;
}
if (setting.name === 'falukantFigureMode' && normalizedValue) {
values.figureMode = normalizedValue;
}
}
return values;
} catch (error) {
console.error('Error loading Falukant visual settings:', error);
return { ...FALUKANT_VISUAL_DEFAULTS };
}
}

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>