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
+3
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+12
View File
@@ -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))
+30
View File
@@ -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)
@@ -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)
},
},
})
+46
View File
@@ -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',
+2
View File
@@ -12,6 +12,8 @@ export type PhoneAppId =
| 'photos'
| 'app-store'
| 'settings'
| 'snake'
| 'memory'
export type AppLaunchOrigin = {
borderRadius: number
+2
View File
@@ -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 },
+588
View File
@@ -0,0 +1,588 @@
<script setup lang="ts">
import {
ChevronLeft,
Circle,
Cloud,
Diamond,
Flower2,
Heart,
Moon,
Play,
Sparkles,
Star,
Sun,
Triangle,
Volume2,
VolumeX,
Zap,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { playMemorySound } from '@/features/games/memory/audio'
import { useMemoryStore } from '@/features/games/memory/store'
import type {
MemoryDifficulty,
} from '@/features/games/memory/types'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const memory = useMemoryStore()
const difficulties: Array<{
columns: number
difficulty: MemoryDifficulty
pairs: number
rows: number
}> = [
{ columns: 3, difficulty: 'small', pairs: 6, rows: 4 },
{ columns: 4, difficulty: 'medium', pairs: 8, rows: 4 },
{ columns: 4, difficulty: 'large', pairs: 10, rows: 5 },
]
const symbols = {
star: { color: '#f6b93b', icon: Star },
heart: { color: '#ff6b81', icon: Heart },
moon: { color: '#9b8afb', icon: Moon },
sun: { color: '#ff9f43', icon: Sun },
cloud: { color: '#67b7dc', icon: Cloud },
bolt: { color: '#f5ca4d', icon: Zap },
diamond: { color: '#52c6b8', icon: Diamond },
circle: { color: '#ef7eb5', icon: Circle },
triangle: { color: '#7c9cf5', icon: Triangle },
flower: { color: '#a6d96a', icon: Flower2 },
}
const game = computed(() => memory.game)
let clockTimer: ReturnType<typeof setInterval> | undefined
let mismatchTimer: ReturnType<typeof setTimeout> | undefined
function formatTime(milliseconds: number): string {
const totalSeconds = Math.floor(milliseconds / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function bestLabel(difficulty: MemoryDifficulty): string {
const best = memory.best[difficulty]
if (!best) return phone.t('Apps.memory.noBest')
return `${best.moves} ${phone.t('Apps.memory.moves')} · ${formatTime(best.timeMs)}`
}
function selectCard(cardId: string): void {
const previous = memory.game
if (!previous) return
memory.flip(cardId)
const current = memory.game
if (!current || current === previous) return
if (current.status === 'won') {
playMemorySound('win', memory.soundEnabled)
} else if (current.matchedPairs > previous.matchedPairs) {
playMemorySound('match', memory.soundEnabled)
} else if (current.status === 'resolving') {
playMemorySound('mismatch', memory.soundEnabled)
} else {
playMemorySound('flip', memory.soundEnabled)
}
if (current.status === 'resolving') {
if (mismatchTimer) clearTimeout(mismatchTimer)
mismatchTimer = setTimeout(() => memory.resolveMismatch(), 720)
}
}
function toggleSound(): void {
const enabled = !memory.soundEnabled
memory.setSoundEnabled(enabled)
if (enabled) playMemorySound('flip', true)
}
function restart(): void {
const difficulty = memory.game?.difficulty
if (difficulty) memory.start(difficulty)
}
function returnToMenu(): void {
if (mismatchTimer) {
clearTimeout(mismatchTimer)
mismatchTimer = undefined
}
memory.showMenu()
}
memory.hydrate()
onMounted(() => {
memory.resume()
clockTimer = setInterval(() => memory.updateElapsed(), 100)
})
onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer)
if (mismatchTimer) clearTimeout(mismatchTimer)
memory.resolveMismatch()
memory.pause()
})
</script>
<template>
<main class="memory-app" :aria-label="phone.t('Apps.memory.name')">
<header class="memory-header">
<div>
<span>{{ phone.t('Apps.memory.eyebrow') }}</span>
<h1>{{ phone.t('Apps.memory.name') }}</h1>
</div>
<div class="memory-header__actions">
<Sparkles :size="23" aria-hidden="true" />
<button
type="button"
:aria-label="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
:title="phone.t(memory.soundEnabled ? 'Apps.memory.mute' : 'Apps.memory.unmute')"
@click="toggleSound"
>
<Volume2 v-if="memory.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</div>
</header>
<section v-if="!game" class="memory-menu">
<div class="memory-hero" aria-hidden="true">
<span class="memory-hero__card memory-hero__card--back"></span>
<span class="memory-hero__card memory-hero__card--front">
<Star :size="34" fill="currentColor" />
</span>
</div>
<div class="memory-intro">
<h2>{{ phone.t('Apps.memory.chooseTitle') }}</h2>
<p>{{ phone.t('Apps.memory.chooseBody') }}</p>
</div>
<div class="memory-difficulties">
<button
v-for="option in difficulties"
:key="option.difficulty"
type="button"
@click="memory.start(option.difficulty)"
>
<span class="memory-difficulty__size">
{{ option.columns }}×{{ option.rows }}
</span>
<span class="memory-difficulty__name">
{{ phone.t(`Apps.memory.difficulties.${option.difficulty}`) }}
</span>
<span class="memory-difficulty__best">
{{ bestLabel(option.difficulty) }}
</span>
<Play :size="16" fill="currentColor" />
</button>
</div>
</section>
<section v-else class="memory-game">
<div class="memory-stats">
<button
type="button"
class="memory-menu-button"
:aria-label="phone.t('Apps.memory.backToMenu')"
:title="phone.t('Apps.memory.backToMenu')"
@click="returnToMenu"
>
<ChevronLeft :size="18" :stroke-width="2.6" aria-hidden="true" />
</button>
<div>
<span>{{ phone.t('Apps.memory.time') }}</span>
<strong>{{ formatTime(memory.elapsedMs) }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.memory.moves') }}</span>
<strong>{{ game.moves }}</strong>
</div>
<button type="button" @click="restart">
{{ phone.t('Apps.memory.restart') }}
</button>
</div>
<div
class="memory-board"
:class="`memory-board--${game.difficulty}`"
:aria-label="phone.t('Apps.memory.board')"
>
<button
v-for="card in game.cards"
:key="card.id"
type="button"
class="memory-game-card"
:class="{
'memory-game-card--flipped': card.state !== 'hidden',
'memory-game-card--matched': card.state === 'matched',
'memory-game-card--mismatch':
game.status === 'resolving' && game.selectedIds.includes(card.id),
}"
:disabled="card.state !== 'hidden' || game.status !== 'playing'"
:aria-label="
phone.t(
card.state === 'hidden'
? 'Apps.memory.hiddenCard'
: 'Apps.memory.revealedCard',
)
"
@click="selectCard(card.id)"
>
<span class="memory-game-card__inner">
<span class="memory-game-card__face memory-game-card__back">
<i></i><i></i><i></i><i></i>
</span>
<span
class="memory-game-card__face memory-game-card__front"
:style="{ color: symbols[card.symbol as keyof typeof symbols].color }"
>
<component
:is="symbols[card.symbol as keyof typeof symbols].icon"
:size="game.difficulty === 'small' ? 30 : 24"
:stroke-width="2.2"
fill="currentColor"
/>
</span>
</span>
</button>
<div v-if="game.status === 'won'" class="memory-complete">
<div class="memory-complete__burst"><Star :size="35" fill="currentColor" /></div>
<span>{{ phone.t('Apps.memory.completeEyebrow') }}</span>
<h2>{{ phone.t('Apps.memory.completeTitle') }}</h2>
<p>
{{ game.moves }} {{ phone.t('Apps.memory.moves') }} ·
{{ formatTime(memory.elapsedMs) }}
</p>
<button type="button" class="memory-primary" @click="restart">
{{ phone.t('Apps.memory.playAgain') }}
</button>
<button type="button" class="memory-secondary" @click="returnToMenu">
{{ phone.t('Apps.memory.menu') }}
</button>
</div>
</div>
</section>
</main>
</template>
<style scoped>
.memory-app {
position: absolute;
inset: 0;
overflow: hidden;
padding: 52px 17px 27px;
color: #302a48;
background:
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
}
.memory-header {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
}
.memory-header span {
display: block;
color: #8175a2;
font-size: 9px;
font-weight: 800;
letter-spacing: 1.25px;
text-transform: uppercase;
}
.memory-header h1 {
margin: 1px 0 0;
font-size: 24px;
line-height: 1;
letter-spacing: -0.7px;
}
.memory-header__actions {
display: flex;
align-items: center;
gap: 5px;
}
.memory-header__actions > svg,
.memory-header__actions button {
box-sizing: content-box;
padding: 9px;
border: 0;
border-radius: 13px;
color: #7658c7;
background: rgb(255 255 255 / 54%);
}
.memory-header__actions button {
display: grid;
place-items: center;
cursor: pointer;
}
.memory-menu {
height: calc(100% - 52px);
display: flex;
flex-direction: column;
justify-content: center;
gap: 17px;
}
.memory-hero {
position: relative;
width: 130px;
height: 108px;
margin: 0 auto;
}
.memory-hero__card {
position: absolute;
width: 70px;
height: 88px;
display: grid;
place-items: center;
border: 4px solid rgb(255 255 255 / 75%);
border-radius: 17px;
box-shadow: 0 12px 22px rgb(72 52 118 / 18%);
}
.memory-hero__card--back {
left: 9px;
top: 4px;
background: repeating-linear-gradient(45deg, #8e72db 0 7px, #9f83e6 7px 14px);
transform: rotate(-12deg);
}
.memory-hero__card--front {
right: 9px;
bottom: 0;
color: #f4b83f;
background: #fffaf0;
transform: rotate(10deg);
}
.memory-intro { text-align: center; }
.memory-intro h2 { margin: 0; font-size: 23px; letter-spacing: -0.6px; }
.memory-intro p { margin: 6px 25px 0; color: #7d7397; font-size: 12px; line-height: 1.4; }
.memory-difficulties {
display: grid;
gap: 7px;
}
.memory-difficulties button {
min-height: 58px;
display: grid;
grid-template-columns: 48px 1fr auto;
grid-template-rows: 1fr 1fr;
align-items: center;
column-gap: 10px;
padding: 8px 12px;
border: 1px solid rgb(91 67 143 / 8%);
border-radius: 16px;
color: #342d4c;
background: rgb(255 255 255 / 62%);
box-shadow: 0 5px 14px rgb(80 58 129 / 8%);
text-align: left;
}
.memory-difficulty__size {
grid-row: 1 / 3;
display: grid;
place-items: center;
height: 39px;
border-radius: 11px;
color: #fff;
background: linear-gradient(135deg, #9a7be1, #7558c4);
font-size: 13px;
font-weight: 800;
}
.memory-difficulty__name { align-self: end; font-size: 13px; font-weight: 800; }
.memory-difficulty__best { align-self: start; color: #8b819f; font-size: 9px; }
.memory-difficulties svg { grid-column: 3; grid-row: 1 / 3; color: #7658c7; }
.memory-game { padding-top: 5px; }
.memory-stats {
height: 50px;
display: grid;
grid-template-columns: 30px auto auto 1fr;
align-items: center;
gap: 11px;
}
.memory-stats div { display: grid; }
.memory-stats span { color: #81769c; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.memory-stats strong { font-size: 18px; line-height: 1.05; }
.memory-stats button { justify-self: end; border: 0; color: #7052bf; background: transparent; font-size: 11px; font-weight: 800; }
.memory-stats .memory-menu-button {
width: 30px;
height: 30px;
display: grid;
place-items: center;
justify-self: start;
padding: 0;
border: 1px solid rgb(105 79 160 / 10%);
border-radius: 10px;
background: rgb(255 255 255 / 48%);
box-shadow: 0 3px 8px rgb(75 52 121 / 8%);
cursor: pointer;
}
.memory-board {
position: relative;
display: grid;
gap: 8px;
padding: 9px;
border: 1px solid rgb(101 77 153 / 9%);
border-radius: 20px;
background: rgb(255 255 255 / 31%);
box-shadow: inset 0 1px 0 rgb(255 255 255 / 50%);
perspective: 900px;
}
.memory-board--small { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.memory-board--medium,
.memory-board--large { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.memory-board--large { gap: 6px; padding: 7px; }
.memory-game-card {
aspect-ratio: 0.82;
padding: 0;
border: 0;
border-radius: 13px;
background: transparent;
cursor: pointer;
perspective: 500px;
}
.memory-game-card:disabled { cursor: default; }
.memory-game-card__inner {
position: relative;
width: 100%;
height: 100%;
display: block;
transform-style: preserve-3d;
transition: transform 360ms cubic-bezier(0.22, 0.68, 0.3, 1);
}
.memory-game-card--flipped .memory-game-card__inner { transform: rotateY(180deg); }
.memory-game-card__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
border: 2px solid rgb(255 255 255 / 75%);
border-radius: 13px;
backface-visibility: hidden;
box-shadow: 0 5px 10px rgb(73 52 117 / 13%);
}
.memory-game-card__back {
position: absolute;
grid-template-columns: repeat(2, 6px);
grid-template-rows: repeat(2, 6px);
gap: 6px;
background: linear-gradient(145deg, #9d82e1, #7558bf);
}
.memory-game-card__back::before {
position: absolute;
inset: 5px;
border: 1px solid rgb(255 255 255 / 15%);
border-radius: 9px;
background: radial-gradient(circle at 24% 18%, rgb(255 255 255 / 12%), transparent 42%);
content: "";
}
.memory-game-card__back i { width: 6px; height: 6px; border-radius: 50%; background: rgb(255 255 255 / 30%); }
.memory-game-card__front {
background: radial-gradient(circle at 50% 42%, #fff 0 25%, #fffaf1 72%, #f8f1e7 100%);
transform: rotateY(180deg);
}
.memory-game-card--matched .memory-game-card__front {
background: #f7fff2;
box-shadow: 0 0 0 2px rgb(121 199 107 / 32%), 0 6px 12px rgb(71 143 65 / 14%);
}
.memory-game-card--matched {
animation: memory-match-pop 520ms cubic-bezier(0.2, 0.9, 0.25, 1.25);
}
.memory-game-card--mismatch {
animation: memory-mismatch-shake 420ms ease-in-out;
}
@keyframes memory-match-pop {
0% { transform: scale(1) translateY(0); }
38% { transform: scale(1.12) translateY(-4px); }
68% { transform: scale(0.97) translateY(1px); }
100% { transform: scale(1) translateY(0); }
}
@keyframes memory-mismatch-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-3px) rotate(-1.5deg); }
50% { transform: translateX(3px) rotate(1.5deg); }
75% { transform: translateX(-2px) rotate(-1deg); }
}
.memory-complete {
position: absolute;
z-index: 5;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-radius: 20px;
background: rgb(244 239 255 / 90%);
backdrop-filter: blur(6px);
text-align: center;
}
.memory-complete__burst {
width: 68px;
height: 68px;
display: grid;
place-items: center;
border-radius: 50%;
color: #f3b63e;
background: #fff9e8;
box-shadow: 0 9px 22px rgb(100 75 151 / 17%);
}
.memory-complete > span { color: #8175a2; font-size: 9px; font-weight: 800; letter-spacing: 1px; text-transform: uppercase; }
.memory-complete h2 { margin: 0; font-size: 27px; }
.memory-complete p { margin: -4px 0 4px; color: #756b8e; font-size: 13px; }
.memory-primary,
.memory-secondary {
min-width: 155px;
min-height: 42px;
border-radius: 14px;
font-size: 12px;
font-weight: 800;
}
.memory-primary { border: 0; color: #fff; background: linear-gradient(135deg, #9a7be1, #7051bf); box-shadow: 0 8px 17px rgb(91 61 164 / 22%); }
.memory-secondary { border: 1px solid rgb(92 72 134 / 14%); color: #6e6091; background: rgb(255 255 255 / 48%); }
button:active { transform: scale(0.97); }
@media (prefers-reduced-motion: reduce) {
.memory-game-card__inner { transition-duration: 1ms; }
.memory-game-card--matched,
.memory-game-card--mismatch { animation: none; }
}
</style>
+667
View File
@@ -0,0 +1,667 @@
<script setup lang="ts">
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Pause,
Play,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
SNAKE_BOARD_HEIGHT,
SNAKE_BOARD_WIDTH,
} from '@/features/games/snake/engine'
import { useSnakeStore } from '@/features/games/snake/store'
import type {
SnakeDirection,
SnakePoint,
SnakeSpeed,
} from '@/features/games/snake/types'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const snake = useSnakeStore()
const touchStart = ref<SnakePoint | null>(null)
const speedOptions: SnakeSpeed[] = ['relaxed', 'normal', 'fast']
const directionButtons: Array<{
direction: SnakeDirection
icon: typeof ChevronUp
}> = [
{ direction: 'up', icon: ChevronUp },
{ direction: 'left', icon: ChevronLeft },
{ direction: 'down', icon: ChevronDown },
{ direction: 'right', icon: ChevronRight },
]
const game = computed(() => snake.game)
const boardMotionStyle = computed(() => ({
'--snake-motion-duration': `${Math.min(
110,
Math.round(snake.tickMilliseconds * 0.7),
)}ms`,
}))
let gameTimer: ReturnType<typeof setInterval> | undefined
function cellStyle(point: SnakePoint): Record<string, string> {
return {
height: `${100 / SNAKE_BOARD_HEIGHT}%`,
left: `${(point.x / SNAKE_BOARD_WIDTH) * 100}%`,
top: `${(point.y / SNAKE_BOARD_HEIGHT) * 100}%`,
width: `${100 / SNAKE_BOARD_WIDTH}%`,
}
}
function bodySegmentStyle(point: SnakePoint): Record<string, string> {
return {
height: `${(0.72 / SNAKE_BOARD_HEIGHT) * 100}%`,
left: `${((point.x + 0.14) / SNAKE_BOARD_WIDTH) * 100}%`,
top: `${((point.y + 0.14) / SNAKE_BOARD_HEIGHT) * 100}%`,
width: `${(0.72 / SNAKE_BOARD_WIDTH) * 100}%`,
}
}
function connectorStyle(
first: SnakePoint,
second: SnakePoint,
): Record<string, string> {
if (first.y === second.y) {
return {
height: `${(0.62 / SNAKE_BOARD_HEIGHT) * 100}%`,
left: `${((Math.min(first.x, second.x) + 0.5) / SNAKE_BOARD_WIDTH) * 100}%`,
top: `${((first.y + 0.19) / SNAKE_BOARD_HEIGHT) * 100}%`,
width: `${(Math.abs(first.x - second.x) / SNAKE_BOARD_WIDTH) * 100}%`,
}
}
return {
height: `${(Math.abs(first.y - second.y) / SNAKE_BOARD_HEIGHT) * 100}%`,
left: `${((first.x + 0.19) / SNAKE_BOARD_WIDTH) * 100}%`,
top: `${((Math.min(first.y, second.y) + 0.5) / SNAKE_BOARD_HEIGHT) * 100}%`,
width: `${(0.62 / SNAKE_BOARD_WIDTH) * 100}%`,
}
}
function stopGameTimer(): void {
if (gameTimer) clearInterval(gameTimer)
gameTimer = undefined
}
function syncGameTimer(): void {
stopGameTimer()
if (snake.game?.status === 'playing') {
gameTimer = setInterval(() => snake.tick(), snake.tickMilliseconds)
}
}
function handleKeydown(event: KeyboardEvent): void {
const directionByKey: Partial<Record<string, SnakeDirection>> = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
ArrowUp: 'up',
a: 'left',
d: 'right',
s: 'down',
w: 'up',
}
const direction = directionByKey[event.key]
if (direction) {
event.preventDefault()
snake.turn(direction)
} else if (event.key === ' ' && snake.game) {
event.preventDefault()
if (snake.game.status === 'paused') {
snake.resume()
} else {
snake.pause()
}
}
}
function beginSwipe(event: TouchEvent): void {
const touch = event.changedTouches[0]
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
}
function endSwipe(event: TouchEvent): void {
const start = touchStart.value
const touch = event.changedTouches[0]
touchStart.value = null
if (!start || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return
if (Math.abs(deltaX) > Math.abs(deltaY)) {
snake.turn(deltaX > 0 ? 'right' : 'left')
} else {
snake.turn(deltaY > 0 ? 'down' : 'up')
}
}
snake.hydrate()
watch(
() => [snake.game?.status, snake.tickMilliseconds],
syncGameTimer,
{ immediate: true },
)
onMounted(() => window.addEventListener('keydown', handleKeydown))
onBeforeUnmount(() => {
stopGameTimer()
snake.pause()
window.removeEventListener('keydown', handleKeydown)
})
</script>
<template>
<main class="snake-app" :aria-label="phone.t('Apps.snake.name')">
<header class="snake-header">
<span class="snake-brand">{{ phone.t('Apps.snake.name') }}</span>
<div class="snake-score-card">
<span>{{ phone.t('Apps.snake.highScore') }}</span>
<strong>{{ snake.highScore }}</strong>
</div>
</header>
<section v-if="!game" class="snake-menu">
<div class="snake-mark" aria-hidden="true">
<span class="snake-mark__eye"></span>
<span class="snake-mark__fruit"></span>
</div>
<div>
<h1>{{ phone.t('Apps.snake.readyTitle') }}</h1>
<p>{{ phone.t('Apps.snake.readyBody') }}</p>
</div>
<fieldset class="snake-speed-picker">
<legend>{{ phone.t('Apps.snake.speed') }}</legend>
<button
v-for="speed in speedOptions"
:key="speed"
type="button"
:class="{ active: snake.speed === speed }"
@click="snake.setSpeed(speed)"
>
{{ phone.t(`Apps.snake.speeds.${speed}`) }}
</button>
</fieldset>
<button type="button" class="snake-primary" @click="snake.start">
<Play :size="18" fill="currentColor" />
{{ phone.t('Apps.snake.start') }}
</button>
</section>
<section v-else class="snake-game">
<div class="snake-game__meta">
<span>{{ phone.t('Apps.snake.score') }}</span>
<strong>{{ game.score }}</strong>
<button
v-if="game.status !== 'game-over'"
type="button"
:aria-label="
phone.t(
game.status === 'paused'
? 'Apps.snake.resume'
: 'Apps.snake.pause',
)
"
@click="game.status === 'paused' ? snake.resume() : snake.pause()"
>
<Play v-if="game.status === 'paused'" :size="18" fill="currentColor" />
<Pause v-else :size="18" fill="currentColor" />
</button>
</div>
<div
class="snake-board"
:style="boardMotionStyle"
:aria-label="phone.t('Apps.snake.board')"
@touchstart.passive="beginSwipe"
@touchend.passive="endSwipe"
>
<span
v-for="(_, index) in game.body.slice(1)"
:key="`connector-${index}`"
class="snake-body-connector"
:style="connectorStyle(game.body[index], game.body[index + 1])"
></span>
<span
v-for="(segment, index) in game.body"
:key="`body-${index}`"
class="snake-body-segment"
:class="{ 'snake-body-segment--tail': index === game.body.length - 1 }"
:style="bodySegmentStyle(segment)"
></span>
<span
class="snake-head"
:class="`snake-head--${game.direction}`"
:style="cellStyle(game.body[0])"
>
<i class="snake-head__eye snake-head__eye--top"></i>
<i class="snake-head__eye snake-head__eye--bottom"></i>
</span>
<span class="snake-fruit" :style="cellStyle(game.fruit)">
<i></i>
</span>
<div v-if="game.status !== 'playing'" class="snake-overlay">
<template v-if="game.status === 'paused'">
<h2>{{ phone.t('Apps.snake.paused') }}</h2>
<button type="button" class="snake-primary" @click="snake.resume">
<Play :size="17" fill="currentColor" />
{{ phone.t('Apps.snake.resume') }}
</button>
</template>
<template v-else>
<span class="snake-overline">{{ phone.t('Apps.snake.score') }} {{ game.score }}</span>
<h2>{{ phone.t('Apps.snake.gameOver') }}</h2>
<button type="button" class="snake-primary" @click="snake.start">
{{ phone.t('Apps.snake.restart') }}
</button>
<button type="button" class="snake-secondary" @click="snake.showMenu">
{{ phone.t('Apps.snake.menu') }}
</button>
</template>
</div>
</div>
<p class="snake-hint">{{ phone.t('Apps.snake.swipeHint') }}</p>
<div class="snake-controls" :aria-label="phone.t('Apps.snake.controls')">
<button
v-for="control in directionButtons"
:key="control.direction"
type="button"
:class="`snake-control--${control.direction}`"
:aria-label="phone.t(`Apps.snake.directions.${control.direction}`)"
@click="snake.turn(control.direction)"
>
<component :is="control.icon" :size="24" :stroke-width="2.6" />
</button>
</div>
</section>
</main>
</template>
<style scoped>
.snake-app {
position: absolute;
inset: 0;
overflow: hidden;
padding: 52px 18px 28px;
color: #eef7ec;
background:
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: none;
touch-action: none;
}
.snake-header,
.snake-game__meta {
display: flex;
align-items: center;
justify-content: space-between;
}
.snake-brand {
font-size: 24px;
font-weight: 800;
letter-spacing: -0.7px;
}
.snake-score-card {
display: grid;
grid-template-columns: auto auto;
align-items: center;
gap: 2px 9px;
padding: 7px 11px;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 13px;
background: rgb(255 255 255 / 7%);
}
.snake-score-card span {
grid-row: 1 / 3;
max-width: 50px;
color: #9db3a8;
font-size: 9px;
font-weight: 700;
line-height: 1.05;
text-transform: uppercase;
}
.snake-score-card strong {
font-size: 20px;
line-height: 1;
}
.snake-menu {
height: calc(100% - 56px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 25px;
text-align: center;
}
.snake-mark {
position: relative;
width: 118px;
height: 118px;
border: 22px solid #65c85d;
border-top-color: #84df6d;
border-radius: 50%;
filter: drop-shadow(0 14px 18px rgb(0 0 0 / 26%));
}
.snake-mark::after {
content: "";
position: absolute;
right: -25px;
top: -20px;
width: 39px;
height: 29px;
border-radius: 55% 60% 55% 45%;
background: #85df6e;
transform: rotate(13deg);
}
.snake-mark__eye {
position: absolute;
z-index: 2;
right: -13px;
top: -10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #142b25;
}
.snake-mark__fruit {
position: absolute;
right: -43px;
bottom: -17px;
width: 25px;
height: 25px;
border-radius: 48% 52% 55% 45%;
background: #ff5f52;
box-shadow: inset -4px -4px 0 rgb(139 21 22 / 22%);
}
.snake-menu h1,
.snake-overlay h2 {
margin: 0;
font-size: 28px;
letter-spacing: -0.8px;
}
.snake-menu p {
max-width: 265px;
margin: 7px 0 0;
color: #a6bbb0;
font-size: 13px;
line-height: 1.4;
}
.snake-speed-picker {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 5px;
border: 0;
border-radius: 14px;
background: rgb(255 255 255 / 7%);
}
.snake-speed-picker legend {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.snake-speed-picker button,
.snake-secondary {
min-height: 38px;
border: 0;
border-radius: 10px;
color: #9fb3a9;
background: transparent;
font-size: 12px;
font-weight: 700;
}
.snake-speed-picker button.active {
color: #10241e;
background: #dff6d9;
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
}
.snake-primary {
min-width: 170px;
min-height: 47px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 22px;
border: 0;
border-radius: 16px;
color: #10241e;
background: linear-gradient(135deg, #8ae276, #61c95c);
box-shadow: 0 9px 22px rgb(63 176 78 / 25%);
font-size: 14px;
font-weight: 800;
}
.snake-game {
padding-top: 8px;
}
.snake-game__meta {
height: 43px;
justify-content: flex-start;
gap: 8px;
}
.snake-game__meta span {
color: #9fb3a9;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.snake-game__meta strong {
font-size: 22px;
}
.snake-game__meta button {
width: 34px;
height: 34px;
display: grid;
place-items: center;
margin-left: auto;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 50%;
color: #dff6d9;
background: rgb(255 255 255 / 7%);
}
.snake-board {
position: relative;
width: 100%;
aspect-ratio: 16 / 18;
overflow: hidden;
border: 1px solid rgb(167 231 157 / 16%);
border-radius: 18px;
background-color: #162d26;
background-image:
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
background-size: calc(100% / 16) calc(100% / 18);
box-shadow: inset 0 0 35px rgb(0 0 0 / 20%), 0 17px 35px rgb(0 0 0 / 20%);
}
.snake-head,
.snake-fruit {
position: absolute;
display: block;
}
.snake-body-connector,
.snake-body-segment {
position: absolute;
z-index: 1;
background: #6dcc62;
transition-duration: var(--snake-motion-duration);
transition-timing-function: cubic-bezier(0.22, 0.68, 0.3, 1);
will-change: left, top, width, height;
}
.snake-body-connector {
border-radius: 999px;
transition-property: left, top, width, height;
}
.snake-body-segment {
border-radius: 50%;
transition-property: left, top;
}
.snake-body-segment--tail {
background: #62be5c;
transform: scale(0.86);
}
.snake-head {
z-index: 3;
border: 1px solid #438f48;
border-radius: 45% 55% 55% 45%;
background: #86dc6b;
box-shadow:
inset -2px -2px 0 rgb(35 112 51 / 14%),
0 2px 3px rgb(2 15 8 / 24%);
transform-origin: center;
transition-duration: var(--snake-motion-duration);
transition-property: left, top;
transition-timing-function: cubic-bezier(0.22, 0.68, 0.3, 1);
will-change: left, top;
}
.snake-head--right { transform: scale(0.92) rotate(0deg); }
.snake-head--down { transform: scale(0.92) rotate(90deg); }
.snake-head--left { transform: scale(0.92) rotate(180deg); }
.snake-head--up { transform: scale(0.92) rotate(-90deg); }
.snake-head__eye {
position: absolute;
right: 18%;
width: 4px;
height: 4px;
border: 1px solid #f5ffe9;
border-radius: 50%;
background: #10241e;
}
.snake-head__eye--top { top: 17%; }
.snake-head__eye--bottom { bottom: 17%; }
.snake-fruit {
z-index: 2;
padding: 3px;
border-radius: 50%;
background-color: #ff5c50;
box-shadow:
inset -2px -3px 0 rgb(130 17 21 / 27%),
0 0 0 3px rgb(255 92 80 / 10%),
0 3px 5px rgb(0 0 0 / 25%);
animation: snake-fruit-pulse 1.35s ease-in-out infinite;
}
.snake-fruit i {
position: absolute;
left: 50%;
top: 0;
width: 2px;
height: 5px;
border-radius: 2px;
background: #9ee07a;
transform: rotate(25deg);
}
@keyframes snake-fruit-pulse {
0%, 100% { transform: scale(0.88); }
50% { transform: scale(1.06); }
}
.snake-overlay {
position: absolute;
z-index: 5;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 13px;
background: rgb(6 16 13 / 82%);
backdrop-filter: blur(4px);
}
.snake-overlay .snake-primary {
min-height: 43px;
}
.snake-overline {
color: #91e475;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.snake-secondary {
min-width: 140px;
color: #c0d0c8;
border: 1px solid rgb(255 255 255 / 10%);
}
.snake-hint {
margin: 9px 0 6px;
color: #71867c;
font-size: 10px;
text-align: center;
}
.snake-controls {
position: relative;
width: 132px;
height: 91px;
margin: 0 auto;
}
.snake-controls button {
position: absolute;
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 13px;
color: #cae6d1;
background: rgb(255 255 255 / 7%);
}
.snake-control--up { left: 45px; top: 0; }
.snake-control--left { left: 0; top: 45px; }
.snake-control--down { left: 45px; top: 45px; }
.snake-control--right { right: 0; top: 45px; }
button:active {
transform: scale(0.96);
}
</style>
+18
View File
@@ -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" },
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-D2k1S7R7.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-G8JTwQWl.css">
<script type="module" crossorigin src="./assets/sky-index-CweS_ic7.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-yBie1g9x.css">
</head>
<body>
<div id="app"></div>
+1
View File
@@ -14,6 +14,7 @@ local allowed_device_namespaces = {
alarms = true,
media = true,
apps = true,
games = true,
}
local function trim(value)