fix: ensure consistent r×c grid layout for memory matching game
- Replace dynamic column calculation with proper grid dimensions that respect total card count - Calculate exact rows and columns needed for balanced grid layout - Add grid balancing logic to avoid uneven bottom rows when possible - Update grid configuration for 12-pair difficulty to use 6×4 layout consistently - Fix wide screen issue where cards were distributed as 8+4 instead of proper grid 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,32 +16,34 @@ export function GamePhase() {
|
||||
return (
|
||||
<div className={css({
|
||||
width: '100%',
|
||||
minHeight: '600px',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
})}>
|
||||
|
||||
{/* Game Header */}
|
||||
{/* Game Header - Compact on mobile */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.1), rgba(118, 75, 162, 0.1))',
|
||||
padding: '20px',
|
||||
borderRadius: '16px',
|
||||
marginBottom: '20px',
|
||||
border: '1px solid rgba(102, 126, 234, 0.2)'
|
||||
padding: { base: '8px 12px', sm: '12px 16px', md: '16px 20px' },
|
||||
borderRadius: { base: '8px', md: '12px' },
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
border: '1px solid rgba(102, 126, 234, 0.2)',
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px'
|
||||
gap: { base: '8px', sm: '12px', md: '16px' }
|
||||
})}>
|
||||
|
||||
{/* Game Type & Difficulty Info */}
|
||||
{/* Game Type & Difficulty Info - Hidden on mobile */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
display: { base: 'none', sm: 'flex' },
|
||||
alignItems: 'center',
|
||||
gap: '20px'
|
||||
gap: { base: '8px', md: '16px' }
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
@@ -153,29 +155,30 @@ export function GamePhase() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Player Indicator (Multiplayer Mode) */}
|
||||
{/* Current Player Indicator (Multiplayer Mode) - Compact on mobile */}
|
||||
{state.gameMode === 'multiplayer' && currentPlayerData && (
|
||||
<div className={css({
|
||||
marginTop: '16px',
|
||||
marginTop: { base: '8px', md: '12px' },
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<div className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 24px',
|
||||
gap: { base: '6px', sm: '8px', md: '12px' },
|
||||
padding: { base: '6px 12px', sm: '8px 16px', md: '12px 24px' },
|
||||
background: `linear-gradient(135deg, ${currentPlayerData.color}, ${currentPlayerData.color}dd)`,
|
||||
color: 'white',
|
||||
borderRadius: '20px',
|
||||
fontSize: '18px',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
fontSize: { base: '12px', sm: '14px', md: '16px' },
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.2)'
|
||||
})}>
|
||||
<span className={css({ fontSize: '48px' })}>
|
||||
<span className={css({ fontSize: { base: '20px', sm: '28px', md: '36px' } })}>
|
||||
{currentPlayerData.emoji}
|
||||
</span>
|
||||
<span>{currentPlayerData.name}'s Turn</span>
|
||||
<span className={css({ fontSize: '24px' })}>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>{currentPlayerData.name}'s Turn</span>
|
||||
<span className={css({ display: { base: 'inline', sm: 'none' } })}>Turn</span>
|
||||
<span className={css({ fontSize: { base: '16px', sm: '20px', md: '24px' } })}>
|
||||
🎯
|
||||
</span>
|
||||
</div>
|
||||
@@ -184,22 +187,32 @@ export function GamePhase() {
|
||||
</div>
|
||||
|
||||
{/* Memory Grid - The main game area */}
|
||||
<MemoryGrid />
|
||||
<div className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
<MemoryGrid />
|
||||
</div>
|
||||
|
||||
{/* Helpful Instructions */}
|
||||
{/* Helpful Instructions - Hidden on mobile */}
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
marginTop: { base: '8px', md: '16px' },
|
||||
padding: { base: '8px', md: '12px' },
|
||||
background: 'rgba(248, 250, 252, 0.8)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)'
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||
display: { base: 'none', md: 'block' },
|
||||
flexShrink: 0
|
||||
})}>
|
||||
<p className={css({
|
||||
fontSize: '16px',
|
||||
fontSize: '14px',
|
||||
color: 'gray.600',
|
||||
margin: 0,
|
||||
lineHeight: '1.5'
|
||||
lineHeight: '1.4'
|
||||
})}>
|
||||
{state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus representations with their numerical values! Look for patterns and remember card positions.'
|
||||
@@ -209,9 +222,9 @@ export function GamePhase() {
|
||||
|
||||
{state.gameMode === 'multiplayer' && (
|
||||
<p className={css({
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
color: 'gray.500',
|
||||
margin: '8px 0 0 0'
|
||||
margin: '6px 0 0 0'
|
||||
})}>
|
||||
Take turns finding matches. The player with the most pairs wins!
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { useUserProfile } from '../../../../contexts/UserProfileContext'
|
||||
import { GameCard } from './GameCard'
|
||||
@@ -8,6 +8,76 @@ import { EmojiPicker } from './EmojiPicker'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
// Custom hook to calculate proper grid dimensions for consistent r×c layout
|
||||
function useGridDimensions(gridConfig: any, totalCards: number) {
|
||||
const [gridDimensions, setGridDimensions] = useState(() => {
|
||||
// Calculate optimal rows and columns based on total cards and viewport
|
||||
if (typeof window !== 'undefined') {
|
||||
const aspectRatio = window.innerWidth / window.innerHeight
|
||||
return calculateOptimalGrid(totalCards, aspectRatio, gridConfig)
|
||||
}
|
||||
return { columns: gridConfig.mobileColumns || 3, rows: Math.ceil(totalCards / (gridConfig.mobileColumns || 3)) }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
function calculateOptimalGrid(cards: number, aspectRatio: number, config: any) {
|
||||
// For consistent grid layout, we need to ensure r×c = totalCards
|
||||
// Choose columns based on viewport, then calculate exact rows needed
|
||||
|
||||
let targetColumns
|
||||
const width = window.innerWidth
|
||||
|
||||
// Choose column count based on viewport
|
||||
if (aspectRatio >= 1.6 && width >= 1200) {
|
||||
// Ultra-wide: prefer wider grids
|
||||
targetColumns = config.landscapeColumns || config.desktopColumns || 6
|
||||
} else if (aspectRatio >= 1.33 && width >= 768) {
|
||||
// Desktop/landscape: use desktop columns
|
||||
targetColumns = config.desktopColumns || config.landscapeColumns || 6
|
||||
} else if (aspectRatio >= 1.0 && width >= 600) {
|
||||
// Tablet: use tablet columns
|
||||
targetColumns = config.tabletColumns || config.desktopColumns || 4
|
||||
} else {
|
||||
// Mobile: use mobile columns
|
||||
targetColumns = config.mobileColumns || 3
|
||||
}
|
||||
|
||||
// Calculate exact rows needed for this column count
|
||||
const rows = Math.ceil(cards / targetColumns)
|
||||
|
||||
// If we have leftover cards that would create an uneven bottom row,
|
||||
// try to redistribute for a more balanced grid
|
||||
const leftoverCards = cards % targetColumns
|
||||
if (leftoverCards > 0 && leftoverCards < targetColumns / 2 && targetColumns > 3) {
|
||||
// Try one less column for a more balanced grid
|
||||
const altColumns = targetColumns - 1
|
||||
const altRows = Math.ceil(cards / altColumns)
|
||||
const altLeftover = cards % altColumns
|
||||
|
||||
// Use alternative if it creates a more balanced grid
|
||||
if (altLeftover === 0 || altLeftover > leftoverCards) {
|
||||
return { columns: altColumns, rows: altRows }
|
||||
}
|
||||
}
|
||||
|
||||
return { columns: targetColumns, rows }
|
||||
}
|
||||
|
||||
const updateGrid = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const aspectRatio = window.innerWidth / window.innerHeight
|
||||
setGridDimensions(calculateOptimalGrid(totalCards, aspectRatio, gridConfig))
|
||||
}
|
||||
|
||||
updateGrid()
|
||||
window.addEventListener('resize', updateGrid)
|
||||
return () => window.removeEventListener('resize', updateGrid)
|
||||
}, [gridConfig, totalCards])
|
||||
|
||||
return gridDimensions
|
||||
}
|
||||
|
||||
export function MemoryGrid() {
|
||||
const { state, flipCard } = useMemoryPairs()
|
||||
const { profile, updatePlayerEmoji } = useUserProfile()
|
||||
@@ -18,6 +88,8 @@ export function MemoryGrid() {
|
||||
}
|
||||
|
||||
const gridConfig = getGridConfiguration(state.difficulty)
|
||||
const gridDimensions = useGridDimensions(gridConfig, state.gameCards.length)
|
||||
|
||||
|
||||
const handleCardClick = (cardId: string) => {
|
||||
flipCard(cardId)
|
||||
@@ -204,23 +276,18 @@ export function MemoryGrid() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
{/* Cards Grid - Consistent r×c Layout */}
|
||||
<div
|
||||
className={css({
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: '12px',
|
||||
gap: '6px',
|
||||
justifyContent: 'center',
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
// Responsive grid adjustments
|
||||
'@media (max-width: 768px)': {
|
||||
gap: '8px',
|
||||
padding: '0 10px'
|
||||
}
|
||||
})}
|
||||
style={{
|
||||
gridTemplateColumns: gridConfig.gridTemplate,
|
||||
width: 'fit-content'
|
||||
padding: '0 8px',
|
||||
// Consistent grid ensuring all cards fit in r×c layout
|
||||
gridTemplateColumns: `repeat(${gridDimensions.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${gridDimensions.rows}, 1fr)`
|
||||
}}
|
||||
>
|
||||
{state.gameCards.map(card => {
|
||||
@@ -259,29 +326,15 @@ export function MemoryGrid() {
|
||||
key={card.id}
|
||||
className={css({
|
||||
aspectRatio: '3/4',
|
||||
// Responsive card sizing
|
||||
'@media (min-width: 1024px)': {
|
||||
width: gridConfig.cardSize.width,
|
||||
height: gridConfig.cardSize.height
|
||||
},
|
||||
'@media (max-width: 1023px) and (min-width: 768px)': {
|
||||
width: `calc(${gridConfig.cardSize.width} * 0.8)`,
|
||||
height: `calc(${gridConfig.cardSize.height} * 0.8)`
|
||||
},
|
||||
'@media (max-width: 767px)': {
|
||||
width: `calc(${gridConfig.cardSize.width} * 0.6)`,
|
||||
height: `calc(${gridConfig.cardSize.height} * 0.6)`
|
||||
},
|
||||
// Fully responsive card sizing - no fixed pixel sizes
|
||||
width: '100%',
|
||||
minWidth: '100px',
|
||||
maxWidth: '200px',
|
||||
// Dimming effect for invalid cards
|
||||
opacity: isDimmed ? 0.3 : 1,
|
||||
transition: 'opacity 0.3s ease',
|
||||
filter: isDimmed ? 'grayscale(0.7)' : 'none'
|
||||
})}
|
||||
style={{
|
||||
width: gridConfig.cardSize.width,
|
||||
height: gridConfig.cardSize.height
|
||||
}}
|
||||
>
|
||||
})}>
|
||||
<GameCard
|
||||
card={card}
|
||||
isFlipped={isFlipped}
|
||||
|
||||
@@ -37,28 +37,19 @@ export function MemoryPairsGame() {
|
||||
|
||||
<header className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: { base: '16px', sm: '20px', md: '30px' },
|
||||
px: { base: '4', md: '0' }
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
px: { base: '4', md: '0' },
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: { base: '24px', sm: '32px', md: '40px', lg: '48px' },
|
||||
fontSize: { base: '16px', sm: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
textShadow: '2px 2px 4px rgba(0,0,0,0.3)',
|
||||
marginBottom: { base: '6px', md: '10px' },
|
||||
lineHeight: { base: '1.2', md: '1.1' }
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 0
|
||||
})}>
|
||||
Memory Pairs Challenge
|
||||
Memory Pairs
|
||||
</h1>
|
||||
<p className={css({
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
maxWidth: '600px',
|
||||
lineHeight: { base: '1.4', md: '1.3' },
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
Match pairs of abacus representations with their numerical values, or find complement pairs that add up to 5 or 10!
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className={css({
|
||||
@@ -66,12 +57,12 @@ export function MemoryPairsGame() {
|
||||
maxWidth: '1200px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
padding: { base: '16px', sm: '24px', md: '32px', lg: '40px' },
|
||||
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||
minHeight: { base: '60vh', md: '500px' },
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <GamePhase />}
|
||||
|
||||
@@ -149,16 +149,16 @@ export function SetupPhase() {
|
||||
<p className={css({
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
color: 'gray.600',
|
||||
marginBottom: { base: '24px', sm: '32px', md: '40px' },
|
||||
lineHeight: '1.6',
|
||||
display: { base: 'none', sm: 'block' }
|
||||
marginBottom: { base: '16px', sm: '20px', md: '32px' },
|
||||
lineHeight: '1.4',
|
||||
display: { base: 'none', md: 'block' }
|
||||
})}>
|
||||
Configure your memory challenge. Choose your preferred mode, game type, and difficulty level.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gap: { base: '20px', sm: '24px', md: '32px' },
|
||||
gap: { base: '12px', sm: '16px', md: '24px' },
|
||||
margin: '0 auto',
|
||||
flex: 1
|
||||
})}>
|
||||
@@ -166,29 +166,29 @@ export function SetupPhase() {
|
||||
{/* Current Player Setup */}
|
||||
<div className={css({
|
||||
background: 'linear-gradient(135deg, #f3f4f6, #e5e7eb)',
|
||||
rounded: { base: 'xl', md: '2xl' },
|
||||
p: { base: '4', md: '6' },
|
||||
rounded: { base: 'lg', md: 'xl' },
|
||||
p: { base: '3', sm: '4', md: '5' },
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.300'
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: { base: '16px', sm: '18px', md: '20px' },
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
mb: { base: '2', md: '3' },
|
||||
mb: { base: '1', sm: '2', md: '2' },
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
🎮 Current Setup
|
||||
</h3>
|
||||
<div className={css({
|
||||
fontSize: { base: '14px', md: '16px' },
|
||||
fontSize: { base: '12px', sm: '13px', md: '14px' },
|
||||
color: 'gray.700',
|
||||
textAlign: 'center'
|
||||
})}>
|
||||
<p>
|
||||
<strong>{activePlayerCount}</strong> player{activePlayerCount !== 1 ? 's' : ''} selected
|
||||
</p>
|
||||
<p className={css({ fontSize: { base: '12px', md: '14px' }, color: 'gray.600', mt: '1' })}>
|
||||
<p className={css({ fontSize: { base: '11px', sm: '12px', md: '13px' }, color: 'gray.600', mt: '1', display: { base: 'none', sm: 'block' } })}>
|
||||
{activePlayerCount === 1
|
||||
? 'Solo challenge mode - focus & memory'
|
||||
: `${activePlayerCount}-player battle mode - compete for the most pairs`
|
||||
@@ -451,25 +451,26 @@ export function SetupPhase() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Game Preview */}
|
||||
{/* Game Preview - Hidden on mobile */}
|
||||
<div className={css({
|
||||
background: 'gray.50',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginTop: '20px'
|
||||
padding: '16px',
|
||||
marginTop: '16px',
|
||||
display: { base: 'none', md: 'block' }
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: '18px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
marginBottom: '8px',
|
||||
color: 'gray.700'
|
||||
})}>
|
||||
Game Preview
|
||||
</h3>
|
||||
<div className={css({
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
color: 'gray.600',
|
||||
lineHeight: '1.5'
|
||||
lineHeight: '1.4'
|
||||
})}>
|
||||
<p><strong>Mode:</strong> {activePlayerCount === 1 ? 'Single Player' : `${activePlayerCount} Players`}</p>
|
||||
<p><strong>Type:</strong> {state.gameType === 'abacus-numeral' ? 'Abacus-Numeral Matching' : 'Complement Pairs'}</p>
|
||||
|
||||
@@ -132,42 +132,53 @@ export function generateGameCards(gameType: GameType, difficulty: Difficulty): G
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to get difficulty-based grid configuration
|
||||
// Utility function to get responsive grid configuration based on difficulty and screen size
|
||||
export function getGridConfiguration(difficulty: Difficulty) {
|
||||
const configs: Record<Difficulty, {
|
||||
totalCards: number;
|
||||
columns: number;
|
||||
rows: number;
|
||||
// Orientation-optimized responsive columns
|
||||
mobileColumns: number; // Portrait mobile
|
||||
tabletColumns: number; // Tablet
|
||||
desktopColumns: number; // Desktop/landscape
|
||||
landscapeColumns: number; // Landscape mobile/tablet
|
||||
cardSize: { width: string; height: string };
|
||||
gridTemplate: string;
|
||||
}> = {
|
||||
6: {
|
||||
totalCards: 12,
|
||||
columns: 4,
|
||||
rows: 3,
|
||||
mobileColumns: 3, // 3x4 grid in portrait
|
||||
tabletColumns: 4, // 4x3 grid on tablet
|
||||
desktopColumns: 4, // 4x3 grid on desktop
|
||||
landscapeColumns: 6, // 6x2 grid in landscape
|
||||
cardSize: { width: '140px', height: '180px' },
|
||||
gridTemplate: 'repeat(4, 1fr)'
|
||||
gridTemplate: 'repeat(3, 1fr)'
|
||||
},
|
||||
8: {
|
||||
totalCards: 16,
|
||||
columns: 4,
|
||||
rows: 4,
|
||||
mobileColumns: 3, // 3x6 grid in portrait (some spillover)
|
||||
tabletColumns: 4, // 4x4 grid on tablet
|
||||
desktopColumns: 4, // 4x4 grid on desktop
|
||||
landscapeColumns: 6, // 6x3 grid in landscape (some spillover)
|
||||
cardSize: { width: '120px', height: '160px' },
|
||||
gridTemplate: 'repeat(4, 1fr)'
|
||||
gridTemplate: 'repeat(3, 1fr)'
|
||||
},
|
||||
12: {
|
||||
totalCards: 24,
|
||||
columns: 6,
|
||||
rows: 4,
|
||||
mobileColumns: 3, // 3x8 grid in portrait
|
||||
tabletColumns: 4, // 4x6 grid on tablet
|
||||
desktopColumns: 6, // 6x4 grid on desktop
|
||||
landscapeColumns: 6, // 6x4 grid in landscape (changed from 8x3)
|
||||
cardSize: { width: '100px', height: '140px' },
|
||||
gridTemplate: 'repeat(6, 1fr)'
|
||||
gridTemplate: 'repeat(3, 1fr)'
|
||||
},
|
||||
15: {
|
||||
totalCards: 30,
|
||||
columns: 6,
|
||||
rows: 5,
|
||||
mobileColumns: 3, // 3x10 grid in portrait
|
||||
tabletColumns: 5, // 5x6 grid on tablet
|
||||
desktopColumns: 6, // 6x5 grid on desktop
|
||||
landscapeColumns: 10, // 10x3 grid in landscape
|
||||
cardSize: { width: '90px', height: '120px' },
|
||||
gridTemplate: 'repeat(6, 1fr)'
|
||||
gridTemplate: 'repeat(3, 1fr)'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user