Files
sky_phone/frontend/src/views/apps/MemoryApp.vue
T
2026-08-20 12:38:46 +02:00

616 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
ChevronLeft,
Circle,
Cloud,
Diamond,
Flower2,
Heart,
Moon,
Play,
Sparkles,
Star,
Sun,
Triangle,
Volume2,
VolumeX,
Zap,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { playMemorySound } from '@/features/games/memory/audio'
import { useMemoryStore } from '@/features/games/memory/store'
import type {
MemoryDifficulty,
} from '@/features/games/memory/types'
import { usePhoneStore } from '@/stores/phone'
import { SkyButton } from '@/ui'
const phone = usePhoneStore()
const memory = useMemoryStore()
const difficulties: Array<{
columns: number
difficulty: MemoryDifficulty
pairs: number
rows: number
}> = [
{ columns: 3, difficulty: 'small', pairs: 6, rows: 4 },
{ columns: 4, difficulty: 'medium', pairs: 8, rows: 4 },
{ columns: 4, difficulty: 'large', pairs: 10, rows: 5 },
]
const symbols = {
star: { color: '#f6b93b', icon: Star },
heart: { color: '#ff6b81', icon: Heart },
moon: { color: '#9b8afb', icon: Moon },
sun: { color: '#ff9f43', icon: Sun },
cloud: { color: '#67b7dc', icon: Cloud },
bolt: { color: '#f5ca4d', icon: Zap },
diamond: { color: '#52c6b8', icon: Diamond },
circle: { color: '#ef7eb5', icon: Circle },
triangle: { color: '#7c9cf5', icon: Triangle },
flower: { color: '#a6d96a', icon: Flower2 },
}
const game = computed(() => memory.game)
let clockTimer: ReturnType<typeof setInterval> | undefined
let mismatchTimer: ReturnType<typeof setTimeout> | undefined
function formatTime(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function bestLabel(difficulty: MemoryDifficulty): string {
const best = memory.best[difficulty]
if (!best) return phone.t('Apps.memory.noBest')
return `${best.moves} ${phone.t('Apps.memory.moves')} · ${formatTime(best.timeMs)}`
}
function selectCard(cardId: string): void {
const previous = memory.game
if (!previous) return
memory.flip(cardId)
const current = memory.game
if (!current || current === previous) return
if (current.status === 'won') {
playMemorySound('win', memory.soundEnabled)
} else if (current.matchedPairs > previous.matchedPairs) {
playMemorySound('match', memory.soundEnabled)
} else if (current.status === 'resolving') {
playMemorySound('mismatch', memory.soundEnabled)
} else {
playMemorySound('flip', memory.soundEnabled)
}
if (current.status === 'resolving') {
if (mismatchTimer) clearTimeout(mismatchTimer)
mismatchTimer = setTimeout(() => memory.resolveMismatch(), 720)
}
}
function toggleSound(): void {
const enabled = !memory.soundEnabled
memory.setSoundEnabled(enabled)
if (enabled) playMemorySound('flip', true)
}
function restart(): void {
const difficulty = memory.game?.difficulty
if (difficulty) memory.start(difficulty)
}
function returnToMenu(): void {
if (mismatchTimer) {
clearTimeout(mismatchTimer)
mismatchTimer = undefined
}
memory.showMenu()
}
memory.hydrate()
onMounted(() => {
memory.resume()
clockTimer = setInterval(() => memory.updateElapsed(), 100)
})
onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer)
if (mismatchTimer) clearTimeout(mismatchTimer)
memory.resolveMismatch()
memory.pause()
})
</script>
<template>
<main
class="memory-app"
:class="{ 'memory-app--playing': game }"
:aria-label="phone.t('Apps.memory.name')"
>
<header v-if="!game" class="memory-header">
<div>
<span>{{ phone.t('Apps.memory.eyebrow') }}</span>
<h1>{{ phone.t('Apps.memory.name') }}</h1>
</div>
<div class="memory-header__actions">
<Sparkles :size="23" aria-hidden="true" />
<SkyButton glass icon-only rounded
type="button"
:aria-label="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
:title="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
@click="toggleSound"
>
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</SkyButton>
</div>
</header>
<section v-if="!game" class="memory-menu">
<div class="memory-hero" aria-hidden="true">
<span class="memory-hero__card memory-hero__card--back"></span>
<span class="memory-hero__card memory-hero__card--front">
<Star :size="34" fill="currentColor" />
</span>
</div>
<div class="memory-intro">
<h2>{{ phone.t('Apps.memory.chooseTitle') }}</h2>
<p>{{ phone.t('Apps.memory.chooseBody') }}</p>
</div>
<div class="memory-difficulties">
<button
v-for="option in difficulties"
:key="option.difficulty"
type="button"
@click="memory.start(option.difficulty)"
>
<span class="memory-difficulty__size">
{{ option.columns }}×{{ option.rows }}
</span>
<span class="memory-difficulty__name">
{{ phone.t(`Apps.memory.difficulties.${option.difficulty}`) }}
</span>
<span class="memory-difficulty__best">
{{ bestLabel(option.difficulty) }}
</span>
<Play :size="16" fill="currentColor" />
</button>
</div>
</section>
<section v-else class="memory-game">
<div class="memory-stats">
<SkyButton glass icon-only rounded
type="button"
class="memory-menu-button"
:aria-label="phone.t('Apps.memory.backToMenu')"
:title="phone.t('Apps.memory.backToMenu')"
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
</SkyButton>
<div>
<span>{{ phone.t('Apps.memory.time') }}</span>
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.memory.moves') }}</span>
<strong>{{ game.moves }}</strong>
</div>
<button type="button" @click="restart">
{{ phone.t('Apps.memory.restart') }}
</button>
</div>
<div
class="memory-board"
:class="`memory-board--${game.difficulty}`"
:aria-label="phone.t('Apps.memory.board')"
>
<button
v-for="card in game.cards"
:key="card.id"
type="button"
class="memory-game-card"
:class="{
'memory-game-card--flipped': card.state !== 'hidden',
'memory-game-card--matched': card.state === 'matched',
'memory-game-card--mismatch':
game.status === 'resolving' && game.selectedIds.includes(card.id),
}"
:disabled="card.state !== 'hidden' || game.status !== 'playing'"
:aria-label="
phone.t(
card.state === 'hidden'
? 'Apps.memory.hiddenCard'
: 'Apps.memory.revealedCard',
)
"
@click="selectCard(card.id)"
>
<span class="memory-game-card__inner">
<span class="memory-game-card__face memory-game-card__back">
<i></i><i></i><i></i><i></i>
</span>
<span
class="memory-game-card__face memory-game-card__front"
:style="{ color: symbols[card.symbol as keyof typeof symbols].color }"
>
<component
:is="symbols[card.symbol as keyof typeof symbols].icon"
:size="game.difficulty === 'small' ? 30 : 24"
:stroke-width="2.2"
fill="currentColor"
/>
</span>
</span>
</button>
<div v-if="game.status === 'won'" class="memory-complete">
<div class="memory-complete__burst"><Star :size="35" fill="currentColor" /></div>
<span>{{ phone.t('Apps.memory.completeEyebrow') }}</span>
<h2>{{ phone.t('Apps.memory.completeTitle') }}</h2>
<p>
{{ game.moves }} {{ phone.t('Apps.memory.moves') }} ·
{{ formatTime(memory.elapsedMs) }}
</p>
<button type="button" class="memory-primary" @click="restart">
{{ phone.t('Apps.memory.playAgain') }}
</button>
<button type="button" class="memory-secondary" @click="returnToMenu">
{{ phone.t('Apps.memory.menu') }}
</button>
</div>
</div>
</section>
</main>
</template>
<style scoped>
.memory-app {
position: absolute;
inset: 0;
overflow: hidden;
padding: 52px 17px 27px;
color: #302a48;
background:
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
}
.memory-app--playing {
padding: 0;
}
.memory-header {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
}
.memory-header span {
display: block;
color: #8175a2;
font-size: 11px;
font-weight: 800;
letter-spacing: 1.25px;
text-transform: uppercase;
}
.memory-header h1 {
margin: 1px 0 0;
font-size: 24px;
line-height: 1;
letter-spacing: -0.7px;
}
.memory-header__actions {
display: flex;
align-items: center;
gap: 5px;
}
.memory-header__actions > svg,
.memory-header__actions button:not(.sky-button--glass) {
box-sizing: content-box;
padding: 9px;
border: 0;
border-radius: 13px;
color: #7658c7;
background: rgb(255 255 255 / 54%);
}
.memory-header__actions .sky-button--glass { color: #7658c7; }
.memory-header__actions button {
display: grid;
place-items: center;
cursor: pointer;
}
.memory-menu {
height: calc(100% - 52px);
display: flex;
flex-direction: column;
justify-content: center;
gap: 17px;
}
.memory-hero {
position: relative;
width: 130px;
height: 108px;
margin: 0 auto;
}
.memory-hero__card {
position: absolute;
width: 70px;
height: 88px;
display: grid;
place-items: center;
border: 4px solid rgb(255 255 255 / 75%);
border-radius: 17px;
box-shadow: 0 12px 22px rgb(72 52 118 / 18%);
}
.memory-hero__card--back {
left: 9px;
top: 4px;
background: repeating-linear-gradient(45deg, #8e72db 0 7px, #9f83e6 7px 14px);
transform: rotate(-12deg);
}
.memory-hero__card--front {
right: 9px;
bottom: 0;
color: #f4b83f;
background: #fffaf0;
transform: rotate(10deg);
}
.memory-intro { text-align: center; }
.memory-intro h2 { margin: 0; font-size: 23px; letter-spacing: -0.6px; }
.memory-intro p { margin: 7px 18px 0; color: #74698f; font-size: 14px; line-height: 1.4; }
.memory-difficulties {
display: grid;
gap: 7px;
}
.memory-difficulties button {
min-height: 62px;
display: grid;
grid-template-columns: 48px 1fr auto;
grid-template-rows: 1fr 1fr;
align-items: center;
column-gap: 10px;
padding: 8px 12px;
border: 1px solid rgb(91 67 143 / 8%);
border-radius: 16px;
color: #342d4c;
background: rgb(255 255 255 / 62%);
box-shadow: 0 5px 14px rgb(80 58 129 / 8%);
text-align: left;
}
.memory-difficulty__size {
grid-row: 1 / 3;
display: grid;
place-items: center;
height: 39px;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, #9a7be1, #7558c4);
font-size: 14px;
font-weight: 800;
}
.memory-difficulty__name { align-self: end; font-size: 15px; font-weight: 800; }
.memory-difficulty__best { align-self: start; color: #74698f; font-size: 11.5px; line-height: 1.2; }
.memory-difficulties svg { grid-column: 3; grid-row: 1 / 3; color: #7658c7; }
.memory-game {
position: absolute;
inset: 0;
}
.memory-stats {
position: absolute;
z-index: 7;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 1fr auto;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(105 79 160 / 10%);
border-radius: 21px;
background: rgb(246 241 255 / 64%);
box-shadow: 0 8px 24px rgb(75 52 121 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.memory-stats div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.memory-stats span { color: #74698f; font-size: 10.5px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.memory-stats strong { display: block; font-size: 19px; line-height: 20px; }
.memory-stats button:not(.sky-button--glass) { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 13px; font-weight: 800; }
.memory-stats .memory-menu-button {
--sky-touch-target: 32px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
color: #7052bf;
cursor: pointer;
}
.memory-board {
position: absolute;
inset: 0;
display: grid;
gap: 8px;
align-content: center;
padding: 108px 17px 28px;
border: 0;
border-radius: 0;
background:
radial-gradient(circle at 12% 88%, rgb(135 105 207 / 12%), transparent 31%),
radial-gradient(circle at 88% 16%, rgb(255 255 255 / 54%), transparent 28%);
perspective: 900px;
}
.memory-board--small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.memory-board--medium,
.memory-board--large { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.memory-board--large { gap: 6px; padding-right: 15px; padding-left: 15px; }
.memory-game-card {
aspect-ratio: 0.82;
padding: 0;
border: 0;
border-radius: 13px;
background: transparent;
cursor: pointer;
perspective: 500px;
}
.memory-game-card:disabled { cursor: default; }
.memory-game-card__inner {
position: relative;
width: 100%;
height: 100%;
display: block;
transform-style: preserve-3d;
transition: transform 360ms cubic-bezier(0.22, 0.68, 0.3, 1);
}
.memory-game-card--flipped .memory-game-card__inner { transform: rotateY(180deg); }
.memory-game-card__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
border: 2px solid rgb(255 255 255 / 75%);
border-radius: 13px;
backface-visibility: hidden;
box-shadow: 0 5px 10px rgb(73 52 117 / 13%);
}
.memory-game-card__back {
position: absolute;
grid-template-columns: repeat(2, 6px);
grid-template-rows: repeat(2, 6px);
gap: 6px;
background: linear-gradient(145deg, #9d82e1, #7558bf);
}
.memory-game-card__back::before {
position: absolute;
inset: 5px;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 9px;
background: radial-gradient(circle at 24% 18%, rgb(255 255 255 / 12%), transparent 42%);
content: "";
}
.memory-game-card__back i { width: 6px; height: 6px; border-radius: 50%; background: rgb(255 255 255 / 30%); }
.memory-game-card__front {
background: radial-gradient(circle at 50% 42%, #fff 0 25%, #fffaf1 72%, #f8f1e7 100%);
transform: rotateY(180deg);
}
.memory-game-card--matched .memory-game-card__front {
background: #f7fff2;
box-shadow: 0 0 0 2px rgb(121 199 107 / 32%), 0 6px 12px rgb(71 143 65 / 14%);
}
.memory-game-card--matched {
animation: memory-match-pop 520ms cubic-bezier(0.2, 0.9, 0.25, 1.25);
}
.memory-game-card--mismatch {
animation: memory-mismatch-shake 420ms ease-in-out;
}
@keyframes memory-match-pop {
0% { transform: scale(1) translateY(0); }
38% { transform: scale(1.12) translateY(-4px); }
68% { transform: scale(0.97) translateY(1px); }
100% { transform: scale(1) translateY(0); }
}
@keyframes memory-mismatch-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px) rotate(-1.5deg); }
50% { transform: translateX(3px) rotate(1.5deg); }
75% { transform: translateX(-2px) rotate(-1deg); }
}
.memory-complete {
position: absolute;
z-index: 5;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 0;
background: rgb(244 239 255 / 90%);
backdrop-filter: blur(6px);
text-align: center;
}
.memory-complete__burst {
width: 68px;
height: 68px;
display: grid;
place-items: center;
border-radius: 50%;
color: #f3b63e;
background: #fff9e8;
box-shadow: 0 9px 22px rgb(100 75 151 / 17%);
}
.memory-complete > span { color: #74698f; font-size: 11px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; }
.memory-complete h2 { margin: 0; font-size: 27px; }
.memory-complete p { margin: -4px 0 4px; color: #756b8e; font-size: 14px; }
.memory-primary,
.memory-secondary {
min-width: 155px;
min-height: 42px;
border-radius: 14px;
font-size: 14px;
font-weight: 800;
}
.memory-primary { border: 0; color: #fff; background: linear-gradient(135deg, #9a7be1, #7051bf); box-shadow: 0 8px 17px rgb(91 61 164 / 22%); }
.memory-secondary { border: 1px solid rgb(92 72 134 / 14%); color: #6e6091; background: rgb(255 255 255 / 48%); }
button:active { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
.memory-game-card__inner { transition-duration: 1ms; }
.memory-game-card--matched,
.memory-game-card--mismatch { animation: none; }
}
</style>