ENH - amplify minesweeper effects

This commit is contained in:
smx.pusha
2026-08-06 09:10:47 +02:00
parent 3dd5c90a72
commit abc7010c11
7 changed files with 284 additions and 9 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,13 @@
import flagUrl from '@/assets/audio/minesweeper/flag.wav?url'
import clearUrl from '@/assets/audio/minesweeper/clear.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'
export type MinesweeperSound = 'clear' | 'flag' | 'mine' | 'reveal' | 'win'
const soundUrls: Record<MinesweeperSound, string> = {
clear: clearUrl,
flag: flagUrl,
mine: mineUrl,
reveal: revealUrl,
+281 -8
View File
@@ -7,7 +7,13 @@ import {
Volume2,
VolumeX,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import {
computed,
onBeforeUnmount,
onMounted,
ref,
type CSSProperties,
} from 'vue'
import { playMinesweeperSound } from '@/features/games/minesweeper/audio'
import { MINESWEEPER_DIFFICULTIES } from '@/features/games/minesweeper/engine'
@@ -28,10 +34,25 @@ const flagsPlaced = computed(
const minesRemaining = computed(() =>
Math.max(0, (minesweeper.game?.mineCount ?? 0) - flagsPlaced.value),
)
const roundEffect = ref<'explosion' | 'victory' | null>(null)
const resultVisible = ref(true)
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
})
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
function formatTime(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000)
@@ -64,13 +85,32 @@ function reveal(cellId: number): void {
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('reveal', minesweeper.soundEnabled)
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) playMinesweeperSound('flag', minesweeper.soundEnabled)
@@ -100,7 +140,13 @@ function toggleSound(): void {
}
function restart(): void {
if (minesweeper.game) minesweeper.start(minesweeper.game.difficulty)
if (!minesweeper.game) return
if (effectTimer) clearTimeout(effectTimer)
effectTimer = undefined
roundEffect.value = null
resultVisible.value = true
minesweeper.start(minesweeper.game.difficulty)
}
minesweeper.hydrate()
@@ -112,6 +158,7 @@ onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer)
if (longPressTimer) clearTimeout(longPressTimer)
if (suppressTimer) clearTimeout(suppressTimer)
if (effectTimer) clearTimeout(effectTimer)
minesweeper.pause()
minesweeper.persist()
})
@@ -182,7 +229,14 @@ onBeforeUnmount(() => {
<p class="minesweeper-long-press">{{ phone.t('Apps.minesweeper.longPressHint') }}</p>
</section>
<section v-else-if="game" class="minesweeper-game">
<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"
@@ -243,7 +297,32 @@ onBeforeUnmount(() => {
<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">
<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" />
@@ -397,7 +476,7 @@ onBeforeUnmount(() => {
.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-game { position: relative; 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; }
@@ -434,7 +513,7 @@ onBeforeUnmount(() => {
.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--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; }
@@ -445,7 +524,196 @@ onBeforeUnmount(() => {
.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); } }
@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: 46px 0 0;
border-radius: 18px;
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-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); }
12% { transform: translate(-7px, 3px) rotate(-1deg); }
24% { transform: translate(6px, -4px) rotate(1deg); }
38% { transform: translate(-5px, -2px); }
52% { transform: translate(4px, 3px); }
68% { transform: translate(-2px, -2px); }
82% { transform: translate(2px, 1px); }
}
@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;
@@ -475,6 +743,11 @@ onBeforeUnmount(() => {
button:active { transform: scale(0.96); }
@media (prefers-reduced-motion: reduce) {
.minesweeper-game--exploding .minesweeper-board,
.minesweeper-game--exploding::after,
.minesweeper-blast > *,
.minesweeper-celebration i,
.minesweeper-game--victory .minesweeper-cell--flagged,
.minesweeper-cell--revealed,
.minesweeper-cell--exploded { animation: none; }
}