new: usr: add initialization bonus points' system to the gameplay #5
This commit is contained in:
@@ -287,6 +287,14 @@ git push origin v2026.01
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Game Documentation
|
||||||
|
|
||||||
|
For detailed information about game mechanics, bonus systems, and scoring rules, see the [docs](./docs/) directory:
|
||||||
|
|
||||||
|
- **[Bonus Points System](./docs/game-mechanics/BONUS_POINTS_SYSTEM.md)** — Complete reference for all bonus point types, calculation rules, and implementation details
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
LGPL-3.0 — see [LICENSE](LICENSE) for details.
|
LGPL-3.0 — see [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
242
assets/css/mineseeker/_bonus-box.scss
Normal file
242
assets/css/mineseeker/_bonus-box.scss
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/*!*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#mine-wrapper .bonus-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: #07090d;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
align-self: stretch;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .bonus-box.red-bonus {
|
||||||
|
background: linear-gradient(to bottom, #2a0502 0%, #4a1510 100%);
|
||||||
|
border-color: rgba(246, 125, 82, 0.4);
|
||||||
|
color: rgba(246, 125, 82, 0.85);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(246, 125, 82, 0.85);
|
||||||
|
box-shadow: 0 0 12px rgba(173, 10, 5, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .bonus-box.blue-bonus {
|
||||||
|
background: linear-gradient(to bottom, #050f18 0%, #0f2838 100%);
|
||||||
|
border-color: rgba(149, 207, 245, 0.4);
|
||||||
|
color: rgba(149, 207, 245, 0.85);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(149, 207, 245, 0.85);
|
||||||
|
box-shadow: 0 0 12px rgba(35, 111, 135, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .bonus-box__icon {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .bonus-box__value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: 'Rajdhani', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 22px 14px;
|
||||||
|
border-bottom: 1px solid rgba(35, 111, 135, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-label {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: rgba(149, 207, 245, 0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
color: #f6d572;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(35, 111, 135, 0.4);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(149, 207, 245, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-column {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-column--red {
|
||||||
|
border-color: rgba(246, 125, 82, 0.35);
|
||||||
|
background: linear-gradient(to bottom, rgba(74, 6, 3, 0.35), rgba(107, 37, 21, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-column--blue {
|
||||||
|
border-color: rgba(149, 207, 245, 0.35);
|
||||||
|
background: linear-gradient(to bottom, rgba(11, 37, 48, 0.35), rgba(22, 61, 85, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-column-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-column-name {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-column-total {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f6d572;
|
||||||
|
|
||||||
|
.fa {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stats {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 2px;
|
||||||
|
border-bottom: 1px dashed rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stat-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stat-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stat-value {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 22px 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.bsd-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
@import 'mineseeker/grid';
|
@import 'mineseeker/grid';
|
||||||
@import 'mineseeker/back-button';
|
@import 'mineseeker/back-button';
|
||||||
@import 'mineseeker/timer';
|
@import 'mineseeker/timer';
|
||||||
|
@import 'mineseeker/bonus-box';
|
||||||
@import 'mineseeker/responsive';
|
@import 'mineseeker/responsive';
|
||||||
@import 'mineseeker/waiting-dialog';
|
@import 'mineseeker/waiting-dialog';
|
||||||
|
|||||||
25
assets/js/mine-seeker/components/BonusBox.jsx
Normal file
25
assets/js/mine-seeker/components/BonusBox.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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 from 'react';
|
||||||
|
|
||||||
|
const BonusBox = ({ color, points, onClick, title }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bonus-box ${color}-bonus`}
|
||||||
|
onClick={onClick}
|
||||||
|
title={title || 'View bonus statistics'}
|
||||||
|
aria-label={`${color} bonus points: ${points}`}
|
||||||
|
>
|
||||||
|
<i className="fa fa-star bonus-box__icon" />
|
||||||
|
<span className="bonus-box__value">{points}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BonusBox;
|
||||||
97
assets/js/mine-seeker/components/BonusStatsDialog.jsx
Normal file
97
assets/js/mine-seeker/components/BonusStatsDialog.jsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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 from 'react';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import { BONUS_LABELS } from '@mine-utils';
|
||||||
|
|
||||||
|
const DIALOG_SX = {
|
||||||
|
'& .MuiDialog-paper': {
|
||||||
|
background: '#07090d',
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '46px 46px',
|
||||||
|
border: '1px solid rgba(35, 111, 135, 0.4)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)',
|
||||||
|
width: '560px',
|
||||||
|
maxWidth: '94vw',
|
||||||
|
overflow: 'hidden',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
'& .MuiBackdrop-root': {
|
||||||
|
background: 'rgba(2, 4, 8, 0.88)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPlayerName = name => {
|
||||||
|
if (name && name.startsWith('anon_')) {
|
||||||
|
return 'Anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name && 10 < name.length) {
|
||||||
|
return name.substring(0, 7) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return name || 'Unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
const PlayerColumn = ({ color, player }) => (
|
||||||
|
<div className={`bsd-column bsd-column--${color}`}>
|
||||||
|
<div className="bsd-column-header">
|
||||||
|
<span className="bsd-column-name">{formatPlayerName(player.name)}</span>
|
||||||
|
<span className="bsd-column-total">
|
||||||
|
<i className="fa fa-star" />
|
||||||
|
{player.bonusPoints}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="bsd-stats">
|
||||||
|
{Object.entries(BONUS_LABELS).map(([key, { label, desc }]) => (
|
||||||
|
<li key={key} className="bsd-stat">
|
||||||
|
<div className="bsd-stat-text">
|
||||||
|
<span className="bsd-stat-label">{label}</span>
|
||||||
|
<span className="bsd-stat-desc">{desc}</span>
|
||||||
|
</div>
|
||||||
|
<span className="bsd-stat-value">{player.bonusStats?.[key] ?? 0}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BonusStatsDialog = ({ open, onClose, red, blue }) => (
|
||||||
|
<Dialog open={open} onClose={onClose} sx={DIALOG_SX}>
|
||||||
|
<div className="bsd">
|
||||||
|
<div className="bsd-header">
|
||||||
|
<div className="bsd-header-text">
|
||||||
|
<span className="bsd-label">Scoring</span>
|
||||||
|
<h2 className="bsd-title">
|
||||||
|
<i className="fa fa-star" />
|
||||||
|
Bonus Statistics
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button className="bsd-close" onClick={onClose} aria-label="Close">
|
||||||
|
<i className="fa fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bsd-body">
|
||||||
|
<PlayerColumn color="red" player={red} />
|
||||||
|
<PlayerColumn color="blue" player={blue} />
|
||||||
|
</div>
|
||||||
|
<p className="bsd-note">
|
||||||
|
Bonus points are awarded alongside the main score for skillful play.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BonusStatsDialog;
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useGame } from '@mine-contexts';
|
import { useGame } from '@mine-contexts';
|
||||||
|
import BonusBox from './BonusBox';
|
||||||
|
import BonusStatsDialog from './BonusStatsDialog';
|
||||||
|
|
||||||
const renderAvatar = player => {
|
const renderAvatar = player => {
|
||||||
if (!player.registered) return null;
|
if (!player.registered) return null;
|
||||||
@@ -27,6 +29,7 @@ const GameTimer = () => {
|
|||||||
const [redTime, setRedTime] = useState(0);
|
const [redTime, setRedTime] = useState(0);
|
||||||
const [blueTime, setBlueTime] = useState(0);
|
const [blueTime, setBlueTime] = useState(0);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
|
||||||
const timerIntervalRef = useRef(null);
|
const timerIntervalRef = useRef(null);
|
||||||
const gameStartedRef = useRef(false);
|
const gameStartedRef = useRef(false);
|
||||||
|
|
||||||
@@ -160,8 +163,12 @@ const GameTimer = () => {
|
|||||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBonusDialog = () => setBonusDialogOpen(true);
|
||||||
|
const closeBonusDialog = () => setBonusDialogOpen(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-timer-container">
|
<div className="game-timer-container">
|
||||||
|
<BonusBox color="red" points={red.bonusPoints ?? 0} onClick={openBonusDialog} />
|
||||||
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
|
<div className={`game-timer red-timer ${!activePlayer ? 'active' : ''}`}>
|
||||||
{renderAvatar(red)}
|
{renderAvatar(red)}
|
||||||
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
|
<i className={`fa ${!activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
|
||||||
@@ -172,6 +179,8 @@ const GameTimer = () => {
|
|||||||
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
|
<i className={`fa ${activePlayer ? 'fa-hourglass-half' : 'fa-hourglass-start'} timer-icon`} />
|
||||||
<span className="timer-display">{formatTime(blueTime)}</span>
|
<span className="timer-display">{formatTime(blueTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<BonusBox color="blue" points={blue.bonusPoints ?? 0} onClick={openBonusDialog} />
|
||||||
|
<BonusStatsDialog open={bonusDialogOpen} onClose={closeBonusDialog} red={red} blue={blue} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { Fragment, useState } from 'react';
|
||||||
import { useGame } from '@mine-contexts';
|
import { useGame } from '@mine-contexts';
|
||||||
import User from './User';
|
import User from './User';
|
||||||
|
import BonusStatsDialog from '../BonusStatsDialog';
|
||||||
|
|
||||||
const UserControl = ({ resign }) => {
|
const UserControl = ({ resign }) => {
|
||||||
const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame();
|
const { webPlayer, activePlayer, mines, foundMines, red, blue, onBombToggle } = useGame();
|
||||||
|
const [bonusDialogOpen, setBonusDialogOpen] = useState(false);
|
||||||
const activeColor = activePlayer ? 'blue' : 'red';
|
const activeColor = activePlayer ? 'blue' : 'red';
|
||||||
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
|
const resignClass = 'resign' + (activeColor !== webPlayer ? ' disabled' : '');
|
||||||
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
|
const minesClass = 'active-mines' + (foundMines ? ' found-mine' : '');
|
||||||
@@ -24,30 +26,44 @@ const UserControl = ({ resign }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBonusClick = () => {
|
||||||
|
setBonusDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="users">
|
<Fragment>
|
||||||
<User
|
<div className="users">
|
||||||
color="blue" webPlayer={webPlayer} {...blue}
|
<User
|
||||||
onClickBombSelector={() => handleBombClick('blue', 1)}
|
color="blue" webPlayer={webPlayer} {...blue}
|
||||||
/>
|
onClickBombSelector={() => handleBombClick('blue', 1)}
|
||||||
<div className="active-mines-container">
|
onBonusClick={handleBonusClick}
|
||||||
<i className="fa fa-star" />
|
/>
|
||||||
<div className={minesClass}>
|
<div className="active-mines-container">
|
||||||
<div className="active-mines-nbr">{mines}</div>
|
<i className="fa fa-star" />
|
||||||
<div className="active-mines-shine" />
|
<div className={minesClass}>
|
||||||
|
<div className="active-mines-nbr">{mines}</div>
|
||||||
|
<div className="active-mines-shine" />
|
||||||
|
</div>
|
||||||
|
<i className="fa fa-star" />
|
||||||
</div>
|
</div>
|
||||||
<i className="fa fa-star" />
|
<div className="clear" />
|
||||||
|
<User
|
||||||
|
color="red" webPlayer={webPlayer} {...red}
|
||||||
|
onClickBombSelector={() => handleBombClick('red', 0)}
|
||||||
|
onBonusClick={handleBonusClick}
|
||||||
|
/>
|
||||||
|
<button className={resignClass} onClick={resign}>
|
||||||
|
<div className="resign-shine" />
|
||||||
|
Resign
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="clear" />
|
<BonusStatsDialog
|
||||||
<User
|
open={bonusDialogOpen}
|
||||||
color="red" webPlayer={webPlayer} {...red}
|
onClose={() => setBonusDialogOpen(false)}
|
||||||
onClickBombSelector={() => handleBombClick('red', 0)}
|
red={red}
|
||||||
|
blue={blue}
|
||||||
/>
|
/>
|
||||||
<button className={resignClass} onClick={resign}>
|
</Fragment>
|
||||||
<div className="resign-shine" />
|
|
||||||
Resign
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,18 @@ export const GameProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyStep = stepData => {
|
const applyStep = stepData => {
|
||||||
const { player, bomb: isBomb, minesFound = 0, revealedCells = [], redPoints: rp, bluePoints: bp } = stepData;
|
const {
|
||||||
|
player,
|
||||||
|
bomb: isBomb,
|
||||||
|
minesFound = 0,
|
||||||
|
revealedCells = [],
|
||||||
|
redPoints: rp,
|
||||||
|
bluePoints: bp,
|
||||||
|
redBonusPoints = 0,
|
||||||
|
blueBonusPoints = 0,
|
||||||
|
redBonusStats = {},
|
||||||
|
blueBonusStats = {},
|
||||||
|
} = stepData;
|
||||||
|
|
||||||
if (isBomb) {
|
if (isBomb) {
|
||||||
sounds.current.bomb.play();
|
sounds.current.bomb.play();
|
||||||
@@ -176,6 +187,18 @@ export const GameProvider = ({ children }) => {
|
|||||||
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
|
syncBlue(p => ({ ...p, mines: 'blue' === player ? bp : p.mines }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update bonus points and stats
|
||||||
|
syncRed(p => ({
|
||||||
|
...p,
|
||||||
|
bonusPoints: 'red' === player ? redBonusPoints : p.bonusPoints,
|
||||||
|
bonusStats: 'red' === player ? redBonusStats : p.bonusStats,
|
||||||
|
}));
|
||||||
|
syncBlue(p => ({
|
||||||
|
...p,
|
||||||
|
bonusPoints: 'blue' === player ? blueBonusPoints : p.bonusPoints,
|
||||||
|
bonusStats: 'blue' === player ? blueBonusStats : p.bonusStats,
|
||||||
|
}));
|
||||||
|
|
||||||
syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
|
syncRed(p => ({ ...p, enabledBomb: rp <= bp }));
|
||||||
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
|
syncBlue(p => ({ ...p, enabledBomb: bp <= rp }));
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,23 @@ export const IMAGES = {
|
|||||||
bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`,
|
bombPos: (hor, vert) => `${IMG}bg-bomb-${hor}-${vert}-outbg.png`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const BONUS_STATS_DEF = {
|
||||||
|
blindHits: 0, chainBest: 0, chainCurrent: 0, lastMineHits: 0, edgeMines: 0, biggestReveal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BONUS_LABELS = {
|
||||||
|
blindHits: { label: 'Blind hits', desc: 'Mines clicked with no revealed number nearby' },
|
||||||
|
chainBest: { label: 'Best chain', desc: 'Longest streak of consecutive mine-clicks' },
|
||||||
|
chainCurrent: { label: 'Current chain', desc: 'Active consecutive mine-click streak' },
|
||||||
|
lastMineHits: { label: 'Endgame mines', desc: 'Mines clicked while few remain on the board' },
|
||||||
|
edgeMines: { label: 'Edge mines', desc: 'Mines clicked on the board boundary' },
|
||||||
|
biggestReveal: { label: 'Biggest reveal', desc: 'Largest number of safe cells revealed in one click' },
|
||||||
|
};
|
||||||
|
|
||||||
export const PLAYER_DEF = {
|
export const PLAYER_DEF = {
|
||||||
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
|
name: '...', desc: '', active: false, mines: 0, haveBomb: true, enabledBomb: true,
|
||||||
registered: false, avatar: null,
|
registered: false, avatar: null,
|
||||||
|
bonusPoints: 0, bonusStats: { ...BONUS_STATS_DEF },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DESC = {
|
export const DESC = {
|
||||||
|
|||||||
@@ -7,4 +7,4 @@
|
|||||||
* file that was distributed with this source code.
|
* file that was distributed with this source code.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, bombRadius, initCells, patchCells } from './constants';
|
export { DESC, IMAGES, BOMB_SYMBOLS, PLAYER_DEF, BONUS_STATS_DEF, BONUS_LABELS, bombRadius, initCells, patchCells } from './constants';
|
||||||
|
|||||||
47
docs/README.md
Normal file
47
docs/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Mine-Seeker Game Documentation
|
||||||
|
|
||||||
|
This directory contains comprehensive documentation about the Mine-Seeker game mechanics and implementation.
|
||||||
|
|
||||||
|
## Game Mechanics
|
||||||
|
|
||||||
|
### [Bonus Points System](./game-mechanics/BONUS_POINTS_SYSTEM.md)
|
||||||
|
Complete reference for the bonus points system including:
|
||||||
|
- All 6 bonus point types (Blind Hit, Chain Combo, Edge Mine, Endgame Mine, Safe Cell Bonus, Biggest Reveal)
|
||||||
|
- Calculation rules and examples
|
||||||
|
- Bonus statistics tracking
|
||||||
|
- Player name formatting in dialogs
|
||||||
|
- Database schema
|
||||||
|
- Implementation notes
|
||||||
|
- Testing checklist
|
||||||
|
|
||||||
|
**Recommended for**: Developers working on bonus system, AI assistants implementing or debugging bonus features, understanding game scoring mechanics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Bonus Points at a Glance
|
||||||
|
| Bonus Type | Points | Condition |
|
||||||
|
|-----------|--------|-----------|
|
||||||
|
| Blind Hit | +2 | Mine with no revealed numbered neighbors |
|
||||||
|
| Edge Mine | +1 | Mine on board boundary (row/col 0 or 15) |
|
||||||
|
| Endgame Mine | +3 | Mine clicked when ≤10 mines remain |
|
||||||
|
| Safe Cell | +0.5 each | ≥2 safe cells revealed (min requirement) |
|
||||||
|
| Chain Combo | Tracked | Consecutive mine clicks (no safe clicks) |
|
||||||
|
| Biggest Reveal | Tracked | Largest number of safe cells revealed |
|
||||||
|
|
||||||
|
### Key Rules
|
||||||
|
- Safe cell bonus only awarded for ≥2 cells minimum
|
||||||
|
- Chain counter resets on any safe cell click
|
||||||
|
- Endgame threshold: 51 - (redPoints + bluePoints) ≤ 10
|
||||||
|
- Bonus stats are per-player and persist in database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Using This Information
|
||||||
|
- Backend: `/src/Util/TopicManager.php`
|
||||||
|
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx`
|
||||||
|
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx`
|
||||||
|
- Constants: `/assets/js/mine-seeker/utils/constants.jsx`
|
||||||
|
|
||||||
|
|
||||||
133
docs/game-mechanics/BONUS_POINTS_SYSTEM.md
Normal file
133
docs/game-mechanics/BONUS_POINTS_SYSTEM.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Mine-Seeker Bonus Points System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Mine-Seeker game includes a bonus points system that rewards skilled play. Bonus points are tracked separately from the main score and displayed in the "Bonus Statistics" dialog.
|
||||||
|
|
||||||
|
## Bonus Point Types
|
||||||
|
|
||||||
|
### 1. Blind Hit (+2 points)
|
||||||
|
**When**: Click a mine with no revealed numbered neighbors around it.
|
||||||
|
|
||||||
|
**Example**: Mine surrounded by unrevealed cells = +2 points
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Chain Combo
|
||||||
|
**When**: Click consecutive mines without clicking any safe cell in between.
|
||||||
|
|
||||||
|
**Tracked as**:
|
||||||
|
- `chainCurrent`: Current streak (resets when you click a safe cell)
|
||||||
|
- `chainBest`: Longest streak achieved
|
||||||
|
|
||||||
|
**Example**: Mine → Mine → Mine = chainBest becomes 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Edge Mine (+1 point)
|
||||||
|
**When**: Click a mine on the board boundary (row 0, row 15, col 0, or col 15).
|
||||||
|
|
||||||
|
**Example**: Click a mine on the edge = +1 point
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Endgame Mine (+3 points)
|
||||||
|
**When**: Click a mine when 10 or fewer mines remain on the board.
|
||||||
|
|
||||||
|
**Calculation**: `51 total mines - (red_points + blue_points) = mines_remaining`
|
||||||
|
|
||||||
|
**Example**: When 8 mines remain, click one = +3 points
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Safe Cell Bonus (+0.5 points per cell)
|
||||||
|
**When**: Click a safe cell and reveal 2 or more cells.
|
||||||
|
|
||||||
|
**Important**: Minimum 2 cells required. Single cell reveals = 0 points.
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
- Reveal 1 safe cell = 0 points
|
||||||
|
- Reveal 2 safe cells = 1.0 points
|
||||||
|
- Reveal 11 safe cells = 5.5 points
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Biggest Reveal (Tracking stat)
|
||||||
|
**What**: Tracks the largest number of safe cells revealed in one click.
|
||||||
|
|
||||||
|
**Example**: Largest reveal in a game = 15 cells shown in stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus Statistics Display
|
||||||
|
|
||||||
|
### Dialog Shows
|
||||||
|
- Both players' bonus statistics side-by-side
|
||||||
|
- Each stat with label, description, and value
|
||||||
|
- Total bonus points per player
|
||||||
|
|
||||||
|
### Player Name Rules
|
||||||
|
- `anon_*` usernames → displays as "Anonymous"
|
||||||
|
- Names longer than 10 chars → truncated to 7 chars + "..." (example: `VeryLongName` → `VeryLon...`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tracked Statistics
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
blindHits: 0, // Blind hit mines clicked
|
||||||
|
chainBest: 0, // Longest mine streak
|
||||||
|
chainCurrent: 0, // Current active streak
|
||||||
|
lastMineHits: 0, // Endgame mines clicked
|
||||||
|
edgeMines: 0, // Edge mine clicks
|
||||||
|
biggestReveal: 0 // Largest safe cell reveal
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
- `red_bonus_points` (FLOAT) — Red player's total bonus points
|
||||||
|
- `blue_bonus_points` (FLOAT) — Blue player's total bonus points
|
||||||
|
- `red_bonus_stats` (JSON) — Red player's tracked stats
|
||||||
|
- `blue_bonus_stats` (JSON) — Blue player's tracked stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Documentation Maintenance
|
||||||
|
|
||||||
|
**IMPORTANT**: This documentation must be updated whenever:
|
||||||
|
- New bonus types are added
|
||||||
|
- Point values change
|
||||||
|
- Bonus calculation logic changes
|
||||||
|
- New stats are tracked
|
||||||
|
- Bonus display rules change
|
||||||
|
|
||||||
|
**Update these files**:
|
||||||
|
1. This file (`BONUS_POINTS_SYSTEM.md`) — Update descriptions and examples
|
||||||
|
2. Code comments in `/src/Util/TopicManager.php` — Explain calculation logic
|
||||||
|
3. `/docs/README.md` — Update Quick Reference table if values change
|
||||||
|
|
||||||
|
**Keep documentation**:
|
||||||
|
- ✅ Simple and clear
|
||||||
|
- ✅ With real code examples
|
||||||
|
- ✅ Synchronized with actual code behavior
|
||||||
|
- ✅ Updated before/after feature changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Files
|
||||||
|
- Backend: `/src/Util/TopicManager.php` — Bonus calculation logic
|
||||||
|
- Frontend: `/assets/js/mine-seeker/contexts/GameProvider.jsx` — State sync
|
||||||
|
- UI: `/assets/js/mine-seeker/components/BonusStatsDialog.jsx` — Display dialog
|
||||||
|
- Constants: `/assets/js/mine-seeker/utils/constants.jsx` — Labels and defaults
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Checklist for Changes
|
||||||
|
- [ ] Code changes implemented
|
||||||
|
- [ ] This documentation updated
|
||||||
|
- [ ] `/docs/README.md` Quick Reference table updated
|
||||||
|
- [ ] Code comments added/updated
|
||||||
|
- [ ] Examples updated to match new behavior
|
||||||
|
|
||||||
@@ -62,6 +62,18 @@ class PlayedGame
|
|||||||
#[Column(length: 7, nullable: true)]
|
#[Column(length: 7, nullable: true)]
|
||||||
private ?string $resign = null;
|
private ?string $resign = null;
|
||||||
|
|
||||||
|
#[Column(nullable: true)]
|
||||||
|
private ?float $redBonusPoints = null;
|
||||||
|
|
||||||
|
#[Column(nullable: true)]
|
||||||
|
private ?float $blueBonusPoints = null;
|
||||||
|
|
||||||
|
#[Column(nullable: true)]
|
||||||
|
private ?array $redBonusStats = null;
|
||||||
|
|
||||||
|
#[Column(nullable: true)]
|
||||||
|
private ?array $blueBonusStats = null;
|
||||||
|
|
||||||
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
#[Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
private ?DateTime $created = null;
|
private ?DateTime $created = null;
|
||||||
|
|
||||||
@@ -222,6 +234,46 @@ class PlayedGame
|
|||||||
$this->resign = $resign;
|
$this->resign = $resign;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRedBonusPoints(): ?float
|
||||||
|
{
|
||||||
|
return $this->redBonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRedBonusPoints(?float $redBonusPoints): void
|
||||||
|
{
|
||||||
|
$this->redBonusPoints = $redBonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBlueBonusPoints(): ?float
|
||||||
|
{
|
||||||
|
return $this->blueBonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBlueBonusPoints(?float $blueBonusPoints): void
|
||||||
|
{
|
||||||
|
$this->blueBonusPoints = $blueBonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRedBonusStats(): ?array
|
||||||
|
{
|
||||||
|
return $this->redBonusStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRedBonusStats(?array $redBonusStats): void
|
||||||
|
{
|
||||||
|
$this->redBonusStats = $redBonusStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBlueBonusStats(): ?array
|
||||||
|
{
|
||||||
|
return $this->blueBonusStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBlueBonusStats(?array $blueBonusStats): void
|
||||||
|
{
|
||||||
|
$this->blueBonusStats = $blueBonusStats;
|
||||||
|
}
|
||||||
|
|
||||||
public function getCreated(): ?DateTime
|
public function getCreated(): ?DateTime
|
||||||
{
|
{
|
||||||
return $this->created;
|
return $this->created;
|
||||||
@@ -247,3 +299,5 @@ class PlayedGame
|
|||||||
return $this->steps;
|
return $this->steps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
48
src/Migrations/2026/04/Version20260418104430.php
Normal file
48
src/Migrations/2026/04/Version20260418104430.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Migrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Version20260418104430
|
||||||
|
*
|
||||||
|
* @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. 18.
|
||||||
|
*/
|
||||||
|
final class Version20260418104430 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add bonus stats to the playing experience';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE played_game ADD red_bonus_points DOUBLE PRECISION DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE played_game ADD blue_bonus_points DOUBLE PRECISION DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE played_game ADD red_bonus_stats JSON DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE played_game ADD blue_bonus_stats JSON DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE played_game DROP red_bonus_points');
|
||||||
|
$this->addSql('ALTER TABLE played_game DROP blue_bonus_points');
|
||||||
|
$this->addSql('ALTER TABLE played_game DROP red_bonus_stats');
|
||||||
|
$this->addSql('ALTER TABLE played_game DROP blue_bonus_stats');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,12 +44,12 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
|||||||
readonly class TopicManager implements TopicManagerInterface
|
readonly class TopicManager implements TopicManagerInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
private HubInterface $hub,
|
private HubInterface $hub,
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private LoggerInterface $logger,
|
private LoggerInterface $logger,
|
||||||
|
private CacheManager $cacheManager,
|
||||||
private PlayedGameRepository $playedGameRepository,
|
private PlayedGameRepository $playedGameRepository,
|
||||||
private UserRepository $userRepository,
|
private UserRepository $userRepository,
|
||||||
private CacheManager $cacheManager,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +96,8 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
if ($count === 1) {
|
if ($count === 1) {
|
||||||
// One player waiting — mark as active and announce to the lobby
|
// One player waiting — mark as active and announce to the lobby
|
||||||
$playedGame->setUpdated(new DateTime());
|
$playedGame->setUpdated(new DateTime());
|
||||||
$this->entityManager->persist($playedGame);
|
$this->em->persist($playedGame);
|
||||||
$this->entityManager->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
|
$displayName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
|
||||||
$this->publishToLobby([
|
$this->publishToLobby([
|
||||||
@@ -121,8 +121,8 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
$users = $this->getUserCollection($playedGame);
|
$users = $this->getUserCollection($playedGame);
|
||||||
if ($this->getPlayerCount($users) === 1) {
|
if ($this->getPlayerCount($users) === 1) {
|
||||||
$playedGame->setUpdated(new DateTime('2000-01-01 00:00:00'));
|
$playedGame->setUpdated(new DateTime('2000-01-01 00:00:00'));
|
||||||
$this->entityManager->persist($playedGame);
|
$this->em->persist($playedGame);
|
||||||
$this->entityManager->flush();
|
$this->em->flush();
|
||||||
$this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
|
$this->publishToLobby(['action' => 'leave', 'gameAssoc' => $gameAssoc]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,25 +176,40 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||||
$grid = $this->loadGrid($gameAssoc);
|
$grid = $this->loadGrid($gameAssoc);
|
||||||
|
|
||||||
// Cells already revealed by previous steps (as "row,col" => true map)
|
/** Cells already revealed by previous steps (as "row,col" => true map) */
|
||||||
$alreadyRevealed = $this->buildRevealedMap($playedGame);
|
$alreadyRevealed = $this->buildRevealedMap($playedGame);
|
||||||
|
|
||||||
// Determine which cells to reveal for this step
|
/** Determine which cells to reveal for this step */
|
||||||
if ($isBomb) {
|
if ($isBomb) {
|
||||||
$revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed);
|
$revealedCells = $this->getBombRevealedCells($grid, $coords[0], $coords[1], $alreadyRevealed);
|
||||||
} elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) {
|
} elseif ('m' === ($grid[$coords[0]][$coords[1]] ?? null)) {
|
||||||
// Direct click on a mine — reveal it immediately (flood-fill skips mines)
|
/** Direct click on a mine — reveal it immediately (flood-fill skips mines) */
|
||||||
$revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']];
|
$revealedCells = [['row' => $coords[0], 'col' => $coords[1], 'value' => 'm']];
|
||||||
} else {
|
} else {
|
||||||
$revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed);
|
$revealedCells = $this->floodFill($grid, $coords[0], $coords[1], $alreadyRevealed);
|
||||||
}
|
}
|
||||||
|
|
||||||
$minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value']));
|
$minesFound = count(array_filter($revealedCells, static fn($c) => 'm' === $c['value']));
|
||||||
|
$safeCellsFound = count(array_filter($revealedCells, static fn($c) => 'm' !== $c['value']));
|
||||||
|
|
||||||
$redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0);
|
$redPoints = ($playedGame->getRedPoints() ?? 0) + ('red' === $player ? $minesFound : 0);
|
||||||
$bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0);
|
$bluePoints = ($playedGame->getBluePoints() ?? 0) + ('blue' === $player ? $minesFound : 0);
|
||||||
$gameOver = $redPoints > 25 || $bluePoints > 25;
|
$gameOver = $redPoints > 25 || $bluePoints > 25;
|
||||||
|
|
||||||
// Reveal remaining mines when the game ends
|
/** Calculate bonus points and stats */
|
||||||
|
$bonusData = $this->calculateBonuses(
|
||||||
|
$playedGame,
|
||||||
|
$player,
|
||||||
|
$coords,
|
||||||
|
$grid,
|
||||||
|
$alreadyRevealed,
|
||||||
|
$minesFound,
|
||||||
|
$safeCellsFound,
|
||||||
|
$redPoints,
|
||||||
|
$bluePoints
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Reveal remaining mines when the game ends */
|
||||||
$leftMines = [];
|
$leftMines = [];
|
||||||
if ($gameOver) {
|
if ($gameOver) {
|
||||||
$finalRevealed = $alreadyRevealed;
|
$finalRevealed = $alreadyRevealed;
|
||||||
@@ -204,23 +219,27 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
$leftMines = $this->getLeftMines($grid, $finalRevealed);
|
$leftMines = $this->getLeftMines($grid, $finalRevealed);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints);
|
$this->saveStepToDb($gameAssoc, $event, $player, $revealedCells, $redPoints, $bluePoints, $bonusData);
|
||||||
|
|
||||||
$users = $this->getUserCollection($playedGame);
|
$users = $this->getUserCollection($playedGame);
|
||||||
$count = $this->getPlayerCount($users);
|
$count = $this->getPlayerCount($users);
|
||||||
$topic = 'mineseeker/channel/' . $gameAssoc;
|
$topic = 'mineseeker/channel/' . $gameAssoc;
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'coords' => $coords,
|
'coords' => $coords,
|
||||||
'player' => $player,
|
'player' => $player,
|
||||||
'bomb' => $isBomb,
|
'bomb' => $isBomb,
|
||||||
'revealedCells' => $revealedCells,
|
'revealedCells' => $revealedCells,
|
||||||
'minesFound' => $minesFound,
|
'minesFound' => $minesFound,
|
||||||
'redPoints' => $redPoints,
|
'redPoints' => $redPoints,
|
||||||
'bluePoints' => $bluePoints,
|
'bluePoints' => $bluePoints,
|
||||||
'resign' => null,
|
'resign' => null,
|
||||||
'gameOver' => $gameOver,
|
'gameOver' => $gameOver,
|
||||||
'leftMines' => $leftMines,
|
'leftMines' => $leftMines,
|
||||||
|
'redBonusPoints' => $bonusData['redBonusPoints'],
|
||||||
|
'blueBonusPoints' => $bonusData['blueBonusPoints'],
|
||||||
|
'redBonusStats' => $bonusData['redBonusStats'],
|
||||||
|
'blueBonusStats' => $bonusData['blueBonusStats'],
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -260,6 +279,155 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
return $grid;
|
return $grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function calculateBonuses(
|
||||||
|
PlayedGame $playedGame,
|
||||||
|
string $player,
|
||||||
|
array $coords,
|
||||||
|
array $grid,
|
||||||
|
array $alreadyRevealed,
|
||||||
|
int $minesFound,
|
||||||
|
int $safeCellsFound,
|
||||||
|
int $redPoints,
|
||||||
|
int $bluePoints
|
||||||
|
): array {
|
||||||
|
/** Initialize or load existing bonus stats */
|
||||||
|
$redBonusStats = $playedGame->getRedBonusStats() ?? [
|
||||||
|
'blindHits' => 0,
|
||||||
|
'chainBest' => 0,
|
||||||
|
'chainCurrent' => 0,
|
||||||
|
'lastMineHits' => 0,
|
||||||
|
'edgeMines' => 0,
|
||||||
|
'biggestReveal' => 0,
|
||||||
|
];
|
||||||
|
$blueBonusStats = $playedGame->getBlueBonusStats() ?? [
|
||||||
|
'blindHits' => 0,
|
||||||
|
'chainBest' => 0,
|
||||||
|
'chainCurrent' => 0,
|
||||||
|
'lastMineHits' => 0,
|
||||||
|
'edgeMines' => 0,
|
||||||
|
'biggestReveal' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$redBonusPoints = $playedGame->getRedBonusPoints() ?? 0;
|
||||||
|
$blueBonusPoints = $playedGame->getBlueBonusPoints() ?? 0;
|
||||||
|
|
||||||
|
$isRed = 'red' === $player;
|
||||||
|
$currentStats = $isRed ? $redBonusStats : $blueBonusStats;
|
||||||
|
$bonusPoints = 0;
|
||||||
|
|
||||||
|
/** Track biggest reveal (safe cells count) if any safe cells were revealed */
|
||||||
|
if ($safeCellsFound > 0) {
|
||||||
|
if ($safeCellsFound > $currentStats['biggestReveal']) {
|
||||||
|
$currentStats['biggestReveal'] = $safeCellsFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only calculate bonuses if mines were found */
|
||||||
|
if ($minesFound > 0) {
|
||||||
|
/** Check Blind Hit: the clicked mine cell has no revealed numbered neighbors */
|
||||||
|
if ($this->isBlindHit($coords, $grid, $alreadyRevealed)) {
|
||||||
|
$currentStats['blindHits']++;
|
||||||
|
$bonusPoints += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check Edge Mine: the clicked cell is on the boundary */
|
||||||
|
if ($this->isEdgeMine($coords)) {
|
||||||
|
$currentStats['edgeMines']++;
|
||||||
|
$bonusPoints += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check Endgame Mine: when few mines remain on the board */
|
||||||
|
$totalMinesOnBoard = 51;
|
||||||
|
$minesRevealed = $redPoints + $bluePoints;
|
||||||
|
$minesRemaining = $totalMinesOnBoard - $minesRevealed;
|
||||||
|
|
||||||
|
if ($minesRemaining <= 10) {
|
||||||
|
$currentStats['lastMineHits']++;
|
||||||
|
$bonusPoints += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chain combo: increment consecutive mine-click counter */
|
||||||
|
$currentStats['chainCurrent']++;
|
||||||
|
if ($currentStats['chainCurrent'] > $currentStats['chainBest']) {
|
||||||
|
$currentStats['chainBest'] = $currentStats['chainCurrent'];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/** No mines found - reset chain for this player */
|
||||||
|
$currentStats['chainCurrent'] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add points for safe cells revealed (each safe cell revealed = +0.5 bonus point)
|
||||||
|
* Only award points if at least 2 safe cells were revealed
|
||||||
|
*/
|
||||||
|
if ($safeCellsFound >= 2) {
|
||||||
|
$bonusPoints += ($safeCellsFound * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the appropriate player's stats and points */
|
||||||
|
if ($isRed) {
|
||||||
|
$redBonusStats = $currentStats;
|
||||||
|
$redBonusPoints += $bonusPoints;
|
||||||
|
} else {
|
||||||
|
$blueBonusStats = $currentStats;
|
||||||
|
$blueBonusPoints += $bonusPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist updated stats to the database */
|
||||||
|
$playedGame->setRedBonusStats($redBonusStats);
|
||||||
|
$playedGame->setBlueBonusStats($blueBonusStats);
|
||||||
|
$playedGame->setRedBonusPoints($redBonusPoints);
|
||||||
|
$playedGame->setBlueBonusPoints($blueBonusPoints);
|
||||||
|
$this->em->persist($playedGame);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'redBonusPoints' => $redBonusPoints,
|
||||||
|
'blueBonusPoints' => $blueBonusPoints,
|
||||||
|
'redBonusStats' => $redBonusStats,
|
||||||
|
'blueBonusStats' => $blueBonusStats,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a mine was clicked with no revealed numbered neighbors (blind hit).
|
||||||
|
* Returns true if none of the 8 surrounding cells show a number.
|
||||||
|
*/
|
||||||
|
private function isBlindHit(array $coords, array $grid, array $alreadyRevealed): bool
|
||||||
|
{
|
||||||
|
$row = $coords[0];
|
||||||
|
$col = $coords[1];
|
||||||
|
$dirs = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
|
||||||
|
|
||||||
|
foreach ($dirs as [$dr, $dc]) {
|
||||||
|
$nr = $row + $dr;
|
||||||
|
$nc = $col + $dc;
|
||||||
|
$key = $nr . ',' . $nc;
|
||||||
|
|
||||||
|
/** Check if neighbor is revealed and is a numbered cell (not a mine, not hidden) */
|
||||||
|
if (isset($alreadyRevealed[$key])) {
|
||||||
|
$val = $grid[$nr][$nc] ?? null;
|
||||||
|
|
||||||
|
/** If it's a number (0-8), not a mine, it's revealed and visible */
|
||||||
|
if (is_numeric($val) && $val >= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a mine is on the edge/corner of the board.
|
||||||
|
*/
|
||||||
|
private function isEdgeMine(array $coords): bool
|
||||||
|
{
|
||||||
|
$row = $coords[0];
|
||||||
|
$col = $coords[1];
|
||||||
|
|
||||||
|
return 0 === $row || $row === 15 || 0 === $col || $col === 15;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BFS flood-fill starting at (row, col).
|
* BFS flood-fill starting at (row, col).
|
||||||
* Reveals the clicked cell plus all connected zero-value cells and their non-mine borders.
|
* Reveals the clicked cell plus all connected zero-value cells and their non-mine borders.
|
||||||
@@ -414,8 +582,8 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
{
|
{
|
||||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||||
$playedGame->setResign($color);
|
$playedGame->setResign($color);
|
||||||
$this->entityManager->persist($playedGame);
|
$this->em->persist($playedGame);
|
||||||
$this->entityManager->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveStepToDb(
|
private function saveStepToDb(
|
||||||
@@ -425,6 +593,7 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
array $revealedCells,
|
array $revealedCells,
|
||||||
int $redPoints,
|
int $redPoints,
|
||||||
int $bluePoints,
|
int $bluePoints,
|
||||||
|
array $bonusData = []
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||||
@@ -437,16 +606,24 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
$step->setRevealedCells($revealedCells);
|
$step->setRevealedCells($revealedCells);
|
||||||
$step->setPlayedGame($playedGame);
|
$step->setPlayedGame($playedGame);
|
||||||
$step->setCreated(new DateTime());
|
$step->setCreated(new DateTime());
|
||||||
$this->entityManager->persist($step);
|
$this->em->persist($step);
|
||||||
|
|
||||||
$playedGame->setRedPoints($redPoints);
|
$playedGame->setRedPoints($redPoints);
|
||||||
$playedGame->setBluePoints($bluePoints);
|
$playedGame->setBluePoints($bluePoints);
|
||||||
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null);
|
$playedGame->setRedExplodedBomb((bool)$event['bomb'] && 'red' === $player ? true : null);
|
||||||
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
|
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
|
||||||
$playedGame->setUpdated(new DateTime());
|
$playedGame->setUpdated(new DateTime());
|
||||||
$this->entityManager->persist($playedGame);
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
/** Bonus data is already persisted in calculateBonuses, but we ensure it's up to date */
|
||||||
|
if (!empty($bonusData)) {
|
||||||
|
$playedGame->setRedBonusPoints($bonusData['redBonusPoints']);
|
||||||
|
$playedGame->setBlueBonusPoints($bonusData['blueBonusPoints']);
|
||||||
|
$playedGame->setRedBonusStats($bonusData['redBonusStats']);
|
||||||
|
$playedGame->setBlueBonusStats($bonusData['blueBonusStats']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($playedGame);
|
||||||
|
$this->em->flush();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->logger->error($e->getMessage());
|
$this->logger->error($e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -465,8 +642,8 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
||||||
: $this->saveAnonUser($userName, $count, $playedGame, $request);
|
: $this->saveAnonUser($userName, $count, $playedGame, $request);
|
||||||
|
|
||||||
$this->entityManager->persist($playedGame);
|
$this->em->persist($playedGame);
|
||||||
$this->entityManager->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $this->getUserCollection($playedGame);
|
return $this->getUserCollection($playedGame);
|
||||||
}
|
}
|
||||||
@@ -499,7 +676,7 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
$anon->setCountry($this->extractCountry($request));
|
$anon->setCountry($this->extractCountry($request));
|
||||||
$anon->setUserAgent($request->headers->get('User-Agent'));
|
$anon->setUserAgent($request->headers->get('User-Agent'));
|
||||||
$anon->setConnTimestamp(new DateTime());
|
$anon->setConnTimestamp(new DateTime());
|
||||||
$this->entityManager->persist($anon);
|
$this->em->persist($anon);
|
||||||
|
|
||||||
if ($count === 1) {
|
if ($count === 1) {
|
||||||
$random = random_int(0, 1);
|
$random = random_int(0, 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user