mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-01 18:58:58 +00:00
ADD - minesweeper phone game
This commit is contained in:
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 |
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -15,6 +15,7 @@ export type PhoneAppId =
|
||||
| 'snake'
|
||||
| 'memory'
|
||||
| 'number-merge'
|
||||
| 'minesweeper'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user