Refactor OverviewView and NoLoginView to integrate Character3D component

- Replaced avatar display logic in OverviewView with a 3D character representation based on user gender and age.
- Updated NoLoginView to utilize Character3D for displaying mascots, enhancing visual consistency.
- Removed outdated avatar positioning logic and related computed properties for improved code clarity and maintainability.
- Adjusted CSS styles for better layout and responsiveness of character displays.
This commit is contained in:
Torsten Schulz (local)
2026-01-22 11:06:38 +01:00
parent 2be5505c55
commit 33aa2ddd45
3 changed files with 361 additions and 80 deletions

View File

@@ -0,0 +1,330 @@
<template>
<div class="character-3d-container" :class="[`gender-${actualGender}`, `age-${ageGroup}`]">
<div class="character-3d" :style="characterStyle">
<!-- Kopf -->
<div class="head">
<div class="face">
<div class="eye left"></div>
<div class="eye right"></div>
<div class="mouth"></div>
</div>
<!-- Haare -->
<div class="hair" :class="actualGender"></div>
</div>
<!-- Körper -->
<div class="body" :class="actualGender">
<div class="chest"></div>
</div>
<!-- Arme -->
<div class="arm left"></div>
<div class="arm right"></div>
<!-- Beine -->
<div class="leg left"></div>
<div class="leg right"></div>
</div>
</div>
</template>
<script>
export default {
name: 'Character3D',
props: {
gender: {
type: String,
default: null,
validator: (value) => value === null || ['male', 'female'].includes(value)
},
age: {
type: Number,
default: null,
validator: (value) => value === null || (value >= 0 && value <= 120)
}
},
data() {
return {
randomGender: null,
randomAge: null
};
},
computed: {
actualGender() {
if (this.gender) {
return this.gender;
}
// Zufällige Auswahl beim ersten Mount, dann persistent
if (this.randomGender === null) {
this.randomGender = Math.random() < 0.5 ? 'male' : 'female';
}
return this.randomGender;
},
actualAge() {
if (this.age !== null && this.age !== undefined) {
return this.age;
}
// Zufällige Auswahl beim ersten Mount, dann persistent
if (this.randomAge === null) {
// Zufälliges Alter zwischen 18 und 65 Jahren
this.randomAge = Math.floor(Math.random() * 47) + 18;
}
return this.randomAge;
},
ageGroup() {
const age = this.actualAge;
if (age <= 3) return 'toddler';
if (age <= 12) return 'child';
if (age <= 17) return 'teen';
if (age <= 30) return 'young-adult';
if (age <= 50) return 'adult';
if (age <= 70) return 'senior';
return 'elderly';
},
characterStyle() {
const age = this.actualAge;
let scale = 1;
// Skalierung basierend auf Alter
if (age <= 3) {
scale = 0.4; // Kleinkind
} else if (age <= 12) {
scale = 0.6; // Kind
} else if (age <= 17) {
scale = 0.85; // Teenager
} else if (age <= 30) {
scale = 1.0; // Junger Erwachsener
} else if (age <= 50) {
scale = 0.95; // Erwachsener
} else if (age <= 70) {
scale = 0.9; // Senior
} else {
scale = 0.85; // Älterer Senior
}
return {
transform: `scale(${scale})`
};
}
}
};
</script>
<style scoped>
.character-3d-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
perspective: 1000px;
perspective-origin: center center;
}
.character-3d {
position: relative;
width: 80%;
height: 80%;
transform-style: preserve-3d;
animation: float 3s ease-in-out infinite;
transform-origin: center bottom;
}
/* Altersbasierte Anpassungen */
.age-toddler .head {
width: 35%;
height: 30%;
}
.age-child .head {
width: 32%;
height: 27%;
}
.age-teen .head {
width: 30%;
height: 25%;
}
.age-elderly .head {
width: 28%;
height: 23%;
}
.age-elderly .hair {
background: linear-gradient(135deg, #d3d3d3 0%, #a9a9a9 100%) !important;
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotateY(0deg);
}
50% {
transform: translateY(-10px) rotateY(5deg);
}
}
/* Kopf */
.head {
position: absolute;
width: 30%;
height: 25%;
left: 50%;
top: 0;
transform: translateX(-50%);
background: linear-gradient(135deg, #ffdbac 0%, #f4c2a1 100%);
border-radius: 50% 50% 45% 45%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
transform-style: preserve-3d;
}
.face {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.eye {
position: absolute;
width: 8%;
height: 8%;
background: #000;
border-radius: 50%;
top: 35%;
}
.eye.left {
left: 30%;
}
.eye.right {
right: 30%;
}
.mouth {
position: absolute;
width: 20%;
height: 8%;
left: 50%;
top: 60%;
transform: translateX(-50%);
border: 2px solid #000;
border-top: none;
border-radius: 0 0 50% 50%;
}
/* Haare */
.hair {
position: absolute;
top: -15%;
left: 50%;
transform: translateX(-50%);
width: 120%;
height: 60%;
border-radius: 50% 50% 0 0;
}
.hair.male {
background: linear-gradient(135deg, #2c1810 0%, #1a0f08 100%);
clip-path: polygon(20% 0%, 80% 0%, 100% 50%, 0% 50%);
}
.hair.female {
background: linear-gradient(135deg, #ffd700 0%, #ffb347 100%);
border-radius: 50% 50% 30% 30%;
box-shadow: 0 -2px 10px rgba(255, 215, 0, 0.3);
}
/* Körper */
.body {
position: absolute;
width: 35%;
height: 40%;
left: 50%;
top: 25%;
transform: translateX(-50%);
border-radius: 20% 20% 10% 10%;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.body.male {
background: linear-gradient(135deg, #4a90e2 0%, #357abd 100%);
}
.body.female {
background: linear-gradient(135deg, #e24a90 0%, #c73a7a 100%);
}
.chest {
position: absolute;
width: 60%;
height: 40%;
left: 50%;
top: 20%;
transform: translateX(-50%);
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
}
/* Arme */
.arm {
position: absolute;
width: 12%;
height: 35%;
background: linear-gradient(135deg, #ffdbac 0%, #f4c2a1 100%);
border-radius: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.arm.left {
left: 15%;
top: 28%;
transform: rotate(-20deg);
}
.arm.right {
right: 15%;
top: 28%;
transform: rotate(20deg);
}
/* Beine */
.leg {
position: absolute;
width: 15%;
height: 30%;
border-radius: 10px 10px 5px 5px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.leg.left {
left: 35%;
top: 65%;
}
.leg.right {
right: 35%;
top: 65%;
}
.gender-male .leg {
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
}
.gender-female .leg {
background: linear-gradient(135deg, #8b4caf 0%, #6b3a8f 100%);
}
/* 3D-Effekt mit Schatten */
.character-3d::before {
content: '';
position: absolute;
width: 60%;
height: 10%;
left: 50%;
bottom: -5%;
transform: translateX(-50%);
background: radial-gradient(ellipse, rgba(0, 0, 0, 0.3) 0%, transparent 70%);
border-radius: 50%;
z-index: -1;
}
</style>

View File

@@ -117,7 +117,12 @@
</div>
</div>
<div v-if="falukantUser?.character" class="imagecontainer">
<div :style="getAvatarStyle" class="avatar"></div>
<div class="character-3d-wrapper">
<Character3D
:gender="falukantUser.character.gender"
:age="falukantUser.character.age"
/>
</div>
<div :style="getHouseStyle" class="house"></div>
</div>
</div>
@@ -125,50 +130,16 @@
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import Character3D from '@/components/Character3D.vue';
import apiClient from '@/utils/axios.js';
import { mapState } from 'vuex';
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: 'FalukantOverviewView',
components: {
StatusBar,
Character3D,
},
data() {
return {
@@ -181,23 +152,6 @@ export default {
},
computed: {
...mapState(['socket']),
getAvatarStyle() {
if (!this.falukantUser || !this.falukantUser.character) return {};
const { gender, age } = this.falukantUser.character;
const imageUrl = `/images/falukant/avatar/${gender}.png`;
const ageGroup = this.getAgeGroup(age);
const genderData = AVATAR_POSITIONS[gender] || {};
const position = genderData.positions?.[ageGroup] || { x: 0, y: 0 };
const width = genderData.width || 100;
const height = genderData.height || 100;
return {
backgroundImage: `url(${imageUrl})`,
backgroundPosition: `-${position.x}px -${position.y}px`,
backgroundSize: "1792px 1024px",
width: `${width}px`,
height: `${height}px`,
};
},
getHouseStyle() {
console.log(this.falukantUser);
if (!this.falukantUser || !this.falukantUser.userHouse?.houseType) return {};
@@ -219,10 +173,6 @@ export default {
imageRendering: 'crisp-edges',
};
},
getAgeColor(age) {
const ageGroup = this.getAgeGroup(age);
return ageGroup === 'child' ? 'blue' : ageGroup === 'teen' ? 'green' : ageGroup === 'adult' ? 'red' : 'gray';
},
moneyValue() {
const m = this.falukantUser?.money;
return typeof m === 'string' ? parseFloat(m) : m;
@@ -282,19 +232,6 @@ export default {
break;
}
},
getAgeGroup(age) {
if (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+";
},
async fetchFalukantUser() {
const falukantUser = await apiClient.get('/api/falukant/user');
if (!falukantUser.data) {
@@ -397,14 +334,18 @@ export default {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
}
.avatar {
.character-3d-wrapper {
width: 300px;
height: 400px;
border: 1px solid #ccc;
border-radius: 4px;
background-repeat: no-repeat;
background-size: cover;
image-rendering: crisp-edges;
background-color: #fdf1db;
display: flex;
justify-content: center;
align-items: center;
}
.house {

View File

@@ -4,7 +4,9 @@
<strong>{{ $t('home.betaNoticeLabel') }}</strong> {{ $t('home.betaNoticeText') }}
</div>
<div class="home-structure">
<div class="mascot"><img src="/images/mascot/mascot_male.png" /></div>
<div class="mascot">
<Character3D gender="male" />
</div>
<div class="actions">
<div>
<h2>{{ $t('home.nologin.welcome') }}</h2>
@@ -36,9 +38,6 @@
<h3>{{ $t('home.nologin.getStarted.title') }}</h3>
<p>{{ $t('home.nologin.getStarted.text', { register: $t('home.nologin.login.register') }) }}</p>
<h2>{{ $t('home.nologin.randomchat') }}</h2>
<button @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
</div>
<div>
<div>
@@ -59,6 +58,10 @@
<div>
<button type="button" @click="doLogin">{{ $t('home.nologin.login.submit') }}</button>
</div>
<div>
<h2>{{ $t('home.nologin.randomchat') }}</h2>
<button @click="openRandomChat">{{ $t('home.nologin.startrandomchat') }}</button>
</div>
<div>
<span @click="openPasswordResetDialog" class="link">{{
$t('home.nologin.login.lostpassword') }}</span> | <span id="o1p5iry1"
@@ -66,7 +69,9 @@
</div>
</div>
</div>
<div class="mascot"><img src="/images/mascot/mascot_female.png" /></div>
<div class="mascot">
<Character3D gender="female" />
</div>
<RandomChatDialog ref="randomChatDialog" />
<RegisterDialog ref="registerDialog" />
<PasswordResetDialog ref="passwordResetDialog" />
@@ -80,6 +85,7 @@
import RandomChatDialog from '@/dialogues/chat/RandomChatDialog.vue';
import RegisterDialog from '@/dialogues/auth/RegisterDialog.vue';
import PasswordResetDialog from '@/dialogues/auth/PasswordResetDialog.vue';
import Character3D from '@/components/Character3D.vue';
import apiClient from '@/utils/axios.js';
import { mapActions } from 'vuex';
@@ -95,6 +101,7 @@ export default {
RandomChatDialog,
RegisterDialog,
PasswordResetDialog,
Character3D,
},
methods: {
...mapActions(['login']),
@@ -154,6 +161,9 @@ export default {
justify-content: center;
align-items: center;
background-color: #fdf1db;
width: 80%;
height: 80%;
min-height: 400px;
}
.actions {