From ff16303a7cd2880fcdfd51ef8a744e245905d87d Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 6 Oct 2025 11:04:41 -0500 Subject: [PATCH] feat: add arcade matching game components and utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add game components (GameCard, GamePhase, SetupPhase, MemoryGrid) - Add player status bar with multiplayer support - Add emoji picker for player customization - Add card generation and validation utilities - Add game scoring system with combo multipliers - Add page route for arcade matching game 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../matching/components/EmojiPicker.tsx | 727 ++++++++++++++++++ .../arcade/matching/components/GameCard.tsx | 516 +++++++++++++ .../arcade/matching/components/GamePhase.tsx | 149 ++++ .../arcade/matching/components/MemoryGrid.tsx | 239 ++++++ .../components/PlayerStatusBar.stories.tsx | 455 +++++++++++ .../matching/components/PlayerStatusBar.tsx | 455 +++++++++++ .../arcade/matching/components/SetupPhase.tsx | 415 ++++++++++ .../components/__tests__/EmojiPicker.test.tsx | 148 ++++ .../matching/context/MemoryPairsContext.tsx | 379 +++++++++ apps/web/src/app/arcade/matching/page.tsx | 13 + .../arcade/matching/utils/cardGeneration.ts | 191 +++++ .../app/arcade/matching/utils/gameScoring.ts | 307 ++++++++ .../arcade/matching/utils/matchValidation.ts | 225 ++++++ 13 files changed, 4219 insertions(+) create mode 100644 apps/web/src/app/arcade/matching/components/EmojiPicker.tsx create mode 100644 apps/web/src/app/arcade/matching/components/GameCard.tsx create mode 100644 apps/web/src/app/arcade/matching/components/GamePhase.tsx create mode 100644 apps/web/src/app/arcade/matching/components/MemoryGrid.tsx create mode 100644 apps/web/src/app/arcade/matching/components/PlayerStatusBar.stories.tsx create mode 100644 apps/web/src/app/arcade/matching/components/PlayerStatusBar.tsx create mode 100644 apps/web/src/app/arcade/matching/components/SetupPhase.tsx create mode 100644 apps/web/src/app/arcade/matching/components/__tests__/EmojiPicker.test.tsx create mode 100644 apps/web/src/app/arcade/matching/context/MemoryPairsContext.tsx create mode 100644 apps/web/src/app/arcade/matching/page.tsx create mode 100644 apps/web/src/app/arcade/matching/utils/cardGeneration.ts create mode 100644 apps/web/src/app/arcade/matching/utils/gameScoring.ts create mode 100644 apps/web/src/app/arcade/matching/utils/matchValidation.ts diff --git a/apps/web/src/app/arcade/matching/components/EmojiPicker.tsx b/apps/web/src/app/arcade/matching/components/EmojiPicker.tsx new file mode 100644 index 00000000..22652c2b --- /dev/null +++ b/apps/web/src/app/arcade/matching/components/EmojiPicker.tsx @@ -0,0 +1,727 @@ +'use client' + +import { useState, useMemo } from 'react' +import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis' +import { css } from '../../../../../styled-system/css' +import emojiData from 'emojibase-data/en/data.json' + +// Proper TypeScript interface for emojibase-data structure +interface EmojibaseEmoji { + label: string + hexcode: string + tags?: string[] + emoji: string + text: string + type: number + order: number + group: number + subgroup: number + version: number + emoticon?: string | string[] // Can be string, array, or undefined +} + +interface EmojiPickerProps { + currentEmoji: string + onEmojiSelect: (emoji: string) => void + onClose: () => void + playerNumber: number +} + +// Emoji group categories from emojibase (matching Unicode CLDR group IDs) +const EMOJI_GROUPS = { + 0: { name: 'Smileys & Emotion', icon: '😀' }, + 1: { name: 'People & Body', icon: '👤' }, + 3: { name: 'Animals & Nature', icon: '🐶' }, + 4: { name: 'Food & Drink', icon: '🍎' }, + 5: { name: 'Travel & Places', icon: '🚗' }, + 6: { name: 'Activities', icon: '⚽' }, + 7: { name: 'Objects', icon: '💡' }, + 8: { name: 'Symbols', icon: '❤️' }, + 9: { name: 'Flags', icon: '🏁' } +} as const + +// Create a map of emoji to their searchable data and group +const emojiMap = new Map() +;(emojiData as EmojibaseEmoji[]).forEach((emoji) => { + if (emoji.emoji) { + // Handle emoticon field which can be string, array, or undefined + const emoticons: string[] = [] + if (emoji.emoticon) { + if (Array.isArray(emoji.emoticon)) { + emoticons.push(...emoji.emoticon.map((e) => e.toLowerCase())) + } else { + emoticons.push(emoji.emoticon.toLowerCase()) + } + } + + emojiMap.set(emoji.emoji, { + keywords: [ + emoji.label?.toLowerCase(), + ...(emoji.tags || []).map((tag: string) => tag.toLowerCase()), + ...emoticons + ].filter(Boolean), + group: emoji.group + }) + } +}) + +// Enhanced search function using emojibase-data +function getEmojiKeywords(emoji: string): string[] { + const data = emojiMap.get(emoji) + if (data) { + return data.keywords + } + + // Fallback categories for emojis not in emojibase-data + if (/[\u{1F600}-\u{1F64F}]/u.test(emoji)) return ['face', 'emotion', 'person', 'expression'] + if (/[\u{1F400}-\u{1F43F}]/u.test(emoji)) return ['animal', 'nature', 'cute', 'pet'] + if (/[\u{1F440}-\u{1F4FF}]/u.test(emoji)) return ['object', 'symbol', 'tool'] + if (/[\u{1F300}-\u{1F3FF}]/u.test(emoji)) return ['nature', 'travel', 'activity', 'place'] + if (/[\u{1F680}-\u{1F6FF}]/u.test(emoji)) return ['transport', 'travel', 'vehicle'] + if (/[\u{2600}-\u{26FF}]/u.test(emoji)) return ['symbol', 'misc', 'sign'] + + return ['misc', 'other'] +} + +export function EmojiPicker({ currentEmoji, onEmojiSelect, onClose, playerNumber }: EmojiPickerProps) { + const [searchFilter, setSearchFilter] = useState('') + const [selectedCategory, setSelectedCategory] = useState(null) + const [hoveredEmoji, setHoveredEmoji] = useState(null) + const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 }) + + // Enhanced search functionality - clear separation between default and search + const isSearching = searchFilter.trim().length > 0 + const isCategoryFiltered = selectedCategory !== null && !isSearching + + // Calculate which categories have emojis + const availableCategories = useMemo(() => { + const categoryCounts: Record = {} + PLAYER_EMOJIS.forEach(emoji => { + const data = emojiMap.get(emoji) + if (data && data.group !== undefined) { + categoryCounts[data.group] = (categoryCounts[data.group] || 0) + 1 + } + }) + return Object.keys(EMOJI_GROUPS) + .map(Number) + .filter(groupId => categoryCounts[groupId] > 0) + }, []) + + const displayEmojis = useMemo(() => { + // Start with all emojis + let emojis = PLAYER_EMOJIS + + // Apply category filter first (unless searching) + if (isCategoryFiltered) { + emojis = emojis.filter(emoji => { + const data = emojiMap.get(emoji) + return data && data.group === selectedCategory + }) + } + + // Then apply search filter + if (!isSearching) { + return emojis + } + + const searchTerm = searchFilter.toLowerCase().trim() + + const results = PLAYER_EMOJIS.filter(emoji => { + const keywords = getEmojiKeywords(emoji) + return keywords.some(keyword => + keyword && keyword.includes(searchTerm) + ) + }) + + // Sort results by relevance + const sortedResults = results.sort((a, b) => { + const aKeywords = getEmojiKeywords(a) + const bKeywords = getEmojiKeywords(b) + + // Exact match priority + const aExact = aKeywords.some(k => k === searchTerm) + const bExact = bKeywords.some(k => k === searchTerm) + + if (aExact && !bExact) return -1 + if (!aExact && bExact) return 1 + + // Word boundary matches (start of word) + const aStartsWithTerm = aKeywords.some(k => k && k.startsWith(searchTerm)) + const bStartsWithTerm = bKeywords.some(k => k && k.startsWith(searchTerm)) + + if (aStartsWithTerm && !bStartsWithTerm) return -1 + if (!aStartsWithTerm && bStartsWithTerm) return 1 + + // Score by number of matching keywords + const aScore = aKeywords.filter(k => k && k.includes(searchTerm)).length + const bScore = bKeywords.filter(k => k && k.includes(searchTerm)).length + + return bScore - aScore + }) + + return sortedResults + }, [searchFilter, isSearching, selectedCategory, isCategoryFiltered]) + + return ( +
+
+ + {/* Header */} +
+

