Merge branch 'master' of https://source.splendidbear.org/SplendidBear-Websites/Mine
This commit is contained in:
@@ -1027,3 +1027,78 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Game Timer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#mine-wrapper .game-timer-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .game-timer {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 115px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red – waiting
|
||||||
|
#mine-wrapper .game-timer.red-timer {
|
||||||
|
background: linear-gradient(to bottom, #4a0603 0%, #6b2515 100%);
|
||||||
|
border-color: #7a1e10;
|
||||||
|
color: rgba(246, 125, 82, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red – active (thinking)
|
||||||
|
#mine-wrapper .game-timer.red-timer.active {
|
||||||
|
background: linear-gradient(to bottom, #ad0a05 0%, #f67d52 100%);
|
||||||
|
border-color: #ff9b6b;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 16px rgba(173, 10, 5, 0.75), 0 0 5px rgba(246, 125, 82, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blue – waiting
|
||||||
|
#mine-wrapper .game-timer.blue-timer {
|
||||||
|
background: linear-gradient(to bottom, #0b2530 0%, #163d55 100%);
|
||||||
|
border-color: #173650;
|
||||||
|
color: rgba(149, 207, 245, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blue – active (thinking)
|
||||||
|
#mine-wrapper .game-timer.blue-timer.active {
|
||||||
|
background: linear-gradient(to bottom, #236f87 0%, #95cff5 100%);
|
||||||
|
border-color: #b8e5ff;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 16px rgba(35, 111, 135, 0.75), 0 0 5px rgba(149, 207, 245, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .game-timer .timer-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .game-timer.active .timer-icon {
|
||||||
|
opacity: 1;
|
||||||
|
animation: timer-icon-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes timer-icon-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 0.85; }
|
||||||
|
50% { transform: scale(1.2); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .game-timer .timer-display {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
165
assets/js/mine-seeker/components/GameTimer.jsx
Normal file
165
assets/js/mine-seeker/components/GameTimer.jsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 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, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useGame } from '@mine-contexts';
|
||||||
|
|
||||||
|
const GameTimer = () => {
|
||||||
|
const { overlay, connectionLost, endRef, activePlayer, webPlayer } = useGame();
|
||||||
|
const [redTime, setRedTime] = useState(0);
|
||||||
|
const [blueTime, setBlueTime] = useState(0);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const timerIntervalRef = useRef(null);
|
||||||
|
const gameStartedRef = useRef(false);
|
||||||
|
|
||||||
|
// Use timestamps instead of counters for more reliable background tracking
|
||||||
|
const redStartTimeRef = useRef(null);
|
||||||
|
const blueStartTimeRef = useRef(null);
|
||||||
|
const lastActivePlayerRef = useRef(null);
|
||||||
|
const pausedRedTimeRef = useRef(0);
|
||||||
|
const pausedBlueTimeRef = useRef(0);
|
||||||
|
|
||||||
|
// Start timer when overlay is hidden (both players connected and game started)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!overlay && !gameStartedRef.current) {
|
||||||
|
gameStartedRef.current = true;
|
||||||
|
setIsRunning(true);
|
||||||
|
setRedTime(0);
|
||||||
|
setBlueTime(0);
|
||||||
|
redStartTimeRef.current = Date.now();
|
||||||
|
blueStartTimeRef.current = Date.now();
|
||||||
|
pausedRedTimeRef.current = 0;
|
||||||
|
pausedBlueTimeRef.current = 0;
|
||||||
|
lastActivePlayerRef.current = activePlayer;
|
||||||
|
}
|
||||||
|
}, [overlay]);
|
||||||
|
|
||||||
|
// Stop timer on game end (resign/win)
|
||||||
|
useEffect(() => {
|
||||||
|
if (endRef.current) {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
}, [endRef.current]);
|
||||||
|
|
||||||
|
// Stop timer on connection loss
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionLost) {
|
||||||
|
setIsRunning(false);
|
||||||
|
}
|
||||||
|
}, [connectionLost]);
|
||||||
|
|
||||||
|
// Handle player switch - pause one timer, resume the other
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRunning) return;
|
||||||
|
|
||||||
|
if (lastActivePlayerRef.current !== activePlayer) {
|
||||||
|
// Player switched, save current accumulated time for whoever was active
|
||||||
|
const startRef = lastActivePlayerRef.current ? blueStartTimeRef.current : redStartTimeRef.current;
|
||||||
|
if (startRef) {
|
||||||
|
const elapsed = Math.floor((Date.now() - startRef) / 1000);
|
||||||
|
if (lastActivePlayerRef.current) {
|
||||||
|
pausedBlueTimeRef.current += elapsed;
|
||||||
|
} else {
|
||||||
|
pausedRedTimeRef.current += elapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the new active player's timer
|
||||||
|
if (activePlayer) {
|
||||||
|
blueStartTimeRef.current = Date.now();
|
||||||
|
} else {
|
||||||
|
redStartTimeRef.current = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastActivePlayerRef.current = activePlayer;
|
||||||
|
}
|
||||||
|
}, [activePlayer, isRunning]);
|
||||||
|
|
||||||
|
// Main timer effect - update display every 100ms
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRunning) {
|
||||||
|
if (timerIntervalRef.current) {
|
||||||
|
clearInterval(timerIntervalRef.current);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timerIntervalRef.current = setInterval(() => {
|
||||||
|
let currentRedTime = pausedRedTimeRef.current;
|
||||||
|
let currentBlueTime = pausedBlueTimeRef.current;
|
||||||
|
|
||||||
|
// Add elapsed time for the active player
|
||||||
|
if (!activePlayer && redStartTimeRef.current) {
|
||||||
|
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
|
||||||
|
} else if (activePlayer && blueStartTimeRef.current) {
|
||||||
|
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRedTime(currentRedTime);
|
||||||
|
setBlueTime(currentBlueTime);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerIntervalRef.current) {
|
||||||
|
clearInterval(timerIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isRunning, activePlayer]);
|
||||||
|
|
||||||
|
// Handle focus/blur to synchronize timer when tab regains focus
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFocus = () => {
|
||||||
|
// Force update when tab regains focus to sync any background drift
|
||||||
|
if (isRunning) {
|
||||||
|
let currentRedTime = pausedRedTimeRef.current;
|
||||||
|
let currentBlueTime = pausedBlueTimeRef.current;
|
||||||
|
|
||||||
|
if (!activePlayer && redStartTimeRef.current) {
|
||||||
|
currentRedTime += Math.floor((Date.now() - redStartTimeRef.current) / 1000);
|
||||||
|
} else if (activePlayer && blueStartTimeRef.current) {
|
||||||
|
currentBlueTime += Math.floor((Date.now() - blueStartTimeRef.current) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRedTime(currentRedTime);
|
||||||
|
setBlueTime(currentBlueTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('focus', handleFocus);
|
||||||
|
return () => window.removeEventListener('focus', handleFocus);
|
||||||
|
}, [isRunning, activePlayer]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (timerIntervalRef.current) {
|
||||||
|
clearInterval(timerIntervalRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = seconds => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="game-timer-container">
|
||||||
|
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
|
||||||
|
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
|
||||||
|
<span className="timer-display">{formatTime(redTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`game-timer blue-timer ${activePlayer ? 'active' : ''}`}>
|
||||||
|
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
|
||||||
|
<span className="timer-display">{formatTime(blueTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameTimer;
|
||||||
@@ -7,10 +7,11 @@
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { Fragment } from 'react';
|
||||||
import { useGame } from '@mine-contexts';
|
import { useGame } from '@mine-contexts';
|
||||||
import GridField from './GridField';
|
import GridField from './GridField';
|
||||||
import UserControl from '../user/UserControl';
|
import UserControl from '../user/UserControl';
|
||||||
|
import GameTimer from '../GameTimer';
|
||||||
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
|
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
|
||||||
|
|
||||||
const GridControl = ({ onClick, resign }) => {
|
const GridControl = ({ onClick, resign }) => {
|
||||||
@@ -40,6 +41,8 @@ const GridControl = ({ onClick, resign }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<GameTimer />
|
||||||
<div className="game-wrapper">
|
<div className="game-wrapper">
|
||||||
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
|
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
|
||||||
<div className="game-overlay-window">
|
<div className="game-overlay-window">
|
||||||
@@ -65,6 +68,7 @@ const GridControl = ({ onClick, resign }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo } from 'react';
|
import React, { memo, useMemo } from 'react';
|
||||||
import { IMAGES } from '@mine-utils';
|
import { IMAGES } from '@mine-utils';
|
||||||
|
|
||||||
const bombSrc = area => {
|
const bombSrc = area => {
|
||||||
@@ -25,26 +25,47 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) {
|
|||||||
+ (active ? ' active' : '')
|
+ (active ? ' active' : '')
|
||||||
+ (active && 'm' === currentObj ? ' mine' : '')
|
+ (active && 'm' === currentObj ? ' mine' : '')
|
||||||
+ ' color-' + currentObj;
|
+ ' color-' + currentObj;
|
||||||
|
const bombSourceString = useMemo(() => bombSrc(bombTargetArea), [bombTargetArea]);
|
||||||
const inner = isNaN(currentImage)
|
|
||||||
? (
|
|
||||||
<div className="flag-mine"><img src={currentImage} alt="" />
|
|
||||||
<div className="flag-mine-base" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
: currentImage ? <div className="flag-number">{currentImage}</div> : null;
|
|
||||||
|
|
||||||
const bSrc = bombSrc(bombTargetArea);
|
|
||||||
const showLast = lastClickedRed || lastClickedBlue;
|
|
||||||
const lastClass = 'field-' + (lastClickedRed ? 'red' : 'blue') + '-last last-clicked';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="field-wrapper" onClick={onClick} onMouseEnter={onMouseEnter}>
|
<div
|
||||||
<img className="field-target" src={IMAGES.target} alt="" />
|
className="field-wrapper"
|
||||||
{bSrc && <img className="field-bomb-target" src={bSrc} alt="" />}
|
onClick={onClick}
|
||||||
{showLast && <img className={lastClass} src={IMAGES.last(lastClickedRed ? 'red' : 'blue')} alt="" />}
|
onMouseEnter={onMouseEnter}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="field-target"
|
||||||
|
src={IMAGES.target}
|
||||||
|
alt="Field of target"
|
||||||
|
/>
|
||||||
|
{bombSourceString && (
|
||||||
|
<img
|
||||||
|
className="field-bomb-target"
|
||||||
|
src={bombSourceString}
|
||||||
|
alt="Field of bomb target"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(lastClickedRed || lastClickedBlue) && (
|
||||||
|
<img
|
||||||
|
className={`field-${lastClickedRed ? 'red' : 'blue'}-last last-clicked`}
|
||||||
|
src={IMAGES.last(lastClickedRed ? 'red' : 'blue')}
|
||||||
|
alt="Last clicked area"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
<div className="field-corner">{inner}</div>
|
<div className="field-corner">
|
||||||
|
{isNaN(currentImage) && (
|
||||||
|
<div className="flag-mine">
|
||||||
|
<img src={currentImage} alt="" />
|
||||||
|
<div className="flag-mine-base" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isNaN(currentImage) && 0 !== currentImage && (
|
||||||
|
<div className="flag-number">
|
||||||
|
{currentImage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { GameBoard } from './GameBoard';
|
export { GameBoard } from './GameBoard';
|
||||||
|
export { default as GameTimer } from './GameTimer';
|
||||||
export { default as GridControl } from './grid/GridControl';
|
export { default as GridControl } from './grid/GridControl';
|
||||||
export { default as GridField } from './grid/GridField';
|
export { default as GridField } from './grid/GridField';
|
||||||
export { default as User } from './user/User';
|
export { default as User } from './user/User';
|
||||||
|
|||||||
@@ -10,4 +10,5 @@
|
|||||||
export { default as useGameRefs } from './useGameRefs';
|
export { default as useGameRefs } from './useGameRefs';
|
||||||
export { default as useGameState } from './useGameState';
|
export { default as useGameState } from './useGameState';
|
||||||
export { default as useServerCommunication } from './useServerCommunication';
|
export { default as useServerCommunication } from './useServerCommunication';
|
||||||
|
export { default as useStepTimer } from './useStepTimer';
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import React, { useEffect, useRef } from 'react';
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useGame } from '@mine-contexts';
|
import { useGame } from '@mine-contexts';
|
||||||
import { DESC } from '@mine-utils';
|
import { DESC } from '@mine-utils';
|
||||||
|
import useStepTimer from './useStepTimer';
|
||||||
|
|
||||||
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */
|
/** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */
|
||||||
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
||||||
@@ -32,6 +33,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
const eventSourceRef = useRef(null);
|
const eventSourceRef = useRef(null);
|
||||||
const rpcUsersRef = useRef(null);
|
const rpcUsersRef = useRef(null);
|
||||||
const stepCacheRef = useRef([]);
|
const stepCacheRef = useRef([]);
|
||||||
|
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
|
||||||
|
const isGameRunningRef = useRef(false);
|
||||||
|
const lastActivePlayerRef = useRef(null);
|
||||||
|
|
||||||
/** REST mutations / queries */
|
/** REST mutations / queries */
|
||||||
|
|
||||||
@@ -80,7 +84,6 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
<a href={`/play/${gameAssoc}`} target="_blank">Play w/ me!</a>
|
<a href={`/play/${gameAssoc}`} target="_blank">Play w/ me!</a>
|
||||||
</div>
|
</div>
|
||||||
) : '');
|
) : '');
|
||||||
|
|
||||||
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
|
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,6 +99,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy,
|
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy,
|
||||||
active: true,
|
active: true,
|
||||||
}));
|
}));
|
||||||
|
isGameRunningRef.current = true;
|
||||||
|
lastActivePlayerRef.current = 1; // Blue starts
|
||||||
|
startNewTurn();
|
||||||
|
resetStepTimer();
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +137,14 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
if (null === payload.data.resign) {
|
if (null === payload.data.resign) {
|
||||||
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
|
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
|
||||||
syncBombSelected(payload.data.bomb);
|
syncBombSelected(payload.data.bomb);
|
||||||
|
|
||||||
|
// Detect if turn switched (other player made a move)
|
||||||
|
// After their move, it's now our turn (or the opposite player's turn)
|
||||||
|
if (lastActivePlayerRef.current !== activePlayerRef.current) {
|
||||||
|
startNewTurn();
|
||||||
|
lastActivePlayerRef.current = activePlayerRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
applyStep(payload.data);
|
applyStep(payload.data);
|
||||||
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
|
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
|
||||||
} else {
|
} else {
|
||||||
@@ -233,7 +248,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
const [r, c] = coords;
|
const [r, c] = coords;
|
||||||
if (cells[r]?.[c]?.active) return;
|
if (cells[r]?.[c]?.active) return;
|
||||||
|
|
||||||
const dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null };
|
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
|
||||||
|
const dataPack = { coords, player: activeColor, bomb: bombSelectedRef.current, resign: null, stepElapsed };
|
||||||
|
|
||||||
if (connectionLostRef.current) {
|
if (connectionLostRef.current) {
|
||||||
stepCacheRef.current.push(dataPack);
|
stepCacheRef.current.push(dataPack);
|
||||||
@@ -251,7 +267,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
|
|
||||||
const clickResign = () => {
|
const clickResign = () => {
|
||||||
const color = activePlayerRef.current ? 'blue' : 'red';
|
const color = activePlayerRef.current ? 'blue' : 'red';
|
||||||
stepMutation.mutate({ resign: color });
|
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
|
||||||
|
stepMutation.mutate({ resign: color, stepElapsed });
|
||||||
resignProcess(webPlayerRef.current);
|
resignProcess(webPlayerRef.current);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
53
assets/js/mine-seeker/hooks/useStepTimer.jsx
Normal file
53
assets/js/mine-seeker/hooks/useStepTimer.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 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 { useRef } from 'react';
|
||||||
|
|
||||||
|
const useStepTimer = () => {
|
||||||
|
// Record when the current turn started (timestamp)
|
||||||
|
const turnStartTimeRef = useRef(null);
|
||||||
|
// Flag to track if we've already recorded a turn start
|
||||||
|
const turnStartedRef = useRef(false);
|
||||||
|
|
||||||
|
const getStepElapsed = (currentActivePlayer, isGameRunning) => {
|
||||||
|
// If game not running, return 0
|
||||||
|
if (!isGameRunning) return 0;
|
||||||
|
|
||||||
|
// Only initialize the turn timer ONCE per call to getStepElapsed
|
||||||
|
// This prevents resetting on multiple calls
|
||||||
|
if (!turnStartedRef.current) {
|
||||||
|
turnStartTimeRef.current = Date.now();
|
||||||
|
turnStartedRef.current = true;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After initialization, just calculate elapsed time
|
||||||
|
if (turnStartTimeRef.current) {
|
||||||
|
return Math.floor((Date.now() - turnStartTimeRef.current) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetStepTimer = () => {
|
||||||
|
turnStartTimeRef.current = null;
|
||||||
|
turnStartedRef.current = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call this when we know a turn has actually changed (from server response)
|
||||||
|
const startNewTurn = () => {
|
||||||
|
turnStartTimeRef.current = Date.now();
|
||||||
|
turnStartedRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { getStepElapsed, resetStepTimer, startNewTurn };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStepTimer;
|
||||||
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Migrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class Version20260411133016
|
|
||||||
*
|
|
||||||
* @package App\Migrations
|
|
||||||
* @author Lang <https://www.splendidbear.org>
|
|
||||||
* @category Class
|
|
||||||
* @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License
|
|
||||||
* @link www.splendidbear.org
|
|
||||||
* @since 2026. 04. 11.
|
|
||||||
*/
|
|
||||||
final class Version20260411133016 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('CREATE SEQUENCE gamer_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
|
||||||
$this->addSql('CREATE SEQUENCE grid_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
|
||||||
$this->addSql('CREATE SEQUENCE grid_row_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
|
||||||
$this->addSql('CREATE SEQUENCE played_game_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
|
||||||
$this->addSql('CREATE SEQUENCE step_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
|
||||||
$this->addSql('CREATE SEQUENCE user_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
|
|
||||||
$this->addSql('CREATE TABLE gamer (id INT NOT NULL, user_name VARCHAR(100) NOT NULL, ip VARCHAR(20) DEFAULT NULL, country VARCHAR(100) DEFAULT NULL, user_agent VARCHAR(255) DEFAULT NULL, conn_timestamp TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE TABLE grid (id INT NOT NULL, played_game_id INT DEFAULT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_2E20D9375AA11DBB ON grid (played_game_id)');
|
|
||||||
$this->addSql('CREATE TABLE grid_row (id INT NOT NULL, grid INT DEFAULT NULL, grid_col JSON NOT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE INDEX IDX_6FAD08EB2E20D937 ON grid_row (grid)');
|
|
||||||
$this->addSql('CREATE TABLE played_game (id INT NOT NULL, red_id INT DEFAULT NULL, red_anon INT DEFAULT NULL, blue_id INT DEFAULT NULL, blue_anon INT DEFAULT NULL, game_assoc VARCHAR(50) NOT NULL, red_points INT DEFAULT NULL, blue_points INT DEFAULT NULL, red_exploded_bomb BOOLEAN DEFAULT NULL, blue_exploded_bomb BOOLEAN DEFAULT NULL, resign VARCHAR(7) DEFAULT NULL, created TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE INDEX IDX_54BE80398BBE8922 ON played_game (red_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_54BE8039F24372EB ON played_game (red_anon)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_54BE80395AB9393F ON played_game (blue_id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_54BE8039C64E7C7C ON played_game (blue_anon)');
|
|
||||||
$this->addSql('CREATE TABLE step (id INT NOT NULL, played_game_id INT DEFAULT NULL, row INT NOT NULL, col INT NOT NULL, w_bomb BOOLEAN DEFAULT NULL, player VARCHAR(10) DEFAULT NULL, revealed_cells JSON DEFAULT NULL, created TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE INDEX IDX_43B9FE3C5AA11DBB ON step (played_game_id)');
|
|
||||||
$this->addSql('CREATE TABLE "user" (id INT NOT NULL, username VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) DEFAULT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649F85E0677 ON "user" (username)');
|
|
||||||
$this->addSql('ALTER TABLE grid ADD CONSTRAINT FK_2E20D9375AA11DBB FOREIGN KEY (played_game_id) REFERENCES played_game (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE grid_row ADD CONSTRAINT FK_6FAD08EB2E20D937 FOREIGN KEY (grid) REFERENCES grid (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE played_game ADD CONSTRAINT FK_54BE80398BBE8922 FOREIGN KEY (red_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE played_game ADD CONSTRAINT FK_54BE8039F24372EB FOREIGN KEY (red_anon) REFERENCES gamer (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE played_game ADD CONSTRAINT FK_54BE80395AB9393F FOREIGN KEY (blue_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE played_game ADD CONSTRAINT FK_54BE8039C64E7C7C FOREIGN KEY (blue_anon) REFERENCES gamer (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
$this->addSql('ALTER TABLE step ADD CONSTRAINT FK_43B9FE3C5AA11DBB FOREIGN KEY (played_game_id) REFERENCES played_game (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('DROP SEQUENCE gamer_id_seq CASCADE');
|
|
||||||
$this->addSql('DROP SEQUENCE grid_id_seq CASCADE');
|
|
||||||
$this->addSql('DROP SEQUENCE grid_row_id_seq CASCADE');
|
|
||||||
$this->addSql('DROP SEQUENCE played_game_id_seq CASCADE');
|
|
||||||
$this->addSql('DROP SEQUENCE step_id_seq CASCADE');
|
|
||||||
$this->addSql('DROP SEQUENCE user_id_seq CASCADE');
|
|
||||||
$this->addSql('ALTER TABLE grid DROP CONSTRAINT FK_2E20D9375AA11DBB');
|
|
||||||
$this->addSql('ALTER TABLE grid_row DROP CONSTRAINT FK_6FAD08EB2E20D937');
|
|
||||||
$this->addSql('ALTER TABLE played_game DROP CONSTRAINT FK_54BE80398BBE8922');
|
|
||||||
$this->addSql('ALTER TABLE played_game DROP CONSTRAINT FK_54BE8039F24372EB');
|
|
||||||
$this->addSql('ALTER TABLE played_game DROP CONSTRAINT FK_54BE80395AB9393F');
|
|
||||||
$this->addSql('ALTER TABLE played_game DROP CONSTRAINT FK_54BE8039C64E7C7C');
|
|
||||||
$this->addSql('ALTER TABLE step DROP CONSTRAINT FK_43B9FE3C5AA11DBB');
|
|
||||||
$this->addSql('DROP TABLE gamer');
|
|
||||||
$this->addSql('DROP TABLE grid');
|
|
||||||
$this->addSql('DROP TABLE grid_row');
|
|
||||||
$this->addSql('DROP TABLE played_game');
|
|
||||||
$this->addSql('DROP TABLE step');
|
|
||||||
$this->addSql('DROP TABLE "user"');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user