375 lines
9.3 KiB
TypeScript
375 lines
9.3 KiB
TypeScript
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 (
|
|
<Root>
|
|
<Indicator />
|
|
<Symbol>{props.symbol}</Symbol>
|
|
<Name
|
|
contenteditable={props.editable ? "true" : "false"}
|
|
spellcheck="false"
|
|
onKeyDown={props.editable ? onKeyDown : undefined}
|
|
>
|
|
{props.name}
|
|
</Name>
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
|
|
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 (
|
|
<Root>
|
|
<Title>{props.title}</Title>
|
|
<Button onClick={() => window.location.href = '/'}>New Game</Button>
|
|
<Button onClick={playAgain}>Play Again</Button>
|
|
</Root>
|
|
)
|
|
},
|
|
})
|
|
|
|
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 <Root data-theme={currentTheme}>{props.children}</Root>
|
|
},
|
|
})
|
|
|
|
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 }) =>
|
|
<Player symbol="⭕️" game={game} name={game.names.o} player="o" active={game.turn === 'o'} editable={myPlayer === 'o'} />
|
|
|
|
const PlayerX = ({ game, myPlayer }: { game: Game, myPlayer: string }) =>
|
|
<Player symbol="❌" game={game} name={game.names.x} player="x" active={game.turn === 'x'} editable={myPlayer === 'x'} />
|
|
|
|
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 (
|
|
<Phone>
|
|
<GameContainer>
|
|
{game.winLine && (
|
|
<WinBanner
|
|
title={`${winnerName} wins!`}
|
|
player={game.winLine.player}
|
|
/>
|
|
)}
|
|
{player === 'x' && <PlayerO game={game} myPlayer={player} />}
|
|
{player === 'o' && <PlayerX game={game} myPlayer={player} />}
|
|
<BoardWrapper>
|
|
<Board>
|
|
<Tile onClick={tryMove(game, player, 0, 0)} player={game.board[0][0]}>{symbol(game.board[0][0])}</Tile>
|
|
<Tile onClick={tryMove(game, player, 0, 1)} player={game.board[0][1]}>{symbol(game.board[0][1])}</Tile>
|
|
<Tile onClick={tryMove(game, player, 0, 2)} col="last" player={game.board[0][2]}>{symbol(game.board[0][2])}</Tile>
|
|
|
|
<Tile onClick={tryMove(game, player, 1, 0)} player={game.board[1][0]}>{symbol(game.board[1][0])}</Tile>
|
|
<Tile onClick={tryMove(game, player, 1, 1)} player={game.board[1][1]}>{symbol(game.board[1][1])}</Tile>
|
|
<Tile onClick={tryMove(game, player, 1, 2)} col="last" player={game.board[1][2]}>{symbol(game.board[1][2])}</Tile>
|
|
|
|
<Tile onClick={tryMove(game, player, 2, 0)} row="last" player={game.board[2][0]}>{symbol(game.board[2][0])}</Tile>
|
|
<Tile onClick={tryMove(game, player, 2, 1)} row="last" player={game.board[2][1]}>{symbol(game.board[2][1])}</Tile>
|
|
<Tile onClick={tryMove(game, player, 2, 2)} row="last" col="last" player={game.board[2][2]}>{symbol(game.board[2][2])}</Tile>
|
|
</Board>
|
|
{game.winLine && (
|
|
<WinLine
|
|
direction={game.winLine.direction}
|
|
position={game.winLine.position}
|
|
player={game.winLine.player}
|
|
/>
|
|
)}
|
|
</BoardWrapper>
|
|
{player === 'x' && <PlayerX game={game} myPlayer={player} />}
|
|
{player === 'o' && <PlayerO game={game} myPlayer={player} />}
|
|
</GameContainer>
|
|
</Phone>
|
|
)
|
|
}
|