mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 10:18:57 +00:00
944 lines
30 KiB
Vue
944 lines
30 KiB
Vue
<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>
|