mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 09:13:24 +00:00
ADD - add Snake and Memory phone games
This commit is contained in:
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user