diff --git a/assets/css/mineseeker/_overlay.scss b/assets/css/mineseeker/_overlay.scss index 50fa80d..a9f1e94 100644 --- a/assets/css/mineseeker/_overlay.scss +++ b/assets/css/mineseeker/_overlay.scss @@ -471,58 +471,122 @@ } #mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 9px; - background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%); - border: 2px solid #2e7a9a; - color: #e0f4ff; - font-family: 'Rajdhani', sans-serif; - font-size: 13px; - font-weight: 800; - letter-spacing: 1px; - text-transform: uppercase; - padding: 12px 24px; - border-radius: 8px; - cursor: pointer; - transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1); - width: 100%; - position: relative; - overflow: hidden; - box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%); + border: 2px solid #2e7a9a; + color: #e0f4ff; + font-family: 'Rajdhani', sans-serif; + font-size: 13px; + font-weight: 800; + letter-spacing: 1px; + text-transform: uppercase; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1); + width: 100%; + position: relative; + overflow: hidden; + box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25); - &::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); - transition: left 0.4s ease; - } + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.4s ease; + } - &:hover { - background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%); - border-color: #5ba4d4; - color: #fff; - box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4); - transform: translateY(-2px); + &:hover { + background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%); + border-color: #5ba4d4; + color: #fff; + box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4); + transform: translateY(-2px); - &::before { - left: 100%; - } - } + &::before { + left: 100%; + } + } - &:active { - transform: translateY(0); - } + &:active { + transform: translateY(0); + } - &.copied { - background: linear-gradient(to bottom, #1a6844 0%, #135233 100%); - border-color: #2a9e60; - color: #a0f0c0; - box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4); - } + &.copied { + background: linear-gradient(to bottom, #1a6844 0%, #135233 100%); + border-color: #2a9e60; + color: #a0f0c0; + box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4); + } } + +#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%); + border: 2px solid #2e7a9a; + color: #e0f4ff; + font-family: 'Rajdhani', sans-serif; + font-size: 13px; + font-weight: 800; + letter-spacing: 1px; + text-transform: uppercase; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1); + width: 100%; + margin-top: 20px; + position: relative; + overflow: hidden; + box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25); + z-index: 10; + + &::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.4s ease; + } + + &:hover { + background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%); + border-color: #5ba4d4; + color: #fff; + box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4); + transform: translateY(-2px); + + &::before { + left: 100%; + } + } + + &:active { + transform: translateY(0); + } + + &.copied { + background: linear-gradient(to bottom, #1a6844 0%, #135233 100%); + border-color: #2a9e60; + color: #a0f0c0; + box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4); + } + + i { + font-size: 15px; + } +} + diff --git a/assets/js/mine-seeker/components/GameBoard.jsx b/assets/js/mine-seeker/components/GameBoard.jsx index a7dd837..216f2f7 100644 --- a/assets/js/mine-seeker/components/GameBoard.jsx +++ b/assets/js/mine-seeker/components/GameBoard.jsx @@ -26,6 +26,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => { return ( diff --git a/assets/js/mine-seeker/components/grid/GridControl.jsx b/assets/js/mine-seeker/components/grid/GridControl.jsx index 4c244ae..c76c5cd 100644 --- a/assets/js/mine-seeker/components/grid/GridControl.jsx +++ b/assets/js/mine-seeker/components/grid/GridControl.jsx @@ -7,20 +7,31 @@ * file that was distributed with this source code. */ -import React, { Fragment } from 'react'; +import React, { Fragment, useState } 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 }) => { +const GridControl = ({ gameAssoc, onClick, resign }) => { const { overlay, overlayTitle, overlaySubTitle, webPlayer, activePlayer, bombSelected, - cells, setCells, + cells, setCells, endRef, } = useGame(); + const [copied, setCopied] = useState(false); + const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null; + + const handleShare = () => { + if (!shareUrl) return; + navigator.clipboard.writeText(shareUrl).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2200); + }); + }; + const handleHover = (row, col) => { if (!bombSelected) return; const activeColor = activePlayer ? 'blue' : 'red'; @@ -47,7 +58,22 @@ const GridControl = ({ onClick, resign }) => {

{overlayTitle}

-

{overlaySubTitle}

