Skip to content
This repository was archived by the owner on Feb 18, 2026. It is now read-only.
Binary file modified public/assets/card_6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/assets/card_back.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/card_back.png
Binary file not shown.
73 changes: 73 additions & 0 deletions src/components/MemogameLeaderboard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { LeaderboardEntry } from '@/types/leaderboard.types'

interface Props {
formatTime: (n: number) => string
leaderboard: readonly LeaderboardEntry[]
}

const props = defineProps<Props>()

function getMedalClass(index: number) {
if (index === 0) return 'medal gold'
if (index === 1) return 'medal silver'
if (index === 2) return 'medal bronze'
return ''
}
function getMedal(index: number) {
return ['🥇', '🥈', '🥉'][index] ?? ''
}
</script>

<template>
<ion-list>
<ion-item v-if="props.leaderboard.length === 0">
<ion-label>No entries yet</ion-label>
</ion-item>

<ion-item v-for="(entry, index) in props.leaderboard" :key="index">
<ion-label>
<h3>{{ index + 1 }}. {{ entry.name }}</h3>
<p>Played {{ new Date(entry.date).toLocaleString() }}</p>
</ion-label>

<ion-badge slot="end" :class="getMedalClass(index)" color="primary">
{{ getMedal(index) }}
{{ props.formatTime(entry.time) }}
</ion-badge>
</ion-item>
</ion-list>
</template>

<style scoped>
ion-card {
width: 100%;
max-width: 740px;
box-shadow: none;
}

ion-button {
min-width: 120px;
}

.medal {
font-weight: 600;
padding-left: 0.5rem;
padding-right: 0.5rem;
}

.medal.gold {
background-color: #ffd700;
color: #000;
}

.medal.silver {
background-color: #c0c0c0;
color: #000;
}

.medal.bronze {
background-color: #cd7f32;
color: #fff;
}
</style>
62 changes: 55 additions & 7 deletions src/components/MemoryCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,77 @@ import { Card } from '@/types/game.types'
import { useMemoryGame } from '@/composables/useMemoryGame'
const { flipCard } = useMemoryGame()

defineProps<{
interface Props {
card: Card
}>()
}

defineProps<Props>()
</script>

<template>
<div class="card" @click="flipCard(card)">
<img :src="card.flipped ? card.path : '/assets/card_back.png'" alt="a memory card" />
<div class="card" :class="{ 'flipped': card.flipped, 'matched': card.matched }" @click="flipCard(card)">
<div class="card-inner">
<div class="card-face card-back">
<img src="/assets/card_back.png" alt="card back" />
</div>
<div class="card-face card-front">
<img :src="card.path" alt="card front" />
</div>
</div>
</div>
</template>

<style scoped>
.card {
flex: 1 1 calc(20% - 20px); /* grow, shrink, width */
flex: 1 1 calc(20% - 20px);
max-width: 120px;
aspect-ratio: 1 / 1;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
perspective: 1000px;
}

.card img {
.card-inner {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s;
border-radius: 8px;
}

.card.flipped .card-inner {
transform: rotateY(180deg);
}

.card-face {
position: absolute;
inset: 0;
display: flex;
backface-visibility: hidden;
justify-content: center;
align-items: center;
border: darkgrey 2px solid;
border-radius: 8px;
overflow: hidden;
background: white;
}

.card-front {
transform: rotateY(180deg);
}

.card-face img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}

.card.matched .card-face {
border-color: darkgreen;
border-width: 5px;
}
</style>
14 changes: 9 additions & 5 deletions src/components/WinModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,20 @@ import { ref, watch } from 'vue'
import { useLeaderboard } from '@/composables/useLeaderboard'
import { useWinModal } from '@/composables/useWinModal'

const props = defineProps<{
interface Props {
isOpen: boolean
pairsFound: number
totalPairs: number
elapsedTime: number
}>()
}

const props = defineProps<Props>()

const emit = defineEmits<{
interface Emits {
(e: 'submitName'): void
}>()
}

const emit = defineEmits<Emits>()

const { addEntry, formatTime } = useLeaderboard()
const { name: defaultName } = useWinModal()
Expand All @@ -42,7 +46,7 @@ const name = ref('')

