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' && }
)
}