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++) {