Private
Public Access
1
0
Files
MineSeeker/assets/js/mine-seeker/contexts/GameContext.jsx

234 lines
9.2 KiB
React
Raw Normal View History

import React, { createContext, useContext, useRef, useState } from 'react';
import { Howl } from 'howler';
import { IMG, PLAYER_DEF, DESC, patchCells, initCells } from '../constants';
// ── Context ──────────────────────────────────────────────────────────────────
const GameContext = createContext(null);
export const useGame = () => useContext(GameContext);
// ── Provider ─────────────────────────────────────────────────────────────────
export const GameProvider = ({ children }) => {
// Refs that are read inside async callbacks (stay in sync with state via sync* helpers)
const webPlayerRef = useRef(null);
const activePlayerRef = useRef(false);
const bombSelectedRef = useRef(false);
const connectionLostRef = useRef(false);
const redRef = useRef({ ...PLAYER_DEF });
const blueRef = useRef({ ...PLAYER_DEF });
const lastClickedRef = useRef({ red: null, blue: null });
const endRef = useRef(false);
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'] }),
});
// Display state
const [webPlayer, setWebPlayer] = useState(null);
const [activePlayer, setActivePlayer] = useState(false);
const [overlay, setOverlay] = useState(true);
const [overlayTitle, setOverlayTitle] = useState('');
const [overlaySubTitle, setOverlaySubTitle] = useState('');
const [mines, setMines] = useState(51);
const [bombSelected, setBombSelected] = useState(false);
const [foundMines, setFoundMines] = useState(false);
const [red, setRed] = useState({ ...PLAYER_DEF });
const [blue, setBlue] = useState({ ...PLAYER_DEF });
const [cells, setCells] = useState(initCells);
const [gridReady, setGridReady] = useState(false);
const [connectionLost, setConnectionLost] = useState(false);
// ── 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: `${IMG}bg-flag-${player}-outbg.png`, 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: IMG + 'bg-left-mine-outbg.png' }));
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 } = 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: `${IMG}bg-flag-${player}-outbg.png`, 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 }));
}
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 => {
const wp = webPlayerRef.current;
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)));
}
};
// ── Context value ─────────────────────────────────────────────────────────
const value = {
// State (for rendering)
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost,
// Refs (needed by useServerComm for async-safe reads)
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
// Setters needed by useServerComm
setCells, setGridReady,
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
// Game logic called by useServerComm
showOverlay, hideOverlay,
applyRevealedCell, applyStep,
makeGameEndIfItEnds, resignProcess,
// UI action (bomb toggle is pure state, no server call)
onBombToggle,
};
return <GameContext.Provider value={value}>{children}</GameContext.Provider>;
};