diff --git a/frontend/src/assets/audio/minesweeper/flag.wav b/frontend/src/assets/audio/minesweeper/flag.wav new file mode 100644 index 0000000..b9a4f72 Binary files /dev/null and b/frontend/src/assets/audio/minesweeper/flag.wav differ diff --git a/frontend/src/assets/audio/minesweeper/mine.wav b/frontend/src/assets/audio/minesweeper/mine.wav new file mode 100644 index 0000000..06b30a5 Binary files /dev/null and b/frontend/src/assets/audio/minesweeper/mine.wav differ diff --git a/frontend/src/assets/audio/minesweeper/reveal.wav b/frontend/src/assets/audio/minesweeper/reveal.wav new file mode 100644 index 0000000..847f532 Binary files /dev/null and b/frontend/src/assets/audio/minesweeper/reveal.wav differ diff --git a/frontend/src/assets/audio/minesweeper/win.wav b/frontend/src/assets/audio/minesweeper/win.wav new file mode 100644 index 0000000..feb76dd Binary files /dev/null and b/frontend/src/assets/audio/minesweeper/win.wav differ diff --git a/frontend/src/assets/img/app-icons/minesweeper.webp b/frontend/src/assets/img/app-icons/minesweeper.webp new file mode 100644 index 0000000..2eeb45b Binary files /dev/null and b/frontend/src/assets/img/app-icons/minesweeper.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index fa391e1..7c63ac4 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -44,6 +44,12 @@ describe('app registry', () => { labelKey: 'Apps.numberMerge.name', route: '/apps/number-merge', }) + expect(PHONE_APPS.find((app) => app.id === 'minesweeper')).toMatchObject({ + dockOrder: null, + gridOrder: 14, + labelKey: 'Apps.minesweeper.name', + route: '/apps/minesweeper', + }) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 72cd43f..edc9bda 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -1,5 +1,6 @@ import { Calculator, + Bomb, Camera, Clock3, Gamepad2, @@ -29,6 +30,7 @@ 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 numberMergeIcon from '@/assets/img/app-icons/number-merge.webp' +import minesweeperIcon from '@/assets/img/app-icons/minesweeper.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' @@ -215,6 +217,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.numberMerge.name', route: '/apps/number-merge', }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/MinesweeperApp.vue')), + ), + dockOrder: null, + gridOrder: 14, + icon: markRaw(Bomb), + iconClass: 'app-icon--minesweeper', + iconImage: minesweeperIcon, + id: 'minesweeper', + labelKey: 'Apps.minesweeper.name', + route: '/apps/minesweeper', + }, ] export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) diff --git a/frontend/src/features/games/minesweeper/audio.ts b/frontend/src/features/games/minesweeper/audio.ts new file mode 100644 index 0000000..19f6cab --- /dev/null +++ b/frontend/src/features/games/minesweeper/audio.ts @@ -0,0 +1,42 @@ +import flagUrl from '@/assets/audio/minesweeper/flag.wav?url' +import mineUrl from '@/assets/audio/minesweeper/mine.wav?url' +import revealUrl from '@/assets/audio/minesweeper/reveal.wav?url' +import winUrl from '@/assets/audio/minesweeper/win.wav?url' + +export type MinesweeperSound = 'flag' | 'mine' | 'reveal' | 'win' + +const soundUrls: Record = { + flag: flagUrl, + mine: mineUrl, + reveal: revealUrl, + win: winUrl, +} +const playerPools = new Map() + +function getPlayers(sound: MinesweeperSound): HTMLAudioElement[] { + const existing = playerPools.get(sound) + if (existing) return existing + + const players = Array.from({ length: 3 }, () => { + const player = new Audio(soundUrls[sound]) + player.preload = 'auto' + player.volume = 0.82 + return player + }) + playerPools.set(sound, players) + return players +} + +export function playMinesweeperSound( + sound: MinesweeperSound, + enabled: boolean, +): void { + if (!enabled) return + + const players = getPlayers(sound) + const player = players.find((candidate) => candidate.paused) ?? players[0] + player.currentTime = 0 + void player.play().catch((error: unknown) => { + console.error(`[Minesweeper audio] Failed to play ${sound}`, error) + }) +} diff --git a/frontend/src/features/games/minesweeper/engine.test.ts b/frontend/src/features/games/minesweeper/engine.test.ts new file mode 100644 index 0000000..d6529e0 --- /dev/null +++ b/frontend/src/features/games/minesweeper/engine.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' + +import { + createMinesweeperGame, + isMinesweeperGameState, + revealMinesweeperCell, + toggleMinesweeperFlag, +} from './engine' +import type { MinesweeperGameState } from './types' + +const chooseFirst = () => 0 + +function preparedGame( + width: number, + height: number, + mines: number[], +): MinesweeperGameState { + const state = createMinesweeperGame('quick') + const mineSet = new Set(mines) + return { + ...state, + cells: state.cells.map((cell) => ({ + ...cell, + adjacentMines: state.cells.filter( + (candidate) => + mineSet.has(candidate.id) && + Math.abs(candidate.row - cell.row) <= 1 && + Math.abs(candidate.column - cell.column) <= 1 && + candidate.id !== cell.id, + ).length, + isMine: mineSet.has(cell.id), + })), + height, + mineCount: mines.length, + status: 'playing', + width, + } +} + +describe('minesweeper engine', () => { + it('creates the configured phone boards', () => { + const game = createMinesweeperGame('classic') + expect(game.width).toBe(7) + expect(game.height).toBe(9) + expect(game.mineCount).toBe(11) + expect(game.cells).toHaveLength(63) + }) + + it('places the exact mine count outside the protected first area', () => { + const original = createMinesweeperGame('quick') + const result = revealMinesweeperCell(original, 14, chooseFirst) + const protectedIds = [7, 8, 9, 13, 14, 15, 19, 20, 21] + expect(result.state.cells.filter((cell) => cell.isMine)).toHaveLength(7) + expect( + result.state.cells + .filter((cell) => protectedIds.includes(cell.id)) + .every((cell) => !cell.isMine), + ).toBe(true) + }) + + it('calculates neighbour counts from the placed mines', () => { + const result = revealMinesweeperCell( + createMinesweeperGame('quick'), + 0, + chooseFirst, + ) + for (const cell of result.state.cells) { + const actual = result.state.cells.filter( + (candidate) => + candidate.isMine && + Math.abs(candidate.row - cell.row) <= 1 && + Math.abs(candidate.column - cell.column) <= 1 && + candidate.id !== cell.id, + ).length + expect(cell.adjacentMines).toBe(actual) + } + }) + + it('reveals an empty area and its numbered boundary but no mine', () => { + const game = preparedGame(6, 8, [0, 5, 42, 47, 43, 44, 45]) + const result = revealMinesweeperCell(game, 20) + expect(result.revealedCount).toBeGreaterThan(1) + expect(result.state.cells.some((cell) => cell.isMine && cell.isRevealed)).toBe(false) + expect( + result.state.cells.some( + (cell) => cell.isRevealed && cell.adjacentMines > 0, + ), + ).toBe(true) + }) + + it('does not reveal a flagged cell', () => { + const game = preparedGame(6, 8, [47]) + const flagged = toggleMinesweeperFlag(game, 10).state + const result = revealMinesweeperCell(flagged, 10) + expect(result.changed).toBe(false) + expect(result.state.cells[10].isRevealed).toBe(false) + }) + + it('reveals all mines and records the exploded cell on loss', () => { + const game = preparedGame(6, 8, [1, 47]) + const result = revealMinesweeperCell(game, 1) + expect(result.state.status).toBe('lost') + expect(result.state.explodedCellId).toBe(1) + expect(result.state.cells.filter((cell) => cell.isMine).every((cell) => cell.isRevealed)).toBe(true) + }) + + it('wins when every safe field is revealed', () => { + let game = preparedGame(6, 8, [47]) + for (const cell of game.cells) { + if (!cell.isMine && !game.cells[cell.id].isRevealed) { + game = revealMinesweeperCell(game, cell.id).state + } + } + expect(game.status).toBe('won') + expect(game.cells[47].isFlagged).toBe(true) + }) + + it('validates persisted games', () => { + const game = createMinesweeperGame('expert') + expect(isMinesweeperGameState(game)).toBe(true) + expect(isMinesweeperGameState({ ...game, mineCount: 99 })).toBe(false) + }) +}) diff --git a/frontend/src/features/games/minesweeper/engine.ts b/frontend/src/features/games/minesweeper/engine.ts new file mode 100644 index 0000000..674c5d2 --- /dev/null +++ b/frontend/src/features/games/minesweeper/engine.ts @@ -0,0 +1,260 @@ +import type { + MinesweeperActionResult, + MinesweeperCell, + MinesweeperDifficulty, + MinesweeperGameState, +} from './types' + +export const MINESWEEPER_DIFFICULTIES: Record< + MinesweeperDifficulty, + { height: number; mines: number; width: number } +> = { + quick: { height: 8, mines: 7, width: 6 }, + classic: { height: 9, mines: 11, width: 7 }, + expert: { height: 10, mines: 16, width: 8 }, +} + +function neighbours( + cell: MinesweeperCell, + width: number, + height: number, +): number[] { + const ids: number[] = [] + for (let rowOffset = -1; rowOffset <= 1; rowOffset += 1) { + for (let columnOffset = -1; columnOffset <= 1; columnOffset += 1) { + if (rowOffset === 0 && columnOffset === 0) continue + const row = cell.row + rowOffset + const column = cell.column + columnOffset + if (row >= 0 && row < height && column >= 0 && column < width) { + ids.push(row * width + column) + } + } + } + return ids +} + +function placeMines( + state: MinesweeperGameState, + firstCellId: number, + random: () => number, +): MinesweeperGameState { + const firstCell = state.cells[firstCellId] + const protectedIds = new Set([ + firstCellId, + ...neighbours(firstCell, state.width, state.height), + ]) + const candidates = state.cells + .map((cell) => cell.id) + .filter((id) => !protectedIds.has(id)) + const mineIds = new Set() + + for (let count = 0; count < state.mineCount; count += 1) { + const candidateIndex = Math.min( + candidates.length - 1, + Math.floor(random() * candidates.length), + ) + const [mineId] = candidates.splice(candidateIndex, 1) + mineIds.add(mineId) + } + + const cells = state.cells.map((cell) => ({ + ...cell, + isMine: mineIds.has(cell.id), + })) + return { + ...state, + cells: cells.map((cell) => ({ + ...cell, + adjacentMines: neighbours(cell, state.width, state.height).filter( + (id) => mineIds.has(id), + ).length, + })), + status: 'playing', + } +} + +export function createMinesweeperGame( + difficulty: MinesweeperDifficulty, +): MinesweeperGameState { + const config = MINESWEEPER_DIFFICULTIES[difficulty] + return { + cells: Array.from({ length: config.width * config.height }, (_, id) => ({ + adjacentMines: 0, + column: id % config.width, + id, + isFlagged: false, + isMine: false, + isRevealed: false, + row: Math.floor(id / config.width), + })), + difficulty, + explodedCellId: null, + height: config.height, + mineCount: config.mines, + status: 'ready', + width: config.width, + } +} + +export function toggleMinesweeperFlag( + state: MinesweeperGameState, + cellId: number, +): MinesweeperActionResult { + if (state.status === 'lost' || state.status === 'won') { + return { changed: false, revealedCount: 0, state } + } + + const cell = state.cells[cellId] + if (!cell || cell.isRevealed) { + return { changed: false, revealedCount: 0, state } + } + + const flagCount = state.cells.filter((candidate) => candidate.isFlagged).length + if (!cell.isFlagged && flagCount >= state.mineCount) { + return { changed: false, revealedCount: 0, state } + } + + return { + changed: true, + revealedCount: 0, + state: { + ...state, + cells: state.cells.map((candidate) => + candidate.id === cellId + ? { ...candidate, isFlagged: !candidate.isFlagged } + : candidate, + ), + }, + } +} + +export function revealMinesweeperCell( + originalState: MinesweeperGameState, + cellId: number, + random: () => number = Math.random, +): MinesweeperActionResult { + if (originalState.status === 'lost' || originalState.status === 'won') { + return { changed: false, revealedCount: 0, state: originalState } + } + + const originalCell = originalState.cells[cellId] + if (!originalCell || originalCell.isFlagged || originalCell.isRevealed) { + return { changed: false, revealedCount: 0, state: originalState } + } + + const state = + originalState.status === 'ready' + ? placeMines(originalState, cellId, random) + : originalState + const selectedCell = state.cells[cellId] + + if (selectedCell.isMine) { + return { + changed: true, + revealedCount: 0, + state: { + ...state, + cells: state.cells.map((cell) => + cell.isMine ? { ...cell, isRevealed: true } : cell, + ), + explodedCellId: cellId, + status: 'lost', + }, + } + } + + const cells = state.cells.map((cell) => ({ ...cell })) + const queue = [cellId] + const visited = new Set() + let revealedCount = 0 + + while (queue.length > 0) { + const currentId = queue.shift() as number + if (visited.has(currentId)) continue + visited.add(currentId) + + const cell = cells[currentId] + if (cell.isFlagged || cell.isMine || cell.isRevealed) continue + cell.isRevealed = true + revealedCount += 1 + + if (cell.adjacentMines === 0) { + for (const neighbourId of neighbours(cell, state.width, state.height)) { + if (!visited.has(neighbourId) && !cells[neighbourId].isMine) { + queue.push(neighbourId) + } + } + } + } + + const safeCellCount = cells.length - state.mineCount + const totalRevealed = cells.filter( + (cell) => cell.isRevealed && !cell.isMine, + ).length + const won = totalRevealed === safeCellCount + + return { + changed: true, + revealedCount, + state: { + ...state, + cells: won + ? cells.map((cell) => + cell.isMine ? { ...cell, isFlagged: true } : cell, + ) + : cells, + status: won ? 'won' : 'playing', + }, + } +} + +export function isMinesweeperGameState( + value: unknown, +): value is MinesweeperGameState { + if (!value || typeof value !== 'object') return false + const game = value as Partial + if ( + !['quick', 'classic', 'expert'].includes(game.difficulty ?? '') || + !['ready', 'playing', 'won', 'lost'].includes(game.status ?? '') || + !Number.isInteger(game.width) || + !Number.isInteger(game.height) || + !Number.isInteger(game.mineCount) || + !Array.isArray(game.cells) || + game.cells.length !== (game.width ?? 0) * (game.height ?? 0) || + (game.explodedCellId !== null && !Number.isInteger(game.explodedCellId)) + ) { + return false + } + + const config = MINESWEEPER_DIFFICULTIES[game.difficulty as MinesweeperDifficulty] + if ( + game.width !== config.width || + game.height !== config.height || + game.mineCount !== config.mines + ) { + return false + } + + for (let id = 0; id < game.cells.length; id += 1) { + const cell = game.cells[id] as Partial + if ( + !cell || + cell.id !== id || + cell.row !== Math.floor(id / config.width) || + cell.column !== id % config.width || + !Number.isInteger(cell.adjacentMines) || + (cell.adjacentMines ?? -1) < 0 || + (cell.adjacentMines ?? 9) > 8 || + typeof cell.isFlagged !== 'boolean' || + typeof cell.isMine !== 'boolean' || + typeof cell.isRevealed !== 'boolean' + ) { + return false + } + } + + const placedMineCount = game.cells.filter((cell) => cell.isMine).length + return game.status === 'ready' + ? placedMineCount === 0 + : placedMineCount === game.mineCount +} diff --git a/frontend/src/features/games/minesweeper/store.ts b/frontend/src/features/games/minesweeper/store.ts new file mode 100644 index 0000000..587686f --- /dev/null +++ b/frontend/src/features/games/minesweeper/store.ts @@ -0,0 +1,140 @@ +import { defineStore } from 'pinia' + +import { useGamesStore } from '@/features/games/store' + +import { + createMinesweeperGame, + isMinesweeperGameState, + revealMinesweeperCell, + toggleMinesweeperFlag, +} from './engine' +import type { + MinesweeperActionResult, + MinesweeperBest, + MinesweeperDifficulty, + MinesweeperGameState, +} from './types' + +type MinesweeperSave = { + best: Partial> + elapsedMs: number + game: MinesweeperGameState | null + soundEnabled: boolean +} + +function isBest(value: unknown): value is MinesweeperBest { + return ( + !!value && + typeof value === 'object' && + typeof (value as Partial).timeMs === 'number' && + ((value as Partial).timeMs ?? -1) >= 0 + ) +} + +export const useMinesweeperStore = defineStore('minesweeper', { + state: () => ({ + best: {} as Partial>, + elapsedMs: 0, + game: null as MinesweeperGameState | null, + hydrated: false, + menuOpen: true, + soundEnabled: true, + startedAt: null as number | null, + }), + actions: { + hydrate(): void { + if (this.hydrated) return + + const saved = useGamesStore().readGame>( + 'minesweeper', + ) + for (const difficulty of ['quick', 'classic', 'expert'] as const) { + if (isBest(saved?.best?.[difficulty])) { + this.best[difficulty] = saved.best[difficulty] + } + } + this.elapsedMs = + typeof saved?.elapsedMs === 'number' && saved.elapsedMs >= 0 + ? saved.elapsedMs + : 0 + this.game = isMinesweeperGameState(saved?.game) + ? structuredClone(saved.game) + : null + if (typeof saved?.soundEnabled === 'boolean') { + this.soundEnabled = saved.soundEnabled + } + this.hydrated = true + }, + persist(): void { + useGamesStore().saveGame('minesweeper', { + best: this.best, + elapsedMs: this.elapsedMs, + game: this.game, + soundEnabled: this.soundEnabled, + } satisfies MinesweeperSave) + }, + start(difficulty: MinesweeperDifficulty): void { + this.game = createMinesweeperGame(difficulty) + this.elapsedMs = 0 + this.menuOpen = false + this.startedAt = null + this.persist() + }, + resumeGame(): void { + if (!this.game) return + this.menuOpen = false + if (this.game.status === 'playing') this.startedAt = Date.now() + }, + showMenu(): void { + this.pause() + this.menuOpen = true + this.persist() + }, + 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 + }, + reveal(cellId: number): MinesweeperActionResult | null { + if (!this.game) return null + + const previousStatus = this.game.status + const actionStartedAt = Date.now() + const result = revealMinesweeperCell(this.game, cellId) + if (!result.changed) return result + + this.game = result.state + if (previousStatus === 'ready') { + this.startedAt = actionStartedAt + } + if (result.state.status === 'won' || result.state.status === 'lost') { + this.pause() + if (result.state.status === 'won') { + const current = this.best[result.state.difficulty] + if (!current || this.elapsedMs < current.timeMs) { + this.best[result.state.difficulty] = { timeMs: this.elapsedMs } + } + } + } + this.persist() + return result + }, + toggleFlag(cellId: number): MinesweeperActionResult | null { + if (!this.game) return null + const result = toggleMinesweeperFlag(this.game, cellId) + if (result.changed) { + this.game = result.state + this.persist() + } + return result + }, + setSoundEnabled(enabled: boolean): void { + this.soundEnabled = enabled + this.persist() + }, + }, +}) diff --git a/frontend/src/features/games/minesweeper/types.ts b/frontend/src/features/games/minesweeper/types.ts new file mode 100644 index 0000000..a233f6a --- /dev/null +++ b/frontend/src/features/games/minesweeper/types.ts @@ -0,0 +1,32 @@ +export type MinesweeperDifficulty = 'classic' | 'expert' | 'quick' +export type MinesweeperStatus = 'lost' | 'playing' | 'ready' | 'won' + +export type MinesweeperCell = { + adjacentMines: number + column: number + id: number + isFlagged: boolean + isMine: boolean + isRevealed: boolean + row: number +} + +export type MinesweeperGameState = { + cells: MinesweeperCell[] + difficulty: MinesweeperDifficulty + explodedCellId: number | null + height: number + mineCount: number + status: MinesweeperStatus + width: number +} + +export type MinesweeperBest = { + timeMs: number +} + +export type MinesweeperActionResult = { + changed: boolean + revealedCount: number + state: MinesweeperGameState +} diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 63e7c84..b4a5b76 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -212,6 +212,37 @@ const defaultLocales: LocaleTree = { wonBody: 'You reached the legendary tile. Continue climbing or begin again.', wonTitle: 'You made 2048!', }, + minesweeper: { + name: 'Minesweeper', + backToMenu: 'Back to game menu', + board: 'Minesweeper board', + chooseBody: + 'Reveal every safe field. Numbers show how many mines touch that field.', + chooseTitle: 'Choose a minefield', + difficulties: { classic: 'Classic', expert: 'Expert', quick: 'Quick' }, + emptyCell: 'Revealed empty field', + eyebrow: 'Mine puzzle', + flaggedCell: 'Flagged field', + gameHint: 'Tap to reveal · Hold or right-click to flag', + hiddenCell: 'Hidden field', + longPressHint: 'Tap to reveal · Hold to place a flag', + lostBody: + 'A mine was hidden there. Mark suspicious fields and try again.', + lostTitle: 'Mine triggered', + mainMenu: 'Main Menu', + mineCell: 'Revealed mine', + mines: 'Mines', + mute: 'Mute game sounds', + noBest: 'No best time yet', + numberCell: 'Revealed field with {count} adjacent mines', + playAgain: 'Play Again', + restart: 'Restart', + resume: 'Continue Game', + time: 'Time', + unmute: 'Turn on game sounds', + wonBody: 'Minefield cleared in {time}.', + wonTitle: 'Field cleared!', + }, map: { name: 'Map', controls: 'Map controls', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 3ab725a..85ebee0 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -15,6 +15,7 @@ export type PhoneAppId = | 'snake' | 'memory' | 'number-merge' + | 'minesweeper' export type AppLaunchOrigin = { borderRadius: number diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index f9a8142..65a452e 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -53,6 +53,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< snake: { enabled: true, sounds: true }, memory: { enabled: true, sounds: true }, 'number-merge': { enabled: true, sounds: true }, + minesweeper: { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/MinesweeperApp.vue b/frontend/src/views/apps/MinesweeperApp.vue new file mode 100644 index 0000000..37fb4c9 --- /dev/null +++ b/frontend/src/views/apps/MinesweeperApp.vue @@ -0,0 +1,475 @@ + + + + + diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index e5b43ae..d1a8087 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -99,6 +99,19 @@ Locales["en"] = { unmute = "Turn on game sounds", wonBody = "You reached the legendary tile. Continue climbing or begin again.", wonTitle = "You made 2048!", }, + minesweeper = { + name = "Minesweeper", backToMenu = "Back to game menu", board = "Minesweeper board", + chooseBody = "Reveal every safe field. Numbers show how many mines touch that field.", + chooseTitle = "Choose a minefield", difficulties = { classic = "Classic", expert = "Expert", quick = "Quick" }, + emptyCell = "Revealed empty field", eyebrow = "Mine puzzle", flaggedCell = "Flagged field", + gameHint = "Tap to reveal · Hold or right-click to flag", hiddenCell = "Hidden field", + longPressHint = "Tap to reveal · Hold to place a flag", + lostBody = "A mine was hidden there. Mark suspicious fields and try again.", lostTitle = "Mine triggered", + mainMenu = "Main Menu", mineCell = "Revealed mine", mines = "Mines", mute = "Mute game sounds", + noBest = "No best time yet", numberCell = "Revealed field with {count} adjacent mines", + playAgain = "Play Again", restart = "Restart", resume = "Continue Game", time = "Time", + unmute = "Turn on game sounds", wonBody = "Minefield cleared in {time}.", wonTitle = "Field cleared!", + }, 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" },