From 18385e497769e8b73940a8def5838a121f122146 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Mar 2026 15:51:09 -0700 Subject: [PATCH] Add customizable player emojis with modal picker --- example/src/client/actions.ts | 10 ++- example/src/client/board.tsx | 32 +++++--- example/src/client/emojiModal.tsx | 131 ++++++++++++++++++++++++++++++ example/src/game.ts | 9 +- example/src/types.ts | 1 + 5 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 example/src/client/emojiModal.tsx diff --git a/example/src/client/actions.ts b/example/src/client/actions.ts index 78bdd44..5484da1 100644 --- a/example/src/client/actions.ts +++ b/example/src/client/actions.ts @@ -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', diff --git a/example/src/client/board.tsx b/example/src/client/board.tsx index 0d321a8..91fb9ea 100644 --- a/example/src/client/board.tsx +++ b/example/src/client/board.tsx @@ -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
{children}
+ } }, 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 }) => - + 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) : () => { } @@ -335,6 +340,7 @@ export default ({ game }: { game: Game }) => { return ( + {game.winLine && ( { {player === 'o' && } - {symbol(game.board[0][0])} - {symbol(game.board[0][1])} - {symbol(game.board[0][2])} + {symbol(game, game.board[0][0])} + {symbol(game, game.board[0][1])} + {symbol(game, game.board[0][2])} - {symbol(game.board[1][0])} - {symbol(game.board[1][1])} - {symbol(game.board[1][2])} + {symbol(game, game.board[1][0])} + {symbol(game, game.board[1][1])} + {symbol(game, game.board[1][2])} - {symbol(game.board[2][0])} - {symbol(game.board[2][1])} - {symbol(game.board[2][2])} + {symbol(game, game.board[2][0])} + {symbol(game, game.board[2][1])} + {symbol(game, game.board[2][2])} {game.winLine && ( { + 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 ( + + {props.children} + + ) + }, +}) + +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 ( + e.stopPropagation()}> + {props.children} + + ) + }, +}) + +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 && + + + + {EMOJI_OPTIONS.map((emoji) => ( + chooseEmoji(game, player, emoji)}>{emoji} + ))} + + + + ) + }, +}) diff --git a/example/src/game.ts b/example/src/game.ts index be63cad..cf2d274 100644 --- a/example/src/game.ts +++ b/example/src/game.ts @@ -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] diff --git a/example/src/types.ts b/example/src/types.ts index 56d7005..61861d0 100644 --- a/example/src/types.ts +++ b/example/src/types.ts @@ -8,6 +8,7 @@ export type Game = { board: [Row, Row, Row] players: Record names: Record + emoji: Record winLine?: WinLine }