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:
Thomas Hallock
2025-09-28 09:22:10 -05:00
parent 0ce351e572
commit f1a0633596
5 changed files with 181 additions and 112 deletions

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 />}

View File

@@ -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>

View File

@@ -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)'
}
}