Add customizable player emojis with modal picker

This commit is contained in:
Chris Wanstrath 2026-03-20 15:51:09 -07:00
parent 9522a5b40c
commit 18385e4977
5 changed files with 168 additions and 15 deletions

View File

@ -1,6 +1,6 @@
// DO NOT MODIFY!
// This file is generated by pyre.
// Generated: 2026-01-24 09:09:49.
// Generated: 2026-01-26 13:25:18.
import { send } from 'pyre/client'
@ -12,6 +12,14 @@ export const playAgain = () => {
})
}
export const emojiChange = (player: 'x' | 'o', emoji: string) => {
send({
type: 'action',
action: 'emojiChange',
args: [player, emoji]
})
}
export const nameChange = (name: string) => {
send({
type: 'action',

View File

@ -1,5 +1,6 @@
import { define, createThemes } from 'forge'
import { move, nameChange, playAgain } from './actions'
import { EmojiModal, openEmojiModal } from './emojiModal'
import type { Game } from '../types'
import { sessionId } from 'pyre/client'
@ -33,6 +34,10 @@ const Player = define('Player', {
parts: {
Symbol: {
fontSize: 32,
cursor: 'pointer',
render({ children }) {
return <div onClick={openEmojiModal}>{children}</div>
}
},
Indicator: {
width: 8,
@ -314,14 +319,14 @@ const BoardWrapper = define('BoardWrapper', {
position: 'relative',
})
const symbol = (tile: string): string =>
tile === 'x' ? '❌' : tile === 'o' ? '⭕️' : ''
const symbol = (game: Game, tile: string): string =>
tile === 'x' ? game.emoji.x : tile === 'o' ? game.emoji.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'} />
<Player symbol={game.emoji.o} 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'} />
<Player symbol={game.emoji.x} 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) : () => { }
@ -335,6 +340,7 @@ export default ({ game }: { game: Game }) => {
return (
<Phone>
<EmojiModal game={game} player={player} />
<GameContainer>
{game.winLine && (
<WinBanner
@ -346,17 +352,17 @@ export default ({ game }: { game: Game }) => {
{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, 0, 0)} player={game.board[0][0]}>{symbol(game, game.board[0][0])}</Tile>
<Tile onClick={tryMove(game, player, 0, 1)} player={game.board[0][1]}>{symbol(game, game.board[0][1])}</Tile>
<Tile onClick={tryMove(game, player, 0, 2)} col="last" player={game.board[0][2]}>{symbol(game, 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, 1, 0)} player={game.board[1][0]}>{symbol(game, game.board[1][0])}</Tile>
<Tile onClick={tryMove(game, player, 1, 1)} player={game.board[1][1]}>{symbol(game, game.board[1][1])}</Tile>
<Tile onClick={tryMove(game, player, 1, 2)} col="last" player={game.board[1][2]}>{symbol(game, 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>
<Tile onClick={tryMove(game, player, 2, 0)} row="last" player={game.board[2][0]}>{symbol(game, game.board[2][0])}</Tile>
<Tile onClick={tryMove(game, player, 2, 1)} row="last" player={game.board[2][1]}>{symbol(game, game.board[2][1])}</Tile>
<Tile onClick={tryMove(game, player, 2, 2)} row="last" col="last" player={game.board[2][2]}>{symbol(game, game.board[2][2])}</Tile>
</Board>
{game.winLine && (
<WinLine

View File

@ -0,0 +1,131 @@
import { define, createThemes } from 'forge'
import { redraw } from 'pyre/client'
import type { Game, xo } from '../types'
import { emojiChange } from './actions'
const theme = createThemes({
light: {
bg: '#fff',
hoverBg: 'rgba(0, 0, 0, 0.05)',
inputBorder: '#999',
},
dark: {
bg: '#1a1a1a',
hoverBg: 'rgba(255, 255, 255, 0.1)',
inputBorder: '#666',
},
})
const EMOJI_OPTIONS = ['❌', '⭕', '🔥', '💀', '👑', '⚡', '🌟', '💎', '🎯'] as const
type Emoji = (typeof EMOJI_OPTIONS)[number]
export let emojiModalOpen = false
export const openEmojiModal = () => {
emojiModalOpen = true
redraw()
}
export const closeEmojiModal = () => {
emojiModalOpen = false
redraw()
}
const chooseEmoji = (game: Game, player: xo, emoji: Emoji) => {
closeEmojiModal()
emojiChange(player, emoji)
}
const ModalBackdrop = define('ModalBackdrop', {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 100,
render({ parts: { Root }, props }) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeEmojiModal()
}
const onMount = () => {
document.addEventListener('keydown', handleKeyDown)
}
return (
<Root ref={onMount} onClick={closeEmojiModal}>
{props.children}
</Root>
)
},
})
const ModalContent = define('ModalContent', {
background: theme('bg'),
borderRadius: 16,
padding: 24,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
render({ parts: { Root }, props }) {
return (
<Root onClick={(e: MouseEvent) => e.stopPropagation()}>
{props.children}
</Root>
)
},
})
const EmojiGrid = define('EmojiGrid', {
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 8,
})
const EmojiCell = define('EmojiCell', {
base: 'button',
width: 72,
height: 72,
fontSize: 40,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: theme('hoverBg'),
border: 'none',
borderRadius: 12,
cursor: 'pointer',
transition: 'transform 0.15s ease, background 0.15s ease',
states: {
hover: {
background: theme('inputBorder'),
transform: 'scale(1.1)',
},
},
})
type EmojiPickerProps = {
game: Game
player: xo
}
export const EmojiModal = define('EmojiModal', {
render({ props }: { props: EmojiPickerProps }) {
const { game, player } = props
return (
emojiModalOpen &&
<ModalBackdrop>
<ModalContent>
<EmojiGrid>
{EMOJI_OPTIONS.map((emoji) => (
<EmojiCell onClick={() => chooseEmoji(game, player, emoji)}>{emoji}</EmojiCell>
))}
</EmojiGrid>
</ModalContent>
</ModalBackdrop>
)
},
})

View File

@ -8,6 +8,7 @@ const newGame: Game = {
turn: 'x',
players: {},
names: { x: 'Player X', o: 'Player O' },
emoji: { x: '❌', o: '⭕️' },
board: [
['', '', ''],
['', '', ''],
@ -31,7 +32,7 @@ const action = setup(newGame, {
}
})
action('playAgain', (ctx) => {
action('playAgain', ctx => {
const game = ctx.game as Game
game.phase = 'play'
game.turn = 'x'
@ -44,6 +45,12 @@ action('playAgain', (ctx) => {
return game
})
action('emojiChange', (ctx, player: 'x' | 'o', emoji: string) => {
const game = ctx.game as Game
if (game.emoji[player]) game.emoji[player] = emoji
return game
})
action('nameChange', (ctx, name: string) => {
const game = ctx.game as Game
const player = game.players[ctx.session]

View File

@ -8,6 +8,7 @@ export type Game = {
board: [Row, Row, Row]
players: Record<string, xo>
names: Record<xo, string>
emoji: Record<xo, string>
winLine?: WinLine
}