ADD - add Snake and Memory phone games

This commit is contained in:
smx.pusha
2026-08-06 08:23:29 +02:00
parent 0a880bc4d4
commit bbf4e69edc
23 changed files with 2074 additions and 2 deletions
@@ -0,0 +1,56 @@
export type MemorySound = 'flip' | 'match' | 'mismatch' | 'win'
type Tone = {
duration: number
frequency: number
offset: number
type: OscillatorType
volume: number
}
const sounds: Record<MemorySound, Tone[]> = {
flip: [
{ duration: 0.055, frequency: 520, offset: 0, type: 'sine', volume: 0.07 },
],
match: [
{ duration: 0.11, frequency: 660, offset: 0, type: 'sine', volume: 0.09 },
{ duration: 0.16, frequency: 880, offset: 0.09, type: 'sine', volume: 0.1 },
],
mismatch: [
{ duration: 0.1, frequency: 210, offset: 0, type: 'triangle', volume: 0.07 },
{ duration: 0.14, frequency: 150, offset: 0.08, type: 'triangle', volume: 0.065 },
],
win: [
{ duration: 0.16, frequency: 523.25, offset: 0, type: 'sine', volume: 0.085 },
{ duration: 0.16, frequency: 659.25, offset: 0.1, type: 'sine', volume: 0.09 },
{ duration: 0.16, frequency: 783.99, offset: 0.2, type: 'sine', volume: 0.095 },
{ duration: 0.28, frequency: 1046.5, offset: 0.3, type: 'sine', volume: 0.1 },
],
}
let audioContext: AudioContext | undefined
export function playMemorySound(sound: MemorySound, enabled: boolean): void {
if (!enabled) return
audioContext ??= new AudioContext()
if (audioContext.state === 'suspended') void audioContext.resume()
const now = audioContext.currentTime
for (const tone of sounds[sound]) {
const oscillator = audioContext.createOscillator()
const gain = audioContext.createGain()
const start = now + tone.offset
const end = start + tone.duration
oscillator.type = tone.type
oscillator.frequency.setValueAtTime(tone.frequency, start)
gain.gain.setValueAtTime(0.0001, start)
gain.gain.exponentialRampToValueAtTime(tone.volume, start + 0.012)
gain.gain.exponentialRampToValueAtTime(0.0001, end)
oscillator.connect(gain)
gain.connect(audioContext.destination)
oscillator.start(start)
oscillator.stop(end)
}
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest'
import {
createMemoryGame,
flipMemoryCard,
MEMORY_PAIR_COUNTS,
resolveMemoryMismatch,
} from './engine'
describe('Memory engine', () => {
it.each(['small', 'medium', 'large'] as const)(
'creates exactly two cards per symbol for %s',
(difficulty) => {
const game = createMemoryGame(difficulty, () => 0.5)
const counts = game.cards.reduce<Record<string, number>>((result, card) => {
result[card.symbol] = (result[card.symbol] ?? 0) + 1
return result
}, {})
expect(game.cards).toHaveLength(MEMORY_PAIR_COUNTS[difficulty] * 2)
expect(Object.values(counts).every((count) => count === 2)).toBe(true)
},
)
it('does not count the same card twice', () => {
const game = createMemoryGame('small', () => 0)
const once = flipMemoryCard(game, game.cards[0].id)
expect(flipMemoryCard(once, game.cards[0].id)).toBe(once)
expect(once.moves).toBe(0)
})
it('matches equal cards and counts one move', () => {
const game = createMemoryGame('small', () => 0)
const pair = game.cards.filter((card) => card.symbol === game.cards[0].symbol)
const first = flipMemoryCard(game, pair[0].id)
const second = flipMemoryCard(first, pair[1].id)
expect(second.moves).toBe(1)
expect(second.matchedPairs).toBe(1)
expect(second.selectedIds).toEqual([])
expect(second.cards.filter((card) => card.state === 'matched')).toHaveLength(2)
})
it('blocks a third card until a mismatch is resolved', () => {
const game = createMemoryGame('small', () => 0)
const firstCard = game.cards[0]
const secondCard = game.cards.find((card) => card.symbol !== firstCard.symbol)!
const thirdCard = game.cards.find(
(card) => card.symbol !== firstCard.symbol && card.id !== secondCard.id,
)!
const first = flipMemoryCard(game, firstCard.id)
const mismatch = flipMemoryCard(first, secondCard.id)
expect(mismatch.status).toBe('resolving')
expect(flipMemoryCard(mismatch, thirdCard.id)).toBe(mismatch)
expect(resolveMemoryMismatch(mismatch).selectedIds).toEqual([])
})
it('marks the completed round as won exactly on the final pair', () => {
let game = createMemoryGame('small', () => 0)
const symbols = [...new Set(game.cards.map((card) => card.symbol))]
for (const symbol of symbols) {
const pair = game.cards.filter((card) => card.symbol === symbol)
game = flipMemoryCard(game, pair[0].id)
game = flipMemoryCard(game, pair[1].id)
}
expect(game.status).toBe('won')
expect(game.moves).toBe(MEMORY_PAIR_COUNTS.small)
expect(flipMemoryCard(game, game.cards[0].id)).toBe(game)
})
})
@@ -0,0 +1,114 @@
import type {
MemoryCard,
MemoryDifficulty,
MemoryGameState,
} from './types'
export const MEMORY_PAIR_COUNTS: Record<MemoryDifficulty, number> = {
small: 6,
medium: 8,
large: 10,
}
const MEMORY_SYMBOLS = [
'star',
'heart',
'moon',
'sun',
'cloud',
'bolt',
'diamond',
'circle',
'triangle',
'flower',
]
function shuffleCards(cards: MemoryCard[], random: () => number): MemoryCard[] {
const shuffled = [...cards]
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const target = Math.min(index, Math.floor(Math.max(0, random()) * (index + 1)))
const current = shuffled[index]
shuffled[index] = shuffled[target]
shuffled[target] = current
}
return shuffled
}
export function createMemoryGame(
difficulty: MemoryDifficulty,
random: () => number = Math.random,
): MemoryGameState {
const symbols = MEMORY_SYMBOLS.slice(0, MEMORY_PAIR_COUNTS[difficulty])
const cards = symbols.flatMap((symbol) => [
{ id: `${symbol}-a`, state: 'hidden' as const, symbol },
{ id: `${symbol}-b`, state: 'hidden' as const, symbol },
])
return {
cards: shuffleCards(cards, random),
difficulty,
matchedPairs: 0,
moves: 0,
selectedIds: [],
status: 'playing',
}
}
export function flipMemoryCard(
state: MemoryGameState,
cardId: string,
): MemoryGameState {
if (state.status !== 'playing') return state
const selected = state.cards.find((card) => card.id === cardId)
if (!selected || selected.state !== 'hidden') return state
const cards = state.cards.map((card) =>
card.id === cardId ? { ...card, state: 'revealed' as const } : card,
)
const selectedIds = [...state.selectedIds, cardId]
if (selectedIds.length === 1) return { ...state, cards, selectedIds }
const first = cards.find((card) => card.id === selectedIds[0])
const second = cards.find((card) => card.id === selectedIds[1])
const moves = state.moves + 1
if (first?.symbol === second?.symbol) {
const matchedCards = cards.map((card) =>
selectedIds.includes(card.id)
? { ...card, state: 'matched' as const }
: card,
)
const matchedPairs = state.matchedPairs + 1
return {
...state,
cards: matchedCards,
matchedPairs,
moves,
selectedIds: [],
status:
matchedPairs === MEMORY_PAIR_COUNTS[state.difficulty]
? 'won'
: 'playing',
}
}
return { ...state, cards, moves, selectedIds, status: 'resolving' }
}
export function resolveMemoryMismatch(state: MemoryGameState): MemoryGameState {
if (state.status !== 'resolving') return state
return {
...state,
cards: state.cards.map((card) =>
state.selectedIds.includes(card.id)
? { ...card, state: 'hidden' as const }
: card,
),
selectedIds: [],
status: 'playing',
}
}
+113
View File
@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import {
createMemoryGame,
flipMemoryCard,
resolveMemoryMismatch,
} from './engine'
import type {
MemoryBest,
MemoryDifficulty,
MemoryGameState,
} from './types'
type MemorySave = {
best: Partial<Record<MemoryDifficulty, MemoryBest>>
soundEnabled: boolean
}
function isMemoryBest(value: unknown): value is MemoryBest {
if (!value || typeof value !== 'object') return false
const best = value as Partial<MemoryBest>
return (
typeof best.moves === 'number' &&
best.moves > 0 &&
typeof best.timeMs === 'number' &&
best.timeMs >= 0
)
}
export const useMemoryStore = defineStore('memory', {
state: () => ({
best: {} as Partial<Record<MemoryDifficulty, MemoryBest>>,
elapsedMs: 0,
game: null as MemoryGameState | null,
hydrated: false,
soundEnabled: true,
startedAt: null as number | null,
}),
actions: {
hydrate(): void {
if (this.hydrated) return
const saved = useGamesStore().readGame<Partial<MemorySave>>('memory')
for (const difficulty of ['small', 'medium', 'large'] as const) {
const candidate = saved?.best?.[difficulty]
if (isMemoryBest(candidate)) this.best[difficulty] = candidate
}
if (typeof saved?.soundEnabled === 'boolean') {
this.soundEnabled = saved.soundEnabled
}
this.hydrated = true
},
persist(): void {
useGamesStore().saveGame('memory', {
best: this.best,
soundEnabled: this.soundEnabled,
} satisfies MemorySave)
},
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled
this.persist()
},
start(difficulty: MemoryDifficulty): void {
this.game = createMemoryGame(difficulty)
this.elapsedMs = 0
this.startedAt = Date.now()
},
updateElapsed(now = Date.now()): void {
if (this.startedAt === null) return
this.elapsedMs += now - this.startedAt
this.startedAt = now
},
pause(): void {
this.updateElapsed()
this.startedAt = null
},
resume(): void {
if (this.game?.status === 'playing' && this.startedAt === null) {
this.startedAt = Date.now()
}
},
flip(cardId: string): void {
if (!this.game) return
const previousStatus = this.game.status
this.game = flipMemoryCard(this.game, cardId)
if (previousStatus !== 'won' && this.game.status === 'won') {
this.updateElapsed()
this.startedAt = null
const result = { moves: this.game.moves, timeMs: this.elapsedMs }
const current = this.best[this.game.difficulty]
if (
!current ||
result.moves < current.moves ||
(result.moves === current.moves && result.timeMs < current.timeMs)
) {
this.best[this.game.difficulty] = result
this.persist()
}
}
},
resolveMismatch(): void {
if (this.game) this.game = resolveMemoryMismatch(this.game)
},
showMenu(): void {
this.pause()
this.game = null
this.elapsedMs = 0
},
},
})
@@ -0,0 +1,25 @@
export type MemoryDifficulty = 'small' | 'medium' | 'large'
export type MemoryCardState = 'hidden' | 'revealed' | 'matched'
export type MemoryCard = {
id: string
state: MemoryCardState
symbol: string
}
export type MemoryGameStatus = 'playing' | 'resolving' | 'won'
export type MemoryGameState = {
cards: MemoryCard[]
difficulty: MemoryDifficulty
matchedPairs: number
moves: number
selectedIds: string[]
status: MemoryGameStatus
}
export type MemoryBest = {
moves: number
timeMs: number
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import {
createSnakeGame,
SNAKE_BOARD_WIDTH,
stepSnake,
turnSnake,
} from './engine'
import type { SnakeGameState } from './types'
describe('Snake engine', () => {
it('moves one cell without changing its length', () => {
const game = createSnakeGame(() => 0)
const next = stepSnake(game, () => 0)
expect(next.body[0]).toEqual({ x: game.body[0].x + 1, y: game.body[0].y })
expect(next.body).toHaveLength(game.body.length)
})
it('grows, scores, and places fruit outside the body', () => {
const game = createSnakeGame(() => 0)
game.fruit = { x: game.body[0].x + 1, y: game.body[0].y }
const next = stepSnake(game, () => 0)
expect(next.score).toBe(1)
expect(next.body).toHaveLength(game.body.length + 1)
expect(next.body).not.toContainEqual(next.fruit)
})
it('ignores an immediate reverse direction', () => {
const game = createSnakeGame(() => 0)
expect(turnSnake(game, 'left')).toBe(game)
expect(turnSnake(game, 'up').pendingDirection).toBe('up')
})
it('ends the game on wall collision', () => {
const game: SnakeGameState = {
...createSnakeGame(() => 0),
body: [
{ x: SNAKE_BOARD_WIDTH - 1, y: 4 },
{ x: SNAKE_BOARD_WIDTH - 2, y: 4 },
],
}
expect(stepSnake(game).status).toBe('game-over')
})
it('ends the game on body collision', () => {
const game: SnakeGameState = {
body: [
{ x: 4, y: 4 },
{ x: 4, y: 3 },
{ x: 3, y: 3 },
{ x: 3, y: 4 },
{ x: 3, y: 5 },
],
direction: 'up',
fruit: { x: 10, y: 10 },
pendingDirection: 'left',
score: 0,
status: 'playing',
}
expect(stepSnake(game).status).toBe('game-over')
})
it('does not advance a paused game', () => {
const game: SnakeGameState = {
...createSnakeGame(() => 0),
status: 'paused',
}
expect(stepSnake(game)).toBe(game)
})
})
+118
View File
@@ -0,0 +1,118 @@
import type {
SnakeDirection,
SnakeGameState,
SnakePoint,
} from './types'
export const SNAKE_BOARD_WIDTH = 16
export const SNAKE_BOARD_HEIGHT = 18
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
up: { x: 0, y: -1 },
right: { x: 1, y: 0 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
}
const OPPOSITE_DIRECTIONS: Record<SnakeDirection, SnakeDirection> = {
up: 'down',
right: 'left',
down: 'up',
left: 'right',
}
function pointsMatch(first: SnakePoint, second: SnakePoint): boolean {
return first.x === second.x && first.y === second.y
}
function placeFruit(
body: SnakePoint[],
random: () => number,
): SnakePoint {
const openCells: SnakePoint[] = []
for (let y = 0; y < SNAKE_BOARD_HEIGHT; y += 1) {
for (let x = 0; x < SNAKE_BOARD_WIDTH; x += 1) {
const point = { x, y }
if (!body.some((segment) => pointsMatch(segment, point))) {
openCells.push(point)
}
}
}
const index = Math.min(
openCells.length - 1,
Math.floor(Math.max(0, random()) * openCells.length),
)
return openCells[index] ?? body[0]
}
export function createSnakeGame(random: () => number = Math.random): SnakeGameState {
const centerX = Math.floor(SNAKE_BOARD_WIDTH / 2)
const centerY = Math.floor(SNAKE_BOARD_HEIGHT / 2)
const body = [
{ x: centerX, y: centerY },
{ x: centerX - 1, y: centerY },
{ x: centerX - 2, y: centerY },
]
return {
body,
direction: 'right',
fruit: placeFruit(body, random),
pendingDirection: 'right',
score: 0,
status: 'playing',
}
}
export function turnSnake(
state: SnakeGameState,
direction: SnakeDirection,
): SnakeGameState {
if (
state.status !== 'playing' ||
direction === OPPOSITE_DIRECTIONS[state.direction]
) {
return state
}
return { ...state, pendingDirection: direction }
}
export function stepSnake(
state: SnakeGameState,
random: () => number = Math.random,
): SnakeGameState {
if (state.status !== 'playing') return state
const direction = state.pendingDirection
const vector = DIRECTION_VECTORS[direction]
const head = state.body[0]
const nextHead = { x: head.x + vector.x, y: head.y + vector.y }
const ateFruit = pointsMatch(nextHead, state.fruit)
const collisionBody = ateFruit ? state.body : state.body.slice(0, -1)
const hitWall =
nextHead.x < 0 ||
nextHead.x >= SNAKE_BOARD_WIDTH ||
nextHead.y < 0 ||
nextHead.y >= SNAKE_BOARD_HEIGHT
const hitBody = collisionBody.some((segment) =>
pointsMatch(segment, nextHead),
)
if (hitWall || hitBody) {
return { ...state, direction, status: 'game-over' }
}
const body = [nextHead, ...state.body]
if (!ateFruit) body.pop()
return {
...state,
body,
direction,
fruit: ateFruit ? placeFruit(body, random) : state.fruit,
score: state.score + (ateFruit ? 1 : 0),
}
}
@@ -0,0 +1,84 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import { createSnakeGame, stepSnake, turnSnake } from './engine'
import type { SnakeDirection, SnakeGameState, SnakeSpeed } from './types'
type SnakeSave = {
highScore: number
speed: SnakeSpeed
}
const SPEED_TICK_MS: Record<SnakeSpeed, number> = {
relaxed: 210,
normal: 155,
fast: 110,
}
function isSnakeSpeed(value: unknown): value is SnakeSpeed {
return value === 'relaxed' || value === 'normal' || value === 'fast'
}
export const useSnakeStore = defineStore('snake', {
state: () => ({
game: null as SnakeGameState | null,
highScore: 0,
hydrated: false,
speed: 'normal' as SnakeSpeed,
}),
getters: {
tickMilliseconds: (state) => SPEED_TICK_MS[state.speed],
},
actions: {
hydrate(): void {
if (this.hydrated) return
const saved = useGamesStore().readGame<Partial<SnakeSave>>('snake')
this.highScore =
typeof saved?.highScore === 'number' && saved.highScore >= 0
? Math.floor(saved.highScore)
: 0
this.speed = isSnakeSpeed(saved?.speed) ? saved.speed : 'normal'
this.hydrated = true
},
persist(): void {
useGamesStore().saveGame('snake', {
highScore: this.highScore,
speed: this.speed,
} satisfies SnakeSave)
},
setSpeed(speed: SnakeSpeed): void {
this.speed = speed
this.persist()
},
start(): void {
this.game = createSnakeGame()
},
pause(): void {
if (this.game?.status === 'playing') {
this.game = { ...this.game, status: 'paused' }
}
},
resume(): void {
if (this.game?.status === 'paused') {
this.game = { ...this.game, status: 'playing' }
}
},
turn(direction: SnakeDirection): void {
if (this.game) this.game = turnSnake(this.game, direction)
},
tick(): void {
if (!this.game) return
this.game = stepSnake(this.game)
if (this.game.status === 'game-over' && this.game.score > this.highScore) {
this.highScore = this.game.score
this.persist()
}
},
showMenu(): void {
this.game = null
},
},
})
@@ -0,0 +1,19 @@
export type SnakeDirection = 'up' | 'right' | 'down' | 'left'
export type SnakePoint = {
x: number
y: number
}
export type SnakeSpeed = 'relaxed' | 'normal' | 'fast'
export type SnakeGameStatus = 'playing' | 'paused' | 'game-over'
export type SnakeGameState = {
body: SnakePoint[]
direction: SnakeDirection
fruit: SnakePoint
pendingDirection: SnakeDirection
score: number
status: SnakeGameStatus
}
+24
View File
@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { usePhoneStore } from '@/stores/phone'
export const useGamesStore = defineStore('games', {
state: () => ({
games: {} as Record<string, unknown>,
}),
actions: {
hydrate(payload: unknown): void {
this.games =
payload && typeof payload === 'object'
? structuredClone(payload as Record<string, unknown>)
: {}
},
readGame<T>(gameId: string): T | undefined {
return this.games[gameId] as T | undefined
},
saveGame(gameId: string, payload: unknown): void {
this.games[gameId] = structuredClone(payload)
usePhoneStore().saveDeviceNamespace('games', this.games)
},
},
})