Spaces:
Runtime error
Runtime error
| import { useState, useEffect } from 'react' | |
| import { useNavigate, useLocation } from 'react-router-dom' | |
| import { | |
| theme, | |
| useThemeMode, | |
| THEMES, | |
| getPlayerName, | |
| getPlayer2Name, | |
| setPlayerName, | |
| setPlayer2Name, | |
| getPlayer1Symbol, | |
| setPlayer1Symbol, | |
| getPlayer2Symbol, | |
| setPlayer2Symbol, | |
| getPlayerColor, | |
| setPlayerColor, | |
| getPlayer2Color, | |
| setPlayer2Color, | |
| getPlayerDrawTower, | |
| setPlayerDrawTower, | |
| getPlayer2DrawTower, | |
| setPlayer2DrawTower | |
| } from '../theme.ts' | |
| import SymbolPicker from '../components/SymbolPicker.tsx' | |
| import ThemeSelector from '../components/ThemeSelector.tsx' | |
| import type { GameRules } from 'shared/types.ts' | |
| import { binAssetUrl } from '../binAssets' | |
| import { PushButton, pushButtonVars } from '../components/PushButton' | |
| import { audioManager } from '../game/effects/AudioManager.ts' | |
| type LocalPlayerType = 'human' | 'bot' | |
| type BotProfile = 'normal' | 'expert' | 'ai' | 'ai+' | |
| type LegacyBotProfile = BotProfile | 'balanced' | |
| type RulesMode = 'tournament' | 'custom' | |
| const TOURNAMENT_RULES: GameRules = { swap2: true, noOverlines: true, boardSize: 15 } | |
| function normalizeBotProfile(profile?: LegacyBotProfile): BotProfile { | |
| if (profile === 'expert' || profile === 'ai' || profile === 'ai+') return profile | |
| return 'normal' | |
| } | |
| const PRESET_COLORS = [ | |
| '#58a6ff', // Bright Blue | |
| '#f0883e', // Orange/Coral | |
| '#ef4444', // Red | |
| '#10b981', // Emerald Green | |
| '#a78bfa', // Lavender Purple | |
| '#f9a8d4', // Rose Pink | |
| '#fbbf24', // Yellow Gold | |
| ] | |
| const getPresetColors = (defaultColor: string) => { | |
| const base = [...PRESET_COLORS] | |
| const normalized = defaultColor.toLowerCase() | |
| if (!base.some(c => c.toLowerCase() === normalized)) { | |
| return [defaultColor, ...base] | |
| } | |
| return base | |
| } | |
| function PlayerPiecePreview({ symbol, color, drawTower }: { symbol: string; color: string; drawTower: boolean }) { | |
| const isEmoji = symbol.length > 1 || (symbol.codePointAt(0) ?? 0) > 0xffff | |
| const textShadow = isEmoji | |
| ? '0 2px 5px rgba(0,0,0,0.75)' | |
| : '0 1px 3px rgba(0,0,0,0.45)' | |
| return ( | |
| <span | |
| aria-hidden="true" | |
| style={{ | |
| position: 'relative', | |
| display: 'inline-flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| width: 52, | |
| height: 52, | |
| borderRadius: '50%', | |
| background: drawTower ? color : 'transparent', | |
| color: drawTower ? '#fff' : color, | |
| fontSize: isEmoji ? '2rem' : '1.7rem', | |
| fontWeight: 800, | |
| lineHeight: 1, | |
| textShadow, | |
| boxShadow: drawTower | |
| ? `0 8px 0 color-mix(in srgb, ${color} 68%, #000), 0 12px 18px rgba(0,0,0,0.35)` | |
| : 'none', | |
| transform: drawTower ? 'translateY(-3px)' : 'none', | |
| }} | |
| > | |
| {drawTower && ( | |
| <span | |
| style={{ | |
| position: 'absolute', | |
| inset: 4, | |
| borderRadius: '50%', | |
| border: '1px solid rgba(255,255,255,0.38)', | |
| boxShadow: 'inset 5px 5px 8px rgba(255,255,255,0.18), inset -6px -6px 10px rgba(0,0,0,0.22)', | |
| }} | |
| /> | |
| )} | |
| <span style={{ position: 'relative', zIndex: 1 }}>{symbol}</span> | |
| </span> | |
| ) | |
| } | |
| export default function LocalSetup() { | |
| const navigate = useNavigate() | |
| const location = useLocation() | |
| const setupState = (location.state as { rules?: GameRules; botProfile?: LegacyBotProfile } | null) | |
| const rules = setupState?.rules || { swap2: true, noOverlines: true, boardSize: 15 } | |
| const [name1, setName1] = useState(getPlayerName()) | |
| const [name2, setName2] = useState(getPlayer2Name()) | |
| const [symbol1, setSymbol1] = useState(getPlayer1Symbol()) | |
| const [symbol2, setSymbol2] = useState(getPlayer2Symbol()) | |
| const [color1, setColor1] = useState(getPlayerColor() || theme.player1Str) | |
| const [color2, setColor2] = useState(getPlayer2Color() || theme.player2Str) | |
| const [hasCustomColor1, setHasCustomColor1] = useState(!!getPlayerColor()) | |
| const [hasCustomColor2, setHasCustomColor2] = useState(!!getPlayer2Color()) | |
| const [drawTower1, setDrawTower1] = useState(getPlayerDrawTower()) | |
| const [drawTower2, setDrawTower2] = useState(getPlayer2DrawTower()) | |
| const [themeMode, setThemeMode] = useThemeMode() | |
| const [selectorOpen, setSelectorOpen] = useState(false) | |
| const [symbolPickerFor, setSymbolPickerFor] = useState<1 | 2 | null>(null) | |
| const [player1Type, setPlayer1Type] = useState<LocalPlayerType>('human') | |
| const [player2Type, setPlayer2Type] = useState<LocalPlayerType>('bot') | |
| const [botProfile, setBotProfile] = useState<BotProfile>(normalizeBotProfile(setupState?.botProfile)) | |
| const [opponentType, setOpponentType] = useState<'streamer' | 'coach' | 'analyst' | 'taunt'>('taunt') | |
| const [tauntStyle, setTauntStyle] = useState<'friendly' | 'funny' | 'lover' | 'serious'>('funny') | |
| const [, setMuteTick] = useState(0) | |
| useEffect(() => { | |
| if (!hasCustomColor1) { | |
| setColor1(theme.player1Str) | |
| } | |
| }, [themeMode, hasCustomColor1]) | |
| useEffect(() => { | |
| if (!hasCustomColor2) { | |
| setColor2(theme.player2Str) | |
| } | |
| }, [themeMode, hasCustomColor2]) | |
| const swapPlayers = () => { | |
| setName1(name2) | |
| setName2(name1) | |
| setSymbol1(symbol2) | |
| setSymbol2(symbol1) | |
| setColor1(color2) | |
| setColor2(color1) | |
| setPlayer1Type(player2Type) | |
| setPlayer2Type(player1Type) | |
| setHasCustomColor1(hasCustomColor2) | |
| setHasCustomColor2(hasCustomColor1) | |
| setDrawTower1(drawTower2) | |
| setDrawTower2(drawTower1) | |
| } | |
| const startGame = () => { | |
| setPlayerName(name1.trim() || 'Caro 1') | |
| setPlayer2Name(name2.trim() || 'Caro 2') | |
| setPlayer1Symbol(symbol1) | |
| setPlayer2Symbol(symbol2) | |
| setPlayerColor(color1) | |
| setPlayer2Color(color2) | |
| setPlayerDrawTower(drawTower1) | |
| setPlayer2DrawTower(drawTower2) | |
| navigate('/play/local', { | |
| state: { | |
| player1Name: name1.trim() || 'Caro 1', | |
| player2Name: name2.trim() || 'Caro 2', | |
| player1Symbol: symbol1, | |
| player2Symbol: symbol2, | |
| player1Type, | |
| player2Type, | |
| botProfile, | |
| opponentType, | |
| tauntStyle, | |
| rules: rules, | |
| } | |
| }) | |
| } | |
| const setupBotVsBot = () => { | |
| setPlayer1Type('bot') | |
| setPlayer2Type('bot') | |
| if (!name1.trim()) setName1('Bot 1') | |
| if (!name2.trim()) setName2('Bot 2') | |
| } | |
| const typeButtonStyle = (active: boolean, accent: string) => ({ | |
| padding: '0.35rem 0.7rem', | |
| fontSize: '0.8rem', | |
| cursor: 'pointer', | |
| background: active ? accent : theme.bg, | |
| color: active ? '#fff' : theme.text, | |
| border: `1px solid ${theme.border}`, | |
| borderRadius: 6, | |
| fontWeight: active ? 700 : 500, | |
| minWidth: 60, | |
| minHeight: 36, | |
| }) | |
| const profileButtonStyle = (active: boolean) => ({ | |
| padding: '0.45rem 0.9rem', | |
| fontSize: '0.85rem', | |
| cursor: 'pointer', | |
| background: active ? theme.player2Str : theme.bg, | |
| color: active ? '#fff' : theme.text, | |
| border: `1px solid ${active ? theme.player2Str : theme.border}`, | |
| borderRadius: 6, | |
| fontWeight: active ? 700 : 500, | |
| minWidth: 92, | |
| minHeight: 40, | |
| }) | |
| return ( | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| height: '100dvh', | |
| fontFamily: 'system-ui, -apple-system, sans-serif', | |
| gap: '1.5rem', | |
| padding: '1rem', | |
| background: `linear-gradient(rgba(13, 17, 23, 0.68), rgba(13, 17, 23, 0.78)), url('${binAssetUrl('bg/start-menu-bg.jpg')}') center / cover no-repeat fixed, ${theme.bgStr}`, | |
| color: theme.text, | |
| overflow: 'auto', | |
| }}> | |
| <div style={{ position: 'absolute', top: 12, right: 12, display: 'flex', gap: 6 }}> | |
| <PushButton | |
| label={audioManager.muted ? '🔇' : '🔊'} | |
| onClick={() => { audioManager.toggleMute(); setMuteTick(t => t + 1) }} | |
| title={audioManager.muted ? 'Unmute audio' : 'Mute audio'} | |
| style={pushButtonVars({ | |
| front: theme.bgPanel, | |
| text: theme.textMuted, | |
| padding: '0.5rem 0.8rem', | |
| fontSize: '0.85rem', | |
| fontWeight: 500, | |
| radius: '6px', | |
| })} | |
| /> | |
| <PushButton | |
| label={THEMES[themeMode].emoji} | |
| onClick={() => setSelectorOpen(true)} | |
| style={pushButtonVars({ | |
| front: theme.bgPanel, | |
| text: theme.textMuted, | |
| padding: '0.5rem 0.8rem', | |
| fontSize: '0.85rem', | |
| fontWeight: 500, | |
| radius: '6px', | |
| })} | |
| /> | |
| </div> | |
| {selectorOpen && ( | |
| <ThemeSelector | |
| current={themeMode} | |
| onSelect={setThemeMode} | |
| onClose={() => setSelectorOpen(false)} | |
| /> | |
| )} | |
| <h1 style={{ fontSize: 'clamp(1.5rem, 6vw, 2.2rem)', fontWeight: 800, letterSpacing: '-0.03em', textAlign: 'center' }}>Local Game Setup</h1> | |
| <div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center' }}> | |
| {/* Player 1 */} | |
| <div style={{ | |
| display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', | |
| background: theme.bgPanel, padding: '1.25rem 1.5rem', borderRadius: 12, | |
| border: `1px solid ${theme.border}`, | |
| }}> | |
| <span style={{ color: color1, fontWeight: 700, fontSize: '1.05rem' }}>Player 1</span> | |
| <input | |
| value={name1} | |
| onChange={e => setName1(e.target.value)} | |
| placeholder="Enter name" | |
| maxLength={24} | |
| style={{ | |
| padding: '0.6rem 1rem', fontSize: '1rem', background: theme.bg, | |
| color: theme.text, border: `1px solid ${theme.border}`, borderRadius: 6, | |
| textAlign: 'center', outline: 'none', width: 140 | |
| }} | |
| /> | |
| <button | |
| onClick={() => { audioManager.play('click'); setSymbolPickerFor(1) }} | |
| style={{ | |
| padding: 0, | |
| cursor: 'pointer', background: 'transparent', | |
| color: '#fff', border: 'none', borderRadius: '50%', | |
| fontWeight: 700, minWidth: 62, minHeight: 66, | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| transition: 'transform 0.15s ease', | |
| }} | |
| onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.05)'} | |
| onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'} | |
| title="Choose Player 1 symbol" | |
| > | |
| <PlayerPiecePreview symbol={symbol1} color={color1} drawTower={drawTower1} /> | |
| </button> | |
| {/* Display Tower toggle */} | |
| <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', color: theme.text, fontSize: '0.85rem', userSelect: 'none', marginTop: '0.25rem' }}> | |
| <div style={{ | |
| width: 32, height: 18, borderRadius: 9, position: 'relative', | |
| background: drawTower1 ? color1 : theme.border, | |
| transition: 'background 0.2s ease', | |
| }} | |
| onClick={() => setDrawTower1(!drawTower1)} | |
| > | |
| <div style={{ | |
| width: 14, height: 14, borderRadius: '50%', background: '#fff', | |
| position: 'absolute', top: 2, | |
| left: drawTower1 ? 16 : 2, | |
| transition: 'left 0.2s ease', | |
| }} /> | |
| </div> | |
| Display Tower | |
| </label> | |
| {/* Collapsible Drawer for Player 1 details */} | |
| <div style={{ | |
| maxHeight: drawTower1 ? '160px' : '0px', | |
| opacity: drawTower1 ? 1 : 0, | |
| overflow: 'hidden', | |
| transition: 'all 0.35s cubic-bezier(0.4, 0, 0.2, 1)', | |
| width: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '0.25rem', | |
| }}> | |
| {/* Color selector for Player 1 */} | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', marginTop: '0.5rem' }}> | |
| <span style={{ color: theme.textMuted, fontSize: '0.75rem', fontWeight: 600 }}>COLOR</span> | |
| <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center', maxWidth: 150 }}> | |
| {getPresetColors(theme.player1Str).map(c => { | |
| const isSelected = color1.toLowerCase() === c.toLowerCase() | |
| return ( | |
| <button | |
| key={c} | |
| title={c} | |
| onClick={() => { | |
| audioManager.play('click') | |
| setColor1(c) | |
| setHasCustomColor1(true) | |
| }} | |
| style={{ | |
| width: 20, | |
| height: 20, | |
| borderRadius: '50%', | |
| background: c, | |
| border: isSelected ? `2px solid ${theme.text}` : '1px solid rgba(255,255,255,0.15)', | |
| cursor: 'pointer', | |
| boxShadow: '0 1px 4px rgba(0,0,0,0.15)', | |
| padding: 0, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| transition: 'transform 0.1s ease', | |
| }} | |
| onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.2)'} | |
| onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'} | |
| > | |
| {isSelected && ( | |
| <span style={{ color: '#fff', fontSize: '0.65rem', textShadow: '0 0.5px 1px rgba(0,0,0,0.5)' }}>✓</span> | |
| )} | |
| </button> | |
| ) | |
| })} | |
| {/* Native color picker circle */} | |
| <div style={{ position: 'relative', width: 20, height: 20 }}> | |
| <input | |
| type="color" | |
| value={color1} | |
| onChange={(e) => { | |
| setColor1(e.target.value) | |
| setHasCustomColor1(true) | |
| }} | |
| style={{ | |
| opacity: 0, | |
| position: 'absolute', | |
| inset: 0, | |
| width: '100%', | |
| height: '100%', | |
| cursor: 'pointer', | |
| zIndex: 2, | |
| }} | |
| /> | |
| <div style={{ | |
| width: 20, | |
| height: 20, | |
| borderRadius: '50%', | |
| background: 'linear-gradient(45deg, red, orange, yellow, green, blue, purple)', | |
| border: '1px solid rgba(255,255,255,0.15)', | |
| boxShadow: '0 1px 4px rgba(0,0,0,0.15)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| fontSize: '0.65rem', | |
| color: '#fff', | |
| fontWeight: 'bold', | |
| textShadow: '0 1px 1px rgba(0,0,0,0.5)', | |
| }}> | |
| + | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}> | |
| <button | |
| onClick={() => { audioManager.play('click'); setPlayer1Type('human') }} | |
| style={typeButtonStyle(player1Type === 'human', color1)} | |
| > | |
| Human | |
| </button> | |
| <button | |
| onClick={() => { audioManager.play('click'); setPlayer1Type('bot') }} | |
| style={typeButtonStyle(player1Type === 'bot', color1)} | |
| > | |
| Bot | |
| </button> | |
| </div> | |
| </div> | |
| {/* Swap button in between P1 and P2 */} | |
| <button | |
| onClick={() => { audioManager.play('click'); swapPlayers() }} | |
| title="Swap Players" | |
| style={{ | |
| background: 'rgba(185, 255, 195, 0.38)', | |
| color: theme.text, | |
| border: `1px solid ${theme.border}`, | |
| borderRadius: '50%', | |
| width: 44, | |
| height: 44, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| cursor: 'pointer', | |
| fontSize: '1.25rem', | |
| boxShadow: '0 4px 12px rgba(0,0,0,0.15)', | |
| transition: 'transform 0.2s ease, border-color 0.2s', | |
| zIndex: 2, | |
| margin: '0.5rem 0', | |
| }} | |
| onMouseEnter={(e) => { | |
| e.currentTarget.style.transform = 'scale(1.1) rotate(180deg)'; | |
| e.currentTarget.style.borderColor = color1; | |
| }} | |
| onMouseLeave={(e) => { | |
| e.currentTarget.style.transform = 'scale(1) rotate(0deg)'; | |
| e.currentTarget.style.borderColor = theme.border; | |
| }} | |
| > | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" style={{ display: 'block' }}> | |
| <path d="M20 7H7.5M20 7L15.75 3M20 7L15.75 11" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M4 17H16.5M4 17L8.25 13M4 17L8.25 21" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"/> | |
| </svg> | |
| </button> | |
| {/* Player 2 */} | |
| <div style={{ | |
| display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.75rem', | |
| background: theme.bgPanel, padding: '1.25rem 1.5rem', borderRadius: 12, | |
| border: `1px solid ${theme.border}`, | |
| }}> | |
| <span style={{ color: color2, fontWeight: 700, fontSize: '1.05rem' }}>Player 2</span> | |
| <input | |
| value={name2} | |
| onChange={e => setName2(e.target.value)} | |
| placeholder="Enter name" | |
| maxLength={24} | |
| style={{ | |
| padding: '0.6rem 1rem', fontSize: '1rem', background: theme.bg, | |
| color: theme.text, border: `1px solid ${theme.border}`, borderRadius: 6, | |
| textAlign: 'center', outline: 'none', width: 140 | |
| }} | |
| /> | |
| <button | |
| onClick={() => { audioManager.play('click'); setSymbolPickerFor(2) }} | |
| style={{ | |
| padding: 0, | |
| cursor: 'pointer', background: 'transparent', | |
| color: '#fff', border: 'none', borderRadius: '50%', | |
| fontWeight: 700, minWidth: 62, minHeight: 66, | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| transition: 'transform 0.15s ease', | |
| }} | |
| onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.05)'} | |
| onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'} | |
| title="Choose Player 2 symbol" | |
| > | |
| <PlayerPiecePreview symbol={symbol2} color={color2} drawTower={drawTower2} /> | |
| </button> | |
| {/* Display Tower toggle */} | |
| <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', color: theme.text, fontSize: '0.85rem', userSelect: 'none', marginTop: '0.25rem' }}> | |
| <div style={{ | |
| width: 32, height: 18, borderRadius: 9, position: 'relative', | |
| background: drawTower2 ? color2 : theme.border, | |
| transition: 'background 0.2s ease', | |
| }} | |
| onClick={() => setDrawTower2(!drawTower2)} | |
| > | |
| <div style={{ | |
| width: 14, height: 14, borderRadius: '50%', background: '#fff', | |
| position: 'absolute', top: 2, | |
| left: drawTower2 ? 16 : 2, | |
| transition: 'left 0.2s ease', | |
| }} /> | |
| </div> | |
| Display Tower | |
| </label> | |
| {/* Collapsible Drawer for Player 2 details */} | |
| <div style={{ | |
| maxHeight: drawTower2 ? '160px' : '0px', | |
| opacity: drawTower2 ? 1 : 0, | |
| overflow: 'hidden', | |
| transition: 'all 0.35s cubic-bezier(0.4, 0, 0.2, 1)', | |
| width: '100%', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| gap: '0.25rem', | |
| }}> | |
| {/* Color selector for Player 2 */} | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem', marginTop: '0.5rem' }}> | |
| <span style={{ color: theme.textMuted, fontSize: '0.75rem', fontWeight: 600 }}>COLOR</span> | |
| <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'center', maxWidth: 150 }}> | |
| {getPresetColors(theme.player2Str).map(c => { | |
| const isSelected = color2.toLowerCase() === c.toLowerCase() | |
| return ( | |
| <button | |
| key={c} | |
| title={c} | |
| onClick={() => { | |
| audioManager.play('click') | |
| setColor2(c) | |
| setHasCustomColor2(true) | |
| }} | |
| style={{ | |
| width: 20, | |
| height: 20, | |
| borderRadius: '50%', | |
| background: c, | |
| border: isSelected ? `2px solid ${theme.text}` : '1px solid rgba(255,255,255,0.15)', | |
| cursor: 'pointer', | |
| boxShadow: '0 1px 4px rgba(0,0,0,0.15)', | |
| padding: 0, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| transition: 'transform 0.1s ease', | |
| }} | |
| onMouseEnter={(e) => e.currentTarget.style.transform = 'scale(1.2)'} | |
| onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'} | |
| > | |
| {isSelected && ( | |
| <span style={{ color: '#fff', fontSize: '0.65rem', textShadow: '0 0.5px 1px rgba(0,0,0,0.5)' }}>✓</span> | |
| )} | |
| </button> | |
| ) | |
| })} | |
| {/* Native color picker circle */} | |
| <div style={{ position: 'relative', width: 20, height: 20 }}> | |
| <input | |
| type="color" | |
| value={color2} | |
| onChange={(e) => { | |
| setColor2(e.target.value) | |
| setHasCustomColor2(true) | |
| }} | |
| style={{ | |
| opacity: 0, | |
| position: 'absolute', | |
| inset: 0, | |
| width: '100%', | |
| height: '100%', | |
| cursor: 'pointer', | |
| zIndex: 2, | |
| }} | |
| /> | |
| <div style={{ | |
| width: 20, | |
| height: 20, | |
| borderRadius: '50%', | |
| background: 'linear-gradient(45deg, red, orange, yellow, green, blue, purple)', | |
| border: '1px solid rgba(255,255,255,0.15)', | |
| boxShadow: '0 1px 4px rgba(0,0,0,0.15)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| fontSize: '0.65rem', | |
| color: '#fff', | |
| fontWeight: 'bold', | |
| textShadow: '0 1px 1px rgba(0,0,0,0.5)', | |
| }}> | |
| + | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}> | |
| <button | |
| onClick={() => { audioManager.play('click'); setPlayer2Type('human') }} | |
| style={typeButtonStyle(player2Type === 'human', color2)} | |
| > | |
| Human | |
| </button> | |
| <button | |
| onClick={() => { audioManager.play('click'); setPlayer2Type('bot') }} | |
| style={typeButtonStyle(player2Type === 'bot', color2)} | |
| > | |
| Bot | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Readonly Rules Summary */} | |
| <div style={{ | |
| display: 'flex', flexDirection: 'column', gap: '0.4rem', alignItems: 'center', | |
| background: theme.bgPanel, padding: '0.75rem 1.5rem', borderRadius: 10, | |
| border: `1px solid ${theme.border}`, fontSize: '0.85rem', color: theme.textMuted, | |
| textAlign: 'center', maxWidth: 320, | |
| }}> | |
| <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em' }}>GAME RULES</strong> | |
| <div> | |
| <span>{rules.swap2 ? 'Swap2 Enabled' : 'No Swap2'}</span> | |
| <span> · </span> | |
| <span>{rules.noOverlines ? 'No overline (6+)' : 'Overline wins'}</span> | |
| <span> · </span> | |
| <span>Board {rules.boardSize}×{rules.boardSize}</span> | |
| </div> | |
| </div> | |
| {(player1Type === 'bot' || player2Type === 'bot') && ( | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '1.25rem', | |
| alignItems: 'center', | |
| background: theme.bgPanel, | |
| padding: '1.5rem', | |
| borderRadius: 16, | |
| border: `1px solid ${theme.border}`, | |
| boxShadow: '0 8px 32px rgba(0,0,0,0.15)', | |
| maxWidth: 400, | |
| width: '100%', | |
| }}> | |
| {/* Difficulty/Profile */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center', width: '100%' }}> | |
| <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Difficulty</strong> | |
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | |
| <button | |
| onClick={() => { audioManager.play('click'); setBotProfile('normal') }} | |
| style={profileButtonStyle(botProfile === 'normal')} | |
| > | |
| Normal | |
| </button> | |
| <button | |
| onClick={() => { audioManager.play('click'); setBotProfile('expert') }} | |
| style={profileButtonStyle(botProfile === 'expert')} | |
| > | |
| Expert | |
| </button> | |
| <button | |
| onClick={() => { audioManager.play('click'); setBotProfile('ai') }} | |
| style={profileButtonStyle(botProfile === 'ai')} | |
| > | |
| AI | |
| </button> | |
| <button | |
| onClick={() => { audioManager.play('click'); setBotProfile('ai+') }} | |
| style={profileButtonStyle(botProfile === 'ai+')} | |
| > | |
| AI+ | |
| </button> | |
| </div> | |
| </div> | |
| {/* Opponent Type */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center', width: '100%' }}> | |
| <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Opponent Type</strong> | |
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | |
| {(['streamer', 'coach', 'analyst', 'taunt'] as const).map(t => { | |
| const isActive = opponentType === t | |
| return ( | |
| <button | |
| key={t} | |
| onClick={() => { audioManager.play('click'); setOpponentType(t) }} | |
| style={{ | |
| padding: '0.45rem 0.9rem', | |
| fontSize: '0.85rem', | |
| cursor: 'pointer', | |
| background: isActive ? theme.player1Str : theme.bg, | |
| color: isActive ? '#fff' : theme.text, | |
| border: `1px solid ${isActive ? theme.player1Str : theme.border}`, | |
| borderRadius: 6, | |
| fontWeight: isActive ? 700 : 500, | |
| minWidth: 80, | |
| minHeight: 40, | |
| textTransform: 'capitalize', | |
| }} | |
| > | |
| {t} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| {/* Taunt Style / Personality (Conditional) */} | |
| {opponentType === 'taunt' && ( | |
| <div style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '0.5rem', | |
| alignItems: 'center', | |
| width: '100%', | |
| }}> | |
| <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Personality</strong> | |
| <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}> | |
| {(['friendly', 'funny', 'lover', 'serious'] as const).map(s => { | |
| const isActive = tauntStyle === s | |
| return ( | |
| <button | |
| key={s} | |
| onClick={() => { audioManager.play('click'); setTauntStyle(s) }} | |
| style={{ | |
| padding: '0.45rem 0.9rem', | |
| fontSize: '0.85rem', | |
| cursor: 'pointer', | |
| background: isActive ? theme.player2Str : theme.bg, | |
| color: isActive ? '#fff' : theme.text, | |
| border: `1px solid ${isActive ? theme.player2Str : theme.border}`, | |
| borderRadius: 6, | |
| fontWeight: isActive ? 700 : 500, | |
| minWidth: 80, | |
| minHeight: 40, | |
| textTransform: 'capitalize', | |
| }} | |
| > | |
| {s} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', justifyContent: 'center' }}> | |
| <PushButton | |
| label="Bot vs Bot" | |
| onClick={setupBotVsBot} | |
| style={pushButtonVars({ | |
| front: theme.bgPanel, | |
| text: theme.text, | |
| padding: '0.7rem 1.4rem', | |
| fontSize: '1rem', | |
| fontWeight: 600, | |
| minWidth: '150px', | |
| radius: '8px', | |
| })} | |
| /> | |
| <PushButton | |
| label="Start Game" | |
| onClick={startGame} | |
| style={pushButtonVars({ | |
| front: theme.player2Str, | |
| padding: '0.75rem 2rem', | |
| fontSize: '1.05rem', | |
| fontWeight: 600, | |
| minWidth: '180px', | |
| radius: '8px', | |
| })} | |
| /> | |
| </div> | |
| <PushButton | |
| label="← Back to menu" | |
| onClick={() => navigate('/')} | |
| style={pushButtonVars({ | |
| front: theme.bgPanel, | |
| text: theme.textMuted, | |
| padding: '0.5rem 1rem', | |
| fontSize: '0.9rem', | |
| fontWeight: 500, | |
| radius: '8px', | |
| })} | |
| /> | |
| {symbolPickerFor && ( | |
| <SymbolPicker | |
| current={symbolPickerFor === 1 ? symbol1 : symbol2} | |
| onSelect={(s) => { | |
| if (symbolPickerFor === 1) setSymbol1(s) | |
| else setSymbol2(s) | |
| setSymbolPickerFor(null) | |
| }} | |
| onClose={() => setSymbolPickerFor(null)} | |
| /> | |
| )} | |
| </div> | |
| ) | |
| } | |