chg: usr: add share button to the overlay when the game ends #4
This commit is contained in:
@@ -471,58 +471,122 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
|
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .share-copy-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
|
background: linear-gradient(to bottom, #236f87 0%, #1a5068 100%);
|
||||||
border: 2px solid #2e7a9a;
|
border: 2px solid #2e7a9a;
|
||||||
color: #e0f4ff;
|
color: #e0f4ff;
|
||||||
font-family: 'Rajdhani', sans-serif;
|
font-family: 'Rajdhani', sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
|
box-shadow: 0 4px 12px rgba(35, 111, 135, 0.25);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: -100%;
|
left: -100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||||
transition: left 0.4s ease;
|
transition: left 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
|
background: linear-gradient(to bottom, #2d8aa8 0%, #236f87 100%);
|
||||||
border-color: #5ba4d4;
|
border-color: #5ba4d4;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
|
box-shadow: 0 8px 24px rgba(35, 111, 135, 0.4);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
left: 100%;
|
left: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.copied {
|
&.copied {
|
||||||
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
|
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
|
||||||
border-color: #2a9e60;
|
border-color: #2a9e60;
|
||||||
color: #a0f0c0;
|
color: #a0f0c0;
|
||||||
box-shadow: 0 4px 12px rgba(26, 104, 68, 0.4);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GridControl
|
<GridControl
|
||||||
|
gameAssoc={gameAssoc}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
resign={resign}
|
resign={resign}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,20 +7,31 @@
|
|||||||
* file that was distributed with this source code.
|
* 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 { 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 GameTimer from '../GameTimer';
|
||||||
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
|
import { BOMB_SYMBOLS, bombRadius } from '@mine-utils';
|
||||||
|
|
||||||
const GridControl = ({ onClick, resign }) => {
|
const GridControl = ({ gameAssoc, onClick, resign }) => {
|
||||||
const {
|
const {
|
||||||
overlay, overlayTitle, overlaySubTitle,
|
overlay, overlayTitle, overlaySubTitle,
|
||||||
webPlayer, activePlayer, bombSelected,
|
webPlayer, activePlayer, bombSelected,
|
||||||
cells, setCells,
|
cells, setCells, endRef,
|
||||||
} = useGame();
|
} = 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) => {
|
const handleHover = (row, col) => {
|
||||||
if (!bombSelected) return;
|
if (!bombSelected) return;
|
||||||
const activeColor = activePlayer ? 'blue' : 'red';
|
const activeColor = activePlayer ? 'blue' : 'red';
|
||||||
@@ -47,7 +58,22 @@ const GridControl = ({ onClick, resign }) => {
|
|||||||
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
|
<div className={`game-overlay${overlay ? '' : ' hide'}`}>
|
||||||
<div className="game-overlay-window">
|
<div className="game-overlay-window">
|
||||||
<h1>{overlayTitle}</h1>
|
<h1>{overlayTitle}</h1>
|
||||||
<h2>{overlaySubTitle}</h2>
|
{'string' === typeof overlaySubTitle ? (
|
||||||
|
<h2>{overlaySubTitle}</h2>
|
||||||
|
) : (
|
||||||
|
overlaySubTitle
|
||||||
|
)}
|
||||||
|
{gameAssoc && endRef.current && (
|
||||||
|
<button
|
||||||
|
className={`game-overlay-share${copied ? ' copied' : ''}`}
|
||||||
|
onClick={handleShare}
|
||||||
|
title="Copy share link"
|
||||||
|
aria-label="Copy share link"
|
||||||
|
>
|
||||||
|
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
||||||
|
{copied ? 'Copied!' : 'Share Battle'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UserControl
|
<UserControl
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export const GameProvider = ({ children }) => {
|
|||||||
connectionLost, setConnectionLost,
|
connectionLost, setConnectionLost,
|
||||||
} = useGameState();
|
} = useGameState();
|
||||||
|
|
||||||
|
const [gameUuid, setGameUuid] = React.useState(null);
|
||||||
|
|
||||||
const sounds = useRef({
|
const sounds = useRef({
|
||||||
click: new Howl({ src: ['/sound/click.mp3'] }),
|
click: new Howl({ src: ['/sound/click.mp3'] }),
|
||||||
bomb: new Howl({ src: ['/sound/bomb.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;
|
const wp = webPlayerRef.current;
|
||||||
|
if (uuid) {
|
||||||
|
setGameUuid(uuid);
|
||||||
|
}
|
||||||
showOverlay(
|
showOverlay(
|
||||||
color === wp ? 'You have been give up' : 'Your opponent has been resigned',
|
color === wp ? 'You have been give up' : 'Your opponent has been resigned',
|
||||||
color === wp ? 'You LOSE!' : 'You WIN!',
|
color === wp ? 'You LOSE!' : 'You WIN!',
|
||||||
@@ -225,9 +230,9 @@ export const GameProvider = ({ children }) => {
|
|||||||
value={{
|
value={{
|
||||||
// State (for rendering)
|
// State (for rendering)
|
||||||
webPlayer, activePlayer, overlay, overlayTitle, overlaySubTitle,
|
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
|
// Setters needed by useServerComm
|
||||||
setCells, setGridReady,
|
setCells, setGridReady, setGameUuid,
|
||||||
// Refs (needed by useServerComm for async-safe reads)
|
// Refs (needed by useServerComm for async-safe reads)
|
||||||
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
||||||
// Sync helpers
|
// Sync helpers
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
/** Async-safe refs */
|
/** Async-safe refs */
|
||||||
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
||||||
/** State setters */
|
/** State setters */
|
||||||
setGridReady,
|
setGridReady, setGameUuid,
|
||||||
/** Sync helpers */
|
/** Sync helpers */
|
||||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||||
/** Game logic */
|
/** Game logic */
|
||||||
@@ -193,9 +193,12 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyStep(payload.data);
|
applyStep(payload.data);
|
||||||
|
if (payload.data.uuid && !endRef.current) {
|
||||||
|
setGameUuid(payload.data.uuid);
|
||||||
|
}
|
||||||
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
|
makeGameEndIfItEnds(payload.data.bluePoints, payload.data.redPoints, false, payload.data.leftMines);
|
||||||
} else {
|
} else {
|
||||||
resignProcess(payload.data.resign);
|
resignProcess(payload.data.resign, payload.data.uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -312,6 +315,9 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
try {
|
try {
|
||||||
const result = await stepMutation.mutateAsync(dataPack);
|
const result = await stepMutation.mutateAsync(dataPack);
|
||||||
applyStep(result);
|
applyStep(result);
|
||||||
|
if (result.uuid && !endRef.current) {
|
||||||
|
setGameUuid(result.uuid);
|
||||||
|
}
|
||||||
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
|
makeGameEndIfItEnds(result.bluePoints, result.redPoints, false, result.leftMines);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isEnvDev && console.error('Step error', e);
|
isEnvDev && console.error('Step error', e);
|
||||||
@@ -321,8 +327,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
const clickResign = () => {
|
const clickResign = () => {
|
||||||
const color = activePlayerRef.current ? 'blue' : 'red';
|
const color = activePlayerRef.current ? 'blue' : 'red';
|
||||||
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
|
const stepElapsed = getStepElapsed(activePlayerRef.current, isGameRunningRef.current);
|
||||||
stepMutation.mutate({ resign: color, stepElapsed });
|
stepMutation.mutate(
|
||||||
resignProcess(webPlayerRef.current);
|
{ resign: color, stepElapsed },
|
||||||
|
{
|
||||||
|
onSuccess: result => {
|
||||||
|
if (result?.uuid && !endRef.current) {
|
||||||
|
resignProcess(webPlayerRef.current, result.uuid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resign = () => {
|
const resign = () => {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Exception;
|
use Exception;
|
||||||
use JsonException;
|
use JsonException;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Random\RandomException;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class RpcManager
|
* Class RpcManager
|
||||||
@@ -34,9 +36,9 @@ use RuntimeException;
|
|||||||
*/
|
*/
|
||||||
class RpcManager implements RpcManagerInterface
|
class RpcManager implements RpcManagerInterface
|
||||||
{
|
{
|
||||||
private const ROWS = 16;
|
private const int ROWS = 16;
|
||||||
private const COLS = 16;
|
private const int COLS = 16;
|
||||||
private const MINES = 51;
|
private const int MINES = 51;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
@@ -99,6 +101,7 @@ class RpcManager implements RpcManagerInterface
|
|||||||
$this->entityManager->persist($grid);
|
$this->entityManager->persist($grid);
|
||||||
|
|
||||||
$playedGame->setGameAssoc($gameAssoc);
|
$playedGame->setGameAssoc($gameAssoc);
|
||||||
|
$playedGame->setUuid(Uuid::fromString($gameAssoc));
|
||||||
$playedGame->setGrid($grid);
|
$playedGame->setGrid($grid);
|
||||||
$playedGame->setCreated(new DateTime());
|
$playedGame->setCreated(new DateTime());
|
||||||
$playedGame->setUpdated(new DateTime());
|
$playedGame->setUpdated(new DateTime());
|
||||||
@@ -117,25 +120,32 @@ class RpcManager implements RpcManagerInterface
|
|||||||
*/
|
*/
|
||||||
private function generateGrid(): array
|
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(
|
$set = array_merge(
|
||||||
array_fill(0, self::MINES, 'm'),
|
array_fill(0, self::MINES, 'm'),
|
||||||
array_fill(0, self::ROWS * self::COLS - self::MINES, 'w'),
|
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--) {
|
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]];
|
[$set[$i], $set[$j]] = [$set[$j], $set[$i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reshape to 2-D
|
/** Reshape to 2-D */
|
||||||
$grid = [];
|
$grid = [];
|
||||||
for ($r = 0; $r < self::ROWS; $r++) {
|
for ($r = 0; $r < self::ROWS; $r++) {
|
||||||
$grid[$r] = array_slice($set, $r * self::COLS, self::COLS);
|
$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]];
|
$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 ($r = 0; $r < self::ROWS; $r++) {
|
||||||
for ($c = 0; $c < self::COLS; $c++) {
|
for ($c = 0; $c < self::COLS; $c++) {
|
||||||
|
|||||||
Reference in New Issue
Block a user