From bbf4e69edc855e966170ab902b6afe8ca976b5d1 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:23:29 +0200 Subject: [PATCH] ADD - add Snake and Memory phone games --- frontend/src/App.vue | 3 + frontend/src/assets/img/app-icons/memory.webp | Bin 0 -> 21116 bytes frontend/src/assets/img/app-icons/snake.webp | Bin 0 -> 7398 bytes frontend/src/config/apps.test.ts | 12 + frontend/src/config/apps.ts | 30 + frontend/src/features/games/memory/audio.ts | 56 ++ .../src/features/games/memory/engine.test.ts | 74 ++ frontend/src/features/games/memory/engine.ts | 114 +++ frontend/src/features/games/memory/store.ts | 113 +++ frontend/src/features/games/memory/types.ts | 25 + .../src/features/games/snake/engine.test.ts | 76 ++ frontend/src/features/games/snake/engine.ts | 118 ++++ frontend/src/features/games/snake/store.ts | 84 +++ frontend/src/features/games/snake/types.ts | 19 + frontend/src/features/games/store.ts | 24 + frontend/src/stores/phone.ts | 46 ++ frontend/src/types/apps.ts | 2 + frontend/src/utils/preferences.ts | 2 + frontend/src/views/apps/MemoryApp.vue | 588 +++++++++++++++ frontend/src/views/apps/SnakeApp.vue | 667 ++++++++++++++++++ sky_phone/config/locales/en.lua | 18 + sky_phone/source/html/index.html | 4 +- sky_phone/source/server/phone.lua | 1 + 23 files changed, 2074 insertions(+), 2 deletions(-) create mode 100644 frontend/src/assets/img/app-icons/memory.webp create mode 100644 frontend/src/assets/img/app-icons/snake.webp create mode 100644 frontend/src/features/games/memory/audio.ts create mode 100644 frontend/src/features/games/memory/engine.test.ts create mode 100644 frontend/src/features/games/memory/engine.ts create mode 100644 frontend/src/features/games/memory/store.ts create mode 100644 frontend/src/features/games/memory/types.ts create mode 100644 frontend/src/features/games/snake/engine.test.ts create mode 100644 frontend/src/features/games/snake/engine.ts create mode 100644 frontend/src/features/games/snake/store.ts create mode 100644 frontend/src/features/games/snake/types.ts create mode 100644 frontend/src/features/games/store.ts create mode 100644 frontend/src/views/apps/MemoryApp.vue create mode 100644 frontend/src/views/apps/SnakeApp.vue 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 0000000000000000000000000000000000000000..c1ff6a6bb0181bf4804ae0fd7eefefa865e482bf GIT binary patch literal 21116 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 TJ&Mjuj|?VaPA01!Za@G4f7FJF literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f6605adac0548d86b348e162fe9ffead96a84501 GIT binary patch literal 7398 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 { 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)