/** * This file is part of the SplendidBear Websites' projects. * * Copyright (c) 2026 @ www.splendidbear.org * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import React, { useRef } from 'react'; import { Howl } from 'howler'; import GameContext from './GameContext'; import { useGameRefs, useGameState } from '@mine-hooks'; import { DESC, IMAGES, patchCells } from '@mine-utils'; export const GameProvider = ({ children }) => { const { webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, redRef, blueRef, lastClickedRef, endRef, } = useGameRefs(); const { webPlayer, setWebPlayer, activePlayer, setActivePlayer, overlay, setOverlay, overlayTitle, setOverlayTitle, overlaySubTitle, setOverlaySubTitle, mines, setMines, bombSelected, setBombSelected, foundMines, setFoundMines, red, setRed, blue, setBlue, cells, setCells, gridReady, setGridReady, connectionLost, setConnectionLost, } = useGameState(); const [gameUuid, setGameUuid] = React.useState(null); const sounds = useRef({ click: new Howl({ src: ['/sound/click.mp3'] }), bomb: new Howl({ src: ['/sound/bomb.mp3'] }), mine: new Howl({ src: ['/sound/mine.mp3'] }), warning: new Howl({ src: ['/sound/warning.mp3'] }), won: new Howl({ src: ['/sound/won.mp3'] }), }); /** Sync helpers (keep ref + state in lockstep) */ const syncWebPlayer = v => { webPlayerRef.current = v; setWebPlayer(v); }; const syncActivePlayer = v => { activePlayerRef.current = v; setActivePlayer(v); }; const syncBombSelected = v => { bombSelectedRef.current = v; setBombSelected(v); }; const syncConnLost = v => { connectionLostRef.current = v; setConnectionLost(v); }; const syncRed = fn => { const n = fn(redRef.current); redRef.current = n; setRed(n); }; const syncBlue = fn => { const n = fn(blueRef.current); blueRef.current = n; setBlue(n); }; /** Overlay */ const showOverlay = (title, sub) => { setOverlay(true); setOverlayTitle(title); setOverlaySubTitle(sub); }; const hideOverlay = () => setOverlay(false); /** Cell helpers */ const applyRevealedCell = (cell, player, isMainCell = false) => { const { row, col, value } = cell; setCells(prev => { if (prev[row][col].active) return prev; const patch = 'm' === value ? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true } : { currentImage: value, currentObj: value, active: true }; if (isMainCell) { patch.lastClickedRed = 'red' === player; patch.lastClickedBlue = 'blue' === player; } return patchCells(prev, [{ row, col, ...patch }]); }); if (isMainCell) lastClickedRef.current = { ...lastClickedRef.current, [player]: [row, col] }; }; const showLeftMines = (leftMines = []) => { if (!leftMines.length) return; setCells(prev => { const patches = leftMines .filter(({ row, col }) => !prev[row][col].active) .map(({ row, col }) => ({ row, col, currentImage: IMAGES.leftMine })); return patches.length ? patchCells(prev, patches) : prev; }); }; /** Game logic */ const changePlayer = () => { const wasColor = activePlayerRef.current ? 'blue' : 'red'; const nextColor = activePlayerRef.current ? 'red' : 'blue'; const nextVal = activePlayerRef.current ? 0 : 1; const desc = wasColor === webPlayerRef.current ? DESC.buddy : DESC.you; syncActivePlayer(nextVal); syncRed(p => ({ ...p, active: 'red' === nextColor, desc: 'red' === nextColor ? desc : '' })); syncBlue(p => ({ ...p, active: 'blue' === nextColor, desc: 'blue' === nextColor ? desc : '' })); }; const applyStep = stepData => { const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp, redBonusPoints = 0, blueBonusPoints = 0, redBonusStats = {}, blueBonusStats = {}, } = stepData; if (isBomb) { sounds.current.bomb.play(); } else if (0 < minesFound) { const cur = ('red' === player ? redRef : blueRef).current.mines; sounds.current[20 < cur + minesFound ? 'warning' : 'mine'].play(); } else { sounds.current.click.play(); } const lc = lastClickedRef.current[player]; setCells(prev => { let next = prev; if (lc) next = patchCells(next, [{ row: lc[0], col: lc[1], lastClickedRed: false, lastClickedBlue: false }]); revealedCells.forEach(({ row, col, value }, idx) => { if (next[row][col].active) return; const patch = 'm' === value ? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true } : { currentImage: value, currentObj: value, active: true }; if (0 === idx) { patch.lastClickedRed = 'red' === player; patch.lastClickedBlue = 'blue' === player; } next = patchCells(next, [{ row, col, ...patch }]); }); if (isBomb) next = next.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c)); return next; }); if (0 < revealedCells.length) { lastClickedRef.current = { ...lastClickedRef.current, [player]: [revealedCells[0].row, revealedCells[0].col] }; } if (0 < minesFound) { setMines(51 - rp - bp); setFoundMines(true); setTimeout(() => setFoundMines(false), 500); syncRed(p => ({ ...p, mines: 'red' === player ? rp : p.mines })); syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines })); } // Update bonus points and stats syncRed(p => ({ ...p, bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints, bonusStats: 'red' === player ? redBonusStats : p.bonusStats, })); syncBlue(p => ({ ...p, bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints, bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats, })); syncRed(p => ({ ...p, enabledBomb: rp <= bp })); syncBlue(p => ({ ...p, enabledBomb: bp <= rp })); if (isBomb || 0 === minesFound) changePlayer(); if (isBomb) { syncBombSelected(false); syncRed(p => 'red' === player ? { ...p, haveBomb: false } : p); syncBlue(p => 'blue' === player ? { ...p, haveBomb: false } : p); } }; const makeGameEndIfItEnds = (bluePoints, redPoints, resign = false, leftMines = []) => { const redWins = 25 < redPoints; const blueWins = 25 < bluePoints; if (redWins || blueWins || resign) { sounds.current.won.play(); if (!resign) showOverlay((redWins ? 'Red' : 'Blue') + ' wins the game!', 'Play again!'); showLeftMines(leftMines); syncActivePlayer(false); syncRed(p => ({ ...p, desc: '' })); syncBlue(p => ({ ...p, desc: '' })); } }; const resignProcess = (color, uuid = null) => { const wp = webPlayerRef.current; if (uuid) { setGameUuid(uuid); } showOverlay( color === wp ? 'You have been give up' : 'Your opponent has been resigned', color === wp ? 'You LOSE!' : 'You WIN!', ); endRef.current = true; makeGameEndIfItEnds(0, 0, true); }; const onBombToggle = () => { const next = !bombSelectedRef.current; syncBombSelected(next); if (!next) { setCells(prev => prev.map(r => r.map(c => null !== c.bombTargetArea ? { ...c, bombTargetArea: null } : c))); } }; return ( {children} ); };