+ {'string' === typeof overlaySubTitle ? ( +

{overlaySubTitle}

+ ) : ( + overlaySubTitle + )} + {gameAssoc && endRef.current && ( + + )}
{ 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'] }), @@ -202,8 +204,11 @@ export const GameProvider = ({ children }) => { } }; - const resignProcess = color => { + 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!', @@ -225,9 +230,9 @@ export const GameProvider = ({ children }) => { value={{ // State (for rendering) webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle, - mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, + mines, bombSelected, foundMines, red, blue, cells, gridReady, connectionLost, gameUuid, // Setters needed by useServerComm - setCells, setGridReady, + setCells, setGridReady, setGameUuid, // Refs (needed by useServerComm for async-safe reads) webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, // Sync helpers diff --git a/assets/js/mine-seeker/hooks/useServerCommunication.jsx b/assets/js/mine-seeker/hooks/useServerCommunication.jsx index d52763f..cd35913 100644 --- a/assets/js/mine-seeker/hooks/useServerCommunication.jsx +++ b/assets/js/mine-seeker/hooks/useServerCommunication.jsx @@ -20,7 +20,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { /** Async-safe refs */ webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef, /** State setters */ - setGridReady, + setGridReady, setGameUuid, /** Sync helpers */ syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue, /** Game logic */ @@ -193,9 +193,12 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { } applyStep(payload.data); + if (payload.data.uuid && !endRef.current) { + setGameUuid(payload.data.uuid); + } makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines); } else { - resignProcess(payload.data.resign); + resignProcess(payload.data.resign, payload.data.uuid); } } }; @@ -312,6 +315,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { try { const result = await stepMutation.mutateAsync(dataPack); applyStep(result); + if (result.uuid && !endRef.current) { + setGameUuid(result.uuid); + } makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines); } catch (e) { isEnvDev && console.error('Step error', e); @@ -321,8 +327,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => { const clickResign = () => { const color = activePlayerRef.current ? 'blue' : 'red'; const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current); - stepMutation.mutate({ resign: color, stepElapsed }); - resignProcess(webPlayerRef.current); + stepMutation.mutate( + { resign: color, stepElapsed }, + { + onSuccess: result => { + if (result?.uuid && !endRef.current) { + resignProcess(webPlayerRef.current, result.uuid); + } + }, + } + ); }; const resign = () => { diff --git a/src/Util/RpcManager.php b/src/Util/RpcManager.php index 96fb6b9..32a24af 100644 --- a/src/Util/RpcManager.php +++ b/src/Util/RpcManager.php @@ -20,7 +20,9 @@ use Doctrine\ORM\EntityManagerInterface; use Exception; use JsonException; use Psr\Log\LoggerInterface; +use Random\RandomException; use RuntimeException; +use Symfony\Component\Uid\Uuid; /** * Class RpcManager @@ -34,9 +36,9 @@ use RuntimeException; */ class RpcManager implements RpcManagerInterface { - private const ROWS = 16; - private const COLS = 16; - private const MINES = 51; + private const int ROWS = 16; + private const int COLS = 16; + private const int MINES = 51; public function __construct( private readonly EntityManagerInterface $entityManager, @@ -99,6 +101,7 @@ class RpcManager implements RpcManagerInterface $this->entityManager->persist($grid); $playedGame->setGameAssoc($gameAssoc); + $playedGame->setUuid(Uuid::fromString($gameAssoc)); $playedGame->setGrid($grid); $playedGame->setCreated(new DateTime()); $playedGame->setUpdated(new DateTime()); @@ -117,25 +120,32 @@ class RpcManager implements RpcManagerInterface */ private function generateGrid(): array { - // Build flat set: 51 mines ('m') + remaining water ('w') + /** Build flat set: 51 mines ('m') + remaining water ('w') */ $set = array_merge( array_fill(0, self::MINES, 'm'), array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'), ); - // Fisher-Yates shuffle + /** + * Fisher-Yates shuffle + * @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle + */ for ($i = count($set) - 1; $i > 0; $i--) { - $j = random_int(0, $i); + try { + $j = random_int(0, $i); + } catch (RandomException $e) { + throw new RuntimeException('Failed to generate random index: ' . $e->getMessage()); + } [$set[$i], $set[$j]] = [$set[$j], $set[$i]]; } - // Reshape to 2-D + /** Reshape to 2-D */ $grid = []; for ($r = 0; $r < self::ROWS; $r++) { $grid[$r] = array_slice($set, $r * self::COLS, self::COLS); } - // Replace 'w' with adjacent-mine count + /** Replace 'w' with adjacent-mine count */ $dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]]; for ($r = 0; $r < self::ROWS; $r++) { for ($c = 0; $c < self::COLS; $c++) {