diff --git a/assets/css/style.mineseeker.scss b/assets/css/style.mineseeker.scss index f439562..274fe92 100644 --- a/assets/css/style.mineseeker.scss +++ b/assets/css/style.mineseeker.scss @@ -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; +} + diff --git a/assets/js/mine-seeker/components/GameTimer.jsx b/assets/js/mine-seeker/components/GameTimer.jsx new file mode 100644 index 0000000..80bc87a --- /dev/null +++ b/assets/js/mine-seeker/components/GameTimer.jsx @@ -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 ( +
+
+ + {formatTime(redTime)} +
+
+ + {formatTime(blueTime)} +
+
+ ); +}; + +export default GameTimer; diff --git a/assets/js/mine-seeker/components/grid/GridControl.jsx b/assets/js/mine-seeker/components/grid/GridControl.jsx index e08f57d..4c244ae 100644 --- a/assets/js/mine-seeker/components/grid/GridControl.jsx +++ b/assets/js/mine-seeker/components/grid/GridControl.jsx @@ -7,10 +7,11 @@ * file that was distributed with this source code. */ -import React from 'react'; +import React, { Fragment } from 'react'; import { useGame } from '@mine-contexts'; import GridField from './GridField'; import UserControl from '../user/UserControl'; +import GameTimer from '../GameTimer'; import { BOMB_SYMBOLS, bombRadius } from '@mine-utils'; const GridControl = ({ onClick, resign }) => { @@ -40,31 +41,34 @@ const GridControl = ({ onClick, resign }) => { }; return ( -
-
-
-

{overlayTitle}

-

{overlaySubTitle}

+ + +
+
+
+

{overlayTitle}

+

{overlaySubTitle}

+
+
+ +
+
+ {cells.flatMap((row, r) => + row.map((cell, c) => ( + onClick([r, c])} + onMouseEnter={() => handleHover(r, c)} + /> + )), + )} +
- -
-
- {cells.flatMap((row, r) => - row.map((cell, c) => ( - onClick([r, c])} - onMouseEnter={() => handleHover(r, c)} - /> - )), - )} -
-
-
+ ); }; diff --git a/assets/js/mine-seeker/components/grid/GridField.jsx b/assets/js/mine-seeker/components/grid/GridField.jsx index d6a06b0..5c0113d 100644 --- a/assets/js/mine-seeker/components/grid/GridField.jsx +++ b/assets/js/mine-seeker/components/grid/GridField.jsx @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { IMAGES } from '@mine-utils'; const bombSrc = area => { @@ -25,26 +25,47 @@ const GridField = memo(function GridField({ cell, onClick, onMouseEnter }) { + (active ? ' active' : '') + (active && 'm' === currentObj ? ' mine' : '') + ' color-' + currentObj; - - const inner = isNaN(currentImage) - ? ( -
-
-
- ) - : currentImage ?
{currentImage}
: null; - - const bSrc = bombSrc(bombTargetArea); - const showLast = lastClickedRed || lastClickedBlue; - const lastClass = 'field-' + (lastClickedRed ? 'red' : 'blue') + '-last last-clicked'; + const bombSourceString = useMemo(() => bombSrc(bombTargetArea), [bombTargetArea]); return ( -
- - {bSrc && } - {showLast && } +
+ Field of target + {bombSourceString && ( + Field of bomb target + )} + {(lastClickedRed || lastClickedBlue) && ( + Last clicked area + )}
-
{inner}
+
+ {isNaN(currentImage) && ( +
+ +
+
+ )} + {!isNaN(currentImage) && 0 !== currentImage && ( +
+ {currentImage} +
+ )} +
); diff --git a/assets/js/mine-seeker/components/index.js b/assets/js/mine-seeker/components/index.js index 22c7c30..1c4363d 100644 --- a/assets/js/mine-seeker/components/index.js +++ b/assets/js/mine-seeker/components/index.js @@ -8,6 +8,7 @@ */ export { GameBoard } from './GameBoard'; +export { default as GameTimer } from './GameTimer'; export { default as GridControl } from './grid/GridControl'; export { default as GridField } from './grid/GridField'; export { default as User } from './user/User'; diff --git a/assets/js/mine-seeker/hooks/index.js b/assets/js/mine-seeker/hooks/index.js index a3d5cbd..7204fc8 100644 --- a/assets/js/mine-seeker/hooks/index.js +++ b/assets/js/mine-seeker/hooks/index.js @@ -10,4 +10,5 @@ export { default as useGameRefs } from './useGameRefs'; export { default as useGameState } from './useGameState'; export { default as useServerCommunication } from './useServerCommunication'; +export { default as useStepTimer } from './useStepTimer'; diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx index 0e59b55..49581b2 100644 --- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -11,6 +11,7 @@ import React, { useEffect, useRef } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { useGame } from '@mine-contexts'; import { DESC } from '@mine-utils'; +import useStepTimer from './useStepTimer'; /** Handles all server communication: SSE (Mercure), REST calls, and the initialization lifecycle. */ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { @@ -32,6 +33,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const eventSourceRef = useRef(null); const rpcUsersRef = useRef(null); const stepCacheRef = useRef([]); + const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer(); + const isGameRunningRef = useRef(false); + const lastActivePlayerRef = useRef(null); /** REST mutations / queries */ @@ -80,7 +84,6 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { Play w/ me!
) : ''); - 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, active: true, })); + isGameRunningRef.current = true; + lastActivePlayerRef.current = 1; // Blue starts + startNewTurn(); + resetStepTimer(); hideOverlay(); }; @@ -130,6 +137,14 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { if (null === payload.data.resign) { isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(',')); 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); makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); } else { @@ -233,7 +248,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const [r, c] = coords; 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) { stepCacheRef.current.push(dataPack); @@ -251,7 +267,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const clickResign = () => { 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); }; diff --git a/assets/js/mine-seeker/hooks/useStepTimer.jsx b/assets/js/mine-seeker/hooks/useStepTimer.jsx new file mode 100644 index 0000000..4a0f58b --- /dev/null +++ b/assets/js/mine-seeker/hooks/useStepTimer.jsx @@ -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; + diff --git a/src/Migrations/2026/04/Version20260411133016.php b/src/Migrations/2026/04/Version20260411133016.php deleted file mode 100644 index 48f6e18..0000000 --- a/src/Migrations/2026/04/Version20260411133016.php +++ /dev/null @@ -1,82 +0,0 @@ - - * @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"'); - } -}