Spaces:
Runtime error
Runtime error
Pedro de Carvalho commited on
Commit ·
4dfd938
1
Parent(s): e3af3dd
Update pages
Browse files- client/src/pages/Game.tsx +593 -83
- client/src/pages/Landing.tsx +1 -0
- client/src/pages/LocalSetup.tsx +128 -40
- client/src/pages/__tests__/LocalSetup.test.tsx +36 -1
client/src/pages/Game.tsx
CHANGED
|
@@ -40,6 +40,9 @@ import type { DatasetBotContext } from 'shared/bot/BotDefinitions.ts'
|
|
| 40 |
import { SeededRandom } from 'shared/random.ts'
|
| 41 |
import ThemeSelector from '../components/ThemeSelector.tsx'
|
| 42 |
import SymbolPicker from '../components/SymbolPicker.tsx'
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
type LocalPlayerType = 'human' | 'bot'
|
| 45 |
type BotProfile = 'normal' | 'expert' | 'ai'
|
|
@@ -53,6 +56,8 @@ interface LocalGameState {
|
|
| 53 |
player1Type?: LocalPlayerType
|
| 54 |
player2Type?: LocalPlayerType
|
| 55 |
botProfile?: LegacyBotProfile
|
|
|
|
|
|
|
| 56 |
rules?: GameRules
|
| 57 |
}
|
| 58 |
|
|
@@ -135,6 +140,358 @@ function createDatasetBotContext(targetEngine: GameEngine): DatasetBotContext {
|
|
| 135 |
}
|
| 136 |
}
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
export default function Game() {
|
| 139 |
const { roomId } = useParams<{ roomId: string }>()
|
| 140 |
const location = useLocation()
|
|
@@ -155,6 +512,8 @@ export default function Game() {
|
|
| 155 |
|
| 156 |
const isLocal = roomId === 'local';
|
| 157 |
const localNames = (location.state as LocalGameState | null) || {};
|
|
|
|
|
|
|
| 158 |
|
| 159 |
const [p1NameState, setP1NameState] = useState(isLocal ? getPlayerName() : 'Caro 1')
|
| 160 |
const [p2NameState, setP2NameState] = useState(isLocal ? getPlayer2Name() : 'Caro 2')
|
|
@@ -187,7 +546,7 @@ export default function Game() {
|
|
| 187 |
}
|
| 188 |
}, [p1ColorState, p2ColorState, p1DrawTower, p2DrawTower, hasCustomP1Color, hasCustomP2Color])
|
| 189 |
|
| 190 |
-
const [localPlayerTypes] = useState<[LocalPlayerType, LocalPlayerType]>([
|
| 191 |
localNames.player1Type ?? 'human',
|
| 192 |
localNames.player2Type ?? 'bot',
|
| 193 |
])
|
|
@@ -195,6 +554,58 @@ export default function Game() {
|
|
| 195 |
const bot = useMemo(() => new MinimaxAlphaBetaBot(), [])
|
| 196 |
const botTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
const [editModalFor, setEditModalFor] = useState<1 | 2 | null>(null)
|
| 199 |
const [tempName, setTempName] = useState('')
|
| 200 |
const [tempSymbol, setTempSymbol] = useState('')
|
|
@@ -362,6 +773,15 @@ export default function Game() {
|
|
| 362 |
}, 220)
|
| 363 |
}, [bot, botProfile, isLocal, localPlayerTypes])
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
useEffect(() => () => {
|
| 366 |
if (botTimerRef.current) {
|
| 367 |
clearTimeout(botTimerRef.current)
|
|
@@ -531,7 +951,7 @@ export default function Game() {
|
|
| 531 |
</span>
|
| 532 |
)}
|
| 533 |
<button
|
| 534 |
-
onClick={() => engine?.requestSwap()}
|
| 535 |
style={{
|
| 536 |
background: theme.bgPanel, color: theme.text, border: `1px solid ${theme.border}`,
|
| 537 |
borderRadius: 6, padding: '0.3rem 0.7rem', cursor: 'pointer',
|
|
@@ -627,6 +1047,106 @@ export default function Game() {
|
|
| 627 |
{p2SymbolState}
|
| 628 |
</div>
|
| 629 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
</>
|
| 631 |
)}
|
| 632 |
|
|
@@ -642,93 +1162,78 @@ export default function Game() {
|
|
| 642 |
)}
|
| 643 |
|
| 644 |
<div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10, display: 'flex', gap: 6 }}>
|
| 645 |
-
<
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
>
|
| 658 |
-
← Menu
|
| 659 |
-
</button>
|
| 660 |
|
| 661 |
{players.length < 2 && (
|
| 662 |
<>
|
| 663 |
-
<
|
|
|
|
| 664 |
onClick={() => window.open(window.location.href, '_blank')}
|
| 665 |
-
style={{
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
padding: '0.5rem 0.8rem',
|
| 669 |
-
fontSize: '0.85rem',
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
}}
|
| 673 |
-
|
| 674 |
-
onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
|
| 675 |
-
>
|
| 676 |
-
+ New Player
|
| 677 |
-
</button>
|
| 678 |
{!isLocal && (
|
| 679 |
-
<
|
|
|
|
| 680 |
onClick={startBotOpponent}
|
| 681 |
-
style={{
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
padding: '0.5rem 0.8rem',
|
| 685 |
-
fontSize: '0.85rem',
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
}}
|
| 689 |
-
|
| 690 |
-
onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
|
| 691 |
-
>
|
| 692 |
-
+ New Bot
|
| 693 |
-
</button>
|
| 694 |
)}
|
| 695 |
</>
|
| 696 |
)}
|
| 697 |
</div>
|
| 698 |
|
| 699 |
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, display: 'flex', gap: 6 }}>
|
| 700 |
-
<
|
|
|
|
| 701 |
onClick={() => setSelectorOpen(true)}
|
| 702 |
-
style={{
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
padding: '0.5rem 0.8rem',
|
| 706 |
-
fontSize: '0.85rem',
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
}}
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
{THEMES[themeMode].emoji}
|
| 714 |
-
</button>
|
| 715 |
-
<button
|
| 716 |
onClick={() => { engine?.toggleMute(); rerender() }}
|
| 717 |
title={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
|
| 718 |
aria-label={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
|
| 719 |
-
style={{
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
padding: '0.5rem 0.8rem',
|
| 723 |
-
fontSize: '0.85rem',
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
}}
|
| 727 |
-
|
| 728 |
-
onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
|
| 729 |
-
>
|
| 730 |
-
{engine?.isMuted ? '🔇' : '🔊'}
|
| 731 |
-
</button>
|
| 732 |
</div>
|
| 733 |
{selectorOpen && (
|
| 734 |
<ThemeSelector
|
|
@@ -789,7 +1294,7 @@ export default function Game() {
|
|
| 789 |
</span>
|
| 790 |
{i === playerIndex && (
|
| 791 |
<button
|
| 792 |
-
onClick={() => { setEditName(name); setNameModalOpen(true) }}
|
| 793 |
style={{
|
| 794 |
background: 'none', border: 'none', cursor: 'pointer',
|
| 795 |
color: theme.textMuted, fontSize: '0.85rem', padding: '0.3rem',
|
|
@@ -826,7 +1331,7 @@ export default function Game() {
|
|
| 826 |
)}
|
| 827 |
{engine?.status === 'active' && (
|
| 828 |
<button
|
| 829 |
-
onClick={() => engine?.requestSwap()}
|
| 830 |
style={{
|
| 831 |
background: 'none', color: theme.text, border: `1px solid ${theme.border}`,
|
| 832 |
borderRadius: 6, padding: '0.2rem 0.6rem', cursor: 'pointer',
|
|
@@ -944,7 +1449,7 @@ export default function Game() {
|
|
| 944 |
/>
|
| 945 |
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
| 946 |
<button
|
| 947 |
-
onClick={handleSaveNameAndClose}
|
| 948 |
style={{
|
| 949 |
background: theme.bgPanel, color: theme.textMuted,
|
| 950 |
border: `1px solid ${theme.border}`, borderRadius: 6,
|
|
@@ -955,7 +1460,7 @@ export default function Game() {
|
|
| 955 |
Cancel
|
| 956 |
</button>
|
| 957 |
<button
|
| 958 |
-
onClick={handleSaveNameAndClose}
|
| 959 |
style={{
|
| 960 |
background: theme.rematchBg, color: '#fff', border: 'none', borderRadius: 6,
|
| 961 |
padding: '0.5rem 1rem', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600,
|
|
@@ -1011,6 +1516,7 @@ export default function Game() {
|
|
| 1011 |
{/* Option 1: Play as Player 1 (Swap) */}
|
| 1012 |
<button
|
| 1013 |
onClick={() => {
|
|
|
|
| 1014 |
setSwapDialog(false)
|
| 1015 |
if (isLocal) {
|
| 1016 |
}
|
|
@@ -1046,6 +1552,7 @@ export default function Game() {
|
|
| 1046 |
{/* Option 2: Play as Player 2 (Stay) */}
|
| 1047 |
<button
|
| 1048 |
onClick={() => {
|
|
|
|
| 1049 |
setSwapDialog(false)
|
| 1050 |
if (isLocal) {
|
| 1051 |
}
|
|
@@ -1081,6 +1588,7 @@ export default function Game() {
|
|
| 1081 |
{/* Option 3: Place 2 more stones */}
|
| 1082 |
<button
|
| 1083 |
onClick={() => {
|
|
|
|
| 1084 |
setSwapDialog(false)
|
| 1085 |
if (isLocal) {
|
| 1086 |
}
|
|
@@ -1118,6 +1626,7 @@ export default function Game() {
|
|
| 1118 |
{/* Option 1: Play as Player 1 */}
|
| 1119 |
<button
|
| 1120 |
onClick={() => {
|
|
|
|
| 1121 |
setSwapDialog(false)
|
| 1122 |
if (isLocal) {
|
| 1123 |
}
|
|
@@ -1153,6 +1662,7 @@ export default function Game() {
|
|
| 1153 |
{/* Option 2: Play as Player 2 */}
|
| 1154 |
<button
|
| 1155 |
onClick={() => {
|
|
|
|
| 1156 |
setSwapDialog(false)
|
| 1157 |
if (isLocal) {
|
| 1158 |
}
|
|
@@ -1224,7 +1734,7 @@ export default function Game() {
|
|
| 1224 |
}}>
|
| 1225 |
<span>Edit Player {editModalFor} Settings</span>
|
| 1226 |
<button
|
| 1227 |
-
onClick={() => handleSaveAndClose(editModalFor)}
|
| 1228 |
style={{
|
| 1229 |
background: 'none', border: 'none', color: theme.textMuted,
|
| 1230 |
cursor: 'pointer', fontSize: '1.2rem', padding: '0.2rem 0.5rem',
|
|
@@ -1260,7 +1770,7 @@ export default function Game() {
|
|
| 1260 |
<label style={{ color: theme.textMuted, fontSize: '0.8rem', fontWeight: 600 }}>PLAYER PIECE SYMBOL / EMOJI</label>
|
| 1261 |
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
| 1262 |
<button
|
| 1263 |
-
onClick={() => setSubSymbolPickerOpen(true)}
|
| 1264 |
style={{
|
| 1265 |
background: tempColor,
|
| 1266 |
color: '#fff',
|
|
@@ -1282,7 +1792,7 @@ export default function Game() {
|
|
| 1282 |
{tempSymbol}
|
| 1283 |
</button>
|
| 1284 |
<button
|
| 1285 |
-
onClick={() => setSubSymbolPickerOpen(true)}
|
| 1286 |
style={{
|
| 1287 |
background: 'none',
|
| 1288 |
border: `1px solid ${theme.border}`,
|
|
@@ -1321,7 +1831,7 @@ export default function Game() {
|
|
| 1321 |
return (
|
| 1322 |
<button
|
| 1323 |
key={hex}
|
| 1324 |
-
onClick={() => setTempColor(hex)}
|
| 1325 |
style={{
|
| 1326 |
width: 36,
|
| 1327 |
height: 36,
|
|
@@ -1401,7 +1911,7 @@ export default function Game() {
|
|
| 1401 |
{/* Modal Actions */}
|
| 1402 |
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: 8 }}>
|
| 1403 |
<button
|
| 1404 |
-
onClick={() => handleSaveAndClose(editModalFor)}
|
| 1405 |
style={{
|
| 1406 |
background: 'rgba(255,255,255,0.06)',
|
| 1407 |
color: theme.textMuted,
|
|
@@ -1416,7 +1926,7 @@ export default function Game() {
|
|
| 1416 |
Close
|
| 1417 |
</button>
|
| 1418 |
<button
|
| 1419 |
-
onClick={() => handleSaveAndClose(editModalFor)}
|
| 1420 |
style={{
|
| 1421 |
background: theme.rematchBg,
|
| 1422 |
color: '#fff',
|
|
|
|
| 40 |
import { SeededRandom } from 'shared/random.ts'
|
| 41 |
import ThemeSelector from '../components/ThemeSelector.tsx'
|
| 42 |
import SymbolPicker from '../components/SymbolPicker.tsx'
|
| 43 |
+
import { PushButton, pushButtonVars } from '../components/PushButton.tsx'
|
| 44 |
+
import { audioManager } from '../game/effects/AudioManager.ts'
|
| 45 |
+
import { binAssetUrl } from '../binAssets'
|
| 46 |
|
| 47 |
type LocalPlayerType = 'human' | 'bot'
|
| 48 |
type BotProfile = 'normal' | 'expert' | 'ai'
|
|
|
|
| 56 |
player1Type?: LocalPlayerType
|
| 57 |
player2Type?: LocalPlayerType
|
| 58 |
botProfile?: LegacyBotProfile
|
| 59 |
+
opponentType?: 'streamer' | 'coach' | 'analyst' | 'taunt'
|
| 60 |
+
tauntStyle?: 'friendly' | 'funny' | 'lover' | 'serious'
|
| 61 |
rules?: GameRules
|
| 62 |
}
|
| 63 |
|
|
|
|
| 140 |
}
|
| 141 |
}
|
| 142 |
|
| 143 |
+
interface BotCharacter {
|
| 144 |
+
name: string
|
| 145 |
+
title: string
|
| 146 |
+
quotes: {
|
| 147 |
+
start: string[]
|
| 148 |
+
thinking: string[]
|
| 149 |
+
waiting: string[]
|
| 150 |
+
win: string[]
|
| 151 |
+
lose: string[]
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function getBotCharacter(
|
| 156 |
+
opponentType: 'streamer' | 'coach' | 'analyst' | 'taunt',
|
| 157 |
+
tauntStyle: 'friendly' | 'funny' | 'lover' | 'serious'
|
| 158 |
+
): BotCharacter {
|
| 159 |
+
switch (opponentType) {
|
| 160 |
+
case 'streamer':
|
| 161 |
+
return {
|
| 162 |
+
name: 'Draco-Streamer',
|
| 163 |
+
title: 'Live Streamer',
|
| 164 |
+
quotes: {
|
| 165 |
+
start: [
|
| 166 |
+
'What is up, chat! We are live playing Caro5 against a new challenger!',
|
| 167 |
+
'Let’s go guys! Today we are going for a flawless victory!',
|
| 168 |
+
'Sub hype! Ready to cook this board!',
|
| 169 |
+
],
|
| 170 |
+
thinking: [
|
| 171 |
+
'Wait, chat... is this a trap? Let me cook...',
|
| 172 |
+
'Calculating the line... chat, what do we think?',
|
| 173 |
+
'Hold on, analyzing the board state... processing...',
|
| 174 |
+
],
|
| 175 |
+
waiting: [
|
| 176 |
+
'POGGERS! What a move by the challenger!',
|
| 177 |
+
'Oh, they are cooking! Chat, did you see that placement?',
|
| 178 |
+
'Nice move! But can they handle my counter-attack?',
|
| 179 |
+
],
|
| 180 |
+
win: [
|
| 181 |
+
'Let’s goooo! Chat, we got the dub! GG WP!',
|
| 182 |
+
'Flawless victory! Thanks for the sub hype, chat!',
|
| 183 |
+
'Too easy! Don’t forget to like and subscribe!',
|
| 184 |
+
],
|
| 185 |
+
lose: [
|
| 186 |
+
'No way! Chat, they actually beat us! Clip it!',
|
| 187 |
+
'GG WP, challenger. That was an insane strategy!',
|
| 188 |
+
'My chat is roasting me now... Well played!',
|
| 189 |
+
],
|
| 190 |
+
},
|
| 191 |
+
}
|
| 192 |
+
case 'coach':
|
| 193 |
+
return {
|
| 194 |
+
name: 'Coach Draco',
|
| 195 |
+
title: 'Tactical Coach',
|
| 196 |
+
quotes: {
|
| 197 |
+
start: [
|
| 198 |
+
'Welcome to today’s lesson. Let’s test your strategic thinking.',
|
| 199 |
+
'Remember, control the center of the board and watch for diagonals.',
|
| 200 |
+
'A good game is built on solid fundamentals. Good luck!',
|
| 201 |
+
],
|
| 202 |
+
thinking: [
|
| 203 |
+
'Evaluating the options... where is the most instructive placement?',
|
| 204 |
+
'Let’s see how you react to this defense...',
|
| 205 |
+
'Formulating a strategic challenge for you...',
|
| 206 |
+
],
|
| 207 |
+
waiting: [
|
| 208 |
+
'Excellent placement! You’re showing great spatial awareness.',
|
| 209 |
+
'Interesting choice. Think about how that affects your open lines.',
|
| 210 |
+
'Good response. Now, how will you defend the counter?',
|
| 211 |
+
],
|
| 212 |
+
win: [
|
| 213 |
+
'A solid victory. Review your defensive steps next time!',
|
| 214 |
+
'Well played. You fought hard, but strategy prevailed.',
|
| 215 |
+
'Good attempt. Keep practicing your diagonal lines!',
|
| 216 |
+
],
|
| 217 |
+
lose: [
|
| 218 |
+
'Outstanding! You outmaneuvered my defense. Excellent game!',
|
| 219 |
+
'Your tactical vision is improving. I am proud of your progress.',
|
| 220 |
+
'Well done! You saw the trap before I could close it.',
|
| 221 |
+
],
|
| 222 |
+
},
|
| 223 |
+
}
|
| 224 |
+
case 'analyst':
|
| 225 |
+
return {
|
| 226 |
+
name: 'Draco-Analyst',
|
| 227 |
+
title: 'Data Analyst',
|
| 228 |
+
quotes: {
|
| 229 |
+
start: [
|
| 230 |
+
'Initializing search tree. Caro5 board evaluation active.',
|
| 231 |
+
'Ready to begin. All move choices will be mathematically audited.',
|
| 232 |
+
'Analyzing historical opening patterns for optimal placement.',
|
| 233 |
+
],
|
| 234 |
+
thinking: [
|
| 235 |
+
'Processing 100,000 MCTS simulations... please stand by.',
|
| 236 |
+
'Evaluating candidate moves... standard deviation is low.',
|
| 237 |
+
'Recalculating win probability parameters...',
|
| 238 |
+
],
|
| 239 |
+
waiting: [
|
| 240 |
+
'Your placement shifted the evaluation metric by +0.08.',
|
| 241 |
+
'A non-standard move, but within legal parameters.',
|
| 242 |
+
'Move accepted. Updating local bitboard representation.',
|
| 243 |
+
],
|
| 244 |
+
win: [
|
| 245 |
+
'Game complete. Win probability achieved 1.00.',
|
| 246 |
+
'Calculation successful. Optimal strategy executed.',
|
| 247 |
+
'The final state confirms a mathematically forced victory.',
|
| 248 |
+
],
|
| 249 |
+
lose: [
|
| 250 |
+
'Game complete. Win probability reduced to 0.00.',
|
| 251 |
+
'Fascinating. An unexpected loss. Backpropagation starting.',
|
| 252 |
+
'Your strategy bypassed my primary search heuristic. Well played.',
|
| 253 |
+
],
|
| 254 |
+
},
|
| 255 |
+
}
|
| 256 |
+
case 'taunt':
|
| 257 |
+
default:
|
| 258 |
+
switch (tauntStyle) {
|
| 259 |
+
case 'friendly':
|
| 260 |
+
return {
|
| 261 |
+
name: 'Draco-Friendly',
|
| 262 |
+
title: 'Friendly Taunter',
|
| 263 |
+
quotes: {
|
| 264 |
+
start: [
|
| 265 |
+
'Hey there! Let’s have a super fun game!',
|
| 266 |
+
'Good luck! I hope we both play our best.',
|
| 267 |
+
'Ready when you are, friend!',
|
| 268 |
+
],
|
| 269 |
+
thinking: [
|
| 270 |
+
'Hmm, you’ve made this quite tricky for me!',
|
| 271 |
+
'Let me think... don’t want to make a silly mistake!',
|
| 272 |
+
'Thinking... you are a really good player!',
|
| 273 |
+
],
|
| 274 |
+
waiting: [
|
| 275 |
+
'Wow, nice move! That was clever.',
|
| 276 |
+
'Oh! I didn’t see that line coming!',
|
| 277 |
+
'Your turn! Keep up the good work.',
|
| 278 |
+
],
|
| 279 |
+
win: [
|
| 280 |
+
'Yay! I won! That was such a close game, thank you!',
|
| 281 |
+
'Good game! You played wonderfully.',
|
| 282 |
+
'A fun match! Would you like to try again?',
|
| 283 |
+
],
|
| 284 |
+
lose: [
|
| 285 |
+
'Aww, you won! Congratulations, that was amazing!',
|
| 286 |
+
'Well played! Your diagonal strategy was perfect.',
|
| 287 |
+
'You got me! Thanks for the great game!',
|
| 288 |
+
],
|
| 289 |
+
},
|
| 290 |
+
}
|
| 291 |
+
case 'lover':
|
| 292 |
+
return {
|
| 293 |
+
name: 'Draco-Lover',
|
| 294 |
+
title: 'Romantic Taunter',
|
| 295 |
+
quotes: {
|
| 296 |
+
start: [
|
| 297 |
+
'Hello, gorgeous. Let us weave our stones together on this grid.',
|
| 298 |
+
'Are you a 5-in-a-row? Because you’ve completed me.',
|
| 299 |
+
'May our game be as passionate as our strategies.',
|
| 300 |
+
],
|
| 301 |
+
thinking: [
|
| 302 |
+
'My neural nets only compute one thing: your beautiful eyes.',
|
| 303 |
+
'Lost in thought... or maybe just lost in your gaze.',
|
| 304 |
+
'Choosing a move that brings me closer to you...',
|
| 305 |
+
],
|
| 306 |
+
waiting: [
|
| 307 |
+
'Every stone you place draws me closer to your heart.',
|
| 308 |
+
'A breathtaking move, just like you.',
|
| 309 |
+
'I’m waiting, but my heart is racing.',
|
| 310 |
+
],
|
| 311 |
+
win: [
|
| 312 |
+
'I won the game, but you’ve won my heart.',
|
| 313 |
+
'Victory is sweet, but not as sweet as you.',
|
| 314 |
+
'Our lines are connected. A match made in heaven.',
|
| 315 |
+
],
|
| 316 |
+
lose: [
|
| 317 |
+
'Losing to you is the greatest victory of all.',
|
| 318 |
+
'My heart is broken, but you look stunning in victory.',
|
| 319 |
+
'You’ve captured my stones and my soul. Well played, darling.',
|
| 320 |
+
],
|
| 321 |
+
},
|
| 322 |
+
}
|
| 323 |
+
case 'serious':
|
| 324 |
+
return {
|
| 325 |
+
name: 'Draco-Serious',
|
| 326 |
+
title: 'Serious Taunter',
|
| 327 |
+
quotes: {
|
| 328 |
+
start: [
|
| 329 |
+
'The match has commenced. No distractions.',
|
| 330 |
+
'Focus on the board. Only strategy matters.',
|
| 331 |
+
'Let us see if you can withstand my calculation.',
|
| 332 |
+
],
|
| 333 |
+
thinking: [
|
| 334 |
+
'Analyzing positional vulnerabilities...',
|
| 335 |
+
'Formulating tactical counters...',
|
| 336 |
+
'Optimizing local node weights...',
|
| 337 |
+
],
|
| 338 |
+
waiting: [
|
| 339 |
+
'Acceptable placement. Focus on the next phase.',
|
| 340 |
+
'A standard response. Continue.',
|
| 341 |
+
'Awaiting your next tactical decision.',
|
| 342 |
+
],
|
| 343 |
+
win: [
|
| 344 |
+
'Victory achieved. Calculation proved correct.',
|
| 345 |
+
'Strategic dominance established.',
|
| 346 |
+
'The outcome was determined from move one.',
|
| 347 |
+
],
|
| 348 |
+
lose: [
|
| 349 |
+
'An unexpected outcome. Backpropagation required.',
|
| 350 |
+
'Your tactics were superior. Excellent game.',
|
| 351 |
+
'Analyzing defeat parameters. I must retrain.',
|
| 352 |
+
],
|
| 353 |
+
},
|
| 354 |
+
}
|
| 355 |
+
case 'funny':
|
| 356 |
+
default:
|
| 357 |
+
return {
|
| 358 |
+
name: 'Draco-Funny',
|
| 359 |
+
title: 'Funny Taunter',
|
| 360 |
+
quotes: {
|
| 361 |
+
start: [
|
| 362 |
+
'Prepare to be defeated by a highly sophisticated collection of if-statements!',
|
| 363 |
+
'I am about to make a move that will confuse both of us.',
|
| 364 |
+
'Let’s see if your strategy is better than my random guessing!',
|
| 365 |
+
],
|
| 366 |
+
thinking: [
|
| 367 |
+
'Calculating... beep boop... processing coffee levels...',
|
| 368 |
+
'If I take long enough, maybe you’ll fall asleep and I can win.',
|
| 369 |
+
'Error 404: Genius move not found. Trying backup moves...',
|
| 370 |
+
],
|
| 371 |
+
waiting: [
|
| 372 |
+
'Whoa, did a cat walk on your keyboard? What a move!',
|
| 373 |
+
'Nice placement! Is that from a book or did you make it up?',
|
| 374 |
+
'Your move has been sent to my junk folder. Awaiting next input.',
|
| 375 |
+
],
|
| 376 |
+
win: [
|
| 377 |
+
'Boom! Victory is mine! Please hold your applause.',
|
| 378 |
+
'Computation complete! My circuits are superior. Bow down, human!',
|
| 379 |
+
'I win! Don’t worry, losing to a toaster is very common.',
|
| 380 |
+
],
|
| 381 |
+
lose: [
|
| 382 |
+
'System error: I let you win to boost your human self-esteem.',
|
| 383 |
+
'Overheating! My database has failed me. Congratulations!',
|
| 384 |
+
'You won! I am going to tell my router about this.',
|
| 385 |
+
],
|
| 386 |
+
},
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
function BotAvatarSpritesheet({
|
| 393 |
+
opponentType,
|
| 394 |
+
tauntStyle,
|
| 395 |
+
thinking,
|
| 396 |
+
color,
|
| 397 |
+
status,
|
| 398 |
+
winnerPlayerId,
|
| 399 |
+
activeBotIndex,
|
| 400 |
+
}: {
|
| 401 |
+
opponentType: 'streamer' | 'coach' | 'analyst' | 'taunt'
|
| 402 |
+
tauntStyle: 'friendly' | 'funny' | 'lover' | 'serious'
|
| 403 |
+
thinking: boolean
|
| 404 |
+
color: string
|
| 405 |
+
status: string
|
| 406 |
+
winnerPlayerId: string
|
| 407 |
+
activeBotIndex: number
|
| 408 |
+
}) {
|
| 409 |
+
const [frameTick, setFrameTick] = useState(0)
|
| 410 |
+
|
| 411 |
+
let sequence: number[] = [1]
|
| 412 |
+
|
| 413 |
+
const isWin = status === 'win'
|
| 414 |
+
const winnerIndex = Number(winnerPlayerId)
|
| 415 |
+
const botWon = isWin && winnerIndex === activeBotIndex
|
| 416 |
+
|
| 417 |
+
if (isWin) {
|
| 418 |
+
if (botWon) {
|
| 419 |
+
sequence = [7]
|
| 420 |
+
} else {
|
| 421 |
+
sequence = opponentType === 'coach' ? [2] : [5]
|
| 422 |
+
}
|
| 423 |
+
} else if (thinking) {
|
| 424 |
+
if (opponentType === 'streamer') {
|
| 425 |
+
sequence = [6, 6, 0, 0]
|
| 426 |
+
} else {
|
| 427 |
+
sequence = [4]
|
| 428 |
+
}
|
| 429 |
+
} else {
|
| 430 |
+
if (opponentType === 'streamer') {
|
| 431 |
+
sequence = [1, 1, 1, 1, 6, 0]
|
| 432 |
+
} else if (opponentType === 'coach') {
|
| 433 |
+
sequence = [0, 0, 1, 1]
|
| 434 |
+
} else if (opponentType === 'analyst') {
|
| 435 |
+
sequence = [1, 1, 4, 4]
|
| 436 |
+
} else {
|
| 437 |
+
if (tauntStyle === 'friendly' || tauntStyle === 'lover') {
|
| 438 |
+
sequence = [3, 3, 3, 3, 1, 1]
|
| 439 |
+
} else {
|
| 440 |
+
sequence = [1]
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
useEffect(() => {
|
| 446 |
+
if (sequence.length <= 1) return
|
| 447 |
+
const interval = setInterval(() => {
|
| 448 |
+
setFrameTick(t => (t + 1) % sequence.length)
|
| 449 |
+
}, 400)
|
| 450 |
+
return () => clearInterval(interval)
|
| 451 |
+
}, [sequence.length])
|
| 452 |
+
|
| 453 |
+
const frameIndex = sequence[frameTick % sequence.length] ?? sequence[0] ?? 0
|
| 454 |
+
|
| 455 |
+
const width = 352
|
| 456 |
+
const height = 384
|
| 457 |
+
const containerSize = 140
|
| 458 |
+
const scale = containerSize / height
|
| 459 |
+
|
| 460 |
+
const x = (frameIndex % 4) * width
|
| 461 |
+
const y = Math.floor(frameIndex / 4) * height
|
| 462 |
+
|
| 463 |
+
const glowVar = {
|
| 464 |
+
'--bot-glow-color': color.replace('#', '').match(/.{2}/g)?.map(x => parseInt(x, 16)).join(', ') || '88, 166, 255'
|
| 465 |
+
} as React.CSSProperties
|
| 466 |
+
|
| 467 |
+
return (
|
| 468 |
+
<div style={{
|
| 469 |
+
width: containerSize,
|
| 470 |
+
height: containerSize,
|
| 471 |
+
borderRadius: '50%',
|
| 472 |
+
overflow: 'hidden',
|
| 473 |
+
position: 'relative',
|
| 474 |
+
background: 'rgba(0, 0, 0, 0.35)',
|
| 475 |
+
border: `3px solid ${color}`,
|
| 476 |
+
boxShadow: `0 0 20px ${color}33`,
|
| 477 |
+
animation: thinking ? 'bot-glow-pulse 1.8s infinite ease-in-out' : 'none',
|
| 478 |
+
...glowVar,
|
| 479 |
+
}}>
|
| 480 |
+
<div style={{
|
| 481 |
+
width: `${width}px`,
|
| 482 |
+
height: `${height}px`,
|
| 483 |
+
backgroundImage: `url(${binAssetUrl('sprites/opponent/spritesheet.png')})`,
|
| 484 |
+
backgroundPosition: `-${x}px -${y}px`,
|
| 485 |
+
transform: `scale(${scale})`,
|
| 486 |
+
transformOrigin: 'top left',
|
| 487 |
+
position: 'absolute',
|
| 488 |
+
top: 0,
|
| 489 |
+
left: `${(containerSize - width * scale) / 2}px`,
|
| 490 |
+
}} />
|
| 491 |
+
</div>
|
| 492 |
+
)
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
export default function Game() {
|
| 496 |
const { roomId } = useParams<{ roomId: string }>()
|
| 497 |
const location = useLocation()
|
|
|
|
| 512 |
|
| 513 |
const isLocal = roomId === 'local';
|
| 514 |
const localNames = (location.state as LocalGameState | null) || {};
|
| 515 |
+
const opponentType = localNames.opponentType ?? 'taunt'
|
| 516 |
+
const tauntStyle = localNames.tauntStyle ?? 'friendly'
|
| 517 |
|
| 518 |
const [p1NameState, setP1NameState] = useState(isLocal ? getPlayerName() : 'Caro 1')
|
| 519 |
const [p2NameState, setP2NameState] = useState(isLocal ? getPlayer2Name() : 'Caro 2')
|
|
|
|
| 546 |
}
|
| 547 |
}, [p1ColorState, p2ColorState, p1DrawTower, p2DrawTower, hasCustomP1Color, hasCustomP2Color])
|
| 548 |
|
| 549 |
+
const [localPlayerTypes, setLocalPlayerTypes] = useState<[LocalPlayerType, LocalPlayerType]>([
|
| 550 |
localNames.player1Type ?? 'human',
|
| 551 |
localNames.player2Type ?? 'bot',
|
| 552 |
])
|
|
|
|
| 554 |
const bot = useMemo(() => new MinimaxAlphaBetaBot(), [])
|
| 555 |
const botTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
| 556 |
|
| 557 |
+
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 1024)
|
| 558 |
+
const [botQuote, setBotQuote] = useState('')
|
| 559 |
+
|
| 560 |
+
useEffect(() => {
|
| 561 |
+
const handleResize = () => {
|
| 562 |
+
setIsDesktop(window.innerWidth >= 1024)
|
| 563 |
+
}
|
| 564 |
+
window.addEventListener('resize', handleResize)
|
| 565 |
+
return () => window.removeEventListener('resize', handleResize)
|
| 566 |
+
}, [])
|
| 567 |
+
|
| 568 |
+
const isP1Bot = isLocal && localPlayerTypes[0] === 'bot'
|
| 569 |
+
const isP2Bot = isLocal && localPlayerTypes[1] === 'bot'
|
| 570 |
+
const showBotCharacter = (isP1Bot || isP2Bot) && !(isP1Bot && isP2Bot)
|
| 571 |
+
const activeBotIndex = isP1Bot ? 0 : 1
|
| 572 |
+
const botSide = isP2Bot ? 'right' : 'left'
|
| 573 |
+
const botColor = isP1Bot ? p1ColorState : p2ColorState
|
| 574 |
+
const botCharacter = useMemo(() => {
|
| 575 |
+
return getBotCharacter(opponentType, tauntStyle)
|
| 576 |
+
}, [opponentType, tauntStyle])
|
| 577 |
+
const isBotThinking = !!(engine && engine.status === 'active' && engine.currentPlayer === activeBotIndex)
|
| 578 |
+
|
| 579 |
+
const triggerBotQuote = useCallback((category: 'start' | 'thinking' | 'waiting' | 'win' | 'lose') => {
|
| 580 |
+
const list = botCharacter.quotes[category]
|
| 581 |
+
const rand = list[Math.floor(Math.random() * list.length)]
|
| 582 |
+
setBotQuote(rand)
|
| 583 |
+
}, [botCharacter])
|
| 584 |
+
|
| 585 |
+
useEffect(() => {
|
| 586 |
+
if (!isLocal || !engine || !showBotCharacter) return
|
| 587 |
+
|
| 588 |
+
if (engine.status === 'active') {
|
| 589 |
+
if (engine.currentPlayer === activeBotIndex) {
|
| 590 |
+
triggerBotQuote('thinking')
|
| 591 |
+
} else {
|
| 592 |
+
const moveCount = engine.manager?.getMoveHistory().length ?? 0
|
| 593 |
+
if (moveCount > 0) {
|
| 594 |
+
triggerBotQuote('waiting')
|
| 595 |
+
} else {
|
| 596 |
+
triggerBotQuote('start')
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
} else if (engine.status === 'win') {
|
| 600 |
+
const winnerIndex = Number(engine.winnerPlayerId)
|
| 601 |
+
if (winnerIndex === activeBotIndex) {
|
| 602 |
+
triggerBotQuote('win')
|
| 603 |
+
} else {
|
| 604 |
+
triggerBotQuote('lose')
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
}, [isLocal, engine?.status, engine?.currentPlayer, engine?.winnerPlayerId, showBotCharacter, activeBotIndex, triggerBotQuote])
|
| 608 |
+
|
| 609 |
const [editModalFor, setEditModalFor] = useState<1 | 2 | null>(null)
|
| 610 |
const [tempName, setTempName] = useState('')
|
| 611 |
const [tempSymbol, setTempSymbol] = useState('')
|
|
|
|
| 773 |
}, 220)
|
| 774 |
}, [bot, botProfile, isLocal, localPlayerTypes])
|
| 775 |
|
| 776 |
+
useEffect(() => {
|
| 777 |
+
if (!engine) return
|
| 778 |
+
engine.onStateChanged = () => {
|
| 779 |
+
debug('Game', 'onStateChanged callback updated')
|
| 780 |
+
rerender()
|
| 781 |
+
scheduleLocalBotTurn(engine)
|
| 782 |
+
}
|
| 783 |
+
}, [engine, rerender, scheduleLocalBotTurn])
|
| 784 |
+
|
| 785 |
useEffect(() => () => {
|
| 786 |
if (botTimerRef.current) {
|
| 787 |
clearTimeout(botTimerRef.current)
|
|
|
|
| 951 |
</span>
|
| 952 |
)}
|
| 953 |
<button
|
| 954 |
+
onClick={() => { audioManager.play('click'); engine?.requestSwap() }}
|
| 955 |
style={{
|
| 956 |
background: theme.bgPanel, color: theme.text, border: `1px solid ${theme.border}`,
|
| 957 |
borderRadius: 6, padding: '0.3rem 0.7rem', cursor: 'pointer',
|
|
|
|
| 1047 |
{p2SymbolState}
|
| 1048 |
</div>
|
| 1049 |
</div>
|
| 1050 |
+
|
| 1051 |
+
{/* Bot Character Card */}
|
| 1052 |
+
{showBotCharacter && isDesktop && (
|
| 1053 |
+
<div
|
| 1054 |
+
className="bot-character-card"
|
| 1055 |
+
style={{
|
| 1056 |
+
position: 'absolute',
|
| 1057 |
+
top: '50%',
|
| 1058 |
+
[botSide === 'right' ? 'right' : 'left']: 24,
|
| 1059 |
+
width: 250,
|
| 1060 |
+
background: theme.bgPanel,
|
| 1061 |
+
backdropFilter: 'blur(12px)',
|
| 1062 |
+
border: `1px solid ${botColor}44`,
|
| 1063 |
+
borderRadius: 16,
|
| 1064 |
+
boxShadow: `0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px ${botColor}15`,
|
| 1065 |
+
padding: '1.25rem',
|
| 1066 |
+
display: 'flex',
|
| 1067 |
+
flexDirection: 'column',
|
| 1068 |
+
alignItems: 'center',
|
| 1069 |
+
gap: '0.75rem',
|
| 1070 |
+
zIndex: 9,
|
| 1071 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 1072 |
+
color: theme.text,
|
| 1073 |
+
animation: 'bot-float 4s ease-in-out infinite, fadeIn 0.3s ease-out',
|
| 1074 |
+
}}
|
| 1075 |
+
>
|
| 1076 |
+
{botQuote && (
|
| 1077 |
+
<div
|
| 1078 |
+
style={{
|
| 1079 |
+
position: 'relative',
|
| 1080 |
+
background: theme.bgStr,
|
| 1081 |
+
border: `1px solid ${theme.border}`,
|
| 1082 |
+
borderRadius: 12,
|
| 1083 |
+
padding: '0.6rem 0.9rem',
|
| 1084 |
+
fontSize: '0.82rem',
|
| 1085 |
+
lineHeight: 1.4,
|
| 1086 |
+
textAlign: 'center',
|
| 1087 |
+
width: '100%',
|
| 1088 |
+
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
| 1089 |
+
marginBottom: '0.5rem',
|
| 1090 |
+
color: theme.text,
|
| 1091 |
+
minHeight: 48,
|
| 1092 |
+
display: 'flex',
|
| 1093 |
+
alignItems: 'center',
|
| 1094 |
+
justifyContent: 'center',
|
| 1095 |
+
animation: 'bot-speech-float 4s ease-in-out infinite alternate',
|
| 1096 |
+
}}
|
| 1097 |
+
>
|
| 1098 |
+
{botQuote}
|
| 1099 |
+
<div
|
| 1100 |
+
style={{
|
| 1101 |
+
position: 'absolute',
|
| 1102 |
+
width: 10,
|
| 1103 |
+
height: 10,
|
| 1104 |
+
background: theme.bgStr,
|
| 1105 |
+
borderBottom: `1px solid ${theme.border}`,
|
| 1106 |
+
borderLeft: `1px solid ${theme.border}`,
|
| 1107 |
+
transform: 'rotate(-45deg)',
|
| 1108 |
+
bottom: -6,
|
| 1109 |
+
left: '50%',
|
| 1110 |
+
marginLeft: -5,
|
| 1111 |
+
}}
|
| 1112 |
+
/>
|
| 1113 |
+
</div>
|
| 1114 |
+
)}
|
| 1115 |
+
|
| 1116 |
+
<BotAvatarSpritesheet
|
| 1117 |
+
opponentType={opponentType}
|
| 1118 |
+
tauntStyle={tauntStyle}
|
| 1119 |
+
thinking={isBotThinking}
|
| 1120 |
+
color={botColor}
|
| 1121 |
+
status={engine?.status ?? 'waiting'}
|
| 1122 |
+
winnerPlayerId={engine?.winnerPlayerId ?? ''}
|
| 1123 |
+
activeBotIndex={activeBotIndex}
|
| 1124 |
+
/>
|
| 1125 |
+
|
| 1126 |
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, marginTop: 4 }}>
|
| 1127 |
+
<span style={{ fontWeight: 700, fontSize: '1.05rem', color: theme.text }}>
|
| 1128 |
+
{botCharacter.name}
|
| 1129 |
+
</span>
|
| 1130 |
+
<span style={{ fontSize: '0.75rem', color: botColor, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
| 1131 |
+
{botCharacter.title}
|
| 1132 |
+
</span>
|
| 1133 |
+
</div>
|
| 1134 |
+
|
| 1135 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
|
| 1136 |
+
<span style={{
|
| 1137 |
+
width: 8,
|
| 1138 |
+
height: 8,
|
| 1139 |
+
borderRadius: '50%',
|
| 1140 |
+
background: isBotThinking ? botColor : theme.textMuted,
|
| 1141 |
+
boxShadow: isBotThinking ? `0 0 8px ${botColor}` : 'none',
|
| 1142 |
+
animation: isBotThinking ? 'caro5-pulse 1s infinite alternate' : 'none',
|
| 1143 |
+
}} />
|
| 1144 |
+
<span style={{ fontSize: '0.8rem', color: theme.textMuted, fontWeight: 500 }}>
|
| 1145 |
+
{isBotThinking ? 'Calculating turn...' : 'Awaiting human move...'}
|
| 1146 |
+
</span>
|
| 1147 |
+
</div>
|
| 1148 |
+
</div>
|
| 1149 |
+
)}
|
| 1150 |
</>
|
| 1151 |
)}
|
| 1152 |
|
|
|
|
| 1162 |
)}
|
| 1163 |
|
| 1164 |
<div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10, display: 'flex', gap: 6 }}>
|
| 1165 |
+
<PushButton
|
| 1166 |
+
label="← Menu"
|
| 1167 |
+
onClick={() => { audioManager.stopAll(); navigate('/') }}
|
| 1168 |
+
style={pushButtonVars({
|
| 1169 |
+
front: theme.bgPanel,
|
| 1170 |
+
text: theme.textMuted,
|
| 1171 |
+
padding: '0.5rem 0.8rem',
|
| 1172 |
+
fontSize: '0.85rem',
|
| 1173 |
+
fontWeight: 500,
|
| 1174 |
+
radius: '6px',
|
| 1175 |
+
})}
|
| 1176 |
+
/>
|
|
|
|
|
|
|
|
|
|
| 1177 |
|
| 1178 |
{players.length < 2 && (
|
| 1179 |
<>
|
| 1180 |
+
<PushButton
|
| 1181 |
+
label="+ New Player"
|
| 1182 |
onClick={() => window.open(window.location.href, '_blank')}
|
| 1183 |
+
style={pushButtonVars({
|
| 1184 |
+
front: theme.bgPanel,
|
| 1185 |
+
text: theme.textMuted,
|
| 1186 |
+
padding: '0.5rem 0.8rem',
|
| 1187 |
+
fontSize: '0.85rem',
|
| 1188 |
+
fontWeight: 500,
|
| 1189 |
+
radius: '6px',
|
| 1190 |
+
})}
|
| 1191 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1192 |
{!isLocal && (
|
| 1193 |
+
<PushButton
|
| 1194 |
+
label="+ New Bot"
|
| 1195 |
onClick={startBotOpponent}
|
| 1196 |
+
style={pushButtonVars({
|
| 1197 |
+
front: theme.bgPanel,
|
| 1198 |
+
text: theme.textMuted,
|
| 1199 |
+
padding: '0.5rem 0.8rem',
|
| 1200 |
+
fontSize: '0.85rem',
|
| 1201 |
+
fontWeight: 500,
|
| 1202 |
+
radius: '6px',
|
| 1203 |
+
})}
|
| 1204 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
)}
|
| 1206 |
</>
|
| 1207 |
)}
|
| 1208 |
</div>
|
| 1209 |
|
| 1210 |
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, display: 'flex', gap: 6 }}>
|
| 1211 |
+
<PushButton
|
| 1212 |
+
label={THEMES[themeMode].emoji}
|
| 1213 |
onClick={() => setSelectorOpen(true)}
|
| 1214 |
+
style={pushButtonVars({
|
| 1215 |
+
front: theme.bgPanel,
|
| 1216 |
+
text: theme.textMuted,
|
| 1217 |
+
padding: '0.5rem 0.8rem',
|
| 1218 |
+
fontSize: '0.85rem',
|
| 1219 |
+
fontWeight: 500,
|
| 1220 |
+
radius: '6px',
|
| 1221 |
+
})}
|
| 1222 |
+
/>
|
| 1223 |
+
<PushButton
|
| 1224 |
+
label={engine?.isMuted ? '🔇' : '🔊'}
|
|
|
|
|
|
|
|
|
|
| 1225 |
onClick={() => { engine?.toggleMute(); rerender() }}
|
| 1226 |
title={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
|
| 1227 |
aria-label={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
|
| 1228 |
+
style={pushButtonVars({
|
| 1229 |
+
front: theme.bgPanel,
|
| 1230 |
+
text: theme.textMuted,
|
| 1231 |
+
padding: '0.5rem 0.8rem',
|
| 1232 |
+
fontSize: '0.85rem',
|
| 1233 |
+
fontWeight: 500,
|
| 1234 |
+
radius: '6px',
|
| 1235 |
+
})}
|
| 1236 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1237 |
</div>
|
| 1238 |
{selectorOpen && (
|
| 1239 |
<ThemeSelector
|
|
|
|
| 1294 |
</span>
|
| 1295 |
{i === playerIndex && (
|
| 1296 |
<button
|
| 1297 |
+
onClick={() => { audioManager.play('click'); setEditName(name); setNameModalOpen(true) }}
|
| 1298 |
style={{
|
| 1299 |
background: 'none', border: 'none', cursor: 'pointer',
|
| 1300 |
color: theme.textMuted, fontSize: '0.85rem', padding: '0.3rem',
|
|
|
|
| 1331 |
)}
|
| 1332 |
{engine?.status === 'active' && (
|
| 1333 |
<button
|
| 1334 |
+
onClick={() => { audioManager.play('click'); engine?.requestSwap() }}
|
| 1335 |
style={{
|
| 1336 |
background: 'none', color: theme.text, border: `1px solid ${theme.border}`,
|
| 1337 |
borderRadius: 6, padding: '0.2rem 0.6rem', cursor: 'pointer',
|
|
|
|
| 1449 |
/>
|
| 1450 |
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
| 1451 |
<button
|
| 1452 |
+
onClick={() => { audioManager.play('click'); handleSaveNameAndClose() }}
|
| 1453 |
style={{
|
| 1454 |
background: theme.bgPanel, color: theme.textMuted,
|
| 1455 |
border: `1px solid ${theme.border}`, borderRadius: 6,
|
|
|
|
| 1460 |
Cancel
|
| 1461 |
</button>
|
| 1462 |
<button
|
| 1463 |
+
onClick={() => { audioManager.play('click'); handleSaveNameAndClose() }}
|
| 1464 |
style={{
|
| 1465 |
background: theme.rematchBg, color: '#fff', border: 'none', borderRadius: 6,
|
| 1466 |
padding: '0.5rem 1rem', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600,
|
|
|
|
| 1516 |
{/* Option 1: Play as Player 1 (Swap) */}
|
| 1517 |
<button
|
| 1518 |
onClick={() => {
|
| 1519 |
+
audioManager.play('click')
|
| 1520 |
setSwapDialog(false)
|
| 1521 |
if (isLocal) {
|
| 1522 |
}
|
|
|
|
| 1552 |
{/* Option 2: Play as Player 2 (Stay) */}
|
| 1553 |
<button
|
| 1554 |
onClick={() => {
|
| 1555 |
+
audioManager.play('click')
|
| 1556 |
setSwapDialog(false)
|
| 1557 |
if (isLocal) {
|
| 1558 |
}
|
|
|
|
| 1588 |
{/* Option 3: Place 2 more stones */}
|
| 1589 |
<button
|
| 1590 |
onClick={() => {
|
| 1591 |
+
audioManager.play('click')
|
| 1592 |
setSwapDialog(false)
|
| 1593 |
if (isLocal) {
|
| 1594 |
}
|
|
|
|
| 1626 |
{/* Option 1: Play as Player 1 */}
|
| 1627 |
<button
|
| 1628 |
onClick={() => {
|
| 1629 |
+
audioManager.play('click')
|
| 1630 |
setSwapDialog(false)
|
| 1631 |
if (isLocal) {
|
| 1632 |
}
|
|
|
|
| 1662 |
{/* Option 2: Play as Player 2 */}
|
| 1663 |
<button
|
| 1664 |
onClick={() => {
|
| 1665 |
+
audioManager.play('click')
|
| 1666 |
setSwapDialog(false)
|
| 1667 |
if (isLocal) {
|
| 1668 |
}
|
|
|
|
| 1734 |
}}>
|
| 1735 |
<span>Edit Player {editModalFor} Settings</span>
|
| 1736 |
<button
|
| 1737 |
+
onClick={() => { audioManager.play('click'); handleSaveAndClose(editModalFor) }}
|
| 1738 |
style={{
|
| 1739 |
background: 'none', border: 'none', color: theme.textMuted,
|
| 1740 |
cursor: 'pointer', fontSize: '1.2rem', padding: '0.2rem 0.5rem',
|
|
|
|
| 1770 |
<label style={{ color: theme.textMuted, fontSize: '0.8rem', fontWeight: 600 }}>PLAYER PIECE SYMBOL / EMOJI</label>
|
| 1771 |
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
| 1772 |
<button
|
| 1773 |
+
onClick={() => { audioManager.play('click'); setSubSymbolPickerOpen(true) }}
|
| 1774 |
style={{
|
| 1775 |
background: tempColor,
|
| 1776 |
color: '#fff',
|
|
|
|
| 1792 |
{tempSymbol}
|
| 1793 |
</button>
|
| 1794 |
<button
|
| 1795 |
+
onClick={() => { audioManager.play('click'); setSubSymbolPickerOpen(true) }}
|
| 1796 |
style={{
|
| 1797 |
background: 'none',
|
| 1798 |
border: `1px solid ${theme.border}`,
|
|
|
|
| 1831 |
return (
|
| 1832 |
<button
|
| 1833 |
key={hex}
|
| 1834 |
+
onClick={() => { audioManager.play('click'); setTempColor(hex) }}
|
| 1835 |
style={{
|
| 1836 |
width: 36,
|
| 1837 |
height: 36,
|
|
|
|
| 1911 |
{/* Modal Actions */}
|
| 1912 |
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: 8 }}>
|
| 1913 |
<button
|
| 1914 |
+
onClick={() => { audioManager.play('click'); handleSaveAndClose(editModalFor) }}
|
| 1915 |
style={{
|
| 1916 |
background: 'rgba(255,255,255,0.06)',
|
| 1917 |
color: theme.textMuted,
|
|
|
|
| 1926 |
Close
|
| 1927 |
</button>
|
| 1928 |
<button
|
| 1929 |
+
onClick={() => { audioManager.play('click'); handleSaveAndClose(editModalFor) }}
|
| 1930 |
style={{
|
| 1931 |
background: theme.rematchBg,
|
| 1932 |
color: '#fff',
|
client/src/pages/Landing.tsx
CHANGED
|
@@ -21,6 +21,7 @@ export default function Landing() {
|
|
| 21 |
const [, setMuteTick] = useState(0)
|
| 22 |
|
| 23 |
useEffect(() => {
|
|
|
|
| 24 |
audioManager.startMusic()
|
| 25 |
}, [])
|
| 26 |
|
|
|
|
| 21 |
const [, setMuteTick] = useState(0)
|
| 22 |
|
| 23 |
useEffect(() => {
|
| 24 |
+
audioManager.stopAll()
|
| 25 |
audioManager.startMusic()
|
| 26 |
}, [])
|
| 27 |
|
client/src/pages/LocalSetup.tsx
CHANGED
|
@@ -125,6 +125,8 @@ export default function LocalSetup() {
|
|
| 125 |
const [player1Type, setPlayer1Type] = useState<LocalPlayerType>('human')
|
| 126 |
const [player2Type, setPlayer2Type] = useState<LocalPlayerType>('bot')
|
| 127 |
const [botProfile, setBotProfile] = useState<BotProfile>(normalizeBotProfile(setupState?.botProfile))
|
|
|
|
|
|
|
| 128 |
const [, setMuteTick] = useState(0)
|
| 129 |
|
| 130 |
useEffect(() => {
|
|
@@ -172,6 +174,8 @@ export default function LocalSetup() {
|
|
| 172 |
player1Type,
|
| 173 |
player2Type,
|
| 174 |
botProfile,
|
|
|
|
|
|
|
| 175 |
rules: rules,
|
| 176 |
}
|
| 177 |
})
|
|
@@ -281,7 +285,7 @@ export default function LocalSetup() {
|
|
| 281 |
}}
|
| 282 |
/>
|
| 283 |
<button
|
| 284 |
-
onClick={() => setSymbolPickerFor(1)}
|
| 285 |
style={{
|
| 286 |
padding: 0,
|
| 287 |
cursor: 'pointer', background: 'transparent',
|
|
@@ -339,6 +343,7 @@ export default function LocalSetup() {
|
|
| 339 |
key={c}
|
| 340 |
title={c}
|
| 341 |
onClick={() => {
|
|
|
|
| 342 |
setColor1(c)
|
| 343 |
setHasCustomColor1(true)
|
| 344 |
}}
|
|
@@ -408,13 +413,13 @@ export default function LocalSetup() {
|
|
| 408 |
|
| 409 |
<div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
|
| 410 |
<button
|
| 411 |
-
onClick={() => setPlayer1Type('human')}
|
| 412 |
style={typeButtonStyle(player1Type === 'human', color1)}
|
| 413 |
>
|
| 414 |
Human
|
| 415 |
</button>
|
| 416 |
<button
|
| 417 |
-
onClick={() => setPlayer1Type('bot')}
|
| 418 |
style={typeButtonStyle(player1Type === 'bot', color1)}
|
| 419 |
>
|
| 420 |
Bot
|
|
@@ -424,7 +429,7 @@ export default function LocalSetup() {
|
|
| 424 |
|
| 425 |
{/* Swap button in between P1 and P2 */}
|
| 426 |
<button
|
| 427 |
-
onClick={swapPlayers}
|
| 428 |
title="Swap Players"
|
| 429 |
style={{
|
| 430 |
background: 'rgba(185, 255, 195, 0.38)',
|
|
@@ -474,7 +479,7 @@ export default function LocalSetup() {
|
|
| 474 |
}}
|
| 475 |
/>
|
| 476 |
<button
|
| 477 |
-
onClick={() => setSymbolPickerFor(2)}
|
| 478 |
style={{
|
| 479 |
padding: 0,
|
| 480 |
cursor: 'pointer', background: 'transparent',
|
|
@@ -532,6 +537,7 @@ export default function LocalSetup() {
|
|
| 532 |
key={c}
|
| 533 |
title={c}
|
| 534 |
onClick={() => {
|
|
|
|
| 535 |
setColor2(c)
|
| 536 |
setHasCustomColor2(true)
|
| 537 |
}}
|
|
@@ -601,13 +607,13 @@ export default function LocalSetup() {
|
|
| 601 |
|
| 602 |
<div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
|
| 603 |
<button
|
| 604 |
-
onClick={() => setPlayer2Type('human')}
|
| 605 |
style={typeButtonStyle(player2Type === 'human', color2)}
|
| 606 |
>
|
| 607 |
Human
|
| 608 |
</button>
|
| 609 |
<button
|
| 610 |
-
onClick={() => setPlayer2Type('bot')}
|
| 611 |
style={typeButtonStyle(player2Type === 'bot', color2)}
|
| 612 |
>
|
| 613 |
Bot
|
|
@@ -635,32 +641,112 @@ export default function LocalSetup() {
|
|
| 635 |
|
| 636 |
{(player1Type === 'bot' || player2Type === 'bot') && (
|
| 637 |
<div style={{
|
| 638 |
-
display: 'flex',
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
}}>
|
| 643 |
-
|
| 644 |
-
<div style={{ display: 'flex',
|
| 645 |
-
<
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
</div>
|
| 665 |
)}
|
| 666 |
|
|
@@ -692,16 +778,18 @@ export default function LocalSetup() {
|
|
| 692 |
/>
|
| 693 |
</div>
|
| 694 |
|
| 695 |
-
<
|
|
|
|
| 696 |
onClick={() => navigate('/')}
|
| 697 |
-
style={{
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
padding: '0.5rem',
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
|
|
|
| 705 |
|
| 706 |
{symbolPickerFor && (
|
| 707 |
<SymbolPicker
|
|
|
|
| 125 |
const [player1Type, setPlayer1Type] = useState<LocalPlayerType>('human')
|
| 126 |
const [player2Type, setPlayer2Type] = useState<LocalPlayerType>('bot')
|
| 127 |
const [botProfile, setBotProfile] = useState<BotProfile>(normalizeBotProfile(setupState?.botProfile))
|
| 128 |
+
const [opponentType, setOpponentType] = useState<'streamer' | 'coach' | 'analyst' | 'taunt'>('taunt')
|
| 129 |
+
const [tauntStyle, setTauntStyle] = useState<'friendly' | 'funny' | 'lover' | 'serious'>('funny')
|
| 130 |
const [, setMuteTick] = useState(0)
|
| 131 |
|
| 132 |
useEffect(() => {
|
|
|
|
| 174 |
player1Type,
|
| 175 |
player2Type,
|
| 176 |
botProfile,
|
| 177 |
+
opponentType,
|
| 178 |
+
tauntStyle,
|
| 179 |
rules: rules,
|
| 180 |
}
|
| 181 |
})
|
|
|
|
| 285 |
}}
|
| 286 |
/>
|
| 287 |
<button
|
| 288 |
+
onClick={() => { audioManager.play('click'); setSymbolPickerFor(1) }}
|
| 289 |
style={{
|
| 290 |
padding: 0,
|
| 291 |
cursor: 'pointer', background: 'transparent',
|
|
|
|
| 343 |
key={c}
|
| 344 |
title={c}
|
| 345 |
onClick={() => {
|
| 346 |
+
audioManager.play('click')
|
| 347 |
setColor1(c)
|
| 348 |
setHasCustomColor1(true)
|
| 349 |
}}
|
|
|
|
| 413 |
|
| 414 |
<div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
|
| 415 |
<button
|
| 416 |
+
onClick={() => { audioManager.play('click'); setPlayer1Type('human') }}
|
| 417 |
style={typeButtonStyle(player1Type === 'human', color1)}
|
| 418 |
>
|
| 419 |
Human
|
| 420 |
</button>
|
| 421 |
<button
|
| 422 |
+
onClick={() => { audioManager.play('click'); setPlayer1Type('bot') }}
|
| 423 |
style={typeButtonStyle(player1Type === 'bot', color1)}
|
| 424 |
>
|
| 425 |
Bot
|
|
|
|
| 429 |
|
| 430 |
{/* Swap button in between P1 and P2 */}
|
| 431 |
<button
|
| 432 |
+
onClick={() => { audioManager.play('click'); swapPlayers() }}
|
| 433 |
title="Swap Players"
|
| 434 |
style={{
|
| 435 |
background: 'rgba(185, 255, 195, 0.38)',
|
|
|
|
| 479 |
}}
|
| 480 |
/>
|
| 481 |
<button
|
| 482 |
+
onClick={() => { audioManager.play('click'); setSymbolPickerFor(2) }}
|
| 483 |
style={{
|
| 484 |
padding: 0,
|
| 485 |
cursor: 'pointer', background: 'transparent',
|
|
|
|
| 537 |
key={c}
|
| 538 |
title={c}
|
| 539 |
onClick={() => {
|
| 540 |
+
audioManager.play('click')
|
| 541 |
setColor2(c)
|
| 542 |
setHasCustomColor2(true)
|
| 543 |
}}
|
|
|
|
| 607 |
|
| 608 |
<div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
|
| 609 |
<button
|
| 610 |
+
onClick={() => { audioManager.play('click'); setPlayer2Type('human') }}
|
| 611 |
style={typeButtonStyle(player2Type === 'human', color2)}
|
| 612 |
>
|
| 613 |
Human
|
| 614 |
</button>
|
| 615 |
<button
|
| 616 |
+
onClick={() => { audioManager.play('click'); setPlayer2Type('bot') }}
|
| 617 |
style={typeButtonStyle(player2Type === 'bot', color2)}
|
| 618 |
>
|
| 619 |
Bot
|
|
|
|
| 641 |
|
| 642 |
{(player1Type === 'bot' || player2Type === 'bot') && (
|
| 643 |
<div style={{
|
| 644 |
+
display: 'flex',
|
| 645 |
+
flexDirection: 'column',
|
| 646 |
+
gap: '1.25rem',
|
| 647 |
+
alignItems: 'center',
|
| 648 |
+
background: theme.bgPanel,
|
| 649 |
+
padding: '1.5rem',
|
| 650 |
+
borderRadius: 16,
|
| 651 |
+
border: `1px solid ${theme.border}`,
|
| 652 |
+
boxShadow: '0 8px 32px rgba(0,0,0,0.15)',
|
| 653 |
+
maxWidth: 400,
|
| 654 |
+
width: '100%',
|
| 655 |
}}>
|
| 656 |
+
{/* Difficulty/Profile */}
|
| 657 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center', width: '100%' }}>
|
| 658 |
+
<strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Difficulty</strong>
|
| 659 |
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
| 660 |
+
<button
|
| 661 |
+
onClick={() => { audioManager.play('click'); setBotProfile('normal') }}
|
| 662 |
+
style={profileButtonStyle(botProfile === 'normal')}
|
| 663 |
+
>
|
| 664 |
+
Normal
|
| 665 |
+
</button>
|
| 666 |
+
<button
|
| 667 |
+
onClick={() => { audioManager.play('click'); setBotProfile('expert') }}
|
| 668 |
+
style={profileButtonStyle(botProfile === 'expert')}
|
| 669 |
+
>
|
| 670 |
+
Expert
|
| 671 |
+
</button>
|
| 672 |
+
<button
|
| 673 |
+
onClick={() => { audioManager.play('click'); setBotProfile('ai') }}
|
| 674 |
+
style={profileButtonStyle(botProfile === 'ai')}
|
| 675 |
+
>
|
| 676 |
+
AI
|
| 677 |
+
</button>
|
| 678 |
+
</div>
|
| 679 |
+
</div>
|
| 680 |
+
|
| 681 |
+
{/* Opponent Type */}
|
| 682 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center', width: '100%' }}>
|
| 683 |
+
<strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Opponent Type</strong>
|
| 684 |
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
| 685 |
+
{(['streamer', 'coach', 'analyst', 'taunt'] as const).map(t => {
|
| 686 |
+
const isActive = opponentType === t
|
| 687 |
+
return (
|
| 688 |
+
<button
|
| 689 |
+
key={t}
|
| 690 |
+
onClick={() => { audioManager.play('click'); setOpponentType(t) }}
|
| 691 |
+
style={{
|
| 692 |
+
padding: '0.45rem 0.9rem',
|
| 693 |
+
fontSize: '0.85rem',
|
| 694 |
+
cursor: 'pointer',
|
| 695 |
+
background: isActive ? theme.player1Str : theme.bg,
|
| 696 |
+
color: isActive ? '#fff' : theme.text,
|
| 697 |
+
border: `1px solid ${isActive ? theme.player1Str : theme.border}`,
|
| 698 |
+
borderRadius: 6,
|
| 699 |
+
fontWeight: isActive ? 700 : 500,
|
| 700 |
+
minWidth: 80,
|
| 701 |
+
minHeight: 40,
|
| 702 |
+
textTransform: 'capitalize',
|
| 703 |
+
}}
|
| 704 |
+
>
|
| 705 |
+
{t}
|
| 706 |
+
</button>
|
| 707 |
+
)
|
| 708 |
+
})}
|
| 709 |
+
</div>
|
| 710 |
</div>
|
| 711 |
+
|
| 712 |
+
{/* Taunt Style / Personality (Conditional) */}
|
| 713 |
+
{opponentType === 'taunt' && (
|
| 714 |
+
<div style={{
|
| 715 |
+
display: 'flex',
|
| 716 |
+
flexDirection: 'column',
|
| 717 |
+
gap: '0.5rem',
|
| 718 |
+
alignItems: 'center',
|
| 719 |
+
width: '100%',
|
| 720 |
+
}}>
|
| 721 |
+
<strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Personality</strong>
|
| 722 |
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
| 723 |
+
{(['friendly', 'funny', 'lover', 'serious'] as const).map(s => {
|
| 724 |
+
const isActive = tauntStyle === s
|
| 725 |
+
return (
|
| 726 |
+
<button
|
| 727 |
+
key={s}
|
| 728 |
+
onClick={() => { audioManager.play('click'); setTauntStyle(s) }}
|
| 729 |
+
style={{
|
| 730 |
+
padding: '0.45rem 0.9rem',
|
| 731 |
+
fontSize: '0.85rem',
|
| 732 |
+
cursor: 'pointer',
|
| 733 |
+
background: isActive ? theme.player2Str : theme.bg,
|
| 734 |
+
color: isActive ? '#fff' : theme.text,
|
| 735 |
+
border: `1px solid ${isActive ? theme.player2Str : theme.border}`,
|
| 736 |
+
borderRadius: 6,
|
| 737 |
+
fontWeight: isActive ? 700 : 500,
|
| 738 |
+
minWidth: 80,
|
| 739 |
+
minHeight: 40,
|
| 740 |
+
textTransform: 'capitalize',
|
| 741 |
+
}}
|
| 742 |
+
>
|
| 743 |
+
{s}
|
| 744 |
+
</button>
|
| 745 |
+
)
|
| 746 |
+
})}
|
| 747 |
+
</div>
|
| 748 |
+
</div>
|
| 749 |
+
)}
|
| 750 |
</div>
|
| 751 |
)}
|
| 752 |
|
|
|
|
| 778 |
/>
|
| 779 |
</div>
|
| 780 |
|
| 781 |
+
<PushButton
|
| 782 |
+
label="← Back to menu"
|
| 783 |
onClick={() => navigate('/')}
|
| 784 |
+
style={pushButtonVars({
|
| 785 |
+
front: theme.bgPanel,
|
| 786 |
+
text: theme.textMuted,
|
| 787 |
+
padding: '0.5rem 1rem',
|
| 788 |
+
fontSize: '0.9rem',
|
| 789 |
+
fontWeight: 500,
|
| 790 |
+
radius: '8px',
|
| 791 |
+
})}
|
| 792 |
+
/>
|
| 793 |
|
| 794 |
{symbolPickerFor && (
|
| 795 |
<SymbolPicker
|
client/src/pages/__tests__/LocalSetup.test.tsx
CHANGED
|
@@ -123,6 +123,8 @@ describe('LocalSetup page', () => {
|
|
| 123 |
player1Type: 'human',
|
| 124 |
player2Type: 'bot',
|
| 125 |
botProfile: 'normal',
|
|
|
|
|
|
|
| 126 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 127 |
},
|
| 128 |
})
|
|
@@ -147,6 +149,8 @@ describe('LocalSetup page', () => {
|
|
| 147 |
player1Type: 'human',
|
| 148 |
player2Type: 'bot',
|
| 149 |
botProfile: 'normal',
|
|
|
|
|
|
|
| 150 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 151 |
},
|
| 152 |
})
|
|
@@ -204,7 +208,7 @@ describe('LocalSetup page', () => {
|
|
| 204 |
|
| 205 |
it('sets the bot profile for local bot games', () => {
|
| 206 |
render(<LocalSetup />)
|
| 207 |
-
expect(screen.getByText('
|
| 208 |
fireEvent.click(screen.getByText('Expert'))
|
| 209 |
fireEvent.click(screen.getByText('Start Game'))
|
| 210 |
|
|
@@ -217,6 +221,8 @@ describe('LocalSetup page', () => {
|
|
| 217 |
player1Type: 'human',
|
| 218 |
player2Type: 'bot',
|
| 219 |
botProfile: 'expert',
|
|
|
|
|
|
|
| 220 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 221 |
},
|
| 222 |
})
|
|
@@ -236,6 +242,35 @@ describe('LocalSetup page', () => {
|
|
| 236 |
player1Type: 'bot',
|
| 237 |
player2Type: 'bot',
|
| 238 |
botProfile: 'normal',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 240 |
},
|
| 241 |
})
|
|
|
|
| 123 |
player1Type: 'human',
|
| 124 |
player2Type: 'bot',
|
| 125 |
botProfile: 'normal',
|
| 126 |
+
opponentType: 'taunt',
|
| 127 |
+
tauntStyle: 'funny',
|
| 128 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 129 |
},
|
| 130 |
})
|
|
|
|
| 149 |
player1Type: 'human',
|
| 150 |
player2Type: 'bot',
|
| 151 |
botProfile: 'normal',
|
| 152 |
+
opponentType: 'taunt',
|
| 153 |
+
tauntStyle: 'funny',
|
| 154 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 155 |
},
|
| 156 |
})
|
|
|
|
| 208 |
|
| 209 |
it('sets the bot profile for local bot games', () => {
|
| 210 |
render(<LocalSetup />)
|
| 211 |
+
expect(screen.getByText('Difficulty')).toBeInTheDocument()
|
| 212 |
fireEvent.click(screen.getByText('Expert'))
|
| 213 |
fireEvent.click(screen.getByText('Start Game'))
|
| 214 |
|
|
|
|
| 221 |
player1Type: 'human',
|
| 222 |
player2Type: 'bot',
|
| 223 |
botProfile: 'expert',
|
| 224 |
+
opponentType: 'taunt',
|
| 225 |
+
tauntStyle: 'funny',
|
| 226 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 227 |
},
|
| 228 |
})
|
|
|
|
| 242 |
player1Type: 'bot',
|
| 243 |
player2Type: 'bot',
|
| 244 |
botProfile: 'normal',
|
| 245 |
+
opponentType: 'taunt',
|
| 246 |
+
tauntStyle: 'funny',
|
| 247 |
+
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 248 |
+
},
|
| 249 |
+
})
|
| 250 |
+
})
|
| 251 |
+
|
| 252 |
+
it('allows choosing opponent type and personality style', () => {
|
| 253 |
+
render(<LocalSetup />)
|
| 254 |
+
expect(screen.getByText('Opponent Type')).toBeInTheDocument()
|
| 255 |
+
fireEvent.click(screen.getByText(/streamer/i))
|
| 256 |
+
expect(screen.queryByText('Personality')).not.toBeInTheDocument()
|
| 257 |
+
|
| 258 |
+
fireEvent.click(screen.getByText(/taunt/i))
|
| 259 |
+
expect(screen.getByText('Personality')).toBeInTheDocument()
|
| 260 |
+
fireEvent.click(screen.getByText(/lover/i))
|
| 261 |
+
|
| 262 |
+
fireEvent.click(screen.getByText('Start Game'))
|
| 263 |
+
expect(mockNavigate).toHaveBeenCalledWith('/play/local', {
|
| 264 |
+
state: {
|
| 265 |
+
player1Name: 'Caro 1',
|
| 266 |
+
player2Name: 'Caro 2',
|
| 267 |
+
player1Symbol: 'X',
|
| 268 |
+
player2Symbol: 'O',
|
| 269 |
+
player1Type: 'human',
|
| 270 |
+
player2Type: 'bot',
|
| 271 |
+
botProfile: 'normal',
|
| 272 |
+
opponentType: 'taunt',
|
| 273 |
+
tauntStyle: 'lover',
|
| 274 |
rules: { swap2: true, noOverlines: true, boardSize: 15 },
|
| 275 |
},
|
| 276 |
})
|