Files
sky_phone/frontend/src/views/apps/MinesweeperApp.vue
T
2026-08-19 13:41:45 +02:00

944 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
Bomb,
ChevronLeft,
Flag,
RotateCcw,
Volume2,
VolumeX,
} from 'lucide-vue-next'
import {
computed,
onBeforeUnmount,
onMounted,
ref,
type CSSProperties,
} 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),
)
const roundEffect = ref<'explosion' | 'victory' | null>(null)
const resultVisible = ref(true)
const markerEffectCellId = ref<number | null>(null)
const markerEffectKey = ref(0)
const blastStyle = computed<CSSProperties>(() => {
const currentGame = minesweeper.game
const explodedCell = currentGame?.cells.find(
(cell) => cell.id === currentGame.explodedCellId,
)
if (!currentGame || !explodedCell) return {}
return {
'--blast-left': `${((explodedCell.column + 0.5) / currentGame.width) * 100}%`,
'--blast-top': `${((explodedCell.row + 0.5) / currentGame.height) * 100}%`,
} as CSSProperties
})
const markerStyle = computed<CSSProperties>(() => {
const currentGame = minesweeper.game
const markedCell = currentGame?.cells.find(
(cell) => cell.id === markerEffectCellId.value,
)
if (!currentGame || !markedCell) return {}
return {
'--marker-left': `${((markedCell.column + 0.5) / currentGame.width) * 100}%`,
'--marker-top': `${((markedCell.row + 0.5) / currentGame.height) * 100}%`,
} as CSSProperties
})
let clockTimer: ReturnType<typeof setInterval> | undefined
let longPressTimer: ReturnType<typeof setTimeout> | undefined
let suppressNextClick = false
let suppressTimer: ReturnType<typeof setTimeout> | undefined
let effectTimer: ReturnType<typeof setTimeout> | undefined
let markerEffectTimer: 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)
startRoundEffect('explosion', 1050)
} else if (result.state.status === 'won') {
playMinesweeperSound('win', minesweeper.soundEnabled)
startRoundEffect('victory', 900)
} else {
playMinesweeperSound(
result.revealedCount >= 5 ? 'clear' : 'reveal',
minesweeper.soundEnabled,
)
}
}
function startRoundEffect(
effect: 'explosion' | 'victory',
duration: number,
): void {
if (effectTimer) clearTimeout(effectTimer)
roundEffect.value = effect
resultVisible.value = false
effectTimer = setTimeout(() => {
roundEffect.value = null
resultVisible.value = true
effectTimer = undefined
}, duration)
}
function toggleFlag(cellId: number): void {
const result = minesweeper.toggleFlag(cellId)
if (!result?.changed) return
const markedCell = result.state.cells.find((cell) => cell.id === cellId)
if (markedCell?.isFlagged) {
if (markerEffectTimer) clearTimeout(markerEffectTimer)
markerEffectCellId.value = cellId
markerEffectKey.value += 1
playMinesweeperSound('place', minesweeper.soundEnabled)
markerEffectTimer = setTimeout(() => {
markerEffectCellId.value = null
markerEffectTimer = undefined
}, 720)
} else {
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) return
if (effectTimer) clearTimeout(effectTimer)
if (markerEffectTimer) clearTimeout(markerEffectTimer)
effectTimer = undefined
markerEffectTimer = undefined
roundEffect.value = null
markerEffectCellId.value = null
resultVisible.value = true
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)
if (effectTimer) clearTimeout(effectTimer)
if (markerEffectTimer) clearTimeout(markerEffectTimer)
minesweeper.pause()
minesweeper.persist()
})
</script>
<template>
<main
class="minesweeper-app"
:class="{ 'minesweeper-app--playing': !minesweeper.menuOpen && game }"
:aria-label="phone.t('Apps.minesweeper.name')"
>
<header v-if="minesweeper.menuOpen" 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"
:class="{
'minesweeper-game--exploding': roundEffect === 'explosion',
'minesweeper-game--victory': roundEffect === 'victory',
}"
>
<div class="minesweeper-toolbar">
<button
type="button"
class="minesweeper-toolbar__icon"
:aria-label="phone.t('Apps.minesweeper.backToMenu')"
@pointerup.stop="minesweeper.showMenu()"
@click.stop="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--marking': markerEffectCellId === 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="markerEffectCellId !== null"
:key="markerEffectKey"
class="minesweeper-marker-drop"
:style="markerStyle"
aria-hidden="true"
>
<span class="minesweeper-marker-drop__trail"></span>
<span class="minesweeper-marker-drop__bomb">
<Bomb :size="24" fill="currentColor" />
</span>
<span class="minesweeper-marker-drop__impact"></span>
</div>
<div
v-if="roundEffect === 'explosion'"
class="minesweeper-blast"
:style="blastStyle"
aria-hidden="true"
>
<span class="minesweeper-blast__flash"></span>
<span class="minesweeper-blast__ring"></span>
<span class="minesweeper-blast__core"><Bomb :size="25" fill="currentColor" /></span>
<i v-for="particle in 12" :key="particle"></i>
</div>
<div
v-if="roundEffect === 'victory'"
class="minesweeper-celebration"
aria-hidden="true"
>
<i v-for="particle in 18" :key="particle"></i>
</div>
<div
v-if="
resultVisible && (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"
@pointerup.stop="minesweeper.showMenu()"
@click.stop="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: var(--sky-font-family);
touch-action: manipulation;
user-select: none;
}
.minesweeper-app--playing {
padding: 0;
}
.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 {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 12% 88%, rgb(26 129 130 / 14%), transparent 32%),
radial-gradient(circle at 88% 16%, rgb(238 255 249 / 45%), transparent 29%);
}
.minesweeper-toolbar {
position: absolute;
z-index: 10;
top: 66px;
right: 18px;
left: 18px;
height: 42px;
display: grid;
grid-template-columns: 32px 1fr 1fr 32px;
align-items: center;
gap: 4px;
padding: 4px;
border: 1px solid rgb(23 83 87 / 10%);
border-radius: 21px;
background: rgb(226 249 242 / 65%);
box-shadow: 0 8px 24px rgb(23 73 75 / 13%);
backdrop-filter: blur(14px);
box-sizing: border-box;
}
.minesweeper-toolbar div { height: 32px; display: grid; grid-template-rows: 10px 20px; align-content: center; justify-items: center; }
.minesweeper-toolbar span { color: #5a8484; font-size: 10px; font-weight: 800; line-height: 10px; text-transform: uppercase; }
.minesweeper-toolbar strong { display: block; font-size: 18px; line-height: 20px; }
.minesweeper-toolbar .minesweeper-toolbar__icon {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
box-shadow: none;
}
.minesweeper-board {
position: absolute;
top: 50%;
right: 14px;
left: 14px;
display: grid;
grid-template-columns: repeat(var(--minesweeper-columns), minmax(0, 1fr));
gap: 3px;
padding: 7px;
border: 0;
border-radius: 18px;
background: #187a7e;
box-shadow: inset 0 2px 2px rgb(255 255 255 / 10%), 0 15px 27px rgb(20 79 81 / 18%);
transform: translateY(-45%);
}
.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--marking > svg { animation: minesweeper-marker-flag 720ms ease-out; }
.minesweeper-cell--mine { color: #263c51; background: #dce5dd; }
.minesweeper-cell--exploded { color: #fff; background: radial-gradient(circle, #263238 0 31%, #743e37 34% 55%, #df584e 58%); box-shadow: inset 0 0 9px #050b0c, 0 0 12px #ff5a3c; animation: minesweeper-explode 720ms 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% { filter: brightness(4); transform: scale(0.7); } 28% { filter: brightness(2); transform: scale(1.32); } 62% { transform: scale(0.9); } 100% { filter: brightness(0.8); transform: scale(1); } }
.minesweeper-game--exploding .minesweeper-board {
animation: minesweeper-board-shake 680ms cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
.minesweeper-game--exploding::after {
position: absolute;
z-index: 9;
inset: 0;
background: #ffb341;
content: "";
pointer-events: none;
animation: minesweeper-screen-flash 520ms ease-out forwards;
}
.minesweeper-blast {
position: absolute;
z-index: 12;
top: var(--blast-top);
left: var(--blast-left);
width: 0;
height: 0;
pointer-events: none;
}
.minesweeper-marker-drop {
position: absolute;
z-index: 11;
top: var(--marker-top);
left: var(--marker-left);
width: 0;
height: 0;
pointer-events: none;
}
.minesweeper-marker-drop__bomb {
position: absolute;
z-index: 2;
width: 38px;
height: 38px;
display: grid;
place-items: center;
margin: -19px;
border: 2px solid #667985;
border-radius: 50%;
color: #172c36;
background: radial-gradient(circle at 35% 28%, #687b83, #263c45 48%, #12272f 75%);
box-shadow: inset -4px -5px 8px rgb(0 0 0 / 35%), 0 5px 9px rgb(11 42 45 / 42%);
animation: minesweeper-bomb-drop 720ms cubic-bezier(0.2, 0.8, 0.35, 1) forwards;
}
.minesweeper-marker-drop__trail {
position: absolute;
z-index: 1;
width: 7px;
height: 95px;
margin: -87px 0 0 27px;
border-radius: 50%;
background: linear-gradient(to bottom, transparent, #ffe38a 45%, #ff6c42 83%, transparent);
filter: blur(2px);
transform: rotate(35deg);
transform-origin: bottom;
animation: minesweeper-bomb-trail 570ms ease-out forwards;
}
.minesweeper-marker-drop__impact {
position: absolute;
width: 34px;
height: 34px;
margin: -17px;
border: 4px solid #8df3df;
border-radius: 50%;
box-shadow: 0 0 12px #4bc4bd;
opacity: 0;
animation: minesweeper-marker-impact 720ms ease-out forwards;
}
.minesweeper-blast__flash,
.minesweeper-blast__ring,
.minesweeper-blast__core,
.minesweeper-blast i {
position: absolute;
top: 0;
left: 0;
}
.minesweeper-blast__flash {
width: 112px;
height: 112px;
margin: -56px;
border-radius: 50%;
background: radial-gradient(circle, #fff 0 7%, #ffdf68 12%, #ff713d 32%, transparent 68%);
animation: minesweeper-blast-flash 560ms ease-out forwards;
}
.minesweeper-blast__ring {
width: 32px;
height: 32px;
margin: -16px;
border: 6px solid #ffd166;
border-radius: 50%;
box-shadow: 0 0 18px #ff713d;
animation: minesweeper-blast-ring 720ms ease-out forwards;
}
.minesweeper-blast__core {
width: 44px;
height: 44px;
display: grid;
place-items: center;
margin: -22px;
border-radius: 50%;
color: #202a32;
background: #f6b846;
filter: drop-shadow(0 0 8px #fff0a6);
animation: minesweeper-blast-core 620ms ease-out forwards;
}
.minesweeper-blast i {
--particle-x: 58px;
--particle-y: 0;
width: 7px;
height: 7px;
margin: -3px;
border-radius: 2px;
background: #ffcf55;
box-shadow: 0 0 7px #ff743d;
animation: minesweeper-debris 760ms ease-out forwards;
}
.minesweeper-blast i:nth-of-type(2) { --particle-x: 51px; --particle-y: 30px; }
.minesweeper-blast i:nth-of-type(3) { --particle-x: 29px; --particle-y: 54px; }
.minesweeper-blast i:nth-of-type(4) { --particle-x: 0; --particle-y: 66px; }
.minesweeper-blast i:nth-of-type(5) { --particle-x: -34px; --particle-y: 58px; }
.minesweeper-blast i:nth-of-type(6) { --particle-x: -61px; --particle-y: 28px; }
.minesweeper-blast i:nth-of-type(7) { --particle-x: -68px; --particle-y: -5px; }
.minesweeper-blast i:nth-of-type(8) { --particle-x: -49px; --particle-y: -43px; }
.minesweeper-blast i:nth-of-type(9) { --particle-x: -12px; --particle-y: -65px; }
.minesweeper-blast i:nth-of-type(10) { --particle-x: 28px; --particle-y: -60px; }
.minesweeper-blast i:nth-of-type(11) { --particle-x: 61px; --particle-y: -36px; }
.minesweeper-blast i:nth-of-type(12) { --particle-x: 72px; --particle-y: 10px; }
.minesweeper-celebration {
position: absolute;
z-index: 12;
inset: 0;
overflow: hidden;
border-radius: 17px;
pointer-events: none;
}
.minesweeper-celebration i {
--confetti-x: 10%;
--confetti-delay: 0ms;
--confetti-color: #ffcf55;
position: absolute;
top: -14px;
left: var(--confetti-x);
width: 7px;
height: 13px;
border-radius: 2px;
background: var(--confetti-color);
animation: minesweeper-confetti 850ms var(--confetti-delay) ease-in forwards;
}
.minesweeper-celebration i:nth-child(3n + 1) { --confetti-color: #ff7065; transform: rotate(22deg); }
.minesweeper-celebration i:nth-child(3n + 2) { --confetti-color: #9cf0d9; transform: rotate(-31deg); }
.minesweeper-celebration i:nth-child(2n) { --confetti-delay: 90ms; }
.minesweeper-celebration i:nth-child(1) { --confetti-x: 4%; }
.minesweeper-celebration i:nth-child(2) { --confetti-x: 10%; }
.minesweeper-celebration i:nth-child(3) { --confetti-x: 17%; }
.minesweeper-celebration i:nth-child(4) { --confetti-x: 24%; }
.minesweeper-celebration i:nth-child(5) { --confetti-x: 31%; }
.minesweeper-celebration i:nth-child(6) { --confetti-x: 38%; }
.minesweeper-celebration i:nth-child(7) { --confetti-x: 45%; }
.minesweeper-celebration i:nth-child(8) { --confetti-x: 52%; }
.minesweeper-celebration i:nth-child(9) { --confetti-x: 59%; }
.minesweeper-celebration i:nth-child(10) { --confetti-x: 66%; }
.minesweeper-celebration i:nth-child(11) { --confetti-x: 73%; }
.minesweeper-celebration i:nth-child(12) { --confetti-x: 80%; }
.minesweeper-celebration i:nth-child(13) { --confetti-x: 87%; }
.minesweeper-celebration i:nth-child(14) { --confetti-x: 94%; }
.minesweeper-celebration i:nth-child(15) { --confetti-x: 14%; }
.minesweeper-celebration i:nth-child(16) { --confetti-x: 42%; }
.minesweeper-celebration i:nth-child(17) { --confetti-x: 70%; }
.minesweeper-celebration i:nth-child(18) { --confetti-x: 90%; }
.minesweeper-game--victory .minesweeper-cell--flagged {
animation: minesweeper-flag-celebrate 520ms ease-out alternate 2;
}
@keyframes minesweeper-board-shake {
0%, 100% { transform: translate(0, -45%); }
12% { transform: translate(-7px, calc(-45% + 3px)) rotate(-1deg); }
24% { transform: translate(6px, calc(-45% - 4px)) rotate(1deg); }
38% { transform: translate(-5px, calc(-45% - 2px)); }
52% { transform: translate(4px, calc(-45% + 3px)); }
68% { transform: translate(-2px, calc(-45% - 2px)); }
82% { transform: translate(2px, calc(-45% + 1px)); }
}
@keyframes minesweeper-bomb-drop {
0% { opacity: 0; transform: translate(105px, -185px) scale(0.55) rotate(160deg); }
12% { opacity: 1; }
66% { transform: translate(0, 0) scale(1) rotate(355deg); }
78% { transform: translate(0, -13px) scale(0.9) rotate(370deg); }
90% { opacity: 1; transform: translate(0, 0) scale(0.78) rotate(380deg); }
100% { opacity: 0; transform: translate(0, 0) scale(0.35) rotate(390deg); }
}
@keyframes minesweeper-bomb-trail {
0% { opacity: 0; scale: 0.4; }
18% { opacity: 0.9; }
72%, 100% { opacity: 0; scale: 1; }
}
@keyframes minesweeper-marker-impact {
0%, 62% { opacity: 0; transform: scale(0.2); }
68% { opacity: 1; }
100% { opacity: 0; transform: scale(2.2); }
}
@keyframes minesweeper-marker-flag {
0%, 68% { opacity: 0; transform: scale(0.35) rotate(-18deg); }
82% { opacity: 1; transform: scale(1.28) rotate(5deg); }
100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes minesweeper-screen-flash {
0% { opacity: 0.82; }
18% { opacity: 0.22; }
100% { opacity: 0; }
}
@keyframes minesweeper-blast-flash {
0% { opacity: 1; transform: scale(0.18); }
45% { opacity: 0.88; transform: scale(1.15); }
100% { opacity: 0; transform: scale(1.7); }
}
@keyframes minesweeper-blast-ring {
0% { opacity: 1; transform: scale(0.15); }
100% { opacity: 0; transform: scale(4.2); }
}
@keyframes minesweeper-blast-core {
0% { opacity: 1; transform: scale(0.35) rotate(-18deg); }
48% { opacity: 1; transform: scale(1.2) rotate(12deg); }
100% { opacity: 0; transform: scale(0.6) rotate(55deg); }
}
@keyframes minesweeper-debris {
0% { opacity: 1; transform: translate(0) rotate(0); }
75% { opacity: 1; }
100% { opacity: 0; transform: translate(var(--particle-x), var(--particle-y)) rotate(260deg); }
}
@keyframes minesweeper-confetti {
0% { opacity: 0; translate: 0 0; rotate: 0deg; }
10% { opacity: 1; }
100% { opacity: 0; translate: 16px 380px; rotate: 520deg; }
}
@keyframes minesweeper-flag-celebrate {
from { filter: brightness(1); transform: scale(1); }
to { filter: brightness(1.3); transform: scale(1.12) rotate(3deg); }
}
.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 { position: relative; z-index: 1; min-width: 155px; min-height: 40px; border-radius: 13px; font-size: 11px; font-weight: 850; pointer-events: auto; }
.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 {
position: absolute;
z-index: 6;
right: 45px;
bottom: 27px;
left: 45px;
margin: 0;
padding: 7px 10px;
border-radius: 999px;
color: #668c8c;
background: rgb(226 249 242 / 52%);
backdrop-filter: blur(10px);
font-size: 9px;
text-align: center;
pointer-events: none;
}
button:active { transform: scale(0.96); }
@media (prefers-reduced-motion: reduce) {
.minesweeper-game--exploding .minesweeper-board,
.minesweeper-game--exploding::after,
.minesweeper-blast > *,
.minesweeper-marker-drop > *,
.minesweeper-cell--marking > svg,
.minesweeper-celebration i,
.minesweeper-game--victory .minesweeper-cell--flagged,
.minesweeper-cell--revealed,
.minesweeper-cell--exploded { animation: none; }
}
</style>