// to set a default value and then automatically update the localname ref when the input value changes
watch(
() => defaultName.value,
defaultName,
(newName) => {
if (newName && newName.trim() && !name.value) {
name.value = newName
Expand Down
9 changes: 6 additions & 3 deletions src/composables/useLeaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export function useLeaderboard() {

// format time to minutes:seconds
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins} min ${secs.toString().padStart(2, '0')}s`
return new Intl.DurationFormat(navigator.language, {
style: 'long',
}).format({
minutes: Math.floor(seconds / 60),
seconds: seconds % 60,
})
}

// reset the leaderboard
Expand Down
27 changes: 14 additions & 13 deletions src/composables/useMemoryGame.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { Card, GameSettings, ValidGameSizes } from '@/types/game.types'
import { useStorageService } from '@/composables/useStorageService'

Expand All @@ -23,6 +23,10 @@ const {
size: 8 as ValidGameSizes,
showTimer: true,
})

export const gameDescription =
'MemoGame is a singleplayer memory game that trains your concentration and recall. Tap two matching cards to flip & find all pairs as fast as possible! Enter your name after winning to save your time on the leaderboard.'

export function useMemoryGame() {
const initCards = () => {
const size = memorySize.value
Expand Down Expand Up @@ -94,19 +98,15 @@ export function useMemoryGame() {
initCards()
}

onMounted(() => {
watch(
ready,
(isReady) => {
if (!isReady) return
const init = () => {
watch(ready, (isReady) => {
if (!isReady) return

memorySize.value = storedSettings.value.size
showTimer.value = storedSettings.value.showTimer
initCards()
},
{ immediate: true }
)
})
memorySize.value = storedSettings.value.size
showTimer.value = storedSettings.value.showTimer
initCards()
})
}

const updateMemorySize = async (value: ValidGameSizes) => {
memorySize.value = value
Expand All @@ -131,6 +131,7 @@ export function useMemoryGame() {
updateMemorySize,
showTimer,
updateShowTimer,
init,
}
}

Expand Down
16 changes: 9 additions & 7 deletions src/composables/useWinModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ const { data, ready, setData } = useStorageService<string>(WINMODAL_KEY, '')
export function useWinModal() {
const name = computed(() => data.value)

watch(
ready,
(isReady) => {
if (!isReady) return
},
{ immediate: true }
)
const init = () => {
watch(
ready,
(isReady) => {
if (!isReady) return
},
)
}

const updateDefaultName = async (newName: string) => {
await setData(newName)
Expand All @@ -29,5 +30,6 @@ export function useWinModal() {
updateDefaultName,
reset,
ready,
init,
}
}
8 changes: 8 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ import '@ionic/vue/css/palettes/high-contrast.system.css'

/* Theme variables */
import './theme/variables.css'
import { useMemoryGame } from './composables/useMemoryGame'
import { useWinModal } from './composables/useWinModal'

const app = createApp(App)
.use(IonicVue, {
mode: 'ios',
})
.use(router)

const { init: initMemoGame } = useMemoryGame()
const { init: initWinModal } = useWinModal()

initMemoGame()
initWinModal()

router.isReady().then(() => {
app.mount('#app')
})
6 changes: 6 additions & 0 deletions src/types/intl.types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare namespace Intl {
declare class DurationFormat {
constructor(locales: string, options: object)
format: (obj: object) => string
}
}
22 changes: 19 additions & 3 deletions src/views/HomeView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<ion-content :fullscreen="true">
<ion-header collapse="condense">
<ion-toolbar color="primary">
<ion-title size="large">Start a Game</ion-title>
<ion-title size="large">MemoGame - Your Memory Game</ion-title>
</ion-toolbar>
</ion-header>

Expand All @@ -21,18 +21,34 @@
<ion-icon slot="start" :icon="trophy"></ion-icon>
Leaderboard
</ion-button>
<ion-button id="guide-trigger" color="dark" router-link="/help">
<ion-icon slot="start" :icon="informationCircle"></ion-icon>
Play Guide
</ion-button>
<ion-button color="dark" router-link="/settings">
<ion-icon slot="start" :icon="cog"></ion-icon>
Open Settings
</ion-button>
</div>
<ion-alert trigger="guide-trigger" header="Play Guide" :buttons="alertButtons" :message="gameDescription"></ion-alert>
</ion-content>
</ion-page>
</template>

<script setup lang="ts">
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonButton, IonIcon } from '@ionic/vue'
import { cog, people, person, trophy } from 'ionicons/icons'
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent, IonAlert, IonButton, IonIcon } from '@ionic/vue'
import { cog, informationCircle, people, person, trophy } from 'ionicons/icons'
import { gameDescription } from '@/composables/useMemoryGame'

const alertButtons = [
{
text: "Okay, Let's Play!",
role: 'cancel',
handler: () => {
// do nothing (closes by default)
},
},
]
</script>

<style scoped>
Expand Down
Loading