import { define, createThemes } from 'forge' import { move, nameChange, playAgain } from './actions' import type { Game } from '../types' import { sessionId } from 'pyre/client' const theme = createThemes({ light: { bg: '#fff', fg: '#333', fgActive: '#000', border: '#333', hoverBg: 'rgba(0, 0, 0, 0.05)', inputBg: '#fff', inputBorder: '#999', }, dark: { bg: '#1a1a1a', fg: '#aaa', fgActive: '#fff', border: '#555', hoverBg: 'rgba(255, 255, 255, 0.1)', inputBg: '#333', inputBorder: '#666', }, }) const Player = define('Player', { display: 'flex', alignItems: 'center', gap: 12, padding: '12px 20px', parts: { Symbol: { fontSize: 32, }, Indicator: { width: 8, height: 8, borderRadius: '50%', background: '#4ade80', opacity: 0, transition: 'opacity 0.2s ease', }, Name: { base: 'span', fontSize: 18, fontWeight: 500, color: theme('fg'), padding: '2px 6px', borderRadius: 4, border: '1px solid transparent', outline: 'none', minWidth: 60, }, }, variants: { active: { parts: { Name: { color: theme('fgActive'), }, Indicator: { opacity: 1, }, }, }, editable: { parts: { Name: { cursor: 'text', states: { hover: { background: theme('hoverBg'), }, focus: { border: `1px solid ${theme('inputBorder')}`, background: theme('inputBg'), }, }, }, }, }, player: { o: { parts: { Symbol: { filter: 'sepia(1) saturate(5) hue-rotate(180deg) brightness(0.9)', }, }, }, }, }, render({ props, parts: { Root, Symbol, Indicator, Name } }) { const onKeyDown = (e: KeyboardEvent) => { if (!e.target) return const target = (e.target as HTMLSpanElement) if (e.key === 'Enter') { e.preventDefault() const newName = target.textContent || `Player ${props.player.toUpperCase()}` nameChange(newName) target.blur() } else if (e.key === 'Escape') { e.preventDefault() target.blur() } } return ( {props.symbol} {props.name} ) }, }) const Board = define('Board', { display: 'inline-grid', gridTemplateColumns: 'repeat(3, 1fr)', position: 'relative', }) const Tile = define('Tile', { base: 'button', width: 120, height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 60, background: 'transparent', border: 'none', borderRight: `3px solid ${theme('border')}`, borderBottom: `3px solid ${theme('border')}`, cursor: 'pointer', transition: 'background 0.15s ease', states: { hover: { background: theme('hoverBg'), }, }, variants: { col: { last: { borderRight: 'none' }, }, row: { last: { borderBottom: 'none' }, }, player: { o: { filter: 'sepia(1) saturate(5) hue-rotate(180deg) brightness(0.9)' }, }, }, }) const WinLine = define('WinLine', { position: 'absolute', borderRadius: 4, variants: { direction: { diagonal: { width: '140%', height: 6, top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(45deg)', }, 'diagonal-reverse': { width: '140%', height: 6, top: '50%', left: '50%', transform: 'translate(-50%, -50%) rotate(-45deg)', }, horizontal: { width: '100%', height: 6, left: 0, transform: 'translateY(-50%)', }, vertical: { width: 6, height: '100%', top: 0, transform: 'translateX(-50%)', }, }, position: { top: { top: 60 }, middle: { top: '50%' }, bottom: { top: 300 }, left: { left: 60 }, center: { left: '50%' }, right: { left: 300 }, }, player: { x: { background: '#e74c3c' }, o: { background: '#3498db' }, }, }, }) const WinBanner = define('WinBanner', { position: 'absolute', top: 24, left: '50%', transform: 'translateX(-50%)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '16px 32px', borderRadius: 12, background: theme('bg'), boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', zIndex: 10, parts: { Title: { fontSize: 20, fontWeight: 'bold', color: theme('fgActive'), }, Button: { base: 'button', fontSize: 14, fontWeight: 600, padding: '8px 20px', borderRadius: 6, border: 'none', cursor: 'pointer', }, }, variants: { player: { x: { parts: { Button: { background: '#e74c3c', color: 'white', }, }, }, o: { parts: { Button: { background: '#3498db', color: 'white', }, }, }, }, }, render({ props, parts: { Root, Title, Button } }) { return ( {props.title} ) }, }) const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') const getTheme = () => darkQuery.matches ? 'dark' : 'light' let currentTheme = getTheme() darkQuery.addEventListener('change', () => { currentTheme = getTheme() document.querySelector('[data-theme]')?.setAttribute('data-theme', currentTheme) }) const Phone = define('Phone', { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: 'min(100vw - 40px, (100vh - 40px) * 9 / 19.5)', height: 'min(100vh - 40px, (100vw - 40px) * 19.5 / 9)', overflow: 'hidden', render({ props, parts: { Root } }) { return {props.children} }, }) const GameContainer = define('Game', { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, width: '100%', height: '100%', }) const BoardWrapper = define('BoardWrapper', { position: 'relative', }) const symbol = (tile: string): string => tile === 'x' ? '❌' : tile === 'o' ? '⭕️' : '' const PlayerO = ({ game, myPlayer }: { game: Game, myPlayer: string }) => const PlayerX = ({ game, myPlayer }: { game: Game, myPlayer: string }) => const tryMove = (game: Game, player: 'x' | 'o', x: number, y: number) => { return game.turn === player && game.phase === 'play' ? () => move(x, y) : () => { } } export default ({ game }: { game: Game }) => { const player = game.players[sessionId] if (!player) throw new Error(`no player for session: ${sessionId}`) const winnerName = game.winLine?.player === 'x' ? game.names.x : game.names.o return ( {game.winLine && ( )} {player === 'x' && } {player === 'o' && } {symbol(game.board[0][0])} {symbol(game.board[0][1])} {symbol(game.board[0][2])} {symbol(game.board[1][0])} {symbol(game.board[1][1])} {symbol(game.board[1][2])} {symbol(game.board[2][0])} {symbol(game.board[2][1])} {symbol(game.board[2][2])} {game.winLine && ( )} {player === 'x' && } {player === 'o' && } ) }