mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 09:18:57 +00:00
ADD - add Snake and Memory phone games
This commit is contained in:
@@ -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 |
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import {
|
||||
createMemoryGame,
|
||||
flipMemoryCard,
|
||||
resolveMemoryMismatch,
|
||||
} from './engine'
|
||||
import type {
|
||||
MemoryBest,
|
||||
MemoryDifficulty,
|
||||
MemoryGameState,
|
||||
} from './types'
|
||||
|
||||
type MemorySave = {
|
||||
best: Partial<Record<MemoryDifficulty, MemoryBest>>
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
function isMemoryBest(value: unknown): value is MemoryBest {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const best = value as Partial<MemoryBest>
|
||||
return (
|
||||
typeof best.moves === 'number' &&
|
||||
best.moves > 0 &&
|
||||
typeof best.timeMs === 'number' &&
|
||||
best.timeMs >= 0
|
||||
)
|
||||
}
|
||||
|
||||
export const useMemoryStore = defineStore('memory', {
|
||||
state: () => ({
|
||||
best: {} as Partial<Record<MemoryDifficulty, MemoryBest>>,
|
||||
elapsedMs: 0,
|
||||
game: null as MemoryGameState | null,
|
||||
hydrated: false,
|
||||
soundEnabled: true,
|
||||
startedAt: null as number | null,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<MemorySave>>('memory')
|
||||
for (const difficulty of ['small', 'medium', 'large'] as const) {
|
||||
const candidate = saved?.best?.[difficulty]
|
||||
if (isMemoryBest(candidate)) this.best[difficulty] = candidate
|
||||
}
|
||||
if (typeof saved?.soundEnabled === 'boolean') {
|
||||
this.soundEnabled = saved.soundEnabled
|
||||
}
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('memory', {
|
||||
best: this.best,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies MemorySave)
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
start(difficulty: MemoryDifficulty): void {
|
||||
this.game = createMemoryGame(difficulty)
|
||||
this.elapsedMs = 0
|
||||
this.startedAt = Date.now()
|
||||
},
|
||||
updateElapsed(now = Date.now()): void {
|
||||
if (this.startedAt === null) return
|
||||
this.elapsedMs += now - this.startedAt
|
||||
this.startedAt = now
|
||||
},
|
||||
pause(): void {
|
||||
this.updateElapsed()
|
||||
this.startedAt = null
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game?.status === 'playing' && this.startedAt === null) {
|
||||
this.startedAt = Date.now()
|
||||
}
|
||||
},
|
||||
flip(cardId: string): void {
|
||||
if (!this.game) return
|
||||
|
||||
const previousStatus = this.game.status
|
||||
this.game = flipMemoryCard(this.game, cardId)
|
||||
if (previousStatus !== 'won' && this.game.status === 'won') {
|
||||
this.updateElapsed()
|
||||
this.startedAt = null
|
||||
const result = { moves: this.game.moves, timeMs: this.elapsedMs }
|
||||
const current = this.best[this.game.difficulty]
|
||||
if (
|
||||
!current ||
|
||||
result.moves < current.moves ||
|
||||
(result.moves === current.moves && result.timeMs < current.timeMs)
|
||||
) {
|
||||
this.best[this.game.difficulty] = result
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
},
|
||||
resolveMismatch(): void {
|
||||
if (this.game) this.game = resolveMemoryMismatch(this.game)
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.game = null
|
||||
this.elapsedMs = 0
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MemoryDifficulty = 'small' | 'medium' | 'large'
|
||||
|
||||
export type MemoryCardState = 'hidden' | 'revealed' | 'matched'
|
||||
|
||||
export type MemoryCard = {
|
||||
id: string
|
||||
state: MemoryCardState
|
||||
symbol: string
|
||||
}
|
||||
|
||||
export type MemoryGameStatus = 'playing' | 'resolving' | 'won'
|
||||
|
||||
export type MemoryGameState = {
|
||||
cards: MemoryCard[]
|
||||
difficulty: MemoryDifficulty
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
selectedIds: string[]
|
||||
status: MemoryGameStatus
|
||||
}
|
||||
|
||||
export type MemoryBest = {
|
||||
moves: number
|
||||
timeMs: number
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSnakeGame,
|
||||
SNAKE_BOARD_WIDTH,
|
||||
stepSnake,
|
||||
turnSnake,
|
||||
} from './engine'
|
||||
import type { SnakeGameState } from './types'
|
||||
|
||||
describe('Snake engine', () => {
|
||||
it('moves one cell without changing its length', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
const next = stepSnake(game, () => 0)
|
||||
|
||||
expect(next.body[0]).toEqual({ x: game.body[0].x + 1, y: game.body[0].y })
|
||||
expect(next.body).toHaveLength(game.body.length)
|
||||
})
|
||||
|
||||
it('grows, scores, and places fruit outside the body', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
game.fruit = { x: game.body[0].x + 1, y: game.body[0].y }
|
||||
const next = stepSnake(game, () => 0)
|
||||
|
||||
expect(next.score).toBe(1)
|
||||
expect(next.body).toHaveLength(game.body.length + 1)
|
||||
expect(next.body).not.toContainEqual(next.fruit)
|
||||
})
|
||||
|
||||
it('ignores an immediate reverse direction', () => {
|
||||
const game = createSnakeGame(() => 0)
|
||||
|
||||
expect(turnSnake(game, 'left')).toBe(game)
|
||||
expect(turnSnake(game, 'up').pendingDirection).toBe('up')
|
||||
})
|
||||
|
||||
it('ends the game on wall collision', () => {
|
||||
const game: SnakeGameState = {
|
||||
...createSnakeGame(() => 0),
|
||||
body: [
|
||||
{ x: SNAKE_BOARD_WIDTH - 1, y: 4 },
|
||||
{ x: SNAKE_BOARD_WIDTH - 2, y: 4 },
|
||||
],
|
||||
}
|
||||
|
||||
expect(stepSnake(game).status).toBe('game-over')
|
||||
})
|
||||
|
||||
it('ends the game on body collision', () => {
|
||||
const game: SnakeGameState = {
|
||||
body: [
|
||||
{ x: 4, y: 4 },
|
||||
{ x: 4, y: 3 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: 3, y: 4 },
|
||||
{ x: 3, y: 5 },
|
||||
],
|
||||
direction: 'up',
|
||||
fruit: { x: 10, y: 10 },
|
||||
pendingDirection: 'left',
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
}
|
||||
|
||||
expect(stepSnake(game).status).toBe('game-over')
|
||||
})
|
||||
|
||||
it('does not advance a paused game', () => {
|
||||
const game: SnakeGameState = {
|
||||
...createSnakeGame(() => 0),
|
||||
status: 'paused',
|
||||
}
|
||||
|
||||
expect(stepSnake(game)).toBe(game)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
SnakeDirection,
|
||||
SnakeGameState,
|
||||
SnakePoint,
|
||||
} from './types'
|
||||
|
||||
export const SNAKE_BOARD_WIDTH = 16
|
||||
export const SNAKE_BOARD_HEIGHT = 18
|
||||
|
||||
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
|
||||
up: { x: 0, y: -1 },
|
||||
right: { x: 1, y: 0 },
|
||||
down: { x: 0, y: 1 },
|
||||
left: { x: -1, y: 0 },
|
||||
}
|
||||
|
||||
const OPPOSITE_DIRECTIONS: Record<SnakeDirection, SnakeDirection> = {
|
||||
up: 'down',
|
||||
right: 'left',
|
||||
down: 'up',
|
||||
left: 'right',
|
||||
}
|
||||
|
||||
function pointsMatch(first: SnakePoint, second: SnakePoint): boolean {
|
||||
return first.x === second.x && first.y === second.y
|
||||
}
|
||||
|
||||
function placeFruit(
|
||||
body: SnakePoint[],
|
||||
random: () => number,
|
||||
): SnakePoint {
|
||||
const openCells: SnakePoint[] = []
|
||||
|
||||
for (let y = 0; y < SNAKE_BOARD_HEIGHT; y += 1) {
|
||||
for (let x = 0; x < SNAKE_BOARD_WIDTH; x += 1) {
|
||||
const point = { x, y }
|
||||
if (!body.some((segment) => pointsMatch(segment, point))) {
|
||||
openCells.push(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const index = Math.min(
|
||||
openCells.length - 1,
|
||||
Math.floor(Math.max(0, random()) * openCells.length),
|
||||
)
|
||||
return openCells[index] ?? body[0]
|
||||
}
|
||||
|
||||
export function createSnakeGame(random: () => number = Math.random): SnakeGameState {
|
||||
const centerX = Math.floor(SNAKE_BOARD_WIDTH / 2)
|
||||
const centerY = Math.floor(SNAKE_BOARD_HEIGHT / 2)
|
||||
const body = [
|
||||
{ x: centerX, y: centerY },
|
||||
{ x: centerX - 1, y: centerY },
|
||||
{ x: centerX - 2, y: centerY },
|
||||
]
|
||||
|
||||
return {
|
||||
body,
|
||||
direction: 'right',
|
||||
fruit: placeFruit(body, random),
|
||||
pendingDirection: 'right',
|
||||
score: 0,
|
||||
status: 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function turnSnake(
|
||||
state: SnakeGameState,
|
||||
direction: SnakeDirection,
|
||||
): SnakeGameState {
|
||||
if (
|
||||
state.status !== 'playing' ||
|
||||
direction === OPPOSITE_DIRECTIONS[state.direction]
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
return { ...state, pendingDirection: direction }
|
||||
}
|
||||
|
||||
export function stepSnake(
|
||||
state: SnakeGameState,
|
||||
random: () => number = Math.random,
|
||||
): SnakeGameState {
|
||||
if (state.status !== 'playing') return state
|
||||
|
||||
const direction = state.pendingDirection
|
||||
const vector = DIRECTION_VECTORS[direction]
|
||||
const head = state.body[0]
|
||||
const nextHead = { x: head.x + vector.x, y: head.y + vector.y }
|
||||
const ateFruit = pointsMatch(nextHead, state.fruit)
|
||||
const collisionBody = ateFruit ? state.body : state.body.slice(0, -1)
|
||||
const hitWall =
|
||||
nextHead.x < 0 ||
|
||||
nextHead.x >= SNAKE_BOARD_WIDTH ||
|
||||
nextHead.y < 0 ||
|
||||
nextHead.y >= SNAKE_BOARD_HEIGHT
|
||||
const hitBody = collisionBody.some((segment) =>
|
||||
pointsMatch(segment, nextHead),
|
||||
)
|
||||
|
||||
if (hitWall || hitBody) {
|
||||
return { ...state, direction, status: 'game-over' }
|
||||
}
|
||||
|
||||
const body = [nextHead, ...state.body]
|
||||
if (!ateFruit) body.pop()
|
||||
|
||||
return {
|
||||
...state,
|
||||
body,
|
||||
direction,
|
||||
fruit: ateFruit ? placeFruit(body, random) : state.fruit,
|
||||
score: state.score + (ateFruit ? 1 : 0),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import { createSnakeGame, stepSnake, turnSnake } from './engine'
|
||||
import type { SnakeDirection, SnakeGameState, SnakeSpeed } from './types'
|
||||
|
||||
type SnakeSave = {
|
||||
highScore: number
|
||||
speed: SnakeSpeed
|
||||
}
|
||||
|
||||
const SPEED_TICK_MS: Record<SnakeSpeed, number> = {
|
||||
relaxed: 210,
|
||||
normal: 155,
|
||||
fast: 110,
|
||||
}
|
||||
|
||||
function isSnakeSpeed(value: unknown): value is SnakeSpeed {
|
||||
return value === 'relaxed' || value === 'normal' || value === 'fast'
|
||||
}
|
||||
|
||||
export const useSnakeStore = defineStore('snake', {
|
||||
state: () => ({
|
||||
game: null as SnakeGameState | null,
|
||||
highScore: 0,
|
||||
hydrated: false,
|
||||
speed: 'normal' as SnakeSpeed,
|
||||
}),
|
||||
getters: {
|
||||
tickMilliseconds: (state) => SPEED_TICK_MS[state.speed],
|
||||
},
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
|
||||
const saved = useGamesStore().readGame<Partial<SnakeSave>>('snake')
|
||||
this.highScore =
|
||||
typeof saved?.highScore === 'number' && saved.highScore >= 0
|
||||
? Math.floor(saved.highScore)
|
||||
: 0
|
||||
this.speed = isSnakeSpeed(saved?.speed) ? saved.speed : 'normal'
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('snake', {
|
||||
highScore: this.highScore,
|
||||
speed: this.speed,
|
||||
} satisfies SnakeSave)
|
||||
},
|
||||
setSpeed(speed: SnakeSpeed): void {
|
||||
this.speed = speed
|
||||
this.persist()
|
||||
},
|
||||
start(): void {
|
||||
this.game = createSnakeGame()
|
||||
},
|
||||
pause(): void {
|
||||
if (this.game?.status === 'playing') {
|
||||
this.game = { ...this.game, status: 'paused' }
|
||||
}
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game?.status === 'paused') {
|
||||
this.game = { ...this.game, status: 'playing' }
|
||||
}
|
||||
},
|
||||
turn(direction: SnakeDirection): void {
|
||||
if (this.game) this.game = turnSnake(this.game, direction)
|
||||
},
|
||||
tick(): void {
|
||||
if (!this.game) return
|
||||
|
||||
this.game = stepSnake(this.game)
|
||||
if (this.game.status === 'game-over' && this.game.score > this.highScore) {
|
||||
this.highScore = this.game.score
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
showMenu(): void {
|
||||
this.game = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SnakeDirection = 'up' | 'right' | 'down' | 'left'
|
||||
|
||||
export type SnakePoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export type SnakeSpeed = 'relaxed' | 'normal' | 'fast'
|
||||
|
||||
export type SnakeGameStatus = 'playing' | 'paused' | 'game-over'
|
||||
|
||||
export type SnakeGameState = {
|
||||
body: SnakePoint[]
|
||||
direction: SnakeDirection
|
||||
fruit: SnakePoint
|
||||
pendingDirection: SnakeDirection
|
||||
score: number
|
||||
status: SnakeGameStatus
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
export const useGamesStore = defineStore('games', {
|
||||
state: () => ({
|
||||
games: {} as Record<string, unknown>,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(payload: unknown): void {
|
||||
this.games =
|
||||
payload && typeof payload === 'object'
|
||||
? structuredClone(payload as Record<string, unknown>)
|
||||
: {}
|
||||
},
|
||||
readGame<T>(gameId: string): T | undefined {
|
||||
return this.games[gameId] as T | undefined
|
||||
},
|
||||
saveGame(gameId: string, payload: unknown): void {
|
||||
this.games[gameId] = structuredClone(payload)
|
||||
usePhoneStore().saveDeviceNamespace('games', this.games)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -12,6 +12,8 @@ export type PhoneAppId =
|
||||
| 'photos'
|
||||
| 'app-store'
|
||||
| 'settings'
|
||||
| 'snake'
|
||||
| 'memory'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ local allowed_device_namespaces = {
|
||||
alarms = true,
|
||||
media = true,
|
||||
apps = true,
|
||||
games = true,
|
||||
}
|
||||
|
||||
local function trim(value)
|
||||
|
||||
Reference in New Issue
Block a user