diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3648726..c7ea800 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -20,6 +20,7 @@ import SimPhonePicker, { } from '@/components/SimPhonePicker.vue' import { PHONE_FRAME_IMAGES } from '@/config/appearance' import { useClockStore } from '@/stores/clock' +import { useGamesStore } from '@/features/games/store' import { useCallsStore } from '@/stores/calls' import { useAccountStore } from '@/stores/account' import { useMailStore } from '@/stores/mail' @@ -66,6 +67,7 @@ const isDevelopment = import.meta.env.DEV const phone = usePhoneStore() const account = useAccountStore() const clock = useClockStore() +const games = useGamesStore() const calls = useCallsStore() const mail = useMailStore() const media = useMediaStore() @@ -106,6 +108,7 @@ function hydratePhone(payload: PhoneOpenPayload): void { account.hydrate(payload.account ?? null) notes.hydrate(payload.notes ?? []) clock.hydrate(payload.device?.data.alarms?.payload) + games.hydrate(payload.device?.data.games?.payload) media.hydrate(payload.device?.data.media?.payload) void mail.bootstrap(payload.account?.email ?? '') void calls.bootstrap() diff --git a/frontend/src/assets/img/app-icons/memory.webp b/frontend/src/assets/img/app-icons/memory.webp new file mode 100644 index 0000000..c1ff6a6 Binary files /dev/null and b/frontend/src/assets/img/app-icons/memory.webp differ diff --git a/frontend/src/assets/img/app-icons/snake.webp b/frontend/src/assets/img/app-icons/snake.webp new file mode 100644 index 0000000..f6605ad Binary files /dev/null and b/frontend/src/assets/img/app-icons/snake.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 0140978..fe047d4 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -26,6 +26,18 @@ describe('app registry', () => { labelKey: 'Apps.weather.name', route: '/apps/weather', }) + expect(PHONE_APPS.find((app) => app.id === 'snake')).toMatchObject({ + dockOrder: null, + gridOrder: 11, + labelKey: 'Apps.snake.name', + route: '/apps/snake', + }) + expect(PHONE_APPS.find((app) => app.id === 'memory')).toMatchObject({ + dockOrder: null, + gridOrder: 12, + labelKey: 'Apps.memory.name', + route: '/apps/memory', + }) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 07fd30c..683315a 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -2,6 +2,8 @@ import { Calculator, Camera, Clock3, + Gamepad2, + Brain, Images, Mail, MapPinned, @@ -23,6 +25,8 @@ import notesIcon from '@/assets/img/app-icons/notes.webp' import photosIcon from '@/assets/img/app-icons/gallery.webp' import phoneIcon from '@/assets/img/app-icons/phone.webp' import settingsIcon from '@/assets/img/app-icons/settings.webp' +import snakeIcon from '@/assets/img/app-icons/snake.webp' +import memoryIcon from '@/assets/img/app-icons/memory.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' @@ -170,6 +174,32 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.settings.name', route: '/apps/settings', }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/SnakeApp.vue')), + ), + dockOrder: null, + gridOrder: 11, + icon: markRaw(Gamepad2), + iconClass: 'app-icon--snake', + iconImage: snakeIcon, + id: 'snake', + labelKey: 'Apps.snake.name', + route: '/apps/snake', + }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/MemoryApp.vue')), + ), + dockOrder: null, + gridOrder: 12, + icon: markRaw(Brain), + iconClass: 'app-icon--memory', + iconImage: memoryIcon, + id: 'memory', + labelKey: 'Apps.memory.name', + route: '/apps/memory', + }, ] export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) diff --git a/frontend/src/features/games/memory/audio.ts b/frontend/src/features/games/memory/audio.ts new file mode 100644 index 0000000..70a1e50 --- /dev/null +++ b/frontend/src/features/games/memory/audio.ts @@ -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 = { + 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) + } +} diff --git a/frontend/src/features/games/memory/engine.test.ts b/frontend/src/features/games/memory/engine.test.ts new file mode 100644 index 0000000..899d1a5 --- /dev/null +++ b/frontend/src/features/games/memory/engine.test.ts @@ -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>((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) + }) +}) diff --git a/frontend/src/features/games/memory/engine.ts b/frontend/src/features/games/memory/engine.ts new file mode 100644 index 0000000..ffc10fe --- /dev/null +++ b/frontend/src/features/games/memory/engine.ts @@ -0,0 +1,114 @@ +import type { + MemoryCard, + MemoryDifficulty, + MemoryGameState, +} from './types' + +export const MEMORY_PAIR_COUNTS: Record = { + 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', + } +} diff --git a/frontend/src/features/games/memory/store.ts b/frontend/src/features/games/memory/store.ts new file mode 100644 index 0000000..3324d83 --- /dev/null +++ b/frontend/src/features/games/memory/store.ts @@ -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> + soundEnabled: boolean +} + +function isMemoryBest(value: unknown): value is MemoryBest { + if (!value || typeof value !== 'object') return false + const best = value as Partial + return ( + typeof best.moves === 'number' && + best.moves > 0 && + typeof best.timeMs === 'number' && + best.timeMs >= 0 + ) +} + +export const useMemoryStore = defineStore('memory', { + state: () => ({ + best: {} as Partial>, + 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>('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 + }, + }, +}) diff --git a/frontend/src/features/games/memory/types.ts b/frontend/src/features/games/memory/types.ts new file mode 100644 index 0000000..092c265 --- /dev/null +++ b/frontend/src/features/games/memory/types.ts @@ -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 +} diff --git a/frontend/src/features/games/snake/engine.test.ts b/frontend/src/features/games/snake/engine.test.ts new file mode 100644 index 0000000..5bfd8ff --- /dev/null +++ b/frontend/src/features/games/snake/engine.test.ts @@ -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) + }) +}) diff --git a/frontend/src/features/games/snake/engine.ts b/frontend/src/features/games/snake/engine.ts new file mode 100644 index 0000000..2a3ec80 --- /dev/null +++ b/frontend/src/features/games/snake/engine.ts @@ -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 = { + up: { x: 0, y: -1 }, + right: { x: 1, y: 0 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, +} + +const OPPOSITE_DIRECTIONS: Record = { + 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), + } +} diff --git a/frontend/src/features/games/snake/store.ts b/frontend/src/features/games/snake/store.ts new file mode 100644 index 0000000..db3ce58 --- /dev/null +++ b/frontend/src/features/games/snake/store.ts @@ -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 = { + 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>('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 + }, + }, +}) diff --git a/frontend/src/features/games/snake/types.ts b/frontend/src/features/games/snake/types.ts new file mode 100644 index 0000000..aeeca50 --- /dev/null +++ b/frontend/src/features/games/snake/types.ts @@ -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 +} diff --git a/frontend/src/features/games/store.ts b/frontend/src/features/games/store.ts new file mode 100644 index 0000000..816216b --- /dev/null +++ b/frontend/src/features/games/store.ts @@ -0,0 +1,24 @@ +import { defineStore } from 'pinia' + +import { usePhoneStore } from '@/stores/phone' + +export const useGamesStore = defineStore('games', { + state: () => ({ + games: {} as Record, + }), + actions: { + hydrate(payload: unknown): void { + this.games = + payload && typeof payload === 'object' + ? structuredClone(payload as Record) + : {} + }, + readGame(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) + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0d0f8b5..efd8629 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -128,6 +128,52 @@ const defaultLocales: LocaleTree = { }, }, calculator: { name: 'Calculator' }, + snake: { + name: 'Snake', + board: 'Snake game board', + controls: 'Direction controls', + directions: { + down: 'Move down', + left: 'Move left', + right: 'Move right', + up: 'Move up', + }, + gameOver: 'Game Over', + highScore: 'High Score', + menu: 'Main Menu', + pause: 'Pause game', + paused: 'Paused', + readyBody: 'Collect fruit, grow longer, and stay clear of every wall.', + readyTitle: 'Ready to play?', + restart: 'Play Again', + resume: 'Resume game', + score: 'Score', + speed: 'Speed', + speeds: { fast: 'Fast', normal: 'Normal', relaxed: 'Relaxed' }, + start: 'Start Game', + swipeHint: 'Swipe, tap the controls, or use arrow keys / WASD', + }, + memory: { + name: 'Memory', + backToMenu: 'Back to board selection', + board: 'Memory card board', + chooseBody: 'Find every matching pair with as few moves as possible.', + chooseTitle: 'Choose a board', + completeEyebrow: 'Board cleared', + completeTitle: 'Great memory!', + difficulties: { large: 'Expert', medium: 'Classic', small: 'Quick' }, + eyebrow: 'Matching game', + hiddenCard: 'Hidden card', + menu: 'Board Selection', + moves: 'Moves', + mute: 'Mute game sounds', + noBest: 'No best score yet', + playAgain: 'Play Again', + restart: 'Restart', + revealedCard: 'Revealed card', + time: 'Time', + unmute: 'Turn on game sounds', + }, map: { name: 'Map', controls: 'Map controls', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index f41235b..da632a2 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -12,6 +12,8 @@ export type PhoneAppId = | 'photos' | 'app-store' | 'settings' + | 'snake' + | 'memory' export type AppLaunchOrigin = { borderRadius: number diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 5acd580..bfaa523 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -50,6 +50,8 @@ const DEFAULT_APP_NOTIFICATIONS: Record< phone: { enabled: true, sounds: true }, 'app-store': { enabled: true, sounds: true }, calculator: { enabled: true, sounds: true }, + snake: { enabled: true, sounds: true }, + memory: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/MemoryApp.vue b/frontend/src/views/apps/MemoryApp.vue new file mode 100644 index 0000000..42d3a96 --- /dev/null +++ b/frontend/src/views/apps/MemoryApp.vue @@ -0,0 +1,588 @@ + + + + + diff --git a/frontend/src/views/apps/SnakeApp.vue b/frontend/src/views/apps/SnakeApp.vue new file mode 100644 index 0000000..b987b07 --- /dev/null +++ b/frontend/src/views/apps/SnakeApp.vue @@ -0,0 +1,667 @@ + + + + + diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 55f86e2..35d21e5 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -61,6 +61,24 @@ Locales["en"] = { }, }, calculator = { name = "Calculator" }, + snake = { + name = "Snake", board = "Snake game board", controls = "Direction controls", + directions = { down = "Move down", left = "Move left", right = "Move right", up = "Move up" }, + gameOver = "Game Over", highScore = "High Score", menu = "Main Menu", pause = "Pause game", paused = "Paused", + readyBody = "Collect fruit, grow longer, and stay clear of every wall.", readyTitle = "Ready to play?", + restart = "Play Again", resume = "Resume game", score = "Score", speed = "Speed", + speeds = { fast = "Fast", normal = "Normal", relaxed = "Relaxed" }, start = "Start Game", + swipeHint = "Swipe, tap the controls, or use arrow keys / WASD", + }, + memory = { + name = "Memory", backToMenu = "Back to board selection", board = "Memory card board", + chooseBody = "Find every matching pair with as few moves as possible.", + chooseTitle = "Choose a board", completeEyebrow = "Board cleared", completeTitle = "Great memory!", + difficulties = { large = "Expert", medium = "Classic", small = "Quick" }, eyebrow = "Matching game", + hiddenCard = "Hidden card", menu = "Board Selection", moves = "Moves", mute = "Mute game sounds", + noBest = "No best score yet", playAgain = "Play Again", restart = "Restart", + revealedCard = "Revealed card", time = "Time", unmute = "Turn on game sounds", + }, camera = { name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls", modes = { timelapse = "Timelapse", slowMo = "Slow-Mo", cinematic = "Cinematic", video = "Video", photo = "Photo", portrait = "Portrait", pano = "Pano" }, diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 2d66aff..cb39de7 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,8 +4,8 @@ Sky Phone - - + +
diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index b8ed000..4094fc6 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -14,6 +14,7 @@ local allowed_device_namespaces = { alarms = true, media = true, apps = true, + games = true, } local function trim(value)