Compare commits
14 Commits
v2026.2.2-
...
v2026.2.5-
| Author | SHA1 | Date | |
|---|---|---|---|
| ba8a0befb0 | |||
| 5ac291de81 | |||
| 991b114a3c | |||
| c79584c7d2 | |||
| e77c8a8f7c | |||
| c2308ba408 | |||
| e5a22cdfe3 | |||
| 09b0d21621 | |||
| 9aef27a0eb | |||
| c00ed57240 | |||
| ef4cf6ef69 | |||
| dc9c5f6545 | |||
| 25f2aaab8c | |||
| 0cc9cdaf07 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,6 +1,30 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v2026.2.3-0 (2026-04-18)
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
* Add initialization bonus points' system to the gameplay #5. [Lang]
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
* Add extended data to battle reports and sharing image to make viewable bonus points #5. [Lang]
|
||||||
|
|
||||||
|
|
||||||
|
## v2026.2.2-9 (2026-04-18)
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
* Add rules page #4. [Lang]
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
* The font-awesome simplifying to work on bare-metal - & fix all warnings at build time #4. [Lang]
|
||||||
|
|
||||||
|
* The css problem had been solved on reponsive gfx on homepage #4. [Lang]
|
||||||
|
|
||||||
|
|
||||||
## v2026.2.1-8 (2026-04-18)
|
## v2026.2.1-8 (2026-04-18)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
29
Makefile
29
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt
|
.PHONY: help start start-build stop build down ps logs prune-everything db-reset mercure-jwt cache-clear og-cache-clear
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
@@ -12,6 +12,8 @@ help:
|
|||||||
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
|
@echo " make prune-everything - Prune volumes, networks and images (DANGEROUS!)"
|
||||||
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
|
@echo " make db-reset - Reset the database (drop, create, migrate) (DANGEROUS!)"
|
||||||
@echo " make ccp - Clear the production cache"
|
@echo " make ccp - Clear the production cache"
|
||||||
|
@echo " make cache-clear - Clear all caches (Vite, Symfony, node_modules)"
|
||||||
|
@echo " make og-cache-clear - Clear Open Graph cache only"
|
||||||
|
|
||||||
start:
|
start:
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -55,3 +57,28 @@ db-reset:
|
|||||||
|
|
||||||
ccp:
|
ccp:
|
||||||
bin/console cache:clear --no-warmup --env=prod
|
bin/console cache:clear --no-warmup --env=prod
|
||||||
|
|
||||||
|
cache-clear:
|
||||||
|
@echo "Clearing all caches..."
|
||||||
|
@rm -rf node_modules/.vite
|
||||||
|
@rm -rf .vite
|
||||||
|
@rm -rf var/og-cache
|
||||||
|
@php bin/console cache:clear --no-warmup
|
||||||
|
@echo "✓ Vite cache cleared"
|
||||||
|
@echo "✓ OG cache cleared"
|
||||||
|
@echo "✓ Symfony cache cleared"
|
||||||
|
@echo ""
|
||||||
|
@echo "Rebuilding assets..."
|
||||||
|
@bun run build
|
||||||
|
@echo ""
|
||||||
|
@echo "✓ All caches cleared and assets rebuilt!"
|
||||||
|
@echo " Next step: Refresh browser with Ctrl+Shift+R"
|
||||||
|
|
||||||
|
og-cache-clear:
|
||||||
|
@echo "Clearing Open Graph cache..."
|
||||||
|
@rm -rf var/og-cache
|
||||||
|
@echo "✓ OG cache cleared!"
|
||||||
|
@echo " Battle card images will be regenerated on next access"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -210,11 +210,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--best {
|
&--bonus {
|
||||||
border-color: rgba(255, 215, 0, 0.15);
|
border-color: rgba(255, 215, 0, 0.18);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: rgba(255, 215, 0, 0.4);
|
border-color: rgba(255, 215, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--avg-bonus {
|
||||||
|
border-color: rgba(230, 184, 60, 0.18);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(230, 184, 60, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--chain {
|
||||||
|
border-color: rgba(94, 232, 154, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(94, 232, 154, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blind {
|
||||||
|
border-color: rgba(255, 140, 90, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(255, 140, 90, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--edge {
|
||||||
|
border-color: rgba(168, 210, 255, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(168, 210, 255, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,8 +280,24 @@
|
|||||||
color: rgba(80, 200, 220, 0.35);
|
color: rgba(80, 200, 220, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat--best & {
|
.profile-stat--bonus & {
|
||||||
color: rgba(255, 215, 0, 0.3);
|
color: rgba(255, 215, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--avg-bonus & {
|
||||||
|
color: rgba(230, 184, 60, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--chain & {
|
||||||
|
color: rgba(94, 232, 154, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--blind & {
|
||||||
|
color: rgba(255, 140, 90, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--edge & {
|
||||||
|
color: rgba(168, 210, 255, 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,9 +337,25 @@
|
|||||||
color: #50c8dc;
|
color: #50c8dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat--best & {
|
.profile-stat--bonus & {
|
||||||
color: #ffd700;
|
color: #ffd700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-stat--avg-bonus & {
|
||||||
|
color: #e6b83c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--chain & {
|
||||||
|
color: #5ee89a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--blind & {
|
||||||
|
color: #ff8c5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stat--edge & {
|
||||||
|
color: #a8d2ff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-stat__label {
|
.profile-stat__label {
|
||||||
@@ -371,7 +435,7 @@
|
|||||||
|
|
||||||
.profile-game {
|
.profile-game {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 26px 76px 22px 1fr 18px auto;
|
grid-template-columns: 60px 76px 22px 1fr 18px auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 11px 16px;
|
padding: 11px 16px;
|
||||||
@@ -400,17 +464,27 @@
|
|||||||
&--draw {
|
&--draw {
|
||||||
border-left-color: rgba(149, 207, 245, 0.25);
|
border-left-color: rgba(149, 207, 245, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--ongoing {
|
||||||
|
border-left-color: rgba(255, 193, 7, 0.4);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-game__badge {
|
.profile-game__badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 20px;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font: 800 10px 'Rajdhani', sans-serif;
|
font: 800 10px 'Rajdhani', sans-serif;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
.profile-game--win & {
|
.profile-game--win & {
|
||||||
background: rgba(42, 158, 96, 0.18);
|
background: rgba(42, 158, 96, 0.18);
|
||||||
@@ -426,12 +500,49 @@
|
|||||||
background: rgba(149, 207, 245, 0.1);
|
background: rgba(149, 207, 245, 0.1);
|
||||||
color: rgba(149, 207, 245, 0.65);
|
color: rgba(149, 207, 245, 0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-game--ongoing & {
|
||||||
|
background: rgba(255, 193, 7, 0.12);
|
||||||
|
color: #ffc107;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: #ffc107;
|
||||||
|
border-right-color: #ffc107;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-game--abandoned & {
|
||||||
|
background: rgba(107, 114, 126, 0.18);
|
||||||
|
color: #6b727e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-game__score {
|
.profile-game__score {
|
||||||
font: 700 14px 'Rajdhani', sans-serif;
|
font: 700 14px 'Rajdhani', sans-serif;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-game__vs {
|
.profile-game__vs {
|
||||||
@@ -461,21 +572,23 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-charts {
|
.profile-charts {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-chart-block {
|
.profile-chart-block {
|
||||||
flex: 1 1 300px;
|
min-width: 0;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(35, 111, 135, 0.2);
|
border: 1px solid rgba(35, 111, 135, 0.2);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 24px 20px 16px;
|
padding: 24px 20px 16px;
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 48px rgba(0, 0, 0, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -484,12 +597,25 @@
|
|||||||
.profile-section__title {
|
.profile-section__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
|
||||||
|
.profile-chart-inner {
|
||||||
|
justify-content: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-chart-inner {
|
.profile-chart-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: auto;
|
overflow: hidden;
|
||||||
|
|
||||||
svg text {
|
svg text {
|
||||||
font-family: 'Rajdhani', sans-serif !important;
|
font-family: 'Rajdhani', sans-serif !important;
|
||||||
@@ -564,6 +690,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bd-continue {
|
||||||
|
background: linear-gradient(135deg, rgba(42, 158, 96, 0.35) 0%, rgba(94, 232, 154, 0.35) 100%);
|
||||||
|
border: 1px solid rgba(94, 232, 154, 0.6);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #5ee89a;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 700 11px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 180ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 0 14px rgba(94, 232, 154, 0.25);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(42, 158, 96, 0.55) 0%, rgba(94, 232, 154, 0.55) 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 20px rgba(94, 232, 154, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.bd-close {
|
.bd-close {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -859,6 +1011,104 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bshare-bonus {
|
||||||
|
padding: 28px 28px 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__title {
|
||||||
|
font: 700 13px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: #ffd700;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
i { font-size: 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__player {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
border-color: rgba(246, 125, 82, 0.15);
|
||||||
|
background: rgba(246, 125, 82, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
border-color: rgba(149, 207, 245, 0.15);
|
||||||
|
background: rgba(149, 207, 245, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__points {
|
||||||
|
font: 700 24px 'Rajdhani', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffed4e);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__label {
|
||||||
|
font: 700 11px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgba(255, 215, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__stat-label {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font: 500 11px 'Rajdhani', sans-serif;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__stat-value {
|
||||||
|
font: 700 13px 'Rajdhani', sans-serif;
|
||||||
|
color: rgba(255, 215, 0, 0.9);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bshare-bonus__stat--empty {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.bshare-btn {
|
.bshare-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-header {
|
.profile-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
250
assets/css/mineseeker/_bonus-box.scss
Normal file
250
assets/css/mineseeker/_bonus-box.scss
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/*!*
|
||||||
|
* 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);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsd-stat-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.48);
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
animation: slideUp 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
@@ -54,6 +56,11 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
margin: 0 0 50px 0;
|
margin: 0 0 50px 0;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 {
|
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window h2 {
|
||||||
@@ -183,6 +190,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
animation: fadeInUp 0.6s ease-out 0.2s both;
|
animation: fadeInUp 0.6s ease-out 0.2s both;
|
||||||
|
|
||||||
|
&.waiting-options--invite-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@@ -264,6 +275,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-divider {
|
.waiting-divider {
|
||||||
@@ -527,6 +543,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: 1 1 0;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share {
|
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-share {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -590,3 +619,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#mine-wrapper .game-wrapper .game-overlay .game-overlay-window .game-overlay-profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
background: linear-gradient(to bottom, #1a6844 0%, #135233 100%);
|
||||||
|
border: 2px solid #2a9e60;
|
||||||
|
color: #d0ffe0;
|
||||||
|
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(42, 158, 96, 0.25);
|
||||||
|
text-decoration: none;
|
||||||
|
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, #238f5c 0%, #1a6844 100%);
|
||||||
|
border-color: #5ee89a;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(42, 158, 96, 0.4);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,10 +105,12 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
padding: 3px 0;
|
padding: 3px 5px;
|
||||||
margin: 0 5px;
|
margin: 0;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-name {
|
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-name {
|
||||||
@@ -142,6 +144,13 @@
|
|||||||
height: 65px;
|
height: 65px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc {
|
#mine-wrapper .game-wrapper .users .user-container.user-blue .user-desc {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ createRoot(wrapper).render(
|
|||||||
<MineSeeker
|
<MineSeeker
|
||||||
env={wrapper.dataset.env}
|
env={wrapper.dataset.env}
|
||||||
gameId={wrapper.dataset.gameId}
|
gameId={wrapper.dataset.gameId}
|
||||||
|
opponentName={wrapper.dataset.opponentName || ''}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||||
|
import Avatar from './battle-dialog/Avatar';
|
||||||
|
import StatRow from './battle-dialog/StatRow';
|
||||||
|
import BonusPoints from './battle-dialog/BonusPoints';
|
||||||
|
|
||||||
const darkTheme = createTheme({ palette: { mode: 'dark' } });
|
const darkTheme = createTheme({ palette: { mode: 'dark' } });
|
||||||
|
|
||||||
@@ -50,102 +53,6 @@ const RESULT_META = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function Avatar({ name, color, avatarUrl }) {
|
|
||||||
const isRed = 'red' === color;
|
|
||||||
const initials = (name || '?').slice(0, 2).toUpperCase();
|
|
||||||
|
|
||||||
const gradient = isRed
|
|
||||||
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
|
|
||||||
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
|
|
||||||
const glow = isRed
|
|
||||||
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
|
|
||||||
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
|
|
||||||
const border = isRed
|
|
||||||
? 'rgba(173,10,5,0.5)'
|
|
||||||
: 'rgba(35,111,135,0.5)';
|
|
||||||
const textColor = isRed ? '#f67d52' : '#95cff5';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 72, height: 72, borderRadius: '50%',
|
|
||||||
background: avatarUrl ? 'transparent' : gradient,
|
|
||||||
border: `2px solid ${border}`,
|
|
||||||
boxShadow: glow,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
font: '800 24px \'Rajdhani\', sans-serif',
|
|
||||||
color: textColor,
|
|
||||||
letterSpacing: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{avatarUrl ? (
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={name}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
objectFit: 'cover',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
initials
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span style={{
|
|
||||||
font: '700 15px \'Rajdhani\', sans-serif',
|
|
||||||
color: textColor,
|
|
||||||
letterSpacing: 1,
|
|
||||||
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
font: '600 10px \'Rajdhani\', sans-serif',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 2,
|
|
||||||
color: 'rgba(255,255,255,0.3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRed ? 'Red' : 'Blue'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatRow({ icon, label, value, valueColor }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center',
|
|
||||||
gap: 10, padding: '9px 0',
|
|
||||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
|
|
||||||
<span style={{
|
|
||||||
font: '500 13px \'Rajdhani\', sans-serif',
|
|
||||||
color: 'rgba(255,255,255,0.45)',
|
|
||||||
flex: 1,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span style={{
|
|
||||||
font: '700 13px \'Rajdhani\', sans-serif',
|
|
||||||
color: valueColor || 'rgba(255,255,255,0.75)',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BattleDialog({ games }) {
|
export default function BattleDialog({ games }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [game, setGame] = useState(null);
|
const [game, setGame] = useState(null);
|
||||||
@@ -171,10 +78,31 @@ export default function BattleDialog({ games }) {
|
|||||||
|
|
||||||
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
|
const meta = RESULT_META[game.result] ?? RESULT_META.draw;
|
||||||
const resign = game.resign;
|
const resign = game.resign;
|
||||||
|
const maxPoints = Math.max(game.redPoints ?? 0, game.bluePoints ?? 0);
|
||||||
const endReason = resign
|
const endReason = resign
|
||||||
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
|
? `${resign.charAt(0).toUpperCase() + resign.slice(1)} resigned`
|
||||||
: 'Points';
|
: 26 <= maxPoints ? 'Points' : 'Abandoned';
|
||||||
const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
|
const shareUrl = `${window.location.origin}/battle/${game.uuid}`;
|
||||||
|
const canContinue = !resign && 26 > maxPoints;
|
||||||
|
const playUrl = `${window.location.origin}/play/${game.uuid}`;
|
||||||
|
|
||||||
|
const formatDuration = (from, to) => {
|
||||||
|
if (!from || !to) return null;
|
||||||
|
const diffMs = new Date(to.replace(' ', 'T')) - new Date(from.replace(' ', 'T'));
|
||||||
|
if (isNaN(diffMs) || 0 >= diffMs) return null;
|
||||||
|
const totalSec = Math.floor(diffMs / 1000);
|
||||||
|
const h = Math.floor(totalSec / 3600);
|
||||||
|
const m = Math.floor((totalSec % 3600) / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
if (0 < h) return `${h}h ${m}m ${s}s`;
|
||||||
|
if (0 < m) return `${m}m ${s}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
};
|
||||||
|
const duration = formatDuration(game.created, game.date);
|
||||||
|
const pointDiff = Math.abs((game.redPoints ?? 0) - (game.bluePoints ?? 0));
|
||||||
|
const winnerColor = (game.redPoints ?? 0) > (game.bluePoints ?? 0) ? '#f67d52'
|
||||||
|
: (game.bluePoints ?? 0) > (game.redPoints ?? 0) ? '#95cff5'
|
||||||
|
: 'rgba(255,255,255,0.45)';
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||||
@@ -195,6 +123,17 @@ export default function BattleDialog({ games }) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{canContinue ? (
|
||||||
|
<a
|
||||||
|
className="bd-continue"
|
||||||
|
href={playUrl}
|
||||||
|
aria-label="Continue the game"
|
||||||
|
title="Continue the game"
|
||||||
|
>
|
||||||
|
<i className="fa fa-play" />
|
||||||
|
Continue
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
|
className={`bd-share${copied ? ' bd-share--copied' : ''}`}
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
@@ -204,19 +143,46 @@ export default function BattleDialog({ games }) {
|
|||||||
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
||||||
{copied ? 'Copied!' : 'Share'}
|
{copied ? 'Copied!' : 'Share'}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
|
<button className="bd-close" onClick={() => setOpen(false)} aria-label="Close">
|
||||||
<i className="fa fa-times" />
|
<i className="fa fa-times" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bd-vs-panel">
|
<div className="bd-vs-panel">
|
||||||
<Avatar name={game.redName} color="red" avatarUrl={game.redAvatar} />
|
<Avatar
|
||||||
|
name={game.redName} color="red" avatarUrl={game.redAvatar}
|
||||||
|
bonusPoints={game.redBonusPoints > game.blueBonusPoints ? game.redBonusPoints : 0}
|
||||||
|
/>
|
||||||
<div className="bd-vs-center">
|
<div className="bd-vs-center">
|
||||||
<div className="bd-vs-score">
|
<div className="bd-vs-score">
|
||||||
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
<span className="bd-vs-score__red">{game.redPoints ?? '—'}</span>
|
||||||
<span className="bd-vs-score__sep">:</span>
|
<span className="bd-vs-score__sep">:</span>
|
||||||
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
|
<span className="bd-vs-score__blue">{game.bluePoints ?? '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bd-vs-score" style={{ marginBottom: 8 }}>
|
||||||
|
<span style={{
|
||||||
|
font: '700 13px \'Rajdhani\', sans-serif',
|
||||||
|
color: '#f67d52',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-star" style={{ fontSize: 11 }} /> {(game.redBonusPoints ?? 0).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span className="bd-vs-score__sep">:</span>
|
||||||
|
<span style={{
|
||||||
|
font: '700 13px \'Rajdhani\', sans-serif',
|
||||||
|
color: '#95cff5',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(game.blueBonusPoints ?? 0).toFixed(1)} <i className="fa fa-star" style={{ fontSize: 11 }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="bd-vs-label">VS</div>
|
<div className="bd-vs-label">VS</div>
|
||||||
<div
|
<div
|
||||||
className="bd-result-badge"
|
className="bd-result-badge"
|
||||||
@@ -225,25 +191,40 @@ export default function BattleDialog({ games }) {
|
|||||||
<i className={`fa ${meta.icon}`} /> {meta.label}
|
<i className={`fa ${meta.icon}`} /> {meta.label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Avatar name={game.blueName} color="blue" avatarUrl={game.blueAvatar} />
|
<Avatar
|
||||||
|
name={game.blueName} color="blue" avatarUrl={game.blueAvatar}
|
||||||
|
bonusPoints={game.blueBonusPoints > game.redBonusPoints ? game.blueBonusPoints : 0}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bd-stats">
|
<div className="bd-stats">
|
||||||
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
<StatRow icon="fa-calendar" label="Date" value={game.date ?? '—'} />
|
||||||
|
{game.created && game.date && game.created !== game.date && (
|
||||||
|
<StatRow icon="fa-clock" label="Started" value={game.created} />
|
||||||
|
)}
|
||||||
|
{duration && (
|
||||||
|
<StatRow icon="fa-hourglass-half" label="Match duration" value={duration} />
|
||||||
|
)}
|
||||||
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
|
<StatRow icon="fa-flag-checkered" label="End reason" value={endReason} />
|
||||||
|
{0 < pointDiff && (
|
||||||
<StatRow
|
<StatRow
|
||||||
icon="fa-bomb" label="Red hit a mine"
|
icon="fa-balance-scale" label="Winning margin"
|
||||||
|
value={`${pointDiff} mine${1 === pointDiff ? '' : 's'}`} valueColor={winnerColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<StatRow
|
||||||
|
icon="fa-bomb" label="Red used bomb"
|
||||||
value={game.redExplodedBomb ? 'Yes' : 'No'}
|
value={game.redExplodedBomb ? 'Yes' : 'No'}
|
||||||
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
|
valueColor={game.redExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
|
||||||
/>
|
/>
|
||||||
<StatRow
|
<StatRow
|
||||||
icon="fa-bomb" label="Blue hit a mine"
|
icon="fa-bomb" label="Blue used bomb"
|
||||||
value={game.blueExplodedBomb ? 'Yes' : 'No'}
|
value={game.blueExplodedBomb ? 'Yes' : 'No'}
|
||||||
valueColor={game.blueExplodedBomb ? '#f67d52' : 'rgba(255,255,255,0.45)'}
|
valueColor={game.blueExplodedBomb ? '#95cff5' : 'rgba(255,255,255,0.45)'}
|
||||||
/>
|
/>
|
||||||
{game.created && game.date && game.created !== game.date && (
|
|
||||||
<StatRow icon="fa-clock-o" label="Started" value={game.created} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<BonusPoints
|
||||||
|
game={game}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BarChart } from '@mui/x-charts/BarChart';
|
import { BarChart } from '@mui/x-charts/BarChart';
|
||||||
|
import { LineChart } from '@mui/x-charts/LineChart';
|
||||||
import { PieChart } from '@mui/x-charts/PieChart';
|
import { PieChart } from '@mui/x-charts/PieChart';
|
||||||
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ const darkTheme = createTheme({
|
|||||||
const WIN_COLOR = '#5ee89a';
|
const WIN_COLOR = '#5ee89a';
|
||||||
const LOSS_COLOR = '#f67d52';
|
const LOSS_COLOR = '#f67d52';
|
||||||
const DRAW_COLOR = '#95cff5';
|
const DRAW_COLOR = '#95cff5';
|
||||||
|
const MINES_COLOR = '#f67d52';
|
||||||
|
const BONUS_COLOR = '#ffd700';
|
||||||
|
|
||||||
const axisStyle = {
|
const axisStyle = {
|
||||||
tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
|
tickLabelStyle: { fill: 'rgba(255,255,255,0.4)', fontSize: 11, fontFamily: '\'Rajdhani\', sans-serif' },
|
||||||
@@ -23,10 +26,12 @@ const axisStyle = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ProfileCharts({ chartData }) {
|
export default function ProfileCharts({ chartData }) {
|
||||||
const { months, wins, losses, draws, pieWins, pieLosses, pieDraws } = chartData;
|
const { months, wins, losses, draws, pieWins, pieLosses, pieDraws, recentGames } = chartData;
|
||||||
const total = pieWins + pieLosses + pieDraws;
|
const total = pieWins + pieLosses + pieDraws;
|
||||||
|
|
||||||
const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v);
|
const hasBars = wins.some(v => 0 < v) || losses.some(v => 0 < v) || draws.some(v => 0 < v);
|
||||||
|
const hasRecent = recentGames
|
||||||
|
&& (recentGames.mines?.some(v => 0 < v) || recentGames.bonus?.some(v => 0 < v));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={darkTheme}>
|
<ThemeProvider theme={darkTheme}>
|
||||||
@@ -97,6 +102,36 @@ export default function ProfileCharts({ chartData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasRecent && (
|
||||||
|
<div className="profile-chart-block profile-chart-block--wide">
|
||||||
|
<h2 className="profile-section__title">
|
||||||
|
<i className="fa fa-line-chart" /> Last {recentGames.labels.length} games — mines & bonus
|
||||||
|
</h2>
|
||||||
|
<div className="profile-chart-inner">
|
||||||
|
<LineChart
|
||||||
|
xAxis={[{ scaleType: 'band', data: recentGames.labels, ...axisStyle }]}
|
||||||
|
yAxis={[{ ...axisStyle }]}
|
||||||
|
series={[
|
||||||
|
{ data: recentGames.mines, label: 'Mines hit', color: MINES_COLOR },
|
||||||
|
{ data: recentGames.bonus, label: 'Bonus points', color: BONUS_COLOR },
|
||||||
|
]}
|
||||||
|
slotProps={{
|
||||||
|
legend: {
|
||||||
|
labelStyle: {
|
||||||
|
fill: 'rgba(255,255,255,0.55)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: '\'Rajdhani\', sans-serif',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
borderRadius={3}
|
||||||
|
height={220}
|
||||||
|
margin={{ top: 10, bottom: 30, left: 40, right: 140 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
98
assets/js/components/battle-dialog/Avatar.jsx
Normal file
98
assets/js/components/battle-dialog/Avatar.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
export default function Avatar({ name, color, avatarUrl, bonusPoints = 0 }) {
|
||||||
|
const isRed = 'red' === color;
|
||||||
|
const initials = (name || '?').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
const gradient = isRed
|
||||||
|
? 'linear-gradient(135deg, rgba(173,10,5,0.6) 0%, rgba(246,125,82,0.4) 100%)'
|
||||||
|
: 'linear-gradient(135deg, rgba(35,111,135,0.6) 0%, rgba(41,128,185,0.4) 100%)';
|
||||||
|
const glow = isRed
|
||||||
|
? '0 0 0 3px rgba(173,10,5,0.2), 0 0 28px rgba(173,10,5,0.35)'
|
||||||
|
: '0 0 0 3px rgba(35,111,135,0.2), 0 0 28px rgba(35,111,135,0.35)';
|
||||||
|
const border = isRed
|
||||||
|
? 'rgba(173,10,5,0.5)'
|
||||||
|
: 'rgba(35,111,135,0.5)';
|
||||||
|
const textColor = isRed ? '#f67d52' : '#95cff5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, position: 'relative' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 72, height: 72, borderRadius: '50%',
|
||||||
|
background: avatarUrl ? 'transparent' : gradient,
|
||||||
|
border: `2px solid ${border}`,
|
||||||
|
boxShadow: glow,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
font: '800 24px \'Rajdhani\', sans-serif',
|
||||||
|
color: textColor,
|
||||||
|
letterSpacing: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{0 < bonusPoints && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -6,
|
||||||
|
right: -6,
|
||||||
|
background: '#ffd700',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxShadow: '0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1)',
|
||||||
|
border: '2px solid rgba(0,0,0,0.5)',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-star" style={{ color: '#000', fontSize: 14 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
font: '700 15px \'Rajdhani\', sans-serif',
|
||||||
|
color: textColor,
|
||||||
|
letterSpacing: 1,
|
||||||
|
maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
font: '600 10px \'Rajdhani\', sans-serif',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: 'rgba(255,255,255,0.3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRed ? 'Red' : 'Blue'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
assets/js/components/battle-dialog/BonusPoints.jsx
Normal file
139
assets/js/components/battle-dialog/BonusPoints.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import StatRow from './StatRow';
|
||||||
|
|
||||||
|
export default function BonusPoints({ game }) {
|
||||||
|
const hasBonuspoints = useMemo(
|
||||||
|
() => 0 < game?.redBonusPoints
|
||||||
|
|| 0 < game?.blueBonusPoints
|
||||||
|
|| game?.redBonusStats?.blindHits
|
||||||
|
|| game?.blueBonusStats?.blindHits,
|
||||||
|
[
|
||||||
|
game?.blueBonusPoints,
|
||||||
|
game?.blueBonusStats?.blindHits,
|
||||||
|
game?.redBonusPoints,
|
||||||
|
game?.redBonusStats?.blindHits,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRedNoBonuses = useMemo(
|
||||||
|
() => !game.redBonusStats?.blindHits
|
||||||
|
&& !game.redBonusStats?.chainBest
|
||||||
|
&& !game.redBonusStats?.edgeMines
|
||||||
|
&& !game.redBonusStats?.lastMineHits
|
||||||
|
&& !game.redBonusStats?.biggestReveal,
|
||||||
|
[
|
||||||
|
game.redBonusStats?.biggestReveal,
|
||||||
|
game.redBonusStats?.blindHits,
|
||||||
|
game.redBonusStats?.chainBest,
|
||||||
|
game.redBonusStats?.edgeMines,
|
||||||
|
game.redBonusStats?.lastMineHits,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasBlueNoBonuses = useMemo(
|
||||||
|
() => !game.blueBonusStats?.blindHits
|
||||||
|
&& !game.blueBonusStats?.chainBest
|
||||||
|
&& !game.blueBonusStats?.edgeMines
|
||||||
|
&& !game.blueBonusStats?.lastMineHits
|
||||||
|
&& !game.blueBonusStats?.biggestReveal,
|
||||||
|
[
|
||||||
|
game.blueBonusStats?.biggestReveal,
|
||||||
|
game.blueBonusStats?.blindHits,
|
||||||
|
game.blueBonusStats?.chainBest,
|
||||||
|
game.blueBonusStats?.edgeMines,
|
||||||
|
game.blueBonusStats?.lastMineHits,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasBonuspoints) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px 0',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
{/* Red Bonus */}
|
||||||
|
<div style={{
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid rgba(173,10,5,0.2)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(173,10,5,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
font: '700 12px \'Rajdhani\', sans-serif',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: '#ffd700',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-star" style={{ marginRight: 8 }} /> Red Bonus Statistics
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
|
<StatRow
|
||||||
|
icon="fa-star" label="Total Bonus Points" value={(game.redBonusPoints ?? 0).toFixed(1)}
|
||||||
|
valueColor="#ffd700"
|
||||||
|
/>
|
||||||
|
{0 < game.redBonusStats?.blindHits
|
||||||
|
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.redBonusStats.blindHits} />}
|
||||||
|
{0 < game.redBonusStats?.chainBest
|
||||||
|
&& <StatRow icon="fa-link" label="Best chain" value={game.redBonusStats.chainBest} />}
|
||||||
|
{0 < game.redBonusStats?.edgeMines
|
||||||
|
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.redBonusStats.edgeMines} />}
|
||||||
|
{0 < game.redBonusStats?.lastMineHits
|
||||||
|
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.redBonusStats.lastMineHits} />}
|
||||||
|
{0 < game.redBonusStats?.biggestReveal
|
||||||
|
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.redBonusStats.biggestReveal} />}
|
||||||
|
{hasRedNoBonuses
|
||||||
|
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid rgba(149,207,245,0.2)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'rgba(149,207,245,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
font: '700 12px \'Rajdhani\', sans-serif',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 2,
|
||||||
|
color: '#ffd700',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa fa-star" style={{ marginRight: 8 }} /> Blue Bonus Statistics
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||||
|
<StatRow
|
||||||
|
icon="fa-star" label="Total Bonus Points" value={(game.blueBonusPoints ?? 0).toFixed(1)}
|
||||||
|
valueColor="#ffd700"
|
||||||
|
/>
|
||||||
|
{0 < game.blueBonusStats?.blindHits
|
||||||
|
&& <StatRow icon="fa-bullseye" label="Blind hits" value={game.blueBonusStats.blindHits} />}
|
||||||
|
{0 < game.blueBonusStats?.chainBest
|
||||||
|
&& <StatRow icon="fa-link" label="Best chain" value={game.blueBonusStats.chainBest} />}
|
||||||
|
{0 < game.blueBonusStats?.edgeMines
|
||||||
|
&& <StatRow icon="fa-border-all" label="Edge mines" value={game.blueBonusStats.edgeMines} />}
|
||||||
|
{0 < game.blueBonusStats?.lastMineHits
|
||||||
|
&& <StatRow icon="fa-hourglass-end" label="Endgame mines" value={game.blueBonusStats.lastMineHits} />}
|
||||||
|
{0 < game.blueBonusStats?.biggestReveal
|
||||||
|
&& <StatRow icon="fa-expand" label="Biggest reveal" value={game.blueBonusStats.biggestReveal} />}
|
||||||
|
{hasBlueNoBonuses
|
||||||
|
&& <StatRow icon="fa-minus-circle" label="Status" value="No bonuses" valueColor="rgba(255,255,255,0.3)" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
assets/js/components/battle-dialog/StatRow.jsx
Normal file
40
assets/js/components/battle-dialog/StatRow.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
export default function StatRow({ icon, label, value, valueColor }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center',
|
||||||
|
gap: 10, padding: '9px 0',
|
||||||
|
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`fa ${icon}`} style={{ width: 16, color: 'rgba(149,207,245,0.4)', fontSize: 13 }} />
|
||||||
|
<span style={{
|
||||||
|
font: '500 13px \'Rajdhani\', sans-serif',
|
||||||
|
color: 'rgba(255,255,255,0.45)',
|
||||||
|
flex: 1,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
font: '700 13px \'Rajdhani\', sans-serif',
|
||||||
|
color: valueColor || 'rgba(255,255,255,0.75)',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { GameBoard } from '@mine-components';
|
|||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const MineSeeker = ({ env, gameId }) => {
|
const MineSeeker = ({ env, gameId, opponentName = '' }) => {
|
||||||
const isEnvDev = 'dev' === env;
|
const isEnvDev = 'dev' === env;
|
||||||
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
|
const gameAssoc = useRef('' !== gameId ? gameId : crypto.randomUUID()).current;
|
||||||
const gameInherited = '' !== gameId;
|
const gameInherited = '' !== gameId;
|
||||||
@@ -25,6 +25,7 @@ const MineSeeker = ({ env, gameId }) => {
|
|||||||
<GameBoard
|
<GameBoard
|
||||||
gameAssoc={gameAssoc}
|
gameAssoc={gameAssoc}
|
||||||
gameInherited={gameInherited}
|
gameInherited={gameInherited}
|
||||||
|
opponentName={opponentName}
|
||||||
isEnvDev={isEnvDev}
|
isEnvDev={isEnvDev}
|
||||||
/>
|
/>
|
||||||
</GameProvider>
|
</GameProvider>
|
||||||
|
|||||||
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;
|
||||||
@@ -12,9 +12,9 @@ import { useGame } from '@mine-contexts';
|
|||||||
import { useServerCommunication } from '@mine-hooks';
|
import { useServerCommunication } from '@mine-hooks';
|
||||||
import GridControl from './grid/GridControl';
|
import GridControl from './grid/GridControl';
|
||||||
|
|
||||||
export const GameBoard = ({ gameAssoc, gameInherited, isEnvDev }) => {
|
export const GameBoard = ({ gameAssoc, gameInherited, opponentName = '', isEnvDev }) => {
|
||||||
const { gridReady } = useGame();
|
const { gridReady } = useGame();
|
||||||
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, isEnvDev);
|
const { onClick, resign } = useServerCommunication(gameAssoc, gameInherited, opponentName, isEnvDev);
|
||||||
|
|
||||||
if (!gridReady) {
|
if (!gridReady) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => {
|
|||||||
<div className="opd-info">
|
<div className="opd-info">
|
||||||
<span className="opd-name">{player.name}</span>
|
<span className="opd-name">{player.name}</span>
|
||||||
<span className="opd-since">
|
<span className="opd-since">
|
||||||
<i className="fa fa-clock-o" />
|
<i className="fa fa-clock" />
|
||||||
{' '}Waiting {formatSince(player.since)}
|
{' '}Waiting {formatSince(player.since)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,22 +9,27 @@
|
|||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { OnlinePlayersDialog } from '@mine-components';
|
import { OnlinePlayersDialog } from '@mine-components';
|
||||||
|
|
||||||
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
|
const WaitingOverlayContent = ({ shareUrl, currentGameAssoc, opponentName = '', inviteOnly = false }) => {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const inviteHeader = inviteOnly && opponentName
|
||||||
|
? `Invite ${opponentName}`
|
||||||
|
: 'Invite a Friend';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="waiting-options">
|
<div className={`waiting-options${inviteOnly ? ' waiting-options--invite-only' : ''}`}>
|
||||||
<div className="waiting-option">
|
<div className="waiting-option">
|
||||||
<div className="waiting-option-header">
|
<div className="waiting-option-header">
|
||||||
<i className="fa fa-link" />
|
<i className="fa fa-link" />
|
||||||
<span>Invite a Friend</span>
|
<span>{inviteHeader}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="waiting-option-desc">Share this link with your opponent</p>
|
<p className="waiting-option-desc">Share this link with your opponent</p>
|
||||||
<ShareLinkBox
|
<ShareLinkBox
|
||||||
url={shareUrl}
|
url={shareUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!inviteOnly && (
|
||||||
|
<Fragment>
|
||||||
<div className="waiting-divider">
|
<div className="waiting-divider">
|
||||||
<span>OR</span>
|
<span>OR</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,13 +47,17 @@ const WaitingOverlayContent = ({ shareUrl, currentGameAssoc }) => {
|
|||||||
Browse Players
|
Browse Players
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!inviteOnly && (
|
||||||
<OnlinePlayersDialog
|
<OnlinePlayersDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
onClose={() => setDialogOpen(false)}
|
onClose={() => setDialogOpen(false)}
|
||||||
currentGameAssoc={currentGameAssoc}
|
currentGameAssoc={currentGameAssoc}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -57,10 +66,12 @@ const ShareLinkBox = ({ url }) => {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url)
|
||||||
|
.then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2500);
|
setTimeout(() => setCopied(false), 2500);
|
||||||
}).catch(() => {});
|
})
|
||||||
|
.catch(() => null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
|
|||||||
|
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
|
const shareUrl = gameAssoc ? `${window.location.origin}/battle/${gameAssoc}` : null;
|
||||||
|
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = () => {
|
||||||
if (!shareUrl) return;
|
if (!shareUrl) return;
|
||||||
@@ -64,6 +65,7 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
|
|||||||
overlaySubTitle
|
overlaySubTitle
|
||||||
)}
|
)}
|
||||||
{gameAssoc && endRef.current && (
|
{gameAssoc && endRef.current && (
|
||||||
|
<div className="game-overlay-actions">
|
||||||
<button
|
<button
|
||||||
className={`game-overlay-share${copied ? ' copied' : ''}`}
|
className={`game-overlay-share${copied ? ' copied' : ''}`}
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
@@ -73,6 +75,16 @@ const GridControl = ({ gameAssoc, onClick, resign }) => {
|
|||||||
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
<i className={`fa ${copied ? 'fa-check' : 'fa-share-alt'}`} />
|
||||||
{copied ? 'Copied!' : 'Share Battle'}
|
{copied ? 'Copied!' : 'Share Battle'}
|
||||||
</button>
|
</button>
|
||||||
|
<a
|
||||||
|
className="game-overlay-profile"
|
||||||
|
href={isAuthenticated ? '/profile' : '/'}
|
||||||
|
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
|
||||||
|
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
|
||||||
|
>
|
||||||
|
<i className={`fa ${isAuthenticated ? 'fa-user' : 'fa-house'}`} />
|
||||||
|
{isAuthenticated ? 'My Profile' : 'Homepage'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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,11 +26,17 @@ const UserControl = ({ resign }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBonusClick = () => {
|
||||||
|
setBonusDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Fragment>
|
||||||
<div className="users">
|
<div className="users">
|
||||||
<User
|
<User
|
||||||
color="blue" webPlayer={webPlayer} {...blue}
|
color="blue" webPlayer={webPlayer} {...blue}
|
||||||
onClickBombSelector={() => handleBombClick('blue', 1)}
|
onClickBombSelector={() => handleBombClick('blue', 1)}
|
||||||
|
onBonusClick={handleBonusClick}
|
||||||
/>
|
/>
|
||||||
<div className="active-mines-container">
|
<div className="active-mines-container">
|
||||||
<i className="fa fa-star" />
|
<i className="fa fa-star" />
|
||||||
@@ -42,12 +50,20 @@ const UserControl = ({ resign }) => {
|
|||||||
<User
|
<User
|
||||||
color="red" webPlayer={webPlayer} {...red}
|
color="red" webPlayer={webPlayer} {...red}
|
||||||
onClickBombSelector={() => handleBombClick('red', 0)}
|
onClickBombSelector={() => handleBombClick('red', 0)}
|
||||||
|
onBonusClick={handleBonusClick}
|
||||||
/>
|
/>
|
||||||
<button className={resignClass} onClick={resign}>
|
<button className={resignClass} onClick={resign}>
|
||||||
<div className="resign-shine" />
|
<div className="resign-shine" />
|
||||||
Resign
|
Resign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<BonusStatsDialog
|
||||||
|
open={bonusDialogOpen}
|
||||||
|
onClose={() => setBonusDialogOpen(false)}
|
||||||
|
red={red}
|
||||||
|
blue={blue}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|
||||||
@@ -234,7 +257,7 @@ export const GameProvider = ({ children }) => {
|
|||||||
// Setters needed by useServerComm
|
// Setters needed by useServerComm
|
||||||
setCells, setGridReady, setGameUuid,
|
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, lastClickedRef, endRef,
|
||||||
// Sync helpers
|
// Sync helpers
|
||||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||||
// Game logic called by useServerComm
|
// Game logic called by useServerComm
|
||||||
|
|||||||
@@ -10,24 +10,20 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { useGame } from '@mine-contexts';
|
import { useGame } from '@mine-contexts';
|
||||||
import { DESC } from '@mine-utils';
|
import { DESC, IMAGES } from '@mine-utils';
|
||||||
import useStepTimer from './useStepTimer';
|
import useStepTimer from './useStepTimer';
|
||||||
import { WaitingOverlayContent } from '@mine-components';
|
import { ChallengeCountdown, WaitingOverlayContent } from '@mine-components';
|
||||||
|
|
||||||
import { ChallengeCountdown } from '@mine-components';
|
const useServerCommunication = (gameAssoc, gameInherited, opponentName, isEnvDev) => {
|
||||||
|
|
||||||
const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|
||||||
const {
|
const {
|
||||||
/** Async-safe refs */
|
/** Async-safe refs */
|
||||||
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, endRef,
|
webPlayerRef, activePlayerRef, bombSelectedRef, connectionLostRef, lastClickedRef, endRef,
|
||||||
/** State setters */
|
/** State setters */
|
||||||
setGridReady, setGameUuid,
|
setCells, setGridReady, setGameUuid,
|
||||||
/** Sync helpers */
|
/** Sync helpers */
|
||||||
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
syncWebPlayer, syncActivePlayer, syncBombSelected, syncConnLost, syncRed, syncBlue,
|
||||||
/** Game logic */
|
/** Game logic */
|
||||||
showOverlay, hideOverlay,
|
showOverlay, hideOverlay, applyStep, makeGameEndIfItEnds, resignProcess,
|
||||||
applyRevealedCell, applyStep,
|
|
||||||
makeGameEndIfItEnds, resignProcess,
|
|
||||||
/** Current cells snapshot (for active-check in onClick) */
|
/** Current cells snapshot (for active-check in onClick) */
|
||||||
cells,
|
cells,
|
||||||
} = useGame();
|
} = useGame();
|
||||||
@@ -35,9 +31,16 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
const eventSourceRef = useRef(null);
|
const eventSourceRef = useRef(null);
|
||||||
const rpcUsersRef = useRef(null);
|
const rpcUsersRef = useRef(null);
|
||||||
const stepCacheRef = useRef([]);
|
const stepCacheRef = useRef([]);
|
||||||
|
const lastStepRef = useRef(null);
|
||||||
|
const isGameFinishedRef = useRef(false);
|
||||||
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
|
const { getStepElapsed, resetStepTimer, startNewTurn } = useStepTimer();
|
||||||
const isGameRunningRef = useRef(false);
|
const isGameRunningRef = useRef(false);
|
||||||
const lastActivePlayerRef = useRef(null);
|
const lastActivePlayerRef = useRef(null);
|
||||||
|
const heartbeatPubIntervalRef = useRef(null);
|
||||||
|
const opponentLastSeenRef = useRef(0);
|
||||||
|
const isTrueRestoredRef = useRef(false);
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 1500;
|
||||||
|
|
||||||
/** REST mutations / queries */
|
/** REST mutations / queries */
|
||||||
|
|
||||||
@@ -75,43 +78,193 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
|
|
||||||
/** Game-start helpers (triggered by server events) */
|
/** Game-start helpers (triggered by server events) */
|
||||||
|
|
||||||
const wInit = (revealedCells = []) => {
|
const wInit = (revealedCells = [], lastStep = {}, gameState = {}, isGameFinished = false) => {
|
||||||
setGridReady(true);
|
/** Detect if this is a restored game */
|
||||||
showOverlay('Choose an opponent!', gameAssoc ? (
|
const isRestoredGame = 0 < revealedCells.length;
|
||||||
|
isTrueRestoredRef.current = isRestoredGame;
|
||||||
|
|
||||||
|
/** Store game finished status */
|
||||||
|
isGameFinishedRef.current = isGameFinished;
|
||||||
|
|
||||||
|
/** Apply game state (points, bonus) immediately for restored games */
|
||||||
|
if (0 < Object.keys(gameState).length) {
|
||||||
|
const {
|
||||||
|
redPoints = 0,
|
||||||
|
bluePoints = 0,
|
||||||
|
redBonusPoints = 0,
|
||||||
|
blueBonusPoints = 0,
|
||||||
|
redBonusStats = {},
|
||||||
|
blueBonusStats = {},
|
||||||
|
} = gameState;
|
||||||
|
syncRed(p => ({
|
||||||
|
...p,
|
||||||
|
mines: redPoints,
|
||||||
|
bonusPoints: redBonusPoints,
|
||||||
|
bonusStats: redBonusStats,
|
||||||
|
}));
|
||||||
|
syncBlue(p => ({
|
||||||
|
...p,
|
||||||
|
mines: bluePoints,
|
||||||
|
bonusPoints: blueBonusPoints,
|
||||||
|
bonusStats: blueBonusStats,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply revealed cells immediately (not in setTimeout) */
|
||||||
|
if (0 < revealedCells.length) {
|
||||||
|
setCells(prev => {
|
||||||
|
let next = prev.map(r => [...r]);
|
||||||
|
revealedCells.forEach(({ row, col, value, player }) => {
|
||||||
|
if (next[row][col].active) return;
|
||||||
|
/** Check if this cell is the last step for either player */
|
||||||
|
const isRedLastStep = lastStep.red && lastStep.red.player === player && lastStep.red.row === row && lastStep.red.col === col;
|
||||||
|
const isBlueLastStep = lastStep.blue && lastStep.blue.player === player && lastStep.blue.row === row && lastStep.blue.col === col;
|
||||||
|
const patch = 'm' === value
|
||||||
|
? { currentImage: IMAGES.flag(player), currentObj: 'm', active: true }
|
||||||
|
: { currentImage: value, currentObj: value, active: true };
|
||||||
|
if (isRedLastStep || isBlueLastStep) {
|
||||||
|
patch.lastClickedRed = 'red' === player;
|
||||||
|
patch.lastClickedBlue = 'blue' === player;
|
||||||
|
}
|
||||||
|
next[row][col] = { ...next[row][col], ...patch };
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update the lastClickedRef so applyStep knows about it */
|
||||||
|
if (lastStep.red) {
|
||||||
|
lastClickedRef.current = {
|
||||||
|
...lastClickedRef.current,
|
||||||
|
red: [lastStep.red.row, lastStep.red.col],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lastStep.blue) {
|
||||||
|
lastClickedRef.current = {
|
||||||
|
...lastClickedRef.current,
|
||||||
|
blue: [lastStep.blue.row, lastStep.blue.col],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Determine overlay message */
|
||||||
|
let overlayTitle, overlaySubtitle;
|
||||||
|
|
||||||
|
if (isGameFinished) {
|
||||||
|
/** Game is finished - show game over message */
|
||||||
|
const redPoints = gameState.redPoints ?? 0;
|
||||||
|
const bluePoints = gameState.bluePoints ?? 0;
|
||||||
|
const winner = redPoints > bluePoints ? 'Red' : 'Blue';
|
||||||
|
overlayTitle = `${winner} wins the game!`;
|
||||||
|
overlaySubtitle = 'Play again!';
|
||||||
|
/** Mark the game as ended */
|
||||||
|
endRef.current = true;
|
||||||
|
} else if (isRestoredGame) {
|
||||||
|
overlayTitle = 'Waiting for opponent to reconnect...';
|
||||||
|
overlaySubtitle = gameAssoc ? (
|
||||||
<WaitingOverlayContent
|
<WaitingOverlayContent
|
||||||
shareUrl={`${window.location.href}/${gameAssoc}`}
|
shareUrl={`${window.location.origin}/play/${gameAssoc}`}
|
||||||
|
currentGameAssoc={gameAssoc}
|
||||||
|
opponentName={opponentName}
|
||||||
|
inviteOnly
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
<p>Waiting for opponent to join...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
overlayTitle = 'Choose an opponent!';
|
||||||
|
overlaySubtitle = gameAssoc ? (
|
||||||
|
<WaitingOverlayContent
|
||||||
|
shareUrl={`${window.location.origin}/battle/${gameAssoc}`}
|
||||||
currentGameAssoc={gameAssoc}
|
currentGameAssoc={gameAssoc}
|
||||||
/>
|
/>
|
||||||
) : '');
|
) : '';
|
||||||
setTimeout(() => revealedCells.forEach(cell => applyRevealedCell(cell, cell.player)), 0);
|
}
|
||||||
|
|
||||||
|
showOverlay(overlayTitle, overlaySubtitle);
|
||||||
|
|
||||||
|
/** Use Promise.resolve to defer setGridReady slightly to ensure overlay is rendered first */
|
||||||
|
Promise.resolve().then(() => setGridReady(true));
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeGameStart = payload => {
|
const makeGameStart = (payload, lastStep = {}) => {
|
||||||
syncActivePlayer(1);
|
/** Don't start a finished game */
|
||||||
|
if (isGameFinishedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If game is being restored and has a most recent step, determine starter based on that */
|
||||||
|
let starterIsBlue;
|
||||||
|
|
||||||
|
/** lastStepRef contains the single most recent step from the server */
|
||||||
|
if (lastStepRef.current && lastStepRef.current.player) {
|
||||||
|
/** The NEXT player is opposite of who made the last step */
|
||||||
|
starterIsBlue = 'red' === lastStepRef.current.player; // If red played last, blue plays next
|
||||||
|
} else {
|
||||||
|
/** New game: blue always starts */
|
||||||
|
starterIsBlue = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const starterColor = starterIsBlue ? 'blue' : 'red';
|
||||||
|
const starterVal = starterIsBlue ? 1 : 0;
|
||||||
|
const starterDesc = starterColor === webPlayerRef.current ? DESC.you : DESC.buddy;
|
||||||
|
syncActivePlayer(starterVal);
|
||||||
syncRed(p => ({
|
syncRed(p => ({
|
||||||
...p,
|
...p,
|
||||||
name: payload.users.red || payload.users.redAnon || p.name,
|
name: payload.users.red || payload.users.redAnon || p.name,
|
||||||
registered: !!payload.users.red,
|
registered: !!payload.users.red,
|
||||||
avatar: payload.users.redAvatar ?? null,
|
avatar: payload.users.redAvatar ?? null,
|
||||||
|
desc: 'red' === starterColor ? starterDesc : '',
|
||||||
|
active: 'red' === starterColor,
|
||||||
}));
|
}));
|
||||||
syncBlue(p => ({
|
syncBlue(p => ({
|
||||||
...p,
|
...p,
|
||||||
name: payload.users.blue || payload.users.blueAnon || p.name,
|
name: payload.users.blue || payload.users.blueAnon || p.name,
|
||||||
registered: !!payload.users.blue,
|
registered: !!payload.users.blue,
|
||||||
avatar: payload.users.blueAvatar ?? null,
|
avatar: payload.users.blueAvatar ?? null,
|
||||||
desc: 'blue' === webPlayerRef.current ? DESC.you : DESC.buddy,
|
desc: 'blue' === starterColor ? starterDesc : '',
|
||||||
active: true,
|
active: 'blue' === starterColor,
|
||||||
}));
|
}));
|
||||||
isGameRunningRef.current = true;
|
isGameRunningRef.current = true;
|
||||||
lastActivePlayerRef.current = 1; // Blue starts
|
lastActivePlayerRef.current = starterVal;
|
||||||
startNewTurn();
|
startNewTurn();
|
||||||
resetStepTimer();
|
resetStepTimer();
|
||||||
|
/**
|
||||||
|
* For a truly restored game, keep the "Waiting for opponent..." overlay
|
||||||
|
* up until we actually see a heartbeat from the other player.
|
||||||
|
*/
|
||||||
|
if (!isTrueRestoredRef.current || 0 !== opponentLastSeenRef.current) {
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishHeartbeat = () => {
|
||||||
|
const me = webPlayerRef.current;
|
||||||
|
if (!me || endRef.current) return;
|
||||||
|
fetch('/api/game/heartbeat/' + gameAssoc, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ color: me }),
|
||||||
|
}).catch(e => isEnvDev && console.warn('Heartbeat publish failed', e));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
if (heartbeatPubIntervalRef.current) return;
|
||||||
|
publishHeartbeat();
|
||||||
|
heartbeatPubIntervalRef.current = setInterval(publishHeartbeat, HEARTBEAT_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatPubIntervalRef.current) {
|
||||||
|
clearInterval(heartbeatPubIntervalRef.current);
|
||||||
|
heartbeatPubIntervalRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mercure / SSE message handlers */
|
/** Mercure / SSE message handlers */
|
||||||
|
|
||||||
const wSubscribe = (payload, rpcUsers = null) => {
|
const wSubscribe = (payload, rpcUsers = null, lastStep = null) => {
|
||||||
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
|
isEnvDev && console.info((payload.user ?? 'user') + ' subscribed');
|
||||||
const firstUser = !rpcUsers;
|
const firstUser = !rpcUsers;
|
||||||
|
|
||||||
@@ -126,13 +279,34 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
&& (!connectionLostRef.current
|
&& (!connectionLostRef.current
|
||||||
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
|
|| (connectionLostRef.current && false === activePlayerRef.current && !endRef.current))
|
||||||
) {
|
) {
|
||||||
makeGameStart(payload);
|
makeGameStart(payload, lastStep);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const wUnsubscribe = payload => {
|
const wUnsubscribe = payload => {
|
||||||
isEnvDev && console.info(payload.msg);
|
isEnvDev && console.info(payload.msg);
|
||||||
showOverlay('The connection has been lost w/ your friend...', 'Please, restart the game!');
|
const isAuthenticated = '1' === document.getElementById('mine-wrapper')?.dataset.isAuthenticated;
|
||||||
|
const redirectPath = isAuthenticated ? '/profile' : '/';
|
||||||
|
const buttonText = isAuthenticated ? 'My Profile' : 'Homepage';
|
||||||
|
const buttonIcon = isAuthenticated ? 'fa-user' : 'fa-house';
|
||||||
|
|
||||||
|
showOverlay(
|
||||||
|
'The connection has been lost w/ your friend...',
|
||||||
|
(
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', width: '100%' }}>
|
||||||
|
<p style={{ margin: 0 }}>Please, restart the game!</p>
|
||||||
|
<a
|
||||||
|
className="game-overlay-profile"
|
||||||
|
href={redirectPath}
|
||||||
|
title={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
|
||||||
|
aria-label={isAuthenticated ? 'Go to your profile' : 'Go to homepage'}
|
||||||
|
>
|
||||||
|
<i className={`fa ${buttonIcon}`} />
|
||||||
|
{buttonText}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const wChallenge = payload => {
|
const wChallenge = payload => {
|
||||||
@@ -147,7 +321,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
|
body: JSON.stringify({ accepted: true, targetGameAssoc: gameAssoc }),
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
|
showOverlay('Challenge accepted!', 'Waiting for the challenger to join...');
|
||||||
}).catch(() => {});
|
}).catch(() => {
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDecline = () => {
|
const handleDecline = () => {
|
||||||
@@ -163,7 +338,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
currentGameAssoc={gameAssoc}
|
currentGameAssoc={gameAssoc}
|
||||||
/>
|
/>
|
||||||
) : '');
|
) : '');
|
||||||
}).catch(() => {});
|
}).catch(() => {
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
declineTimeout = setTimeout(handleDecline, 30000);
|
declineTimeout = setTimeout(handleDecline, 30000);
|
||||||
@@ -188,8 +364,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
|
isEnvDev && console.warn(payload.user + ' stepped to ' + payload.data.coords.join(','));
|
||||||
syncBombSelected(payload.data.bomb);
|
syncBombSelected(payload.data.bomb);
|
||||||
|
|
||||||
// Detect if turn switched (other player made a move)
|
/**
|
||||||
// After their move, it's now our turn (or the opposite player's turn)
|
* Detect if turn switched (other player made a move)
|
||||||
|
* After their move, it's now our turn (or the opposite player's turn)
|
||||||
|
*/
|
||||||
if (lastActivePlayerRef.current !== activePlayerRef.current) {
|
if (lastActivePlayerRef.current !== activePlayerRef.current) {
|
||||||
startNewTurn();
|
startNewTurn();
|
||||||
lastActivePlayerRef.current = activePlayerRef.current;
|
lastActivePlayerRef.current = activePlayerRef.current;
|
||||||
@@ -210,13 +388,23 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
if (undefined !== payload.type) {
|
if (undefined !== payload.type) {
|
||||||
if ('challenge' === payload.type) wChallenge(payload);
|
if ('challenge' === payload.type) wChallenge(payload);
|
||||||
else if ('challenge-response' === payload.type) wChallengeResponse(payload);
|
else if ('challenge-response' === payload.type) wChallengeResponse(payload);
|
||||||
|
else if ('heartbeat' === payload.type) {
|
||||||
|
const me = webPlayerRef.current;
|
||||||
|
if (me && payload.color && payload.color !== me) {
|
||||||
|
const wasFirst = 0 === opponentLastSeenRef.current;
|
||||||
|
opponentLastSeenRef.current = Date.now();
|
||||||
|
if (wasFirst && isTrueRestoredRef.current) {
|
||||||
|
hideOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (undefined !== payload.data) {
|
if (undefined !== payload.data) {
|
||||||
wTopic(payload);
|
wTopic(payload);
|
||||||
} else if (undefined === payload.msg) {
|
} else if (undefined === payload.msg) {
|
||||||
wSubscribe(payload, rpcUsersRef.current);
|
wSubscribe(payload, rpcUsersRef.current, lastStepRef.current);
|
||||||
} else {
|
} else {
|
||||||
wUnsubscribe(payload);
|
wUnsubscribe(payload);
|
||||||
}
|
}
|
||||||
@@ -236,8 +424,8 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
const url = new URL(hubUrl, window.location.origin);
|
const url = new URL(hubUrl, window.location.origin);
|
||||||
|
|
||||||
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
|
url.searchParams.append('topic', 'mineseeker/channel/' + gameAssoc);
|
||||||
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
|
|
||||||
|
|
||||||
|
if (subscriberJwt) url.searchParams.append('authorization', subscriberJwt);
|
||||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||||
|
|
||||||
const es = new EventSource(url.toString());
|
const es = new EventSource(url.toString());
|
||||||
@@ -278,8 +466,22 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rpcUsersRef.current = serverData.users;
|
rpcUsersRef.current = serverData.users;
|
||||||
|
lastStepRef.current = serverData.mostRecentStep || null;
|
||||||
|
|
||||||
|
/** Pass game state (points, bonus) to wInit */
|
||||||
|
const gameState = {
|
||||||
|
redPoints: serverData.redPoints ?? 0,
|
||||||
|
bluePoints: serverData.bluePoints ?? 0,
|
||||||
|
redBonusPoints: serverData.redBonusPoints ?? 0,
|
||||||
|
blueBonusPoints: serverData.blueBonusPoints ?? 0,
|
||||||
|
redBonusStats: serverData.redBonusStats ?? {},
|
||||||
|
blueBonusStats: serverData.blueBonusStats ?? {},
|
||||||
|
};
|
||||||
|
const isGameFinished = serverData.gameFinished ?? false;
|
||||||
|
wInit(serverData.revealedCells || [], serverData.lastStep || {}, gameState, isGameFinished);
|
||||||
|
|
||||||
|
/** Open event source after showing overlay */
|
||||||
openEventSource();
|
openEventSource();
|
||||||
wInit(serverData.revealedCells || []);
|
|
||||||
} else {
|
} else {
|
||||||
await startMutation.mutateAsync();
|
await startMutation.mutateAsync();
|
||||||
openEventSource();
|
openEventSource();
|
||||||
@@ -288,6 +490,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
|
|
||||||
isEnvDev && console.info('Connection initialised — joining channel');
|
isEnvDev && console.info('Connection initialised — joining channel');
|
||||||
await joinMutation.mutateAsync();
|
await joinMutation.mutateAsync();
|
||||||
|
startHeartbeat();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
isEnvDev && console.error('Connection error', e);
|
isEnvDev && console.error('Connection error', e);
|
||||||
setTimeout(() => window.location.reload(), 500);
|
setTimeout(() => window.location.reload(), 500);
|
||||||
@@ -295,6 +498,10 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
|
window.addEventListener('pagehide', () => navigator.sendBeacon('/api/game/leave/' + gameAssoc));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopHeartbeat();
|
||||||
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -338,7 +545,7 @@ const useServerCommunication = (gameAssoc, gameInherited, isEnvDev) => {
|
|||||||
resignProcess(webPlayerRef.current, result.uuid);
|
resignProcess(webPlayerRef.current, result.uuid);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
48
bun.lock
48
bun.lock
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
|
"@fontsource/changa-one": ["@fontsource/changa-one@5.2.8", "http://registry.npmjs.org/@fontsource/changa-one/-/changa-one-5.2.8.tgz", {}, "sha512-gagiU8sMWLs9ejh41NmrYlGdgasSYWFegz5/+22WqYdJVS9HZbaUEXj/6jj3ZKgi+dQZT0kYI+Nha2UQGbO/mA=="],
|
||||||
|
|
||||||
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
|
"@fontsource/open-sans": ["@fontsource/open-sans@5.2.7", "http://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.7.tgz", {}, "sha512-8yfgDYjE5O0vmTPdrcjV35y4yMnctsokmi9gN49Gcsr0sjzkMkR97AnKDe6OqZh2SFkYlR28fxOvi21bYEgMSw=="],
|
||||||
|
|
||||||
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="],
|
"@fontsource/rajdhani": ["@fontsource/rajdhani@5.2.7", "http://registry.npmjs.org/@fontsource/rajdhani/-/rajdhani-5.2.7.tgz", {}, "sha512-7Gy10U688fCdeFfYKebUF2TZotdgH/ghKyMsseXPmB60lpaUHC8aoCSJl5/OpZ+KHKSU2TqBfKfteVkcIXxTAQ=="],
|
||||||
|
|
||||||
@@ -146,11 +146,11 @@
|
|||||||
|
|
||||||
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
|
"@mui/utils": ["@mui/utils@9.0.0", "http://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@mui/types": "^9.0.0", "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-is": "^19.2.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg=="],
|
||||||
|
|
||||||
"@mui/x-charts": ["@mui/x-charts@9.0.1", "http://registry.npmjs.org/@mui/x-charts/-/x-charts-9.0.1.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.0", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-0LyhlGhUm07wGJY0d0U+hSljGS1EHKWgPBsTJ/lBNGDrNc4DI9zSbp4h802LN/eLwMUVXJSI7DH2W3Ef3WsqnQ=="],
|
"@mui/x-charts": ["@mui/x-charts@9.0.2", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "@mui/x-charts-vendor": "^9.0.0", "@mui/x-internal-gestures": "^9.0.2", "@mui/x-internals": "^9.0.0", "bezier-easing": "^2.1.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", "@mui/material": "^7.3.0 || ^9.0.0", "@mui/system": "^7.3.0 || ^9.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/react", "@emotion/styled"] }, "sha512-bKgjGD+uJbDN/g7tMjVmlNdm+iM4UkCJoYruQmgpQ0l+cip8Kn4kmn1iD//rZ35an+LdWaUZ4MHvMzV76D6EJw=="],
|
||||||
|
|
||||||
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
|
"@mui/x-charts-vendor": ["@mui/x-charts-vendor@9.0.0", "http://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@types/d3-array": "^3.2.2", "@types/d3-color": "^3.1.3", "@types/d3-format": "^3.0.4", "@types/d3-interpolate": "^3.0.4", "@types/d3-path": "^3.1.1", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", "@types/d3-time": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/d3-timer": "^3.0.2", "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-format": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-path": "^3.1.0", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", "d3-timer": "^3.0.1", "flatqueue": "^3.0.0", "internmap": "^2.0.3" } }, "sha512-Do91i+fZiNj/4LN5oaGpJoutolzDBDwdfw6tHrx2LKXDMCRlaImCfreLbdbkk7dFsi9fuIP7hWiMV4vDJKPJTA=="],
|
||||||
|
|
||||||
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.0", "http://registry.npmjs.org/@mui/x-internal-gestures/-/x-internal-gestures-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-+fW1EUai25GJbivGRsi3GX4GYsSvzFPvUEjmMgB4POkRBDjrEZNaLdVWfapT6DlWv/Vfbi08bYSuyvhPXGMZjw=="],
|
"@mui/x-internal-gestures": ["@mui/x-internal-gestures@9.0.2", "", { "dependencies": { "@babel/runtime": "^7.28.6" } }, "sha512-xCp99a7cSb7iH1bj4G524ooMOFe92H8m/rONCUiKyj7LvV1YUGzTfHgJysQgDCZJqHYaW7YAGLvwMUyEMZVzqQ=="],
|
||||||
|
|
||||||
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
|
"@mui/x-internals": ["@mui/x-internals@9.0.0", "http://registry.npmjs.org/@mui/x-internals/-/x-internals-9.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.6", "@mui/utils": "9.0.0", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-E/4rdg69JjhyybpPGypCjAKSKLLnSdCFM+O6P/nkUg47+qt3uftxQEhjQO53rcn6ahHl6du/uNZ9BLgeY6kYxQ=="],
|
||||||
|
|
||||||
@@ -230,9 +230,9 @@
|
|||||||
|
|
||||||
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@4.4.1", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.32.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ=="],
|
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@4.4.1", "http://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-4.4.1.tgz", { "dependencies": { "@typescript-eslint/utils": "^8.32.1", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "estraverse": "^5.3.0", "picomatch": "^4.0.2" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-CEigAk7eOLyHvdgmpZsKFwtiqS2wFwI1fn4j09IU9GmD4euFM4jEBAViWeCqaNLlbX2k2+A/Fq9cje4HQBXuJQ=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.97.0", "http://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", {}, "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg=="],
|
"@tanstack/query-core": ["@tanstack/query-core@5.99.0", "", {}, "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ=="],
|
||||||
|
|
||||||
"@tanstack/react-query": ["@tanstack/react-query@5.97.0", "http://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", { "dependencies": { "@tanstack/query-core": "5.97.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ=="],
|
"@tanstack/react-query": ["@tanstack/react-query@5.99.0", "", { "dependencies": { "@tanstack/query-core": "5.99.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "http://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
|
|
||||||
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "http://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
|
||||||
|
|
||||||
"howler": ["howler@2.1.2", "http://registry.npmjs.org/howler/-/howler-2.1.2.tgz", {}, "sha512-oKrTFaVXsDRoB/jik7cEpWKTj7VieoiuzMYJ7E/EU5ayvmpRhumCv3YQ3823zi9VTJkSWAhbryHnlZAionGAJg=="],
|
"howler": ["howler@2.2.4", "", {}, "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "http://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
|
|
||||||
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
"prelude-ls": ["prelude-ls@1.2.1", "http://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
"prop-types": ["prop-types@15.7.2", "http://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.8.1" } }, "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ=="],
|
"prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "http://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
@@ -830,18 +830,6 @@
|
|||||||
|
|
||||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "http://registry.npmjs.org/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
"@mui/material/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"@mui/private-theming/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"@mui/styled-engine/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"@mui/system/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"@mui/utils/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"@mui/x-charts/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "http://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "http://registry.npmjs.org/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "http://registry.npmjs.org/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
@@ -850,38 +838,18 @@
|
|||||||
|
|
||||||
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
"babel-plugin-macros/resolve": ["resolve@1.22.12", "http://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||||
|
|
||||||
"eslint-plugin-react/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "http://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
"micromatch/picomatch": ["picomatch@2.3.2", "http://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||||
|
|
||||||
"prop-types/react-is": ["react-is@16.11.0", "http://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", {}, "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw=="],
|
"prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||||
|
|
||||||
"react-transition-group/prop-types": ["prop-types@15.8.1", "http://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
|
||||||
|
|
||||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "http://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||||
|
|
||||||
"@mui/material/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@mui/private-theming/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@mui/styled-engine/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@mui/system/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@mui/utils/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@mui/x-charts/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "http://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||||
|
|
||||||
"eslint-plugin-react/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"react-transition-group/prop-types/react-is": ["react-is@16.13.1", "http://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
|
||||||
|
|
||||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "http://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"doctrine/doctrine-migrations-bundle": "^3.0",
|
"doctrine/doctrine-migrations-bundle": "^3.0",
|
||||||
"doctrine/orm": "^2.6",
|
"doctrine/orm": "^2.6",
|
||||||
"endroid/qr-code": "^6.1",
|
"endroid/qr-code": "^6.1",
|
||||||
|
"firebase/php-jwt": "^7.0",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"league/flysystem-bundle": "^3.6",
|
"league/flysystem-bundle": "^3.6",
|
||||||
"liip/imagine-bundle": "^2.13",
|
"liip/imagine-bundle": "^2.13",
|
||||||
@@ -43,7 +44,6 @@
|
|||||||
"web-auth/webauthn-framework": "^5.2"
|
"web-auth/webauthn-framework": "^5.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"firebase/php-jwt": "^7.0",
|
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"symfony/dotenv": "7.4.*",
|
"symfony/dotenv": "7.4.*",
|
||||||
"symfony/maker-bundle": "^1.5",
|
"symfony/maker-bundle": "^1.5",
|
||||||
|
|||||||
137
composer.lock
generated
137
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "68e0a67890fc1a4a01f1f2154b477054",
|
"content-hash": "cd6be4d237e7c8f70cc45e42e14eac8a",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
@@ -1780,6 +1780,70 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-02-05T07:01:58+00:00"
|
"time": "2026-02-05T07:01:58+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "firebase/php-jwt",
|
||||||
|
"version": "v7.0.5",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/googleapis/php-jwt.git",
|
||||||
|
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
||||||
|
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"guzzlehttp/guzzle": "^7.4",
|
||||||
|
"phpfastcache/phpfastcache": "^9.2",
|
||||||
|
"phpspec/prophecy-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^9.5",
|
||||||
|
"psr/cache": "^2.0||^3.0",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"psr/http-factory": "^1.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
||||||
|
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Firebase\\JWT\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-3-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Neuman Vong",
|
||||||
|
"email": "neuman+pear@twilio.com",
|
||||||
|
"role": "Developer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Anant Narayanan",
|
||||||
|
"email": "anant@php.net",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
||||||
|
"homepage": "https://github.com/firebase/php-jwt",
|
||||||
|
"keywords": [
|
||||||
|
"jwt",
|
||||||
|
"php"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/googleapis/php-jwt/issues",
|
||||||
|
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
|
||||||
|
},
|
||||||
|
"time": "2026-04-01T20:38:03+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/guzzle",
|
"name": "guzzlehttp/guzzle",
|
||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
@@ -9848,70 +9912,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
{
|
|
||||||
"name": "firebase/php-jwt",
|
|
||||||
"version": "v7.0.5",
|
|
||||||
"source": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/googleapis/php-jwt.git",
|
|
||||||
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
|
|
||||||
},
|
|
||||||
"dist": {
|
|
||||||
"type": "zip",
|
|
||||||
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
|
||||||
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
|
|
||||||
"shasum": ""
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"php": "^8.0"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"guzzlehttp/guzzle": "^7.4",
|
|
||||||
"phpfastcache/phpfastcache": "^9.2",
|
|
||||||
"phpspec/prophecy-phpunit": "^2.0",
|
|
||||||
"phpunit/phpunit": "^9.5",
|
|
||||||
"psr/cache": "^2.0||^3.0",
|
|
||||||
"psr/http-client": "^1.0",
|
|
||||||
"psr/http-factory": "^1.0"
|
|
||||||
},
|
|
||||||
"suggest": {
|
|
||||||
"ext-sodium": "Support EdDSA (Ed25519) signatures",
|
|
||||||
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
|
|
||||||
},
|
|
||||||
"type": "library",
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"Firebase\\JWT\\": "src"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notification-url": "https://packagist.org/downloads/",
|
|
||||||
"license": [
|
|
||||||
"BSD-3-Clause"
|
|
||||||
],
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Neuman Vong",
|
|
||||||
"email": "neuman+pear@twilio.com",
|
|
||||||
"role": "Developer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Anant Narayanan",
|
|
||||||
"email": "anant@php.net",
|
|
||||||
"role": "Developer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
|
|
||||||
"homepage": "https://github.com/firebase/php-jwt",
|
|
||||||
"keywords": [
|
|
||||||
"jwt",
|
|
||||||
"php"
|
|
||||||
],
|
|
||||||
"support": {
|
|
||||||
"issues": "https://github.com/googleapis/php-jwt/issues",
|
|
||||||
"source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
|
|
||||||
},
|
|
||||||
"time": "2026-04-01T20:38:03+00:00"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "nikic/php-parser",
|
"name": "nikic/php-parser",
|
||||||
"version": "v5.7.0",
|
"version": "v5.7.0",
|
||||||
@@ -11289,16 +11289,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "dev",
|
||||||
"stability-flags": {
|
"stability-flags": {
|
||||||
"roave/security-advisories": 20
|
"roave/security-advisories": 20
|
||||||
},
|
},
|
||||||
"prefer-stable": false,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": ">=8.5",
|
"php": ">=8.5",
|
||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-json": "*"
|
"ext-json": "*",
|
||||||
|
"ext-gd": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
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`
|
||||||
|
|
||||||
|
|
||||||
168
docs/game-mechanics/BONUS_POINTS_SYSTEM.md
Normal file
168
docs/game-mechanics/BONUS_POINTS_SYSTEM.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Battle Report Display Components
|
||||||
|
|
||||||
|
**IMPORTANT**: The Bonus Statistics display appears in **two places** that must be kept in sync:
|
||||||
|
|
||||||
|
### 1. Public Battle Share Page
|
||||||
|
**File**: `/templates/Game/battle_share.html.twig`
|
||||||
|
- Displays via `bshare-bonus` CSS classes
|
||||||
|
- Backend data passed from `ProfileController::battleShare()`
|
||||||
|
- Shows bonus stats as HTML table format
|
||||||
|
|
||||||
|
### 2. Profile Dialog (BattleDialog component)
|
||||||
|
**File**: `/assets/js/components/BattleDialog.jsx`
|
||||||
|
- React component using Material-UI Dialog
|
||||||
|
- Displays inside the match details modal on profile page
|
||||||
|
- Shows bonus stats using `StatRow` components in side-by-side boxes
|
||||||
|
|
||||||
|
### Synchronization Requirements
|
||||||
|
|
||||||
|
When making changes to the bonus statistics display, update **BOTH** files:
|
||||||
|
|
||||||
|
1. **Update logic/data** → Edit both template and component
|
||||||
|
2. **Change stat names** → Update both BONUS_LABELS and both display files
|
||||||
|
3. **Modify formatting** → Keep visual consistency between both displays
|
||||||
|
4. **Add new stats** → Add to both the `.twig` template AND the `.jsx` component
|
||||||
|
|
||||||
|
**Checklist for changes**:
|
||||||
|
- [ ] Update `/src/Util/TopicManager.php` if bonus calculation changes
|
||||||
|
- [ ] Update `/templates/Game/battle_share.html.twig` for public display
|
||||||
|
- [ ] Update `/assets/js/components/BattleDialog.jsx` for profile dialog
|
||||||
|
- [ ] Update `/assets/js/mine-seeker/utils/constants.jsx` if adding new stats
|
||||||
|
- [ ] Test both displays show identical data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- [ ] Both battle report displays tested
|
||||||
|
|
||||||
26
package.json
26
package.json
@@ -21,24 +21,24 @@
|
|||||||
"@fontsource/rajdhani": "^5.2.7",
|
"@fontsource/rajdhani": "^5.2.7",
|
||||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@mui/material": "^9.0.0",
|
"@mui/material": "^9.0.0",
|
||||||
"@mui/x-charts": "^9.0.1",
|
"@mui/x-charts": "^9.0.2",
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"howler": "^2.1.2",
|
"howler": "^2.2.4",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.2.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.39.4",
|
||||||
"@stylistic/eslint-plugin": "^4.0.0",
|
"@stylistic/eslint-plugin": "^4.4.1",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react": "^7.0.0",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.15.0",
|
||||||
"sass": "^1.77.0",
|
"sass": "^1.99.0",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
"vite-plugin-symfony": "^8.2.4"
|
"vite-plugin-symfony": "^8.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
1
public/images/technologies/postgresql.svg
Normal file
1
public/images/technologies/postgresql.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="576.095" height="593.844" viewBox="0 0 432.071 445.383"><g style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4"><path d="M323.205 324.227c2.833-23.601 1.984-27.062 19.563-23.239l4.463.392c13.517.615 31.199-2.174 41.587-7 22.362-10.376 35.622-27.7 13.572-23.148-50.297 10.376-53.755-6.655-53.755-6.655 53.111-78.803 75.313-178.836 56.149-203.322-52.27-66.789-142.748-35.206-144.262-34.386l-.482.089c-9.938-2.062-21.06-3.294-33.554-3.496-22.761-.374-40.032 5.967-53.133 15.904 0 0-161.408-66.498-153.899 83.628 1.597 31.936 45.777 241.655 98.47 178.31 19.259-23.163 37.871-42.748 37.871-42.748 9.242 6.14 20.307 9.272 31.912 8.147l.897-.765c-.281 2.876-.157 5.689.359 9.019-13.572 15.167-9.584 17.83-36.723 23.416-27.457 5.659-11.326 15.734-.797 18.367 12.768 3.193 42.305 7.716 62.268-20.224l-.795 3.188c5.325 4.26 4.965 30.619 5.72 49.452.756 18.834 2.017 36.409 5.856 46.771 3.839 10.36 8.369 37.05 44.036 29.406 29.809-6.388 52.6-15.582 54.677-101.107" style="fill:#000;stroke:#000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter"/><path stroke="none" d="M402.395 271.23c-50.302 10.376-53.76-6.655-53.76-6.655 53.111-78.808 75.313-178.843 56.153-203.326-52.27-66.785-142.752-35.2-144.262-34.38l-.486.087c-9.938-2.063-21.06-3.292-33.56-3.496-22.761-.373-40.026 5.967-53.127 15.902 0 0-161.411-66.495-153.904 83.63 1.597 31.938 45.776 241.657 98.471 178.312 19.26-23.163 37.869-42.748 37.869-42.748 9.243 6.14 20.308 9.272 31.908 8.147l.901-.765c-.28 2.876-.152 5.689.361 9.019-13.575 15.167-9.586 17.83-36.723 23.416-27.459 5.659-11.328 15.734-.796 18.367 12.768 3.193 42.307 7.716 62.266-20.224l-.796 3.188c5.319 4.26 9.054 27.711 8.428 48.969s-1.044 35.854 3.147 47.254 8.368 37.05 44.042 29.406c29.809-6.388 45.256-22.942 47.405-50.555 1.525-19.631 4.976-16.729 5.194-34.28l2.768-8.309c3.192-26.611.507-35.196 18.872-31.203l4.463.392c13.517.615 31.208-2.174 41.591-7 22.358-10.376 35.618-27.7 13.573-23.148z" style=""/><path d="M215.866 286.484c-1.385 49.516.348 99.377 5.193 111.495 4.848 12.118 15.223 35.688 50.9 28.045 29.806-6.39 40.651-18.756 45.357-46.051 3.466-20.082 10.148-75.854 11.005-87.281M173.104 38.256S11.583-27.76 19.092 122.365c1.597 31.938 45.779 241.664 98.473 178.316 19.256-23.166 36.671-41.335 36.671-41.335M260.349 26.207c-5.591 1.753 89.848-34.889 144.087 34.417 19.159 24.484-3.043 124.519-56.153 203.329"/><path d="M348.282 263.953s3.461 17.036 53.764 6.653c22.04-4.552 8.776 12.774-13.577 23.155-18.345 8.514-59.474 10.696-60.146-1.069-1.729-30.355 21.647-21.133 19.96-28.739-1.525-6.85-11.979-13.573-18.894-30.338-6.037-14.633-82.796-126.849 21.287-110.183 3.813-.789-27.146-99.002-124.553-100.599-97.385-1.597-94.19 119.762-94.19 119.762" style="stroke-linejoin:bevel"/><path d="M188.604 274.334c-13.577 15.166-9.584 17.829-36.723 23.417-27.459 5.66-11.326 15.733-.797 18.365 12.768 3.195 42.307 7.718 62.266-20.229 6.078-8.509-.036-22.086-8.385-25.547-4.034-1.671-9.428-3.765-16.361 3.994"/><path d="M187.715 274.069c-1.368-8.917 2.93-19.528 7.536-31.942 6.922-18.626 22.893-37.255 10.117-96.339-9.523-44.029-73.396-9.163-73.436-3.193-.039 5.968 2.889 30.26-1.067 58.548-5.162 36.913 23.488 68.132 56.479 64.938"/><path d="M172.517 141.7c-.288 2.039 3.733 7.48 8.976 8.207 5.234.73 9.714-3.522 9.998-5.559.284-2.039-3.732-4.285-8.977-5.015-5.237-.731-9.719.333-9.996 2.367z" style="fill:#fff;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M331.941 137.543c.284 2.039-3.732 7.48-8.976 8.207-5.238.73-9.718-3.522-10.005-5.559-.277-2.039 3.74-4.285 8.979-5.015s9.718.333 10.002 2.368z" style="fill:#fff;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter"/><path d="M350.676 123.432c.863 15.994-3.445 26.888-3.988 43.914-.804 24.748 11.799 53.074-7.191 81.435"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -12,16 +12,15 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\ContactMessage;
|
use App\Entity\ContactMessage;
|
||||||
use App\Form\ContactFormType;
|
use App\Form\ContactFormType;
|
||||||
|
use App\Service\Email\SendContactMailService;
|
||||||
|
use App\Service\MercureJwtService;
|
||||||
|
use App\Service\ResolveUserNamesService;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
|
||||||
use RuntimeException;
|
|
||||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
|
||||||
use Symfony\Component\Mailer\MailerInterface;
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
@@ -43,11 +42,9 @@ class GameController extends AbstractController
|
|||||||
private readonly string $env,
|
private readonly string $env,
|
||||||
#[Autowire(env: 'MERCURE_PUBLIC_URL')]
|
#[Autowire(env: 'MERCURE_PUBLIC_URL')]
|
||||||
private readonly string $mercurePublicUrl,
|
private readonly string $mercurePublicUrl,
|
||||||
#[Autowire(env: 'MERCURE_SUBSCRIBER_JWT')]
|
private readonly MercureJwtService $mercureJwtService,
|
||||||
private readonly string $mercureSubscriberJwt,
|
private readonly ResolveUserNamesService $opponentNameService,
|
||||||
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
|
private readonly SendContactMailService $contactMailService,
|
||||||
private readonly string $appContactMailAddress,
|
|
||||||
private readonly LoggerInterface $logger,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +56,15 @@ class GameController extends AbstractController
|
|||||||
|
|
||||||
#[Route('/play', name: 'MineSeekerBundle_gamePlay')]
|
#[Route('/play', name: 'MineSeekerBundle_gamePlay')]
|
||||||
#[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')]
|
#[Route('/play/{gameAssoc}', name: 'MineSeekerBundle_gamePlayWId')]
|
||||||
public function play(): Response
|
public function play(?string $gameAssoc = null): Response
|
||||||
{
|
{
|
||||||
return $this->render('Game/play.html.twig', [
|
return $this->render('Game/play.html.twig', [
|
||||||
'env' => $this->env,
|
'env' => $this->env,
|
||||||
'mercure_hub_url' => $this->mercurePublicUrl,
|
'mercure_hub_url' => $this->mercurePublicUrl,
|
||||||
'mercure_subscriber_jwt' => $this->mercureSubscriberJwt,
|
'mercure_subscriber_jwt' => $this->mercureJwtService->mintSubscriberToken(
|
||||||
|
$gameAssoc ?? '', $this->opponentNameService->resolveUserName(),
|
||||||
|
),
|
||||||
|
'opponent_name' => $this->opponentNameService->opponentName($gameAssoc),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +92,11 @@ class GameController extends AbstractController
|
|||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$contactMessage->setIpAddress($request->getClientIp());
|
$contactMessage->setIpAddress($request->getClientIp());
|
||||||
|
|
||||||
$em->persist($contactMessage);
|
$em->persist($contactMessage);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
$this->sendMail($mailer, $contactMessage);
|
|
||||||
|
$this->contactMailService->send($contactMessage);
|
||||||
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
|
$this->addFlash('contact_success', 'Thank you for your message! We will get back to you soon.');
|
||||||
|
|
||||||
return $this->redirectToRoute('MineSeekerBundle_contact');
|
return $this->redirectToRoute('MineSeekerBundle_contact');
|
||||||
@@ -116,31 +118,4 @@ class GameController extends AbstractController
|
|||||||
{
|
{
|
||||||
return $this->render('Official/rules.html.twig');
|
return $this->render('Official/rules.html.twig');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendMail(MailerInterface $mailer, ContactMessage $contactMessage): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$mailer->send(
|
|
||||||
new TemplatedEmail()
|
|
||||||
->from('noreply@mineseeker.hu')
|
|
||||||
->to($this->appContactMailAddress)
|
|
||||||
->replyTo($contactMessage->getEmail())
|
|
||||||
->subject('New Contact Message from ' . $contactMessage->getName())
|
|
||||||
->htmlTemplate('emails/contact_notification.html.twig')
|
|
||||||
->context(['message' => $contactMessage])
|
|
||||||
);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'message' => $contactMessage,
|
|
||||||
]);
|
|
||||||
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
|
||||||
} catch (TransportExceptionInterface $e) {
|
|
||||||
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
|
||||||
'exception' => $e,
|
|
||||||
'message' => $contactMessage,
|
|
||||||
]);
|
|
||||||
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\PlayedGame;
|
use App\Entity\PlayedGame;
|
||||||
use App\Repository\PlayedGameRepository;
|
use App\Repository\PlayedGameRepository;
|
||||||
|
use App\Service\ResolveUserNamesService;
|
||||||
use App\Util\RpcManager;
|
use App\Util\RpcManager;
|
||||||
use App\Util\TopicManager;
|
use App\Util\TopicManager;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -41,6 +42,7 @@ class MercureController extends AbstractController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TopicManager $topicManager,
|
private readonly TopicManager $topicManager,
|
||||||
private readonly RpcManager $rpcManager,
|
private readonly RpcManager $rpcManager,
|
||||||
|
private readonly ResolveUserNamesService $userNamesService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,15 +58,18 @@ class MercureController extends AbstractController
|
|||||||
#[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
|
#[Route('/api/game/connect/{gameAssoc}', name: 'MineSeekerBundle_api_game_connect', methods: ['GET'])]
|
||||||
public function connect(string $gameAssoc): Response
|
public function connect(string $gameAssoc): Response
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
|
$payload = $this->rpcManager->getConnectInformation($gameAssoc);
|
||||||
|
|
||||||
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
|
return new Response($payload, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return new Response('', Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])]
|
#[Route('/api/game/join/{gameAssoc}', name: 'MineSeekerBundle_api_game_join', methods: ['POST'])]
|
||||||
public function join(string $gameAssoc, Request $request): JsonResponse
|
public function join(string $gameAssoc): JsonResponse
|
||||||
{
|
{
|
||||||
$this->topicManager->subscribe($gameAssoc, $this->resolveUserName($request), $this->getUser(), $request);
|
$this->topicManager->subscribe($gameAssoc, $this->userNamesService->resolveUserName());
|
||||||
|
|
||||||
return $this->json(['success' => true]);
|
return $this->json(['success' => true]);
|
||||||
}
|
}
|
||||||
@@ -72,15 +77,15 @@ class MercureController extends AbstractController
|
|||||||
#[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])]
|
#[Route('/api/game/step/{gameAssoc}', name: 'MineSeekerBundle_api_game_step', methods: ['POST'])]
|
||||||
public function step(string $gameAssoc, Request $request): JsonResponse
|
public function step(string $gameAssoc, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$result = $this->topicManager->publish($gameAssoc, $this->resolveUserName($request), $request->toArray());
|
$result = $this->topicManager->publish($gameAssoc, $this->userNamesService->resolveUserName(), $request->toArray());
|
||||||
|
|
||||||
return $this->json($result);
|
return $this->json($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])]
|
#[Route('/api/game/leave/{gameAssoc}', name: 'MineSeekerBundle_api_game_leave', methods: ['POST'])]
|
||||||
public function leave(string $gameAssoc, Request $request): JsonResponse
|
public function leave(string $gameAssoc): JsonResponse
|
||||||
{
|
{
|
||||||
$this->topicManager->unSubscribe($gameAssoc, $this->resolveUserName($request));
|
$this->topicManager->unSubscribe($gameAssoc, $this->userNamesService->resolveUserName());
|
||||||
|
|
||||||
return $this->json(['success' => true]);
|
return $this->json(['success' => true]);
|
||||||
}
|
}
|
||||||
@@ -95,7 +100,11 @@ class MercureController extends AbstractController
|
|||||||
return $this->json(['success' => true]);
|
return $this->json(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/api/game/challenge/respond/{challengerGameAssoc}', name: 'MineSeekerBundle_api_game_challenge_respond', methods: ['POST'])]
|
#[Route(
|
||||||
|
'/api/game/challenge/respond/{challengerGameAssoc}',
|
||||||
|
name: 'MineSeekerBundle_api_game_challenge_respond',
|
||||||
|
methods: ['POST'],
|
||||||
|
)]
|
||||||
public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
|
public function challengeRespond(string $challengerGameAssoc, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->toArray();
|
$data = $request->toArray();
|
||||||
@@ -106,6 +115,19 @@ class MercureController extends AbstractController
|
|||||||
return $this->json(['success' => true]);
|
return $this->json(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/api/game/heartbeat/{gameAssoc}', name: 'MineSeekerBundle_api_game_heartbeat', methods: ['POST'])]
|
||||||
|
public function heartbeat(string $gameAssoc, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->toArray();
|
||||||
|
$color = $data['color'] ?? '';
|
||||||
|
if ('red' !== $color && 'blue' !== $color) {
|
||||||
|
return $this->json(['success' => false], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
$this->topicManager->publishHeartbeat($gameAssoc, $color);
|
||||||
|
|
||||||
|
return $this->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
|
#[Route('/api/game/waiting', name: 'MineSeekerBundle_api_game_waiting', methods: ['GET'])]
|
||||||
public function waiting(PlayedGameRepository $repo): JsonResponse
|
public function waiting(PlayedGameRepository $repo): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -128,20 +150,4 @@ class MercureController extends AbstractController
|
|||||||
|
|
||||||
return $this->json($result);
|
return $this->json($result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveUserName(Request $request): string
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (null !== $user) {
|
|
||||||
return $user->getUserIdentifier();
|
|
||||||
}
|
|
||||||
|
|
||||||
$sessionId = $request->getSession()->getId();
|
|
||||||
if (empty($sessionId)) {
|
|
||||||
$sessionId = bin2hex(random_bytes(16));
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'anon_' . $sessionId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,16 +112,22 @@ class ProfileController extends AbstractController
|
|||||||
|
|
||||||
$months = array_column(array_values($monthlyData), 'label');
|
$months = array_column(array_values($monthlyData), 'label');
|
||||||
|
|
||||||
|
$bonus = $this->repo->findBonusStatsForUser($user);
|
||||||
|
|
||||||
return $this->render('Security/profile.html.twig', [
|
return $this->render('Security/profile.html.twig', [
|
||||||
'stats' => [
|
'stats' => [
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'wins' => $wins,
|
'wins' => $wins,
|
||||||
'losses' => $losses,
|
'losses' => $losses,
|
||||||
'draws' => $draws,
|
'draws' => $draws,
|
||||||
'bombs' => $this->repo->countBombsForUser($user),
|
'minesHit' => $this->repo->findTotalMinesForUser($user),
|
||||||
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
|
'winRate' => $total > 0 ? (int)round($wins / $total * 100) : 0,
|
||||||
'avgScore' => $this->repo->findAvgScoreForUser($user),
|
'avgScore' => $this->repo->findAvgScoreForUser($user),
|
||||||
'bestScore' => $this->repo->findBestScoreForUser($user),
|
'bonusPoints' => $bonus['totalBonusPoints'],
|
||||||
|
'avgBonus' => $bonus['avgBonusPoints'],
|
||||||
|
'bestChain' => $bonus['bestChain'],
|
||||||
|
'blindHits' => $bonus['totalBlindHits'],
|
||||||
|
'edgeMines' => $bonus['totalEdgeMines'],
|
||||||
],
|
],
|
||||||
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
|
'recent' => ($recent = $this->repo->findRecentFinishedForUser($user)),
|
||||||
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
|
'gamesData' => array_map(function (PlayedGame $game) use ($userId, $cacheManager): array {
|
||||||
@@ -163,6 +169,10 @@ class ProfileController extends AbstractController
|
|||||||
'result' => $result,
|
'result' => $result,
|
||||||
'myPoints' => $myPts,
|
'myPoints' => $myPts,
|
||||||
'oppPoints' => $oppPts,
|
'oppPoints' => $oppPts,
|
||||||
|
'redBonusPoints' => $game->getRedBonusPoints() ?? 0,
|
||||||
|
'blueBonusPoints' => $game->getBlueBonusPoints() ?? 0,
|
||||||
|
'redBonusStats' => $game->getRedBonusStats() ?? [],
|
||||||
|
'blueBonusStats' => $game->getBlueBonusStats() ?? [],
|
||||||
];
|
];
|
||||||
}, $recent),
|
}, $recent),
|
||||||
'chartData' => [
|
'chartData' => [
|
||||||
@@ -173,10 +183,34 @@ class ProfileController extends AbstractController
|
|||||||
'pieWins' => $wins,
|
'pieWins' => $wins,
|
||||||
'pieLosses' => $losses,
|
'pieLosses' => $losses,
|
||||||
'pieDraws' => $draws,
|
'pieDraws' => $draws,
|
||||||
|
'recentGames' => $this->buildRecentGamesSeries($user, $userId),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build per-game data for the last 15 finished games, oldest → newest.
|
||||||
|
*
|
||||||
|
* @return array{labels:string[],mines:int[],bonus:float[]}
|
||||||
|
*/
|
||||||
|
private function buildRecentGamesSeries(User $user, int $userId): array
|
||||||
|
{
|
||||||
|
$recent = $this->repo->findRecentFinishedForUser($user, 15);
|
||||||
|
$recent = array_reverse($recent);
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
$mines = [];
|
||||||
|
$bonus = [];
|
||||||
|
foreach ($recent as $i => $game) {
|
||||||
|
$isRed = $game->getRed()?->getId() === $userId;
|
||||||
|
$labels[] = '#' . ($i + 1);
|
||||||
|
$mines[] = (int) ($isRed ? $game->getRedPoints() : $game->getBluePoints());
|
||||||
|
$bonus[] = (float) ($isRed ? $game->getRedBonusPoints() : $game->getBlueBonusPoints()) ?: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['labels' => $labels, 'mines' => $mines, 'bonus' => $bonus];
|
||||||
|
}
|
||||||
|
|
||||||
#[Route(
|
#[Route(
|
||||||
'/battle/{uuid}',
|
'/battle/{uuid}',
|
||||||
name: 'MineSeekerBundle_battle_share',
|
name: 'MineSeekerBundle_battle_share',
|
||||||
@@ -197,6 +231,10 @@ class ProfileController extends AbstractController
|
|||||||
$resign = $game->getResign();
|
$resign = $game->getResign();
|
||||||
$redAvatar = $game->getRed()?->getAvatarPath();
|
$redAvatar = $game->getRed()?->getAvatarPath();
|
||||||
$blueAvatar = $game->getBlue()?->getAvatarPath();
|
$blueAvatar = $game->getBlue()?->getAvatarPath();
|
||||||
|
$redBonusPoints = $game->getRedBonusPoints() ?? 0;
|
||||||
|
$blueBonusPoints = $game->getBlueBonusPoints() ?? 0;
|
||||||
|
$redBonusStats = $game->getRedBonusStats() ?? [];
|
||||||
|
$blueBonusStats = $game->getBlueBonusStats() ?? [];
|
||||||
|
|
||||||
if ($resign === 'red') {
|
if ($resign === 'red') {
|
||||||
$summary = "$redName resigned — $blueName wins";
|
$summary = "$redName resigned — $blueName wins";
|
||||||
@@ -223,6 +261,10 @@ class ProfileController extends AbstractController
|
|||||||
'resign' => $resign,
|
'resign' => $resign,
|
||||||
'redAvatar' => $redAvatar,
|
'redAvatar' => $redAvatar,
|
||||||
'blueAvatar' => $blueAvatar,
|
'blueAvatar' => $blueAvatar,
|
||||||
|
'redBonusPoints' => $redBonusPoints,
|
||||||
|
'blueBonusPoints' => $blueBonusPoints,
|
||||||
|
'redBonusStats' => $redBonusStats,
|
||||||
|
'blueBonusStats' => $blueBonusStats,
|
||||||
'ogTitle' => "MineSeeker · $summary",
|
'ogTitle' => "MineSeeker · $summary",
|
||||||
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
'ogDesc' => "Watch the battle replay: $summary — played on MineSeeker, the multiplayer minesweeper.",
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
|
|
||||||
namespace App\Interfaces;
|
namespace App\Interfaces;
|
||||||
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface TopicManagerInterface
|
* Interface TopicManagerInterface
|
||||||
*
|
*
|
||||||
@@ -25,7 +22,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
|||||||
*/
|
*/
|
||||||
interface TopicManagerInterface
|
interface TopicManagerInterface
|
||||||
{
|
{
|
||||||
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void;
|
public function subscribe(string $gameAssoc, string $userName): void;
|
||||||
|
|
||||||
public function unSubscribe(string $gameAssoc, string $userName): void;
|
public function unSubscribe(string $gameAssoc, string $userName): void;
|
||||||
|
|
||||||
|
|||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,6 +260,21 @@ class PlayedGameRepository extends ServiceEntityRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findTotalMinesForUser(User $user): int
|
||||||
|
{
|
||||||
|
$conn = $this->getEntityManager()->getConnection();
|
||||||
|
|
||||||
|
$result = $conn->executeQuery(
|
||||||
|
'SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN g.red_id = :uid THEN g.red_points ELSE g.blue_points END), 0) AS total_pts
|
||||||
|
FROM played_game g
|
||||||
|
WHERE (g.red_id = :uid OR g.blue_id = :uid)',
|
||||||
|
['uid' => $user->getId()],
|
||||||
|
)->fetchAssociative();
|
||||||
|
|
||||||
|
return (int) ($result['total_pts'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
public function findAvgScoreForUser(User $user): int
|
public function findAvgScoreForUser(User $user): int
|
||||||
{
|
{
|
||||||
$conn = $this->getEntityManager()->getConnection();
|
$conn = $this->getEntityManager()->getConnection();
|
||||||
@@ -284,6 +299,49 @@ class PlayedGameRepository extends ServiceEntityRepository
|
|||||||
return (int) round((float) $result['total_pts'] / (int) $result['total_games']);
|
return (int) round((float) $result['total_pts'] / (int) $result['total_games']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates bonus points and bonus stats across all finished games for a user.
|
||||||
|
*
|
||||||
|
* @return array{totalBonusPoints:float,avgBonusPoints:float,bestChain:int,totalBlindHits:int,totalEdgeMines:int}
|
||||||
|
*/
|
||||||
|
public function findBonusStatsForUser(User $user): array
|
||||||
|
{
|
||||||
|
$userId = $user->getId();
|
||||||
|
$qb = $this->createQueryBuilder('g');
|
||||||
|
$qb->where($qb->expr()->orX(
|
||||||
|
$qb->expr()->eq('g.red', ':u'),
|
||||||
|
$qb->expr()->eq('g.blue', ':u'),
|
||||||
|
))->setParameter('u', $user);
|
||||||
|
|
||||||
|
/** @var PlayedGame[] $games */
|
||||||
|
$games = $qb->getQuery()->getResult();
|
||||||
|
|
||||||
|
$totalBonusPoints = 0.0;
|
||||||
|
$bestChain = 0;
|
||||||
|
$totalBlindHits = 0;
|
||||||
|
$totalEdgeMines = 0;
|
||||||
|
$gameCount = 0;
|
||||||
|
|
||||||
|
foreach ($games as $game) {
|
||||||
|
$isRed = $game->getRed()?->getId() === $userId;
|
||||||
|
$totalBonusPoints += (float) (($isRed ? $game->getRedBonusPoints() : $game->getBlueBonusPoints()) ?? 0.0);
|
||||||
|
|
||||||
|
$stats = ($isRed ? $game->getRedBonusStats() : $game->getBlueBonusStats()) ?? [];
|
||||||
|
$bestChain = max($bestChain, (int) ($stats['chainBest'] ?? 0));
|
||||||
|
$totalBlindHits += (int) ($stats['blindHits'] ?? 0);
|
||||||
|
$totalEdgeMines += (int) ($stats['edgeMines'] ?? 0);
|
||||||
|
$gameCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'totalBonusPoints' => round($totalBonusPoints, 1),
|
||||||
|
'avgBonusPoints' => 0 < $gameCount ? round($totalBonusPoints / $gameCount, 1) : 0.0,
|
||||||
|
'bestChain' => $bestChain,
|
||||||
|
'totalBlindHits' => $totalBlindHits,
|
||||||
|
'totalEdgeMines' => $totalEdgeMines,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function findBestScoreForUser(User $user): int
|
public function findBestScoreForUser(User $user): int
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\PlayedGame;
|
||||||
use App\Entity\Step;
|
use App\Entity\Step;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\ORM\NonUniqueResultException;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class StepRepository
|
* Class StepRepository
|
||||||
@@ -35,4 +38,47 @@ class StepRepository extends ServiceEntityRepository
|
|||||||
{
|
{
|
||||||
parent::__construct($registry, Step::class);
|
parent::__construct($registry, Step::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findMostRecent(PlayedGame $playedGame): ?Step
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->createQueryBuilder('s')
|
||||||
|
->andWhere('s.playedGame = :game')
|
||||||
|
->setParameter('game', $playedGame)
|
||||||
|
->orderBy('s.created', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
} catch (NonUniqueResultException $e) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
sprintf(
|
||||||
|
'Expected at most one result for the most recent step of game ID %d, but got multiple.',
|
||||||
|
$playedGame->getId(),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
$e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findMostRecentForPlayer(PlayedGame $playedGame, string $player): ?Step
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->createQueryBuilder('s')
|
||||||
|
->andWhere('s.playedGame = :game')
|
||||||
|
->andWhere('s.player = :player')
|
||||||
|
->setParameter('game', $playedGame)
|
||||||
|
->setParameter('player', $player)
|
||||||
|
->orderBy('s.created', 'DESC')
|
||||||
|
->setMaxResults(1)
|
||||||
|
->getQuery()
|
||||||
|
->getOneOrNullResult();
|
||||||
|
} catch (NonUniqueResultException $e) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Expected at most one result for the most recent step of player "%s" in game ID %d, but got multiple.',
|
||||||
|
0,
|
||||||
|
$e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ class BattleCardGenerator
|
|||||||
{
|
{
|
||||||
$path = $this->cachePath((int)$game->getId());
|
$path = $this->cachePath((int)$game->getId());
|
||||||
|
|
||||||
|
// Always regenerate to ensure bonus points are included
|
||||||
if (is_file($path)) {
|
if (is_file($path)) {
|
||||||
return $path;
|
unlink($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_dir($this->cacheDir)) {
|
if (!is_dir($this->cacheDir)) {
|
||||||
@@ -154,6 +155,12 @@ class BattleCardGenerator
|
|||||||
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
|
$scoreText = $redPts !== null && $bluePts !== null ? $redPts . ' : ' . $bluePts : 'VS';
|
||||||
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
|
$this->centeredText($im, $scoreText, 72, self::WIDTH / 2, 390, $white);
|
||||||
|
|
||||||
|
/** Bonus points below score*/
|
||||||
|
$redBonusPoints = $game->getRedBonusPoints() ?? 0;
|
||||||
|
$blueBonusPoints = $game->getBlueBonusPoints() ?? 0;
|
||||||
|
$bonusText = number_format((float)$redBonusPoints, 1, '.', '') . ' * : * ' . number_format((float)$blueBonusPoints, 1, '.', '');
|
||||||
|
$this->centeredText($im, $bonusText, 24, self::WIDTH / 2, 425, $gold);
|
||||||
|
|
||||||
if ($winner === 'red') {
|
if ($winner === 'red') {
|
||||||
$resultText = $redName . ' wins';
|
$resultText = $redName . ' wins';
|
||||||
$resultColor = $gold;
|
$resultColor = $gold;
|
||||||
@@ -169,11 +176,11 @@ class BattleCardGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($resultText !== '') {
|
if ($resultText !== '') {
|
||||||
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 460, $resultColor);
|
$this->centeredText($im, $resultText, 30, self::WIDTH / 2, 475, $resultColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($resign) {
|
if ($resign) {
|
||||||
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 498, $muted);
|
$this->centeredText($im, ucfirst($resign) . ' resigned', 18, self::WIDTH / 2, 508, $muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
|
$this->centeredText($im, 'mineseeker.hu', 16, self::WIDTH / 2, self::HEIGHT - 20, $muted);
|
||||||
|
|||||||
67
src/Service/Email/SendContactMailService.php
Normal file
67
src/Service/Email/SendContactMailService.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?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\Service\Email;
|
||||||
|
|
||||||
|
use App\Entity\ContactMessage;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class SendContactMailService
|
||||||
|
*
|
||||||
|
* @package App\Service\Email
|
||||||
|
* @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. 19.
|
||||||
|
*/
|
||||||
|
readonly final class SendContactMailService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')]
|
||||||
|
private string $appContactMailAddress,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
private MailerInterface $mailer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(ContactMessage $contactMessage): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->mailer->send(
|
||||||
|
new TemplatedEmail()
|
||||||
|
->from('noreply@mineseeker.hu')
|
||||||
|
->to($this->appContactMailAddress)
|
||||||
|
->replyTo($contactMessage->getEmail())
|
||||||
|
->subject('New Contact Message from ' . $contactMessage->getName())
|
||||||
|
->htmlTemplate('emails/contact_notification.html.twig')
|
||||||
|
->context(['message' => $contactMessage])
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'message' => $contactMessage,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
||||||
|
} catch (TransportExceptionInterface $e) {
|
||||||
|
$this->logger->error('Failed to send contact notification email: ' . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
'message' => $contactMessage,
|
||||||
|
]);
|
||||||
|
throw new RuntimeException('Failed to send contact notification email: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Service/MercureJwtService.php
Normal file
53
src/Service/MercureJwtService.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?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\Service;
|
||||||
|
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class MercureJwtService
|
||||||
|
*
|
||||||
|
* Mints Mercure subscriber JWTs carrying an identifying payload so the hub's
|
||||||
|
* /subscriptions endpoint can report which known player is connected.
|
||||||
|
*
|
||||||
|
* @package App\Service
|
||||||
|
* @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. 19.
|
||||||
|
*/
|
||||||
|
final readonly class MercureJwtService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(env: 'MERCURE_JWT_SECRET')]
|
||||||
|
private string $secret,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mintSubscriberToken(string $gameAssoc, string $userName): string
|
||||||
|
{
|
||||||
|
return JWT::encode(
|
||||||
|
[
|
||||||
|
'mercure' => [
|
||||||
|
'subscribe' => ['*'],
|
||||||
|
'payload' => [
|
||||||
|
'username' => $userName,
|
||||||
|
'gameAssoc' => $gameAssoc,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$this->secret,
|
||||||
|
'HS256'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/Service/ResolveUserNamesService.php
Normal file
91
src/Service/ResolveUserNamesService.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?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\Service;
|
||||||
|
|
||||||
|
use App\Entity\PlayedGame;
|
||||||
|
use App\Repository\PlayedGameRepository;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ResolveUserNamesService
|
||||||
|
*
|
||||||
|
* This only works when a restored game is started
|
||||||
|
*
|
||||||
|
* @package App\Service
|
||||||
|
* @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. 19.
|
||||||
|
*/
|
||||||
|
readonly final class ResolveUserNamesService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private RequestStack $requestStack,
|
||||||
|
private Security $security,
|
||||||
|
private PlayedGameRepository $playedGameRepository,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function opponentName(?string $gameAssoc = null): string
|
||||||
|
{
|
||||||
|
$userName = $this->resolveUserName();
|
||||||
|
|
||||||
|
if (null === $gameAssoc) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $game = $this->playedGameRepository->findOneByGameAssoc($gameAssoc)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveOpponentName($game, $userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveUserName(): string
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
if (null !== $user) {
|
||||||
|
return $user->getUserIdentifier();
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $this->requestStack->getCurrentRequest()->getSession();
|
||||||
|
|
||||||
|
if (!$session->isStarted()) {
|
||||||
|
$session->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
return "anon_{$session->getId()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOpponentName(PlayedGame $game, string $myUserName): string
|
||||||
|
{
|
||||||
|
$redName = $game->getRed()?->getUsername();
|
||||||
|
$blueName = $game->getBlue()?->getUsername();
|
||||||
|
$redAnonName = $game->getRedAnon()?->getUserName();
|
||||||
|
$blueAnonName = $game->getBlueAnon()?->getUserName();
|
||||||
|
|
||||||
|
$isRed = $myUserName === $redName || $myUserName === $redAnonName;
|
||||||
|
$isBlue = $myUserName === $blueName || $myUserName === $blueAnonName;
|
||||||
|
|
||||||
|
if ($isRed) {
|
||||||
|
return $blueName ?? ('' !== ($blueAnonName ?? '') ? 'Guest' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isBlue) {
|
||||||
|
return $redName ?? ('' !== ($redAnonName ?? '') ? 'Guest' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ namespace App\Util;
|
|||||||
use App\Entity\Grid;
|
use App\Entity\Grid;
|
||||||
use App\Entity\GridRow;
|
use App\Entity\GridRow;
|
||||||
use App\Entity\PlayedGame;
|
use App\Entity\PlayedGame;
|
||||||
|
use App\Entity\Step;
|
||||||
use App\Interfaces\RpcManagerInterface;
|
use App\Interfaces\RpcManagerInterface;
|
||||||
use App\Repository\PlayedGameRepository;
|
use App\Repository\PlayedGameRepository;
|
||||||
|
use App\Repository\StepRepository;
|
||||||
use DateTime;
|
use DateTime;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -41,9 +43,10 @@ class RpcManager implements RpcManagerInterface
|
|||||||
private const int MINES = 51;
|
private const int MINES = 51;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
private readonly PlayedGameRepository $playedGameRepository,
|
private readonly PlayedGameRepository $playedGameRepository,
|
||||||
|
private readonly StepRepository $stepRepository,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +61,15 @@ class RpcManager implements RpcManagerInterface
|
|||||||
return base64_encode(json_encode([
|
return base64_encode(json_encode([
|
||||||
'users' => null,
|
'users' => null,
|
||||||
'revealedCells' => null,
|
'revealedCells' => null,
|
||||||
|
'lastStep' => ['red' => null, 'blue' => null],
|
||||||
|
'mostRecentStep' => null,
|
||||||
|
'redPoints' => 0,
|
||||||
|
'bluePoints' => 0,
|
||||||
|
'redBonusPoints' => 0,
|
||||||
|
'blueBonusPoints' => 0,
|
||||||
|
'redBonusStats' => [],
|
||||||
|
'blueBonusStats' => [],
|
||||||
|
'gameFinished' => false,
|
||||||
], JSON_THROW_ON_ERROR));
|
], JSON_THROW_ON_ERROR));
|
||||||
} catch (JsonException $e) {
|
} catch (JsonException $e) {
|
||||||
throw new RuntimeException($e->getMessage());
|
throw new RuntimeException($e->getMessage());
|
||||||
@@ -68,15 +80,42 @@ class RpcManager implements RpcManagerInterface
|
|||||||
$revealedCells = $this->aggregateRevealedCells($playedGame);
|
$revealedCells = $this->aggregateRevealedCells($playedGame);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$redPoints = $playedGame->getRedPoints() ?? 0;
|
||||||
|
$bluePoints = $playedGame->getBluePoints() ?? 0;
|
||||||
|
$gameFinished = $redPoints > 25 || $bluePoints > 25;
|
||||||
|
|
||||||
return base64_encode(json_encode([
|
return base64_encode(json_encode([
|
||||||
'users' => $users,
|
'users' => $users,
|
||||||
'revealedCells' => $revealedCells,
|
'revealedCells' => $revealedCells,
|
||||||
|
'lastStep' => $this->getLastStepPerPlayer($playedGame),
|
||||||
|
'mostRecentStep' => $this->getMostRecentStep($playedGame),
|
||||||
|
'redPoints' => $redPoints,
|
||||||
|
'bluePoints' => $bluePoints,
|
||||||
|
'redBonusPoints' => $playedGame->getRedBonusPoints() ?? 0,
|
||||||
|
'blueBonusPoints' => $playedGame->getBlueBonusPoints() ?? 0,
|
||||||
|
'redBonusStats' => $playedGame->getRedBonusStats() ?? [],
|
||||||
|
'blueBonusStats' => $playedGame->getBlueBonusStats() ?? [],
|
||||||
|
'gameFinished' => $gameFinished,
|
||||||
], JSON_THROW_ON_ERROR));
|
], JSON_THROW_ON_ERROR));
|
||||||
} catch (JsonException $e) {
|
} catch (JsonException $e) {
|
||||||
throw new RuntimeException($e->getMessage());
|
throw new RuntimeException($e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recent step of the game (if any).
|
||||||
|
* Returns an array with player, row, col information or null if no steps exist.
|
||||||
|
*/
|
||||||
|
private function getMostRecentStep(PlayedGame $playedGame): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->stepToArray($this->stepRepository->findMostRecent($playedGame));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logger->error('Error getting most recent step: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function saveGrid(string $gameAssoc): bool
|
public function saveGrid(string $gameAssoc): bool
|
||||||
{
|
{
|
||||||
$existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
|
$existingGame = $this->playedGameRepository->findOneByGameAssoc($gameAssoc);
|
||||||
@@ -94,20 +133,20 @@ class RpcManager implements RpcManagerInterface
|
|||||||
$gridRow = new GridRow();
|
$gridRow = new GridRow();
|
||||||
$gridRow->setGridCol($row);
|
$gridRow->setGridCol($row);
|
||||||
$gridRow->setGrid($grid);
|
$gridRow->setGrid($grid);
|
||||||
$this->entityManager->persist($gridRow);
|
$this->em->persist($gridRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
$grid->setPlayedGame($playedGame);
|
$grid->setPlayedGame($playedGame);
|
||||||
$this->entityManager->persist($grid);
|
$this->em->persist($grid);
|
||||||
|
|
||||||
$playedGame->setGameAssoc($gameAssoc);
|
$playedGame->setGameAssoc($gameAssoc);
|
||||||
$playedGame->setUuid(Uuid::fromString($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());
|
||||||
$this->entityManager->persist($playedGame);
|
$this->em->persist($playedGame);
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->em->flush();
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->logger->error($e->getMessage());
|
$this->logger->error($e->getMessage());
|
||||||
}
|
}
|
||||||
@@ -128,6 +167,7 @@ class RpcManager implements RpcManagerInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fisher-Yates shuffle
|
* Fisher-Yates shuffle
|
||||||
|
*
|
||||||
* @see https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_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--) {
|
||||||
@@ -185,6 +225,37 @@ class RpcManager implements RpcManagerInterface
|
|||||||
return $all;
|
return $all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last step for each player.
|
||||||
|
* Returns an array with 'red' and 'blue' keys, each containing row, col information or null if no steps exist for
|
||||||
|
* that player.
|
||||||
|
*/
|
||||||
|
private function getLastStepPerPlayer(PlayedGame $playedGame): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
'red' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'red')),
|
||||||
|
'blue' => $this->stepToArray($this->stepRepository->findMostRecentForPlayer($playedGame, 'blue')),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logger->error('Error getting last step per player: ' . $e->getMessage());
|
||||||
|
return ['red' => null, 'blue' => null];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stepToArray(?Step $step): ?array
|
||||||
|
{
|
||||||
|
if (null === $step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'player' => $step->getPlayer(),
|
||||||
|
'row' => (int)$step->getRow(),
|
||||||
|
'col' => (int)$step->getCol(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function getUserCollection(PlayedGame $playedGame): array
|
private function getUserCollection(PlayedGame $playedGame): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ use JsonException;
|
|||||||
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
use Liip\ImagineBundle\Imagine\Cache\CacheManager;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\Mercure\HubInterface;
|
use Symfony\Component\Mercure\HubInterface;
|
||||||
use Symfony\Component\Mercure\Update;
|
use Symfony\Component\Mercure\Update;
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class TopicManager
|
* Class TopicManager
|
||||||
@@ -44,18 +43,20 @@ 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,
|
private RequestStack $requestStack,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function subscribe(string $gameAssoc, string $userName, ?UserInterface $user, Request $request): void
|
public function subscribe(string $gameAssoc, string $userName): void
|
||||||
{
|
{
|
||||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||||
|
|
||||||
if (null === $playedGame) {
|
if (null === $playedGame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
|
|
||||||
/** Save the player to the database on a fresh join */
|
/** Save the player to the database on a fresh join */
|
||||||
if (!$isKnown && $count < 2) {
|
if (!$isKnown && $count < 2) {
|
||||||
$users = $this->saveUserToDb($gameAssoc, $userName, $user, $count + 1, $request);
|
$users = $this->saveUserToDb($gameAssoc, $userName, $count + 1);
|
||||||
$count = $this->getPlayerCount($users);
|
$count = $this->getPlayerCount($users);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +97,9 @@ 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->entityManager->flush();
|
$this->em->persist($playedGame);
|
||||||
|
$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 +123,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 +178,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,7 +221,7 @@ 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);
|
||||||
@@ -221,6 +238,10 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
'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 +281,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 +584,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 +595,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,36 +608,44 @@ 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);
|
if ((bool)$event['bomb']) {
|
||||||
$playedGame->setBlueExplodedBomb((bool)$event['bomb'] && 'blue' === $player ? true : null);
|
if ('red' === $player) {
|
||||||
|
$playedGame->setRedExplodedBomb(true);
|
||||||
|
} elseif ('blue' === $player) {
|
||||||
|
$playedGame->setBlueExplodedBomb(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
$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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveUserToDb(
|
private function saveUserToDb(string $gameAssoc, string $userName, int $count): array
|
||||||
string $gameAssoc,
|
{
|
||||||
string $userName,
|
|
||||||
?UserInterface $user,
|
|
||||||
int $count,
|
|
||||||
Request $request
|
|
||||||
): array {
|
|
||||||
$playedGame = $this->getPlayedGame($gameAssoc);
|
$playedGame = $this->getPlayedGame($gameAssoc);
|
||||||
|
|
||||||
null !== $user
|
null !== $this->requestStack->getCurrentRequest()->getUser()
|
||||||
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
? $this->saveRegisteredUser($userName, $count, $playedGame)
|
||||||
: $this->saveAnonUser($userName, $count, $playedGame, $request);
|
: $this->saveAnonUser($userName, $count, $playedGame);
|
||||||
|
|
||||||
$this->entityManager->persist($playedGame);
|
$this->em->persist($playedGame);
|
||||||
$this->entityManager->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return $this->getUserCollection($playedGame);
|
return $this->getUserCollection($playedGame);
|
||||||
}
|
}
|
||||||
@@ -490,16 +669,17 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame, Request $request): void
|
private function saveAnonUser(string $userName, int $count, PlayedGame $playedGame): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$anon = new Gamer();
|
$anon = new Gamer();
|
||||||
$anon->setUserName($userName);
|
$anon->setUserName($userName);
|
||||||
$anon->setIp($request->getClientIp());
|
$anon->setIp($this->requestStack->getCurrentRequest()->getClientIp());
|
||||||
$anon->setCountry($this->extractCountry($request));
|
$anon->setCountry($this->extractCountry());
|
||||||
$anon->setUserAgent($request->headers->get('User-Agent'));
|
$anon->setUserAgent($this->requestStack->getCurrentRequest()->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);
|
||||||
@@ -537,6 +717,7 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
{
|
{
|
||||||
$challengerGame = $this->getPlayedGame($challengerGameAssoc);
|
$challengerGame = $this->getPlayedGame($challengerGameAssoc);
|
||||||
$challengerName = 'Unknown';
|
$challengerName = 'Unknown';
|
||||||
|
|
||||||
if (null !== $challengerGame) {
|
if (null !== $challengerGame) {
|
||||||
$users = $this->getUserCollection($challengerGame);
|
$users = $this->getUserCollection($challengerGame);
|
||||||
$challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
|
$challengerName = $users['red'] ?: $users['redAnon'] ?: $users['blue'] ?: $users['blueAnon'] ?: 'Unknown';
|
||||||
@@ -572,6 +753,22 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function publishHeartbeat(string $gameAssoc, string $color): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->hub->publish(new Update(
|
||||||
|
'mineseeker/channel/' . $gameAssoc,
|
||||||
|
json_encode([
|
||||||
|
'type' => 'heartbeat',
|
||||||
|
'color' => $color,
|
||||||
|
'ts' => (int)(microtime(true) * 1000),
|
||||||
|
], JSON_THROW_ON_ERROR)
|
||||||
|
));
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error('Heartbeat publish error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function publishToLobby(array $data): void
|
private function publishToLobby(array $data): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -584,7 +781,7 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function extractCountry(Request $request): ?string
|
private function extractCountry(): ?string
|
||||||
{
|
{
|
||||||
/** Common headers used by CDNs and proxies to pass country information */
|
/** Common headers used by CDNs and proxies to pass country information */
|
||||||
$countryHeaders = [
|
$countryHeaders = [
|
||||||
@@ -595,7 +792,7 @@ readonly class TopicManager implements TopicManagerInterface
|
|||||||
];
|
];
|
||||||
|
|
||||||
foreach ($countryHeaders as $header) {
|
foreach ($countryHeaders as $header) {
|
||||||
$country = $request->headers->get($header);
|
$country = $this->requestStack->getCurrentRequest()->headers->get($header);
|
||||||
|
|
||||||
if (empty($country)) {
|
if (empty($country)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="bshare-vs">
|
<div class="bshare-vs">
|
||||||
<div class="bshare-player bshare-player--red">
|
<div class="bshare-player bshare-player--red">
|
||||||
<div class="bshare-avatar bshare-avatar--red">
|
<div class="bshare-avatar bshare-avatar--red" style="position: relative;">
|
||||||
{% if redAvatar %}
|
{% if redAvatar %}
|
||||||
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
|
<img src="{{ redAvatar|imagine_filter('avatar_thumb') }}"
|
||||||
alt="{{ redName }}"
|
alt="{{ redName }}"
|
||||||
@@ -39,6 +39,11 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{{ redName|slice(0,2)|upper }}
|
{{ redName|slice(0,2)|upper }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if redBonusPoints > blueBonusPoints %}
|
||||||
|
<div style="position: absolute; bottom: -6px; right: -6px; background: #ffd700; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1); border: 2px solid rgba(0,0,0,0.5); z-index: 10;">
|
||||||
|
<i class="fas fa-star" style="color: #000; font-size: 14px;"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="bshare-player__name">{{ redName }}</span>
|
<span class="bshare-player__name">{{ redName }}</span>
|
||||||
<span class="bshare-player__side">Red</span>
|
<span class="bshare-player__side">Red</span>
|
||||||
@@ -53,6 +58,15 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="bshare-score bshare-score--na">— : —</div>
|
<div class="bshare-score bshare-score--na">— : —</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div style="display: flex; justify-content: center; gap: 0; align-items: center; margin-bottom: 8px;">
|
||||||
|
<span style="font: 700 13px 'Rajdhani', sans-serif; color: #f67d52; display: flex; align-items: center; gap: 4px;">
|
||||||
|
<i class="fas fa-star" style="font-size: 11px;"></i> {{ (redBonusPoints ?? 0)|number_format(1, '.', '') }}
|
||||||
|
</span>
|
||||||
|
<span style="font: 700 13px 'Rajdhani', sans-serif; color: rgba(255,255,255,0.3); margin: 0 8px;">:</span>
|
||||||
|
<span style="font: 700 13px 'Rajdhani', sans-serif; color: #95cff5; display: flex; align-items: center; gap: 4px;">
|
||||||
|
{{ (blueBonusPoints ?? 0)|number_format(1, '.', '') }} <i class="fas fa-star" style="font-size: 11px;"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="bshare-vs__label">VS</div>
|
<div class="bshare-vs__label">VS</div>
|
||||||
{% if resign == 'red' %}
|
{% if resign == 'red' %}
|
||||||
<div class="bshare-badge bshare-badge--blue">
|
<div class="bshare-badge bshare-badge--blue">
|
||||||
@@ -79,7 +93,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="bshare-player bshare-player--blue">
|
<div class="bshare-player bshare-player--blue">
|
||||||
<div class="bshare-avatar bshare-avatar--blue">
|
<div class="bshare-avatar bshare-avatar--blue" style="position: relative;">
|
||||||
{% if blueAvatar %}
|
{% if blueAvatar %}
|
||||||
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
|
<img src="{{ blueAvatar|imagine_filter('avatar_thumb') }}"
|
||||||
alt="{{ blueName }}"
|
alt="{{ blueName }}"
|
||||||
@@ -87,11 +101,32 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
{{ blueName|slice(0,2)|upper }}
|
{{ blueName|slice(0,2)|upper }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if blueBonusPoints > redBonusPoints %}
|
||||||
|
<div style="position: absolute; bottom: -6px; right: -6px; background: #ffd700; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; box-shadow: 0 0 12px rgba(255, 215, 0, 0.6), 0 0 0 2px rgba(7, 9, 13, 1); border: 2px solid rgba(0,0,0,0.5); z-index: 10;">
|
||||||
|
<i class="fas fa-star" style="color: #000; font-size: 14px;"></i>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="bshare-player__name">{{ blueName }}</span>
|
<span class="bshare-player__name">{{ blueName }}</span>
|
||||||
<span class="bshare-player__side">Blue</span>
|
<span class="bshare-player__side">Blue</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% set durationSec = (game.created and game.updated) ? (game.updated|date('U') - game.created|date('U')) : 0 %}
|
||||||
|
{% set durationStr = '' %}
|
||||||
|
{% if durationSec > 0 %}
|
||||||
|
{% set h = (durationSec / 3600)|round(0, 'floor') %}
|
||||||
|
{% set m = ((durationSec % 3600) / 60)|round(0, 'floor') %}
|
||||||
|
{% set s = durationSec % 60 %}
|
||||||
|
{% if h > 0 %}
|
||||||
|
{% set durationStr = h ~ 'h ' ~ m ~ 'm ' ~ s ~ 's' %}
|
||||||
|
{% elseif m > 0 %}
|
||||||
|
{% set durationStr = m ~ 'm ' ~ s ~ 's' %}
|
||||||
|
{% else %}
|
||||||
|
{% set durationStr = s ~ 's' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% set pointDiff = (redPts|default(0) - bluePts|default(0))|abs %}
|
||||||
|
{% set winnerName = redPts|default(0) > bluePts|default(0) ? redName : (bluePts|default(0) > redPts|default(0) ? blueName : null) %}
|
||||||
<div class="bshare-details">
|
<div class="bshare-details">
|
||||||
{% if resign %}
|
{% if resign %}
|
||||||
<div class="bshare-detail">
|
<div class="bshare-detail">
|
||||||
@@ -99,16 +134,28 @@
|
|||||||
<span>{{ resign|capitalize }} resigned</span>
|
<span>{{ resign|capitalize }} resigned</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if durationStr %}
|
||||||
|
<div class="bshare-detail">
|
||||||
|
<i class="fas fa-hourglass-half"></i>
|
||||||
|
<span>Match duration: {{ durationStr }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if pointDiff > 0 and winnerName %}
|
||||||
|
<div class="bshare-detail">
|
||||||
|
<i class="fas fa-balance-scale"></i>
|
||||||
|
<span>{{ winnerName }} won by {{ pointDiff }} mine{{ pointDiff == 1 ? '' : 's' }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if game.redExplodedBomb %}
|
{% if game.redExplodedBomb %}
|
||||||
<div class="bshare-detail bshare-detail--bomb">
|
<div class="bshare-detail bshare-detail--bomb">
|
||||||
<i class="fas fa-bomb"></i>
|
<i class="fas fa-bomb"></i>
|
||||||
<span>{{ redName }} hit a mine</span>
|
<span>{{ redName }} used their bomb</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if game.blueExplodedBomb %}
|
{% if game.blueExplodedBomb %}
|
||||||
<div class="bshare-detail bshare-detail--bomb">
|
<div class="bshare-detail bshare-detail--bomb">
|
||||||
<i class="fas fa-bomb"></i>
|
<i class="fas fa-bomb"></i>
|
||||||
<span>{{ blueName }} hit a mine</span>
|
<span>{{ blueName }} used their bomb</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if game.updated %}
|
{% if game.updated %}
|
||||||
@@ -118,6 +165,104 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% set hasRedStats = redBonusStats is not empty and (redBonusStats.blindHits or redBonusStats.chainBest or redBonusStats.edgeMines or redBonusStats.lastMineHits or redBonusStats.biggestReveal) %}
|
||||||
|
{% set hasBlueStats = blueBonusStats is not empty and (blueBonusStats.blindHits or blueBonusStats.chainBest or blueBonusStats.edgeMines or blueBonusStats.lastMineHits or blueBonusStats.biggestReveal) %}
|
||||||
|
{% if redBonusPoints > 0 or blueBonusPoints > 0 or hasRedStats or hasBlueStats %}
|
||||||
|
<div class="bshare-bonus">
|
||||||
|
<div class="bshare-bonus__title">
|
||||||
|
<i class="fas fa-star"></i> Bonus Statistics
|
||||||
|
</div>
|
||||||
|
<div class="bshare-bonus__grid">
|
||||||
|
{# Red Bonus #}
|
||||||
|
<div class="bshare-bonus__player bshare-bonus__player--red">
|
||||||
|
<div class="bshare-bonus__header">
|
||||||
|
<span class="bshare-bonus__points">{{ redBonusPoints|number_format(1, '.', '') }}</span>
|
||||||
|
<span class="bshare-bonus__label">pts</span>
|
||||||
|
</div>
|
||||||
|
<div class="bshare-bonus__stats">
|
||||||
|
{% if redBonusStats is not empty and redBonusStats.blindHits %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Blind hits</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ redBonusStats.blindHits }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if redBonusStats is not empty and redBonusStats.chainBest %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Best chain</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ redBonusStats.chainBest }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if redBonusStats is not empty and redBonusStats.edgeMines %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Edge mines</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ redBonusStats.edgeMines }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if redBonusStats is not empty and redBonusStats.lastMineHits %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Endgame mines</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ redBonusStats.lastMineHits }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if redBonusStats is not empty and redBonusStats.biggestReveal %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Biggest reveal</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ redBonusStats.biggestReveal }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not hasRedStats %}
|
||||||
|
<div class="bshare-bonus__stat bshare-bonus__stat--empty">
|
||||||
|
<span class="bshare-bonus__stat-label">No bonuses earned</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bshare-bonus__player bshare-bonus__player--blue">
|
||||||
|
<div class="bshare-bonus__header">
|
||||||
|
<span class="bshare-bonus__points">{{ blueBonusPoints|number_format(1, '.', '') }}</span>
|
||||||
|
<span class="bshare-bonus__label">pts</span>
|
||||||
|
</div>
|
||||||
|
<div class="bshare-bonus__stats">
|
||||||
|
{% if blueBonusStats is not empty and blueBonusStats.blindHits %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Blind hits</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ blueBonusStats.blindHits }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if blueBonusStats is not empty and blueBonusStats.chainBest %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Best chain</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ blueBonusStats.chainBest }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if blueBonusStats is not empty and blueBonusStats.edgeMines %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Edge mines</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ blueBonusStats.edgeMines }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if blueBonusStats is not empty and blueBonusStats.lastMineHits %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Endgame mines</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ blueBonusStats.lastMineHits }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if blueBonusStats is not empty and blueBonusStats.biggestReveal %}
|
||||||
|
<div class="bshare-bonus__stat">
|
||||||
|
<span class="bshare-bonus__stat-label">Biggest reveal</span>
|
||||||
|
<span class="bshare-bonus__stat-value">{{ blueBonusStats.biggestReveal }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not hasBlueStats %}
|
||||||
|
<div class="bshare-bonus__stat bshare-bonus__stat--empty">
|
||||||
|
<span class="bshare-bonus__stat-label">No bonuses earned</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="bshare-cta">
|
<div class="bshare-cta">
|
||||||
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
|
<a href="{{ path('MineSeekerBundle_gamePlay') }}" class="bshare-btn">
|
||||||
<i class="fas fa-play"></i> Play MineSeeker
|
<i class="fas fa-play"></i> Play MineSeeker
|
||||||
|
|||||||
@@ -228,6 +228,9 @@
|
|||||||
<a href="https://bun.sh" target="_blank" rel="noopener" class="tech-link">
|
<a href="https://bun.sh" target="_blank" rel="noopener" class="tech-link">
|
||||||
<img src="{{ asset('images/technologies/bun.svg') }}" alt="Bun"/>
|
<img src="{{ asset('images/technologies/bun.svg') }}" alt="Bun"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://www.postgresql.org" target="_blank" rel="noopener" class="tech-link">
|
||||||
|
<img src="{{ asset('images/technologies/postgresql.svg') }}" alt="PostgreSQL"/>
|
||||||
|
</a>
|
||||||
<a href="https://www.jetbrains.com/phpstorm" target="_blank" rel="noopener" class="tech-link">
|
<a href="https://www.jetbrains.com/phpstorm" target="_blank" rel="noopener" class="tech-link">
|
||||||
<img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/>
|
<img src="{{ asset('images/technologies/phpstorm.svg') }}" alt="PHPStorm"/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<div id="mine-wrapper"
|
<div id="mine-wrapper"
|
||||||
data-env="{{ env }}"
|
data-env="{{ env }}"
|
||||||
data-game-id="{{ app.request.get('gameAssoc') }}"
|
data-game-id="{{ app.request.get('gameAssoc') }}"
|
||||||
|
data-opponent-name="{{ opponent_name }}"
|
||||||
|
data-is-authenticated="{{ app.user ? '1' : '0' }}"
|
||||||
data-mercure-hub-url="{{ mercure_hub_url }}"
|
data-mercure-hub-url="{{ mercure_hub_url }}"
|
||||||
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
|
data-mercure-subscriber-jwt="{{ mercure_subscriber_jwt }}"
|
||||||
data-recaptcha-site-key="{{ recaptcha_site_key }}">
|
data-recaptcha-site-key="{{ recaptcha_site_key }}">
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
|
<script src="https://www.google.com/recaptcha/api.js?render={{ recaptcha_site_key }}" async defer></script>
|
||||||
{{ vite_entry_script_tags('contact') }}
|
{{ vite_entry_script_tags('contact', { dependency: 'react' }) }}
|
||||||
<div id="contact-form-wrapper"
|
<div id="contact-form-wrapper"
|
||||||
data-site-key="{{ recaptcha_site_key }}"
|
data-site-key="{{ recaptcha_site_key }}"
|
||||||
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
|
data-recaptcha-field-id="{{ form.recaptcha.vars.id }}">
|
||||||
|
|||||||
@@ -136,5 +136,5 @@
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ vite_entry_script_tags('passkey') }}
|
{{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
{% extends 'Game/index.html.twig' %}
|
{% extends 'Game/index.html.twig' %}
|
||||||
|
|
||||||
|
{% macro stat_val(value, suffix) %}
|
||||||
|
{%- set abbr = value >= 1000 -%}
|
||||||
|
<span class="profile-stat__value"{% if abbr %} title="{{ value }}"{% endif %}>{% if abbr %}{{ (value / 1000)|round(1, 'floor') }}k{% else %}{{ value }}{% endif %}{% if suffix %}<small>{{ suffix }}</small>{% endif %}</span>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block title %} - Profile{% endblock %}
|
{% block title %} - Profile{% endblock %}
|
||||||
|
|
||||||
{% block metas %}
|
{% block metas %}
|
||||||
@@ -45,44 +50,64 @@
|
|||||||
<div class="profile-stats">
|
<div class="profile-stats">
|
||||||
<div class="profile-stat">
|
<div class="profile-stat">
|
||||||
<i class="fas fa-gamepad profile-stat__icon"></i>
|
<i class="fas fa-gamepad profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.total }}</span>
|
{{ _self.stat_val(stats.total) }}
|
||||||
<span class="profile-stat__label">Games played</span>
|
<span class="profile-stat__label">Games played</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stat profile-stat--win">
|
<div class="profile-stat profile-stat--win">
|
||||||
<i class="fas fa-trophy profile-stat__icon"></i>
|
<i class="fas fa-trophy profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.wins }}</span>
|
{{ _self.stat_val(stats.wins) }}
|
||||||
<span class="profile-stat__label">Victories</span>
|
<span class="profile-stat__label">Victories</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stat profile-stat--loss">
|
<div class="profile-stat profile-stat--loss">
|
||||||
<i class="fas fa-flag profile-stat__icon"></i>
|
<i class="fas fa-flag profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.losses }}</span>
|
{{ _self.stat_val(stats.losses) }}
|
||||||
<span class="profile-stat__label">Defeats</span>
|
<span class="profile-stat__label">Defeats</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stat profile-stat--draw">
|
<div class="profile-stat profile-stat--draw">
|
||||||
<i class="fas fa-minus profile-stat__icon"></i>
|
<i class="fas fa-minus profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.draws }}</span>
|
{{ _self.stat_val(stats.draws) }}
|
||||||
<span class="profile-stat__label">Draws</span>
|
<span class="profile-stat__label">Draws</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stat profile-stat--rate">
|
<div class="profile-stat profile-stat--rate">
|
||||||
<i class="fas fa-percent profile-stat__icon"></i>
|
<i class="fas fa-percent profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.winRate }}<small>%</small></span>
|
{{ _self.stat_val(stats.winRate, '%') }}
|
||||||
<span class="profile-stat__label">Win rate</span>
|
<span class="profile-stat__label">Win rate</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stat profile-stat--avg">
|
<div class="profile-stat profile-stat--avg">
|
||||||
<i class="fas fa-chart-line profile-stat__icon"></i>
|
<i class="fas fa-chart-line profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.avgScore }}</span>
|
{{ _self.stat_val(stats.avgScore) }}
|
||||||
<span class="profile-stat__label">Avg score</span>
|
<span class="profile-stat__label">Avg score</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-stat profile-stat--best">
|
|
||||||
<i class="fas fa-star profile-stat__icon"></i>
|
|
||||||
<span class="profile-stat__value">{{ stats.bestScore }}</span>
|
|
||||||
<span class="profile-stat__label">Best score</span>
|
|
||||||
</div>
|
|
||||||
<div class="profile-stat profile-stat--bomb">
|
<div class="profile-stat profile-stat--bomb">
|
||||||
<i class="fas fa-bomb profile-stat__icon"></i>
|
<i class="fas fa-bomb profile-stat__icon"></i>
|
||||||
<span class="profile-stat__value">{{ stats.bombs }}</span>
|
{{ _self.stat_val(stats.minesHit) }}
|
||||||
<span class="profile-stat__label">Mines hit</span>
|
<span class="profile-stat__label">Mines hit</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--bonus">
|
||||||
|
<i class="fas fa-star profile-stat__icon"></i>
|
||||||
|
{{ _self.stat_val(stats.bonusPoints) }}
|
||||||
|
<span class="profile-stat__label">Bonus points</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--avg-bonus">
|
||||||
|
<i class="fas fa-chart-simple profile-stat__icon"></i>
|
||||||
|
{{ _self.stat_val(stats.avgBonus) }}
|
||||||
|
<span class="profile-stat__label">Avg bonus</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--chain">
|
||||||
|
<i class="fas fa-link profile-stat__icon"></i>
|
||||||
|
{{ _self.stat_val(stats.bestChain) }}
|
||||||
|
<span class="profile-stat__label">Best chain</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--blind">
|
||||||
|
<i class="fas fa-bullseye profile-stat__icon"></i>
|
||||||
|
{{ _self.stat_val(stats.blindHits) }}
|
||||||
|
<span class="profile-stat__label">Blind hits</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-stat profile-stat--edge">
|
||||||
|
<i class="fas fa-border-style profile-stat__icon"></i>
|
||||||
|
{{ _self.stat_val(stats.edgeMines) }}
|
||||||
|
<span class="profile-stat__label">Edge mines</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if stats.total > 0 %}
|
{% if stats.total > 0 %}
|
||||||
@@ -104,21 +129,35 @@
|
|||||||
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
|
{% set opp_anon = is_red ? game.blueAnon : game.redAnon %}
|
||||||
|
|
||||||
{% set result = 'draw' %}
|
{% set result = 'draw' %}
|
||||||
|
{% set is_finished = false %}
|
||||||
|
{% set is_anonymous = not opp and opp_anon %}
|
||||||
{% if game.resign == (is_red ? 'red' : 'blue') %}
|
{% if game.resign == (is_red ? 'red' : 'blue') %}
|
||||||
{% set result = 'loss' %}
|
{% set result = 'loss' %}
|
||||||
|
{% set is_finished = true %}
|
||||||
{% elseif game.resign == (is_red ? 'blue' : 'red') %}
|
{% elseif game.resign == (is_red ? 'blue' : 'red') %}
|
||||||
{% set result = 'win' %}
|
{% set result = 'win' %}
|
||||||
|
{% set is_finished = true %}
|
||||||
{% elseif my_points is not null and opp_points is not null %}
|
{% elseif my_points is not null and opp_points is not null %}
|
||||||
{% if my_points > opp_points %}
|
{% if my_points > opp_points %}
|
||||||
{% set result = 'win' %}
|
{% set result = 'win' %}
|
||||||
|
{% set is_finished = (my_points > 25 or opp_points > 25) %}
|
||||||
{% elseif my_points < opp_points %}
|
{% elseif my_points < opp_points %}
|
||||||
{% set result = 'loss' %}
|
{% set result = 'loss' %}
|
||||||
|
{% set is_finished = (my_points > 25 or opp_points > 25) %}
|
||||||
|
{% else %}
|
||||||
|
{% set is_finished = (my_points > 25 or opp_points > 25) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="profile-game profile-game--{{ result }}" data-game-index="{{ loop.index0 }}">
|
<div class="profile-game profile-game--{{ result }}{% if not is_finished and not is_anonymous %} profile-game--ongoing{% elseif is_anonymous %} profile-game--abandoned{% endif %}" data-game-index="{{ loop.index0 }}">
|
||||||
<span class="profile-game__badge">
|
<span class="profile-game__badge">
|
||||||
{{ result == 'win' ? 'W' : (result == 'loss' ? 'L' : 'D') }}
|
{% if is_finished %}
|
||||||
|
{{ result == 'win' ? 'Win' : (result == 'loss' ? 'Loss' : 'Draw') }}
|
||||||
|
{% elseif is_anonymous %}
|
||||||
|
Abandoned
|
||||||
|
{% else %}
|
||||||
|
Ongoing
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="profile-game__score">
|
<span class="profile-game__score">
|
||||||
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
|
{{ my_points ?? '—' }} : {{ opp_points ?? '—' }}
|
||||||
@@ -154,5 +193,5 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
{{ vite_entry_script_tags('profile') }}
|
{{ vite_entry_script_tags('profile', { dependency: 'react' }) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -133,5 +133,5 @@
|
|||||||
|
|
||||||
{% block javascripts %}
|
{% block javascripts %}
|
||||||
{{ parent() }}
|
{{ parent() }}
|
||||||
{{ vite_entry_script_tags('passkey') }}
|
{{ vite_entry_script_tags('passkey', { dependency: 'react' }) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user