ADD - minesweeper phone game

This commit is contained in:
smx.pusha
2026-08-06 09:00:45 +02:00
parent cd0b667d2f
commit 987a5fa334
17 changed files with 1139 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+6
View File
@@ -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))
+15
View File
@@ -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)
@@ -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<MinesweeperSound, string> = {
flag: flagUrl,
mine: mineUrl,
reveal: revealUrl,
win: winUrl,
}
const playerPools = new Map<MinesweeperSound, HTMLAudioElement[]>()
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)
})
}
@@ -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)
})
})
@@ -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<number>()
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<number>()
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<MinesweeperGameState>
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<MinesweeperCell>
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
}
@@ -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<Record<MinesweeperDifficulty, MinesweeperBest>>
elapsedMs: number
game: MinesweeperGameState | null
soundEnabled: boolean
}
function isBest(value: unknown): value is MinesweeperBest {
return (
!!value &&
typeof value === 'object' &&
typeof (value as Partial<MinesweeperBest>).timeMs === 'number' &&
((value as Partial<MinesweeperBest>).timeMs ?? -1) >= 0
)
}
export const useMinesweeperStore = defineStore('minesweeper', {
state: () => ({
best: {} as Partial<Record<MinesweeperDifficulty, MinesweeperBest>>,
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<Partial<MinesweeperSave>>(
'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()
},
},
})
@@ -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
}
+31
View File
@@ -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',
+1
View File
@@ -15,6 +15,7 @@ export type PhoneAppId =
| 'snake'
| 'memory'
| 'number-merge'
| 'minesweeper'
export type AppLaunchOrigin = {
borderRadius: number
+1
View File
@@ -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 },
+475
View File
@@ -0,0 +1,475 @@
<script setup lang="ts">
import {
Bomb,
ChevronLeft,
Flag,
RotateCcw,
Volume2,
VolumeX,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { playMinesweeperSound } from '@/features/games/minesweeper/audio'
import { MINESWEEPER_DIFFICULTIES } from '@/features/games/minesweeper/engine'
import { useMinesweeperStore } from '@/features/games/minesweeper/store'
import type {
MinesweeperCell,
MinesweeperDifficulty,
} from '@/features/games/minesweeper/types'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const minesweeper = useMinesweeperStore()
const difficulties: MinesweeperDifficulty[] = ['quick', 'classic', 'expert']
const game = computed(() => minesweeper.game)
const flagsPlaced = computed(
() => minesweeper.game?.cells.filter((cell) => cell.isFlagged).length ?? 0,
)
const minesRemaining = computed(() =>
Math.max(0, (minesweeper.game?.mineCount ?? 0) - flagsPlaced.value),
)
let clockTimer: ReturnType<typeof setInterval> | undefined
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let suppressNextClick = false
let suppressTimer: ReturnType<typeof setTimeout> | undefined
function formatTime(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000)
return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, '0')}`
}
function bestLabel(difficulty: MinesweeperDifficulty): string {
const best = minesweeper.best[difficulty]
return best ? formatTime(best.timeMs) : phone.t('Apps.minesweeper.noBest')
}
function cellLabel(cell: MinesweeperCell): string {
if (cell.isFlagged) return phone.t('Apps.minesweeper.flaggedCell')
if (!cell.isRevealed) return phone.t('Apps.minesweeper.hiddenCell')
if (cell.isMine) return phone.t('Apps.minesweeper.mineCell')
return cell.adjacentMines > 0
? phone.t('Apps.minesweeper.numberCell', {
count: String(cell.adjacentMines),
})
: phone.t('Apps.minesweeper.emptyCell')
}
function reveal(cellId: number): void {
if (suppressNextClick) {
suppressNextClick = false
return
}
const result = minesweeper.reveal(cellId)
if (!result?.changed) return
if (result.state.status === 'lost') {
playMinesweeperSound('mine', minesweeper.soundEnabled)
} else if (result.state.status === 'won') {
playMinesweeperSound('win', minesweeper.soundEnabled)
} else {
playMinesweeperSound('reveal', minesweeper.soundEnabled)
}
}
function toggleFlag(cellId: number): void {
const result = minesweeper.toggleFlag(cellId)
if (result?.changed) playMinesweeperSound('flag', minesweeper.soundEnabled)
}
function beginLongPress(cellId: number): void {
if (longPressTimer) clearTimeout(longPressTimer)
longPressTimer = setTimeout(() => {
suppressNextClick = true
toggleFlag(cellId)
if (suppressTimer) clearTimeout(suppressTimer)
suppressTimer = setTimeout(() => {
suppressNextClick = false
}, 650)
}, 470)
}
function cancelLongPress(): void {
if (longPressTimer) clearTimeout(longPressTimer)
longPressTimer = undefined
}
function toggleSound(): void {
const enabled = !minesweeper.soundEnabled
minesweeper.setSoundEnabled(enabled)
if (enabled) playMinesweeperSound('flag', true)
}
function restart(): void {
if (minesweeper.game) minesweeper.start(minesweeper.game.difficulty)
}
minesweeper.hydrate()
onMounted(() => {
if (!minesweeper.menuOpen) minesweeper.resumeGame()
clockTimer = setInterval(() => minesweeper.updateElapsed(), 100)
})
onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer)
if (longPressTimer) clearTimeout(longPressTimer)
if (suppressTimer) clearTimeout(suppressTimer)
minesweeper.pause()
minesweeper.persist()
})
</script>
<template>
<main class="minesweeper-app" :aria-label="phone.t('Apps.minesweeper.name')">
<header class="minesweeper-header">
<div>
<span>{{ phone.t('Apps.minesweeper.eyebrow') }}</span>
<h1>{{ phone.t('Apps.minesweeper.name') }}</h1>
</div>
<button
type="button"
:aria-label="
phone.t(
minesweeper.soundEnabled
? 'Apps.minesweeper.mute'
: 'Apps.minesweeper.unmute',
)
"
@click="toggleSound"
>
<Volume2 v-if="minesweeper.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</header>
<section v-if="minesweeper.menuOpen" class="minesweeper-menu">
<div class="minesweeper-hero" aria-hidden="true">
<span></span><span></span><span class="minesweeper-hero__one">1</span>
<span></span><span class="minesweeper-hero__mine"><Bomb :size="27" /></span><span></span>
<span class="minesweeper-hero__two">2</span><span></span><span class="minesweeper-hero__flag"><Flag :size="24" fill="currentColor" /></span>
</div>
<div class="minesweeper-intro">
<h2>{{ phone.t('Apps.minesweeper.chooseTitle') }}</h2>
<p>{{ phone.t('Apps.minesweeper.chooseBody') }}</p>
</div>
<button
v-if="game && game.status !== 'lost' && game.status !== 'won'"
type="button"
class="minesweeper-resume"
@click="minesweeper.resumeGame"
>
{{ phone.t('Apps.minesweeper.resume') }}
<small>{{ phone.t(`Apps.minesweeper.difficulties.${game.difficulty}`) }} · {{ formatTime(minesweeper.elapsedMs) }}</small>
</button>
<div class="minesweeper-difficulties">
<button
v-for="difficulty in difficulties"
:key="difficulty"
type="button"
@click="minesweeper.start(difficulty)"
>
<span class="minesweeper-difficulty__size">
{{ MINESWEEPER_DIFFICULTIES[difficulty].width }}×{{ MINESWEEPER_DIFFICULTIES[difficulty].height }}
</span>
<strong>{{ phone.t(`Apps.minesweeper.difficulties.${difficulty}`) }}</strong>
<small>
{{ MINESWEEPER_DIFFICULTIES[difficulty].mines }} {{ phone.t('Apps.minesweeper.mines') }} · {{ bestLabel(difficulty) }}
</small>
</button>
</div>
<p class="minesweeper-long-press">{{ phone.t('Apps.minesweeper.longPressHint') }}</p>
</section>
<section v-else-if="game" class="minesweeper-game">
<div class="minesweeper-toolbar">
<button
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
@click="minesweeper.showMenu"
>
<ChevronLeft :size="19" :stroke-width="2.7" aria-hidden="true" />
</button>
<div>
<span>{{ phone.t('Apps.minesweeper.mines') }}</span>
<strong>{{ minesRemaining }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.minesweeper.time') }}</span>
<strong>{{ formatTime(minesweeper.elapsedMs) }}</strong>
</div>
<button
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.restart')"
@click="restart"
>
<RotateCcw :size="17" :stroke-width="2.5" aria-hidden="true" />
</button>
</div>
<div
class="minesweeper-board"
:class="`minesweeper-board--${game.difficulty}`"
:style="{ '--minesweeper-columns': game.width }"
:aria-label="phone.t('Apps.minesweeper.board')"
>
<button
v-for="cell in game.cells"
:key="cell.id"
type="button"
class="minesweeper-cell"
:class="{
'minesweeper-cell--revealed': cell.isRevealed,
'minesweeper-cell--flagged': cell.isFlagged,
'minesweeper-cell--mine': cell.isMine && cell.isRevealed,
'minesweeper-cell--exploded': game.explodedCellId === cell.id,
[`minesweeper-cell--number-${cell.adjacentMines}`]:
cell.isRevealed && !cell.isMine && cell.adjacentMines > 0,
}"
:aria-label="cellLabel(cell)"
@click="reveal(cell.id)"
@contextmenu.prevent="toggleFlag(cell.id)"
@pointerdown="beginLongPress(cell.id)"
@pointerup="cancelLongPress"
@pointerleave="cancelLongPress"
@pointercancel="cancelLongPress"
>
<Flag v-if="cell.isFlagged" :size="15" fill="currentColor" aria-hidden="true" />
<Bomb v-else-if="cell.isMine && cell.isRevealed" :size="16" aria-hidden="true" />
<strong v-else-if="cell.isRevealed && cell.adjacentMines > 0">{{ cell.adjacentMines }}</strong>
</button>
<div v-if="game.status === 'won' || game.status === 'lost'" class="minesweeper-overlay">
<span class="minesweeper-overlay__icon">
<Flag v-if="game.status === 'won'" :size="30" fill="currentColor" />
<Bomb v-else :size="30" />
</span>
<h2>{{ phone.t(game.status === 'won' ? 'Apps.minesweeper.wonTitle' : 'Apps.minesweeper.lostTitle') }}</h2>
<p>
{{
phone.t(
game.status === 'won'
? 'Apps.minesweeper.wonBody'
: 'Apps.minesweeper.lostBody',
{ time: formatTime(minesweeper.elapsedMs) },
)
}}
</p>
<button type="button" class="minesweeper-primary" @click="restart">
{{ phone.t('Apps.minesweeper.playAgain') }}
</button>
<button type="button" class="minesweeper-secondary" @click="minesweeper.showMenu">
{{ phone.t('Apps.minesweeper.mainMenu') }}
</button>
</div>
</div>
<p class="minesweeper-game__hint">{{ phone.t('Apps.minesweeper.gameHint') }}</p>
</section>
</main>
</template>
<style scoped>
.minesweeper-app {
position: absolute;
inset: 0;
overflow: hidden;
padding: 52px 16px 27px;
color: #153b42;
background:
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
touch-action: manipulation;
user-select: none;
}
.minesweeper-header {
height: 55px;
display: flex;
align-items: center;
justify-content: space-between;
}
.minesweeper-header span { display: block; color: #59878a; font-size: 9px; font-weight: 800; letter-spacing: 1.1px; text-transform: uppercase; }
.minesweeper-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.7px; }
.minesweeper-header button,
.minesweeper-toolbar__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgb(23 83 87 / 9%);
border-radius: 12px;
color: #246871;
background: rgb(255 255 255 / 48%);
box-shadow: 0 4px 10px rgb(23 73 75 / 8%);
}
.minesweeper-menu {
height: calc(100% - 55px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
}
.minesweeper-hero {
width: 136px;
height: 136px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 7px;
border-radius: 25px;
background: #167e82;
box-shadow: 0 15px 28px rgb(18 87 90 / 20%);
transform: rotate(-2deg);
}
.minesweeper-hero > span {
display: grid;
place-items: center;
border-radius: 10px;
color: #eafff8;
background: linear-gradient(145deg, #46c2bc, #279c9d);
box-shadow: inset 0 2px 0 rgb(255 255 255 / 18%), 0 3px 5px rgb(10 68 71 / 18%);
font-size: 20px;
font-weight: 900;
}
.minesweeper-hero .minesweeper-hero__one,
.minesweeper-hero .minesweeper-hero__two { background: #fff8e9; }
.minesweeper-hero__one { color: #367bd3 !important; }
.minesweeper-hero__two { color: #198768 !important; }
.minesweeper-hero__mine { color: #203b54 !important; background: #46b7b2 !important; }
.minesweeper-hero__flag { color: #f15f57 !important; }
.minesweeper-intro h2 { margin: 0; font-size: 21px; }
.minesweeper-intro p { max-width: 270px; margin: 5px 0 0; color: #5f8584; font-size: 11px; line-height: 1.4; }
.minesweeper-resume {
width: 100%;
min-height: 48px;
display: grid;
place-items: center;
gap: 1px;
border: 0;
border-radius: 14px;
color: #effff9;
background: linear-gradient(135deg, #249b96, #147a81);
box-shadow: 0 7px 15px rgb(20 111 113 / 18%);
font-size: 12px;
font-weight: 850;
}
.minesweeper-resume small { color: #bfece3; font-size: 8px; }
.minesweeper-difficulties { width: 100%; display: grid; gap: 6px; }
.minesweeper-difficulties button {
min-height: 50px;
display: grid;
grid-template-columns: 49px 1fr;
grid-template-rows: 1fr 1fr;
align-items: center;
column-gap: 10px;
padding: 6px 10px;
border: 1px solid rgb(23 91 93 / 8%);
border-radius: 14px;
color: #204b50;
background: rgb(255 255 255 / 45%);
text-align: left;
}
.minesweeper-difficulty__size { grid-row: 1 / 3; display: grid; place-items: center; height: 37px; border-radius: 10px; color: #f0fff9; background: #238f8e; font-size: 11px; font-weight: 850; }
.minesweeper-difficulties strong { align-self: end; font-size: 12px; }
.minesweeper-difficulties small { align-self: start; color: #658a89; font-size: 8px; }
.minesweeper-long-press { margin: 0; color: #628887; font-size: 9px; }
.minesweeper-game { padding-top: 5px; }
.minesweeper-toolbar { height: 46px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
.minesweeper-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
.minesweeper-toolbar span { color: #5a8484; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.minesweeper-toolbar strong { font-size: 16px; }
.minesweeper-board {
position: relative;
display: grid;
grid-template-columns: repeat(var(--minesweeper-columns), minmax(0, 1fr));
gap: 3px;
padding: 7px;
border: 1px solid rgb(16 78 82 / 10%);
border-radius: 18px;
background: #187a7e;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 15px 27px rgb(20 79 81 / 18%);
}
.minesweeper-cell {
aspect-ratio: 1;
min-width: 0;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 7px;
color: #effff8;
background: linear-gradient(145deg, #4bc4bd, #279d9e);
box-shadow: inset 0 2px 0 rgb(255 255 255 / 15%), 0 2px 3px rgb(9 61 64 / 20%);
font-size: 13px;
transition: transform 100ms ease, background 130ms ease;
}
.minesweeper-board--expert .minesweeper-cell { border-radius: 6px; font-size: 11px; }
.minesweeper-cell--revealed { color: #477078; background: #eef5e9; box-shadow: inset 0 1px 3px rgb(39 83 80 / 12%); animation: minesweeper-reveal 170ms ease-out; }
.minesweeper-cell--flagged { color: #f35f58; background: linear-gradient(145deg, #5bcec5, #2ca5a4); }
.minesweeper-cell--mine { color: #263c51; background: #dce5dd; }
.minesweeper-cell--exploded { color: #fff; background: #ed6459; animation: minesweeper-explode 350ms ease-out; }
.minesweeper-cell--number-1 { color: #3275ce; }
.minesweeper-cell--number-2 { color: #188569; }
.minesweeper-cell--number-3 { color: #dd574b; }
.minesweeper-cell--number-4 { color: #7354ad; }
.minesweeper-cell--number-5,
.minesweeper-cell--number-6,
.minesweeper-cell--number-7,
.minesweeper-cell--number-8 { color: #9b4e34; }
@keyframes minesweeper-reveal { from { opacity: 0.45; transform: scale(0.84); } to { opacity: 1; transform: scale(1); } }
@keyframes minesweeper-explode { 0% { transform: scale(0.8); } 48% { transform: scale(1.18); } 100% { transform: scale(1); } }
.minesweeper-overlay {
position: absolute;
z-index: 5;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 22px;
border-radius: 17px;
color: #effff9;
background: rgb(13 66 70 / 86%);
backdrop-filter: blur(5px);
text-align: center;
}
.minesweeper-overlay__icon { width: 58px; height: 58px; display: grid; place-items: center; border-radius: 50%; color: #ff756a; background: rgb(255 255 255 / 10%); }
.minesweeper-overlay h2 { margin: 0; font-size: 24px; }
.minesweeper-overlay p { max-width: 220px; margin: -2px 0 4px; color: #bee1dc; font-size: 10px; line-height: 1.35; }
.minesweeper-primary,
.minesweeper-secondary { min-width: 155px; min-height: 40px; border-radius: 13px; font-size: 11px; font-weight: 850; }
.minesweeper-primary { border: 0; color: #104d51; background: #8ce3d2; }
.minesweeper-secondary { border: 1px solid rgb(255 255 255 / 13%); color: #e6faf5; background: rgb(255 255 255 / 7%); }
.minesweeper-game__hint { margin: 9px 0 0; color: #668c8c; font-size: 9px; text-align: center; }
button:active { transform: scale(0.96); }
@media (prefers-reduced-motion: reduce) {
.minesweeper-cell--revealed,
.minesweeper-cell--exploded { animation: none; }
}
</style>
+13
View File
@@ -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" },