+ Choose Character for Player {playerNumber} +

+ +
+ + {/* Current Selection & Search */} +
+
+
+ {currentEmoji} +
+
+ Current +
+
+ + setSearchFilter(e.target.value)} + className={css({ + flex: 1, + padding: '8px 12px', + border: '2px solid', + borderColor: 'gray.200', + borderRadius: '12px', + fontSize: '14px', + _focus: { + outline: 'none', + borderColor: 'blue.400', + boxShadow: '0 0 0 3px rgba(66, 153, 225, 0.1)' + } + })} + /> + + {isSearching && ( +
0 ? 'green.100' : 'red.100', + borderRadius: '8px', + border: '1px solid', + borderColor: displayEmojis.length > 0 ? 'green.300' : 'red.300' + })}> + {displayEmojis.length > 0 ? `✓ ${displayEmojis.length} found` : '✗ No matches'} +
+ )} +
+ + {/* Category Tabs */} + {!isSearching && ( +
+ + {availableCategories.map((groupId) => { + const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS] + return ( + + ) + })} +
+ )} + + {/* Search Mode Header */} + {isSearching && displayEmojis.length > 0 && ( +
+
+ 🔍 Search Results for "{searchFilter}" +
+
+ Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis • Clear search to see all +
+
+ )} + + {/* Default Mode Header */} + {!isSearching && ( +
+
+ {selectedCategory !== null + ? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}` + : '📝 All Available Characters'} +
+
+ {displayEmojis.length} emojis {selectedCategory !== null ? 'in category' : 'available'} • Use search to find specific emojis +
+
+ )} + + {/* Emoji Grid - Only show when there are emojis to display */} + {displayEmojis.length > 0 && ( +
+
+ {displayEmojis.map(emoji => { + const isSelected = emoji === currentEmoji + const getSelectedBg = () => { + if (!isSelected) return 'transparent' + if (playerNumber === 1) return 'blue.100' + if (playerNumber === 2) return 'pink.100' + if (playerNumber === 3) return 'purple.100' + return 'yellow.100' + } + const getSelectedBorder = () => { + if (!isSelected) return 'transparent' + if (playerNumber === 1) return 'blue.400' + if (playerNumber === 2) return 'pink.400' + if (playerNumber === 3) return 'purple.400' + return 'yellow.400' + } + const getHoverBg = () => { + if (!isSelected) return 'gray.100' + if (playerNumber === 1) return 'blue.200' + if (playerNumber === 2) return 'pink.200' + if (playerNumber === 3) return 'purple.200' + return 'yellow.200' + } + return ( + + ) + })} +
+
+ )} + + {/* No results message */} + {isSearching && displayEmojis.length === 0 && ( +
+
🔍
+
+ No emojis found for "{searchFilter}" +
+
+ Try searching for "face", "smart", "heart", "animal", "food", etc. +
+ +
+ )} + + {/* Quick selection hint */} +
+ 💡 Powered by emojibase-data • Try: "face", "smart", "heart", "animal", "food" • Click to select +
+
+ + {/* Magnifying Glass Preview - SUPER POWERED! */} + {hoveredEmoji && ( +
+ {/* Outer glow ring */} +
+ + {/* Main preview card */} +
+ {/* Sparkle effects */} +
+
+
+ + {hoveredEmoji} +
+ + {/* Arrow pointing down with glow */} +
+
+ )} + + {/* Add magnifying animations */} +