Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d896e95bb5 | ||
|
|
0726176e4d | ||
|
|
6db2740b79 | ||
|
|
1a64decf5a | ||
|
|
75c8ec27b7 | ||
|
|
f2958cd8c4 | ||
|
|
fd1132e8d4 | ||
|
|
c46a098381 | ||
|
|
cabbc82195 | ||
|
|
e5c4a4bae0 | ||
|
|
2a3af973f7 |
37
CHANGELOG.md
37
CHANGELOG.md
@@ -1,3 +1,40 @@
|
||||
## [4.2.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.2.1...v4.2.2) (2025-10-16)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **types:** consolidate type system - eliminate fragmentation ([0726176](https://github.com/antialias/soroban-abacus-flashcards/commit/0726176e4d2666f6f3a289f01736747c33e93879))
|
||||
|
||||
## [4.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.2.0...v4.2.1) (2025-10-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **socket-io:** update import path for socket-server module ([1a64dec](https://github.com/antialias/soroban-abacus-flashcards/commit/1a64decf5afe67c16e1aec283262ffa6132dcd83))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **matching:** complete validator migration to modular location ([f2958cd](https://github.com/antialias/soroban-abacus-flashcards/commit/f2958cd8c424989b8651ea666ce9843e97e75929))
|
||||
|
||||
## [4.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.1.0...v4.2.0) (2025-10-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** migrate matching pairs - phases 1-4 and 7 complete ([2a3af97](https://github.com/antialias/soroban-abacus-flashcards/commit/2a3af973f70ff07de30b38bbe1cdc549a971846f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve TypeScript errors in MemoryGrid and StandardGameLayout ([cabbc82](https://github.com/antialias/soroban-abacus-flashcards/commit/cabbc821955d70f118630dc21a9fcbb6d340f278))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **matching:** migrate to modular game system ([e5c4a4b](https://github.com/antialias/soroban-abacus-flashcards/commit/e5c4a4bae078c69e632945730c61299f7062f4be))
|
||||
* **matching:** remove legacy battle-arena references ([c46a098](https://github.com/antialias/soroban-abacus-flashcards/commit/c46a0983813c87d5e82a5aa32c48a10a49259b00))
|
||||
|
||||
## [4.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.0.3...v4.1.0) (2025-10-16)
|
||||
|
||||
|
||||
|
||||
@@ -79,7 +79,17 @@
|
||||
"Bash(tsc:*)",
|
||||
"Bash(tsc-alias:*)",
|
||||
"Bash(npx tsc-alias:*)",
|
||||
"Bash(timeout 20 pnpm run:*)"
|
||||
"Bash(timeout 20 pnpm run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(for:*)",
|
||||
"Bash(tree:*)",
|
||||
"Bash(do sed -i '' \"s|from ''../context/MemoryPairsContext''|from ''../Provider''|g\" \"$file\")",
|
||||
"Bash(do sed -i '' \"s|from ''../../../../../styled-system/css''|from ''@/styled-system/css''|g\" \"$file\")",
|
||||
"Bash(tee:*)",
|
||||
"Bash(do sed -i '' \"s|from ''@/styled-system/css''|from ''../../../../styled-system/css''|g\" \"$file\")",
|
||||
"Bash(do echo \"=== $game ===\" echo \"Required files:\" ls -1 src/arcade-games/$game/)",
|
||||
"Bash(do echo \"=== $game%/ ===\")",
|
||||
"Bash(ls:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, afterAll, beforeAll } from
|
||||
import { db, schema } from '../src/db'
|
||||
import { createRoom } from '../src/lib/arcade/room-manager'
|
||||
import { addRoomMember } from '../src/lib/arcade/room-membership'
|
||||
import { initializeSocketServer } from '../socket-server'
|
||||
import { initializeSocketServer } from '../src/socket-server'
|
||||
import type { Server as SocketIOServerType } from 'socket.io'
|
||||
|
||||
/**
|
||||
|
||||
299
apps/web/docs/MATCHING_PAIRS_AUDIT.md
Normal file
299
apps/web/docs/MATCHING_PAIRS_AUDIT.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# Matching Pairs Battle - Pre-Migration Audit Results
|
||||
|
||||
**Date**: 2025-01-16
|
||||
**Phase**: 1 - Pre-Migration Audit
|
||||
**Status**: Complete ✅
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Canonical Location**: `/src/app/arcade/matching/` is clearly the more advanced, feature-complete version.
|
||||
|
||||
**Key Findings**:
|
||||
- Arcade version has pause/resume, networked presence, better player ownership
|
||||
- Utils are **identical** between locations (can use either)
|
||||
- **ResultsPhase.tsx** needs manual merge (arcade layout + games Performance Analysis)
|
||||
- **7 files** currently import from `/games/matching/` - must update during migration
|
||||
|
||||
---
|
||||
|
||||
## File-by-File Comparison
|
||||
|
||||
### Components
|
||||
|
||||
#### 1. GameCard.tsx
|
||||
**Differences**: Arcade has helper function `getPlayerIndex()` to reduce code duplication
|
||||
**Decision**: ✅ Use arcade version (better code organization)
|
||||
|
||||
#### 2. PlayerStatusBar.tsx
|
||||
**Differences**:
|
||||
- Arcade: Distinguishes "Your turn" vs "Their turn" based on player ownership
|
||||
- Arcade: Uses `useViewerId()` for authorization
|
||||
- Games: Shows only "Your turn" for all players
|
||||
**Decision**: ✅ Use arcade version (more feature-complete)
|
||||
|
||||
#### 3. ResultsPhase.tsx
|
||||
**Differences**:
|
||||
- Arcade: Modern responsive layout, exits via `exitSession()` to `/arcade`
|
||||
- Games: Has unique "Performance Analysis" section (strengths/improvements)
|
||||
- Games: Simple navigation to `/games`
|
||||
**Decision**: ⚠️ MERGE REQUIRED
|
||||
- Keep arcade's layout, navigation, responsive design
|
||||
- **Add** Performance Analysis section from games version (lines 245-317)
|
||||
|
||||
#### 4. SetupPhase.tsx
|
||||
**Differences**:
|
||||
- Arcade: Full pause/resume with config change warnings
|
||||
- Arcade: Uses action creators (setGameType, setDifficulty, setTurnTimer)
|
||||
- Arcade: Sophisticated "Resume Game" vs "Start Game" button logic
|
||||
- Games: Simple dispatch pattern, no pause/resume
|
||||
**Decision**: ✅ Use arcade version (much more advanced)
|
||||
|
||||
#### 5. EmojiPicker.tsx
|
||||
**Differences**: None (files identical)
|
||||
**Decision**: ✅ Use arcade version (same as games)
|
||||
|
||||
#### 6. GamePhase.tsx
|
||||
**Differences**:
|
||||
- Arcade: Passes hoverCard, viewerId, gameMode to MemoryGrid
|
||||
- Arcade: `enableMultiplayerPresence={true}`
|
||||
- Games: No multiplayer presence features
|
||||
**Decision**: ✅ Use arcade version (has networked presence)
|
||||
|
||||
#### 7. MemoryPairsGame.tsx
|
||||
**Differences**:
|
||||
- Arcade: Provides onExitSession, onSetup, onNewGame callbacks
|
||||
- Arcade: Uses router for navigation
|
||||
- Games: Simple component with just gameName prop
|
||||
**Decision**: ✅ Use arcade version (better integration)
|
||||
|
||||
### Utilities
|
||||
|
||||
#### 1. cardGeneration.ts
|
||||
**Differences**: None (files identical)
|
||||
**Decision**: ✅ Use arcade version (same as games)
|
||||
|
||||
#### 2. matchValidation.ts
|
||||
**Differences**: None (files identical)
|
||||
**Decision**: ✅ Use arcade version (same as games)
|
||||
|
||||
#### 3. gameScoring.ts
|
||||
**Differences**: None (files identical)
|
||||
**Decision**: ✅ Use arcade version (same as games)
|
||||
|
||||
### Context/Types
|
||||
|
||||
#### types.ts
|
||||
**Differences**:
|
||||
- Arcade: PlayerMetadata properly typed (vs `any` in games)
|
||||
- Arcade: Better documentation for pause/resume state
|
||||
- Arcade: Hover state not optional (`playerHovers: {}` vs `playerHovers?: {}`)
|
||||
- Arcade: More complete MemoryPairsContextValue interface
|
||||
**Decision**: ✅ Use arcade version (better types)
|
||||
|
||||
---
|
||||
|
||||
## External Dependencies on `/games/matching/`
|
||||
|
||||
Found **7 imports** that reference `/games/matching/`:
|
||||
|
||||
1. `/src/components/nav/PlayerConfigDialog.tsx`
|
||||
- Imports: `EmojiPicker`
|
||||
- **Action**: Update to `@/arcade-games/matching/components/EmojiPicker`
|
||||
|
||||
2. `/src/lib/arcade/game-configs.ts`
|
||||
- Imports: `Difficulty, GameType` types
|
||||
- **Action**: Update to `@/arcade-games/matching/types`
|
||||
|
||||
3. `/src/lib/arcade/__tests__/arcade-session-integration.test.ts`
|
||||
- Imports: `MemoryPairsState` type
|
||||
- **Action**: Update to `@/arcade-games/matching/types`
|
||||
|
||||
4. `/src/lib/arcade/validation/MatchingGameValidator.ts` (3 imports)
|
||||
- Imports: `GameCard, MemoryPairsState, Player` types
|
||||
- Imports: `generateGameCards` util
|
||||
- Imports: `canFlipCard, validateMatch` utils
|
||||
- **Action**: Will be moved to `/src/arcade-games/matching/Validator.ts` in Phase 3
|
||||
- Update imports to local `./types` and `./utils/*`
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Canonical Source
|
||||
**Use**: `/src/app/arcade/matching/` as the base for all files
|
||||
|
||||
**Exception**: Merge Performance Analysis from `/src/app/games/matching/components/ResultsPhase.tsx`
|
||||
|
||||
### Files to Move (from `/src/app/arcade/matching/`)
|
||||
|
||||
**Components** (7 files):
|
||||
- ✅ GameCard.tsx (as-is)
|
||||
- ✅ PlayerStatusBar.tsx (as-is)
|
||||
- ⚠️ ResultsPhase.tsx (merge with games version)
|
||||
- ✅ SetupPhase.tsx (as-is)
|
||||
- ✅ EmojiPicker.tsx (as-is)
|
||||
- ✅ GamePhase.tsx (as-is)
|
||||
- ✅ MemoryPairsGame.tsx (as-is)
|
||||
|
||||
**Utils** (3 files):
|
||||
- ✅ cardGeneration.ts (as-is)
|
||||
- ✅ matchValidation.ts (as-is)
|
||||
- ✅ gameScoring.ts (as-is)
|
||||
|
||||
**Context**:
|
||||
- ✅ types.ts (as-is)
|
||||
- ✅ RoomMemoryPairsProvider.tsx (convert to modular Provider)
|
||||
|
||||
**Tests**:
|
||||
- ✅ EmojiPicker.test.tsx
|
||||
- ✅ playerMetadata-userId.test.ts
|
||||
|
||||
### Files to Delete (after migration)
|
||||
|
||||
**From `/src/app/arcade/matching/`** (~13 files):
|
||||
- Components: 7 files + 1 test (move, then delete old location)
|
||||
- Context: LocalMemoryPairsProvider.tsx, MemoryPairsContext.tsx, index.ts
|
||||
- Utils: 3 files (move, then delete old location)
|
||||
- page.tsx (replace with redirect)
|
||||
|
||||
**From `/src/app/games/matching/`** (~14 files):
|
||||
- Components: 7 files + 2 tests (delete)
|
||||
- Context: 2 files (delete)
|
||||
- Utils: 3 files (delete)
|
||||
- page.tsx (replace with redirect)
|
||||
|
||||
**Validator**:
|
||||
- `/src/lib/arcade/validation/MatchingGameValidator.ts` (move to modular location)
|
||||
|
||||
**Total files to delete**: ~27 files
|
||||
|
||||
---
|
||||
|
||||
## Special Merge: ResultsPhase.tsx
|
||||
|
||||
### Keep from Arcade Version
|
||||
- Responsive layout (padding, fontSize with base/md breakpoints)
|
||||
- Modern stat cards design
|
||||
- exitSession() navigation to /arcade
|
||||
- Better button styling with gradients
|
||||
|
||||
### Add from Games Version
|
||||
Lines 245-317: Performance Analysis section
|
||||
```tsx
|
||||
{/* Performance Analysis */}
|
||||
<div className={css({
|
||||
background: 'rgba(248, 250, 252, 0.8)',
|
||||
padding: '30px',
|
||||
borderRadius: '16px',
|
||||
marginBottom: '40px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto 40px auto',
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: '24px',
|
||||
marginBottom: '20px',
|
||||
color: 'gray.800',
|
||||
})}>
|
||||
Performance Analysis
|
||||
</h3>
|
||||
|
||||
{analysis.strengths.length > 0 && (
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<h4 className={css({
|
||||
fontSize: '18px',
|
||||
color: 'green.600',
|
||||
marginBottom: '8px',
|
||||
})}>
|
||||
✅ Strengths:
|
||||
</h4>
|
||||
<ul className={css({
|
||||
textAlign: 'left',
|
||||
color: 'gray.700',
|
||||
lineHeight: '1.6',
|
||||
})}>
|
||||
{analysis.strengths.map((strength, index) => (
|
||||
<li key={index}>{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.improvements.length > 0 && (
|
||||
<div>
|
||||
<h4 className={css({
|
||||
fontSize: '18px',
|
||||
color: 'orange.600',
|
||||
marginBottom: '8px',
|
||||
})}>
|
||||
💡 Areas for Improvement:
|
||||
</h4>
|
||||
<ul className={css({
|
||||
textAlign: 'left',
|
||||
color: 'gray.700',
|
||||
lineHeight: '1.6',
|
||||
})}>
|
||||
{analysis.improvements.map((improvement, index) => (
|
||||
<li key={index}>{improvement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Note**: Need to ensure `analysis` variable is computed (may already exist in arcade version from `analyzePerformance` utility)
|
||||
|
||||
---
|
||||
|
||||
## Validator Assessment
|
||||
|
||||
**Location**: `/src/lib/arcade/validation/MatchingGameValidator.ts`
|
||||
**Status**: ✅ Comprehensive and complete (570 lines)
|
||||
|
||||
**Handles all move types**:
|
||||
- FLIP_CARD (with turn validation, player ownership)
|
||||
- START_GAME
|
||||
- CLEAR_MISMATCH
|
||||
- GO_TO_SETUP (with pause state)
|
||||
- SET_CONFIG (with validation)
|
||||
- RESUME_GAME (with config change detection)
|
||||
- HOVER_CARD (networked presence)
|
||||
|
||||
**Ready for migration**: Yes, just needs import path updates
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Phase 2)
|
||||
|
||||
1. Create `/src/arcade-games/matching/index.ts` with game definition
|
||||
2. Register in game registry
|
||||
3. Add type inference to game-configs.ts
|
||||
4. Update validator imports
|
||||
|
||||
---
|
||||
|
||||
## Risks Identified
|
||||
|
||||
### Risk 1: Performance Analysis Feature Loss
|
||||
**Mitigation**: Must manually merge Performance Analysis from games/ResultsPhase.tsx
|
||||
|
||||
### Risk 2: Import References
|
||||
**Mitigation**: 7 files import from games/matching - systematic update required
|
||||
|
||||
### Risk 3: Test Coverage
|
||||
**Mitigation**: Move tests with components, verify they still pass
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Phase 1 audit complete. Clear path forward:
|
||||
- **Arcade version is canonical** for all files
|
||||
- **Utils are identical** - no conflicts
|
||||
- **One manual merge required** (ResultsPhase Performance Analysis)
|
||||
- **7 import updates required** before deletion
|
||||
|
||||
Ready to proceed to Phase 2: Create Modular Game Definition.
|
||||
@@ -1,343 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.getSocketIO = getSocketIO;
|
||||
exports.initializeSocketServer = initializeSocketServer;
|
||||
const socket_io_1 = require("socket.io");
|
||||
const session_manager_1 = require("./src/lib/arcade/session-manager");
|
||||
const room_manager_1 = require("./src/lib/arcade/room-manager");
|
||||
const room_membership_1 = require("./src/lib/arcade/room-membership");
|
||||
const player_manager_1 = require("./src/lib/arcade/player-manager");
|
||||
const MatchingGameValidator_1 = require("./src/lib/arcade/validation/MatchingGameValidator");
|
||||
/**
|
||||
* Get the socket.io server instance
|
||||
* Returns null if not initialized
|
||||
*/
|
||||
function getSocketIO() {
|
||||
return globalThis.__socketIO || null;
|
||||
}
|
||||
function initializeSocketServer(httpServer) {
|
||||
const io = new socket_io_1.Server(httpServer, {
|
||||
path: "/api/socket",
|
||||
cors: {
|
||||
origin: process.env.NEXT_PUBLIC_URL || "http://localhost:3000",
|
||||
credentials: true,
|
||||
},
|
||||
});
|
||||
io.on("connection", (socket) => {
|
||||
console.log("🔌 Client connected:", socket.id);
|
||||
let currentUserId = null;
|
||||
// Join arcade session room
|
||||
socket.on("join-arcade-session", async ({ userId, roomId }) => {
|
||||
currentUserId = userId;
|
||||
socket.join(`arcade:${userId}`);
|
||||
console.log(`👤 User ${userId} joined arcade room`);
|
||||
// If this session is part of a room, also join the game room for multi-user sync
|
||||
if (roomId) {
|
||||
socket.join(`game:${roomId}`);
|
||||
console.log(`🎮 User ${userId} joined game room ${roomId}`);
|
||||
}
|
||||
// Send current session state if exists
|
||||
// For room-based games, look up shared room session
|
||||
try {
|
||||
const session = roomId
|
||||
? await (0, session_manager_1.getArcadeSessionByRoom)(roomId)
|
||||
: await (0, session_manager_1.getArcadeSession)(userId);
|
||||
if (session) {
|
||||
console.log("[join-arcade-session] Found session:", {
|
||||
userId,
|
||||
roomId,
|
||||
version: session.version,
|
||||
sessionUserId: session.userId,
|
||||
});
|
||||
socket.emit("session-state", {
|
||||
gameState: session.gameState,
|
||||
currentGame: session.currentGame,
|
||||
gameUrl: session.gameUrl,
|
||||
activePlayers: session.activePlayers,
|
||||
version: session.version,
|
||||
});
|
||||
} else {
|
||||
console.log("[join-arcade-session] No active session found for:", {
|
||||
userId,
|
||||
roomId,
|
||||
});
|
||||
socket.emit("no-active-session");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching session:", error);
|
||||
socket.emit("session-error", { error: "Failed to fetch session" });
|
||||
}
|
||||
});
|
||||
// Handle game moves
|
||||
socket.on("game-move", async (data) => {
|
||||
console.log("🎮 Game move received:", {
|
||||
userId: data.userId,
|
||||
moveType: data.move.type,
|
||||
playerId: data.move.playerId,
|
||||
timestamp: data.move.timestamp,
|
||||
roomId: data.roomId,
|
||||
fullMove: JSON.stringify(data.move, null, 2),
|
||||
});
|
||||
try {
|
||||
// Special handling for START_GAME - create session if it doesn't exist
|
||||
if (data.move.type === "START_GAME") {
|
||||
// For room-based games, check if room session exists
|
||||
const existingSession = data.roomId
|
||||
? await (0, session_manager_1.getArcadeSessionByRoom)(data.roomId)
|
||||
: await (0, session_manager_1.getArcadeSession)(data.userId);
|
||||
if (!existingSession) {
|
||||
console.log("🎯 Creating new session for START_GAME");
|
||||
// activePlayers must be provided in the START_GAME move data
|
||||
const activePlayers = data.move.data?.activePlayers;
|
||||
if (!activePlayers || activePlayers.length === 0) {
|
||||
console.error("❌ START_GAME move missing activePlayers");
|
||||
socket.emit("move-rejected", {
|
||||
error: "START_GAME requires at least one active player",
|
||||
move: data.move,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Get initial state from validator
|
||||
const initialState =
|
||||
MatchingGameValidator_1.matchingGameValidator.getInitialState({
|
||||
difficulty: 6,
|
||||
gameType: "abacus-numeral",
|
||||
turnTimer: 30,
|
||||
});
|
||||
// Check if user is already in a room for this game
|
||||
const userRoomIds = await (0, room_membership_1.getUserRooms)(
|
||||
data.userId,
|
||||
);
|
||||
let room = null;
|
||||
// Look for an existing active room for this game
|
||||
for (const roomId of userRoomIds) {
|
||||
const existingRoom = await (0, room_manager_1.getRoomById)(
|
||||
roomId,
|
||||
);
|
||||
if (
|
||||
existingRoom &&
|
||||
existingRoom.gameName === "matching" &&
|
||||
existingRoom.status !== "finished"
|
||||
) {
|
||||
room = existingRoom;
|
||||
console.log("🏠 Using existing room:", room.code);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no suitable room exists, create a new one
|
||||
if (!room) {
|
||||
room = await (0, room_manager_1.createRoom)({
|
||||
name: "Auto-generated Room",
|
||||
createdBy: data.userId,
|
||||
creatorName: "Player",
|
||||
gameName: "matching",
|
||||
gameConfig: {
|
||||
difficulty: 6,
|
||||
gameType: "abacus-numeral",
|
||||
turnTimer: 30,
|
||||
},
|
||||
ttlMinutes: 60,
|
||||
});
|
||||
console.log("🏠 Created new room:", room.code);
|
||||
}
|
||||
// Now create the session linked to the room
|
||||
await (0, session_manager_1.createArcadeSession)({
|
||||
userId: data.userId,
|
||||
gameName: "matching",
|
||||
gameUrl: "/arcade/room", // Room-based sessions use /arcade/room
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
});
|
||||
console.log(
|
||||
"✅ Session created successfully with room association",
|
||||
);
|
||||
// Notify all connected clients about the new session
|
||||
const newSession = await (0, session_manager_1.getArcadeSession)(
|
||||
data.userId,
|
||||
);
|
||||
if (newSession) {
|
||||
io.to(`arcade:${data.userId}`).emit("session-state", {
|
||||
gameState: newSession.gameState,
|
||||
currentGame: newSession.currentGame,
|
||||
gameUrl: newSession.gameUrl,
|
||||
activePlayers: newSession.activePlayers,
|
||||
version: newSession.version,
|
||||
});
|
||||
console.log(
|
||||
"📢 Emitted session-state to notify clients of new session",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Apply game move - use roomId for room-based games to access shared session
|
||||
const result = await (0, session_manager_1.applyGameMove)(
|
||||
data.userId,
|
||||
data.move,
|
||||
data.roomId,
|
||||
);
|
||||
if (result.success && result.session) {
|
||||
const moveAcceptedData = {
|
||||
gameState: result.session.gameState,
|
||||
version: result.session.version,
|
||||
move: data.move,
|
||||
};
|
||||
// Broadcast the updated state to all devices for this user
|
||||
io.to(`arcade:${data.userId}`).emit(
|
||||
"move-accepted",
|
||||
moveAcceptedData,
|
||||
);
|
||||
// If this is a room-based session, ALSO broadcast to all users in the room
|
||||
if (result.session.roomId) {
|
||||
io.to(`game:${result.session.roomId}`).emit(
|
||||
"move-accepted",
|
||||
moveAcceptedData,
|
||||
);
|
||||
console.log(
|
||||
`📢 Broadcasted move to game room ${result.session.roomId}`,
|
||||
);
|
||||
}
|
||||
// Update activity timestamp
|
||||
await (0, session_manager_1.updateSessionActivity)(data.userId);
|
||||
} else {
|
||||
// Send rejection only to the requesting socket
|
||||
socket.emit("move-rejected", {
|
||||
error: result.error,
|
||||
move: data.move,
|
||||
versionConflict: result.versionConflict,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing move:", error);
|
||||
socket.emit("move-rejected", {
|
||||
error: "Server error processing move",
|
||||
move: data.move,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Handle session exit
|
||||
socket.on("exit-arcade-session", async ({ userId }) => {
|
||||
console.log("🚪 User exiting arcade session:", userId);
|
||||
try {
|
||||
await (0, session_manager_1.deleteArcadeSession)(userId);
|
||||
io.to(`arcade:${userId}`).emit("session-ended");
|
||||
} catch (error) {
|
||||
console.error("Error ending session:", error);
|
||||
socket.emit("session-error", { error: "Failed to end session" });
|
||||
}
|
||||
});
|
||||
// Keep-alive ping
|
||||
socket.on("ping-session", async ({ userId }) => {
|
||||
try {
|
||||
await (0, session_manager_1.updateSessionActivity)(userId);
|
||||
socket.emit("pong-session");
|
||||
} catch (error) {
|
||||
console.error("Error updating activity:", error);
|
||||
}
|
||||
});
|
||||
// Room: Join
|
||||
socket.on("join-room", async ({ roomId, userId }) => {
|
||||
console.log(`🏠 User ${userId} joining room ${roomId}`);
|
||||
try {
|
||||
// Join the socket room
|
||||
socket.join(`room:${roomId}`);
|
||||
// Mark member as online
|
||||
await (0, room_membership_1.setMemberOnline)(roomId, userId, true);
|
||||
// Get room data
|
||||
const members = await (0, room_membership_1.getRoomMembers)(roomId);
|
||||
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(
|
||||
roomId,
|
||||
);
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj = {};
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
}
|
||||
// Send current room state to the joining user
|
||||
socket.emit("room-joined", {
|
||||
roomId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
// Notify all other members in the room
|
||||
socket.to(`room:${roomId}`).emit("member-joined", {
|
||||
roomId,
|
||||
userId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
console.log(`✅ User ${userId} joined room ${roomId}`);
|
||||
} catch (error) {
|
||||
console.error("Error joining room:", error);
|
||||
socket.emit("room-error", { error: "Failed to join room" });
|
||||
}
|
||||
});
|
||||
// Room: Leave
|
||||
socket.on("leave-room", async ({ roomId, userId }) => {
|
||||
console.log(`🚪 User ${userId} leaving room ${roomId}`);
|
||||
try {
|
||||
// Leave the socket room
|
||||
socket.leave(`room:${roomId}`);
|
||||
// Mark member as offline
|
||||
await (0, room_membership_1.setMemberOnline)(roomId, userId, false);
|
||||
// Get updated members
|
||||
const members = await (0, room_membership_1.getRoomMembers)(roomId);
|
||||
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(
|
||||
roomId,
|
||||
);
|
||||
// Convert memberPlayers Map to object
|
||||
const memberPlayersObj = {};
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
}
|
||||
// Notify remaining members
|
||||
io.to(`room:${roomId}`).emit("member-left", {
|
||||
roomId,
|
||||
userId,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
console.log(`✅ User ${userId} left room ${roomId}`);
|
||||
} catch (error) {
|
||||
console.error("Error leaving room:", error);
|
||||
}
|
||||
});
|
||||
// Room: Players updated
|
||||
socket.on("players-updated", async ({ roomId, userId }) => {
|
||||
console.log(`🎯 Players updated for user ${userId} in room ${roomId}`);
|
||||
try {
|
||||
// Get updated player data
|
||||
const memberPlayers = await (0, player_manager_1.getRoomActivePlayers)(
|
||||
roomId,
|
||||
);
|
||||
// Convert memberPlayers Map to object
|
||||
const memberPlayersObj = {};
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players;
|
||||
}
|
||||
// Broadcast to all members in the room (including sender)
|
||||
io.to(`room:${roomId}`).emit("room-players-updated", {
|
||||
roomId,
|
||||
memberPlayers: memberPlayersObj,
|
||||
});
|
||||
console.log(`✅ Broadcasted player updates for room ${roomId}`);
|
||||
} catch (error) {
|
||||
console.error("Error updating room players:", error);
|
||||
socket.emit("room-error", { error: "Failed to update players" });
|
||||
}
|
||||
});
|
||||
socket.on("disconnect", () => {
|
||||
console.log("🔌 Client disconnected:", socket.id);
|
||||
if (currentUserId) {
|
||||
// Don't delete session on disconnect - it persists across devices
|
||||
console.log(
|
||||
`👤 User ${currentUserId} disconnected but session persists`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Store in globalThis to make accessible across module boundaries
|
||||
globalThis.__socketIO = io;
|
||||
console.log("✅ Socket.IO initialized on /api/socket");
|
||||
return io;
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { PLAYER_EMOJIS } from '../../../../../constants/playerEmojis'
|
||||
import { EmojiPicker } from '../EmojiPicker'
|
||||
|
||||
// Mock the emoji keywords function for testing
|
||||
vi.mock('emojibase-data/en/data.json', () => ({
|
||||
default: [
|
||||
{
|
||||
emoji: '🐱',
|
||||
label: 'cat face',
|
||||
tags: ['cat', 'animal', 'pet', 'cute'],
|
||||
emoticon: ':)',
|
||||
},
|
||||
{
|
||||
emoji: '🐯',
|
||||
label: 'tiger face',
|
||||
tags: ['tiger', 'animal', 'big cat', 'wild'],
|
||||
emoticon: null,
|
||||
},
|
||||
{
|
||||
emoji: '🤩',
|
||||
label: 'star-struck',
|
||||
tags: ['face', 'happy', 'excited', 'star'],
|
||||
emoticon: null,
|
||||
},
|
||||
{
|
||||
emoji: '🎭',
|
||||
label: 'performing arts',
|
||||
tags: ['theater', 'performance', 'drama', 'arts'],
|
||||
emoticon: null,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
describe('EmojiPicker Search Functionality', () => {
|
||||
const mockProps = {
|
||||
currentEmoji: '😀',
|
||||
onEmojiSelect: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
playerNumber: 1 as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('shows all emojis by default (no search)', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
// Should show default header
|
||||
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
|
||||
|
||||
// Should show emoji count
|
||||
expect(
|
||||
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Should show emoji grid
|
||||
const emojiButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
|
||||
})
|
||||
|
||||
test('shows search results when searching for "cat"', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
fireEvent.change(searchInput, { target: { value: 'cat' } })
|
||||
|
||||
// Should show search header
|
||||
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
|
||||
|
||||
// Should show results count
|
||||
expect(screen.getByText(/✓ \d+ found/)).toBeInTheDocument()
|
||||
|
||||
// Should only show cat-related emojis (🐱, 🐯)
|
||||
const emojiButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
|
||||
// Verify only cat emojis are shown
|
||||
const displayedEmojis = emojiButtons.map((btn) => btn.textContent)
|
||||
expect(displayedEmojis).toContain('🐱')
|
||||
expect(displayedEmojis).toContain('🐯')
|
||||
expect(displayedEmojis).not.toContain('🤩')
|
||||
expect(displayedEmojis).not.toContain('🎭')
|
||||
})
|
||||
|
||||
test('shows no results message when search has zero matches', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
|
||||
|
||||
// Should show no results indicator
|
||||
expect(screen.getByText('✗ No matches')).toBeInTheDocument()
|
||||
|
||||
// Should show no results message
|
||||
expect(screen.getByText(/No emojis found for "nonexistentterm"/)).toBeInTheDocument()
|
||||
|
||||
// Should NOT show any emoji buttons
|
||||
const emojiButtons = screen
|
||||
.queryAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
expect(emojiButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns to default view when clearing search', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
|
||||
// Search for something
|
||||
fireEvent.change(searchInput, { target: { value: 'cat' } })
|
||||
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
|
||||
|
||||
// Clear search
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
|
||||
// Should return to default view
|
||||
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Should show all emojis again
|
||||
const emojiButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
|
||||
})
|
||||
|
||||
test('clear search button works from no results state', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
|
||||
// Search for something with no results
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
|
||||
expect(screen.getByText(/No emojis found/)).toBeInTheDocument()
|
||||
|
||||
// Click clear search button
|
||||
const clearButton = screen.getByText(/Clear search to see all/)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// Should return to default view
|
||||
expect(searchInput).toHaveValue('')
|
||||
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,349 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import type { GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
|
||||
// Initial state
|
||||
const initialState: MemoryPairsState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
|
||||
switch (move.type) {
|
||||
case 'START_GAME':
|
||||
// Generate cards and initialize game
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: move.data.cards,
|
||||
cards: move.data.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce(
|
||||
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
|
||||
{}
|
||||
),
|
||||
activePlayers: move.data.activePlayers,
|
||||
currentPlayer: move.data.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
// Optimistically flip the card
|
||||
const card = state.gameCards.find((c) => c.id === move.data.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime:
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2, // Processing if 2 cards flipped
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_MISMATCH': {
|
||||
// Clear mismatched cards and feedback
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
showMismatchFeedback: false,
|
||||
isProcessingMove: false,
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Create context
|
||||
const ArcadeMemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
|
||||
// Provider component
|
||||
export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData()
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Arcade session integration with room-wide sync
|
||||
const {
|
||||
state,
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Enable multi-user sync for room-based games
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Handle mismatch feedback timeout
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
// After 1.5 seconds, clear the flipped cards and feedback
|
||||
const timeout = setTimeout(() => {
|
||||
sendMove({
|
||||
type: 'CLEAR_MISMATCH',
|
||||
playerId: state.currentPlayer, // Use current player ID for CLEAR_MISMATCH
|
||||
data: {},
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length, sendMove, state.currentPlayer])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const { players } = useGameMode()
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
console.log('[canFlipCard] Checking card:', {
|
||||
cardId,
|
||||
isGameActive,
|
||||
isProcessingMove: state.isProcessingMove,
|
||||
currentPlayer: state.currentPlayer,
|
||||
hasRoomData: !!roomData,
|
||||
flippedCardsCount: state.flippedCards.length,
|
||||
})
|
||||
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
console.log('[canFlipCard] Blocked: game not active or processing')
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) {
|
||||
console.log('[canFlipCard] Blocked: card not found or already matched')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
console.log('[canFlipCard] Blocked: card already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) {
|
||||
console.log('[canFlipCard] Blocked: 2 cards already flipped')
|
||||
return false
|
||||
}
|
||||
|
||||
// Authorization check: Only allow flipping if it's your player's turn
|
||||
if (roomData && state.currentPlayer) {
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
console.log('[canFlipCard] Authorization check:', {
|
||||
currentPlayerId: state.currentPlayer,
|
||||
currentPlayerFound: !!currentPlayerData,
|
||||
currentPlayerIsLocal: currentPlayerData?.isLocal,
|
||||
})
|
||||
|
||||
// Block if current player is explicitly marked as remote (isLocal === false)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
console.log('[canFlipCard] BLOCKED: Current player is remote (not your turn)')
|
||||
return false
|
||||
}
|
||||
|
||||
// If player data not found in map, this might be an issue - allow for now but warn
|
||||
if (!currentPlayerData) {
|
||||
console.warn(
|
||||
'[canFlipCard] WARNING: Current player not found in players map, allowing move'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[canFlipCard] ALLOWED: All checks passed')
|
||||
return true
|
||||
},
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
roomData,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
() => ({
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}),
|
||||
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
|
||||
)
|
||||
|
||||
// Action creators - send moves to arcade session
|
||||
const startGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[ArcadeMemoryPairs] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove, roomData])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
console.log('[Client] flipCard called:', {
|
||||
cardId,
|
||||
viewerId,
|
||||
currentPlayer: state.currentPlayer,
|
||||
activePlayers: state.activePlayers,
|
||||
gamePhase: state.gamePhase,
|
||||
canFlip: canFlipCard(cardId),
|
||||
})
|
||||
|
||||
if (!canFlipCard(cardId)) {
|
||||
console.log('[Client] Cannot flip card - canFlipCard returned false')
|
||||
return
|
||||
}
|
||||
|
||||
const move = {
|
||||
type: 'FLIP_CARD' as const,
|
||||
playerId: state.currentPlayer, // Use the current player ID from game state (database player ID)
|
||||
data: { cardId },
|
||||
}
|
||||
console.log('[Client] Sending FLIP_CARD move via sendMove:', move)
|
||||
sendMove(move)
|
||||
},
|
||||
[canFlipCard, sendMove, viewerId, state.currentPlayer, state.activePlayers, state.gamePhase]
|
||||
)
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[ArcadeMemoryPairs] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
// Delete current session and start a new game
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
data: {
|
||||
cards,
|
||||
activePlayers,
|
||||
},
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, sendMove])
|
||||
|
||||
const setGameType = useCallback((_gameType: typeof state.gameType) => {
|
||||
// TODO: Implement via arcade session if needed
|
||||
console.warn('setGameType not yet implemented for arcade mode')
|
||||
}, [])
|
||||
|
||||
const setDifficulty = useCallback((_difficulty: typeof state.difficulty) => {
|
||||
// TODO: Implement via arcade session if needed
|
||||
console.warn('setDifficulty not yet implemented for arcade mode')
|
||||
}, [])
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: { ...state, gameMode },
|
||||
dispatch: () => {
|
||||
// No-op - replaced with sendMove
|
||||
console.warn('dispatch() is deprecated in arcade mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
startGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
}
|
||||
|
||||
return (
|
||||
<ArcadeMemoryPairsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ArcadeMemoryPairsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Hook to use the context
|
||||
export function useArcadeMemoryPairs(): MemoryPairsContextValue {
|
||||
const context = useContext(ArcadeMemoryPairsContext)
|
||||
if (!context) {
|
||||
throw new Error('useArcadeMemoryPairs must be used within an ArcadeMemoryPairsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useReducer } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { useUserPlayers } from '@/hooks/useUserPlayers'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { validateMatch } from '../utils/matchValidation'
|
||||
import { MemoryPairsContext } from './MemoryPairsContext'
|
||||
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
|
||||
// Initial state for local-only games
|
||||
const initialState: MemoryPairsState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
originalConfig: undefined,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
playerHovers: {},
|
||||
}
|
||||
|
||||
// Action types for local reducer
|
||||
type LocalAction =
|
||||
| {
|
||||
type: 'START_GAME'
|
||||
cards: any[]
|
||||
activePlayers: string[]
|
||||
playerMetadata: any
|
||||
}
|
||||
| { type: 'FLIP_CARD'; cardId: string }
|
||||
| { type: 'MATCH_FOUND'; cardIds: [string, string]; playerId: string }
|
||||
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
|
||||
| { type: 'CLEAR_MISMATCH' }
|
||||
| { type: 'SWITCH_PLAYER' }
|
||||
| { type: 'GO_TO_SETUP' }
|
||||
| { type: 'SET_CONFIG'; field: string; value: any }
|
||||
| { type: 'RESUME_GAME' }
|
||||
| { type: 'HOVER_CARD'; playerId: string; cardId: string | null }
|
||||
| { type: 'END_GAME' }
|
||||
|
||||
// Pure client-side reducer with complete game logic
|
||||
function localMemoryPairsReducer(state: MemoryPairsState, action: LocalAction): MemoryPairsState {
|
||||
switch (action.type) {
|
||||
case 'START_GAME':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: action.cards,
|
||||
cards: action.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: action.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: action.activePlayers.reduce(
|
||||
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
|
||||
{}
|
||||
),
|
||||
activePlayers: action.activePlayers,
|
||||
playerMetadata: action.playerMetadata,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
originalConfig: {
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
},
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
const card = state.gameCards.find((c) => c.id === action.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, card]
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime:
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime,
|
||||
isProcessingMove: newFlippedCards.length === 2,
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FOUND': {
|
||||
const [id1, id2] = action.cardIds
|
||||
const updatedCards = state.gameCards.map((card) =>
|
||||
card.id === id1 || card.id === id2
|
||||
? { ...card, matched: true, matchedBy: action.playerId }
|
||||
: card
|
||||
)
|
||||
|
||||
const newMatchedPairs = state.matchedPairs + 1
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[action.playerId]: (state.scores[action.playerId] || 0) + 1,
|
||||
}
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[action.playerId]: (state.consecutiveMatches[action.playerId] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const gameComplete = newMatchedPairs >= state.totalPairs
|
||||
|
||||
return {
|
||||
...state,
|
||||
gameCards: updatedCards,
|
||||
cards: updatedCards,
|
||||
flippedCards: [],
|
||||
matchedPairs: newMatchedPairs,
|
||||
moves: state.moves + 1,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
lastMatchedPair: action.cardIds,
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
gamePhase: gameComplete ? 'results' : state.gamePhase,
|
||||
gameEndTime: gameComplete ? Date.now() : null,
|
||||
// Player keeps their turn on match
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FAILED': {
|
||||
// Reset consecutive matches for current player
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
moves: state.moves + 1,
|
||||
showMismatchFeedback: true,
|
||||
isProcessingMove: true,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
// Don't clear flipped cards yet - CLEAR_MISMATCH will do that
|
||||
}
|
||||
}
|
||||
|
||||
case 'CLEAR_MISMATCH': {
|
||||
// Clear hover for all non-current players
|
||||
const clearedHovers = { ...state.playerHovers }
|
||||
for (const playerId of state.activePlayers) {
|
||||
if (playerId !== state.currentPlayer) {
|
||||
clearedHovers[playerId] = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
showMismatchFeedback: false,
|
||||
isProcessingMove: false,
|
||||
// Clear hovers for non-current players
|
||||
playerHovers: clearedHovers,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SWITCH_PLAYER': {
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
const nextPlayer = state.activePlayers[nextIndex]
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: nextPlayer,
|
||||
currentMoveStartTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
case 'GO_TO_SETUP': {
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'setup',
|
||||
pausedGamePhase: isPausingGame ? state.gamePhase : undefined,
|
||||
pausedGameState: isPausingGame
|
||||
? {
|
||||
gameCards: state.gameCards,
|
||||
currentPlayer: state.currentPlayer,
|
||||
matchedPairs: state.matchedPairs,
|
||||
moves: state.moves,
|
||||
scores: state.scores,
|
||||
activePlayers: state.activePlayers,
|
||||
playerMetadata: state.playerMetadata || {},
|
||||
consecutiveMatches: state.consecutiveMatches,
|
||||
gameStartTime: state.gameStartTime,
|
||||
}
|
||||
: undefined,
|
||||
gameCards: [],
|
||||
cards: [],
|
||||
flippedCards: [],
|
||||
currentPlayer: '',
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
playerMetadata: {},
|
||||
consecutiveMatches: {},
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
...state,
|
||||
[action.field]: action.value,
|
||||
...(action.field === 'difficulty' ? { totalPairs: action.value } : {}),
|
||||
...(clearPausedGame
|
||||
? {
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
originalConfig: undefined,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'RESUME_GAME': {
|
||||
if (!state.pausedGamePhase || !state.pausedGameState) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: state.pausedGamePhase,
|
||||
gameCards: state.pausedGameState.gameCards,
|
||||
cards: state.pausedGameState.gameCards,
|
||||
currentPlayer: state.pausedGameState.currentPlayer,
|
||||
matchedPairs: state.pausedGameState.matchedPairs,
|
||||
moves: state.pausedGameState.moves,
|
||||
scores: state.pausedGameState.scores,
|
||||
activePlayers: state.pausedGameState.activePlayers,
|
||||
playerMetadata: state.pausedGameState.playerMetadata,
|
||||
consecutiveMatches: state.pausedGameState.consecutiveMatches,
|
||||
gameStartTime: state.pausedGameState.gameStartTime,
|
||||
pausedGamePhase: undefined,
|
||||
pausedGameState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'HOVER_CARD': {
|
||||
return {
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[action.playerId]: action.cardId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'END_GAME': {
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Provider component for LOCAL-ONLY play (no network, no arcade session)
|
||||
export function LocalMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// LOCAL-ONLY: Get only the current user's players (no room members)
|
||||
const { data: userPlayers = [] } = useUserPlayers()
|
||||
|
||||
// Build players map from current user's players only
|
||||
const players = useMemo(() => {
|
||||
const map = new Map()
|
||||
userPlayers.forEach((player) => {
|
||||
map.set(player.id, {
|
||||
id: player.id,
|
||||
name: player.name,
|
||||
emoji: player.emoji,
|
||||
color: player.color,
|
||||
isLocal: true,
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [userPlayers])
|
||||
|
||||
// Get active player IDs from current user's players only
|
||||
const activePlayers = useMemo(() => {
|
||||
return userPlayers.filter((p) => p.isActive).map((p) => p.id)
|
||||
}, [userPlayers])
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayers.length > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Pure client-side state with useReducer
|
||||
const [state, dispatch] = useReducer(localMemoryPairsReducer, initialState)
|
||||
|
||||
// Handle mismatch feedback timeout and player switching
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback && state.flippedCards.length === 2) {
|
||||
const timeout = setTimeout(() => {
|
||||
dispatch({ type: 'CLEAR_MISMATCH' })
|
||||
// Switch to next player after mismatch
|
||||
dispatch({ type: 'SWITCH_PLAYER' })
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback, state.flippedCards.length])
|
||||
|
||||
// Handle automatic match checking when 2 cards flipped
|
||||
useEffect(() => {
|
||||
if (state.flippedCards.length === 2 && !state.showMismatchFeedback) {
|
||||
const [card1, card2] = state.flippedCards
|
||||
const isMatch = validateMatch(card1, card2)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (isMatch.isValid) {
|
||||
dispatch({
|
||||
type: 'MATCH_FOUND',
|
||||
cardIds: [card1.id, card2.id],
|
||||
playerId: state.currentPlayer,
|
||||
})
|
||||
// Player keeps turn on match - no SWITCH_PLAYER
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'MATCH_FAILED',
|
||||
cardIds: [card1.id, card2.id],
|
||||
})
|
||||
// SWITCH_PLAYER will happen after CLEAR_MISMATCH timeout
|
||||
}
|
||||
}, 600) // Small delay to show both cards
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.flippedCards, state.showMismatchFeedback, state.currentPlayer])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = useCallback(
|
||||
(cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) {
|
||||
return false
|
||||
}
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.flippedCards.length >= 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// In local play, all local players can flip during their turn
|
||||
const currentPlayerData = players.get(state.currentPlayer)
|
||||
if (currentPlayerData && currentPlayerData.isLocal === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[
|
||||
isGameActive,
|
||||
state.isProcessingMove,
|
||||
state.gameCards,
|
||||
state.flippedCards,
|
||||
state.currentPlayer,
|
||||
players,
|
||||
]
|
||||
)
|
||||
|
||||
const currentGameStatistics: GameStatistics = useMemo(
|
||||
() => ({
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}),
|
||||
[state.moves, state.matchedPairs, state.totalPairs, state.gameStartTime, state.gameEndTime]
|
||||
)
|
||||
|
||||
const hasConfigChanged = useMemo(() => {
|
||||
if (!state.originalConfig) return false
|
||||
return (
|
||||
state.gameType !== state.originalConfig.gameType ||
|
||||
state.difficulty !== state.originalConfig.difficulty ||
|
||||
state.turnTimer !== state.originalConfig.turnTimer
|
||||
)
|
||||
}, [state.gameType, state.difficulty, state.turnTimer, state.originalConfig])
|
||||
|
||||
const canResumeGame = useMemo(() => {
|
||||
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
|
||||
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
|
||||
|
||||
// Action creators
|
||||
const startGame = useCallback(() => {
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({
|
||||
type: 'START_GAME',
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
|
||||
|
||||
const flipCard = useCallback(
|
||||
(cardId: string) => {
|
||||
if (!canFlipCard(cardId)) {
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'FLIP_CARD', cardId })
|
||||
},
|
||||
[canFlipCard]
|
||||
)
|
||||
|
||||
const resetGame = useCallback(() => {
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[LocalMemoryPairs] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
const playerMetadata: { [playerId: string]: any } = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
playerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId || '',
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({
|
||||
type: 'START_GAME',
|
||||
cards,
|
||||
activePlayers,
|
||||
playerMetadata,
|
||||
})
|
||||
}, [state.gameType, state.difficulty, activePlayers, players, viewerId])
|
||||
|
||||
const setGameType = useCallback((gameType: typeof state.gameType) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'gameType', value: gameType })
|
||||
}, [])
|
||||
|
||||
const setDifficulty = useCallback((difficulty: typeof state.difficulty) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'difficulty', value: difficulty })
|
||||
}, [])
|
||||
|
||||
const setTurnTimer = useCallback((turnTimer: typeof state.turnTimer) => {
|
||||
dispatch({ type: 'SET_CONFIG', field: 'turnTimer', value: turnTimer })
|
||||
}, [])
|
||||
|
||||
const resumeGame = useCallback(() => {
|
||||
if (!canResumeGame) {
|
||||
console.warn('[LocalMemoryPairs] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
dispatch({ type: 'RESUME_GAME' })
|
||||
}, [canResumeGame])
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
dispatch({ type: 'GO_TO_SETUP' })
|
||||
}, [])
|
||||
|
||||
const hoverCard = useCallback(
|
||||
(cardId: string | null) => {
|
||||
const playerId = state.currentPlayer || activePlayers[0] || ''
|
||||
if (!playerId) return
|
||||
|
||||
dispatch({
|
||||
type: 'HOVER_CARD',
|
||||
playerId,
|
||||
cardId,
|
||||
})
|
||||
},
|
||||
[state.currentPlayer, activePlayers]
|
||||
)
|
||||
|
||||
const exitSession = useCallback(() => {
|
||||
router.push('/arcade')
|
||||
}, [router])
|
||||
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & {
|
||||
gameMode: GameMode
|
||||
}
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
// No-op - local provider uses action creators instead
|
||||
console.warn('dispatch() is not available in local mode, use action creators instead')
|
||||
},
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
hasConfigChanged,
|
||||
canResumeGame,
|
||||
startGame,
|
||||
resumeGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
goToSetup,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
setTurnTimer,
|
||||
hoverCard,
|
||||
exitSession,
|
||||
gameMode,
|
||||
activePlayers,
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useReducer } from 'react'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { validateMatch } from '../utils/matchValidation'
|
||||
import type {
|
||||
GameStatistics,
|
||||
MemoryPairsAction,
|
||||
MemoryPairsContextValue,
|
||||
MemoryPairsState,
|
||||
PlayerScore,
|
||||
} from './types'
|
||||
|
||||
// Initial state (gameMode removed - now derived from global context)
|
||||
const initialState: MemoryPairsState = {
|
||||
// Core game data
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
|
||||
// Game configuration (gameMode removed)
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
|
||||
// Game progression
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
consecutiveMatches: {},
|
||||
|
||||
// Timing
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
|
||||
// UI state
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
|
||||
// Reducer function
|
||||
function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction): MemoryPairsState {
|
||||
switch (action.type) {
|
||||
// SET_GAME_MODE removed - game mode now derived from global context
|
||||
|
||||
case 'SET_GAME_TYPE':
|
||||
return {
|
||||
...state,
|
||||
gameType: action.gameType,
|
||||
}
|
||||
|
||||
case 'SET_DIFFICULTY':
|
||||
return {
|
||||
...state,
|
||||
difficulty: action.difficulty,
|
||||
totalPairs: action.difficulty,
|
||||
}
|
||||
|
||||
case 'SET_TURN_TIMER':
|
||||
return {
|
||||
...state,
|
||||
turnTimer: action.timer,
|
||||
}
|
||||
|
||||
case 'START_GAME': {
|
||||
// Initialize scores and consecutive matches for all active players
|
||||
const scores: PlayerScore = {}
|
||||
const consecutiveMatches: { [playerId: string]: number } = {}
|
||||
action.activePlayers.forEach((playerId) => {
|
||||
scores[playerId] = 0
|
||||
consecutiveMatches[playerId] = 0
|
||||
})
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: action.cards,
|
||||
cards: action.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores,
|
||||
consecutiveMatches,
|
||||
activePlayers: action.activePlayers,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
const cardToFlip = state.gameCards.find((card) => card.id === action.cardId)
|
||||
if (
|
||||
!cardToFlip ||
|
||||
cardToFlip.matched ||
|
||||
state.flippedCards.length >= 2 ||
|
||||
state.isProcessingMove
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, cardToFlip]
|
||||
const newMoveStartTime =
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime: newMoveStartTime,
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FOUND': {
|
||||
const [card1Id, card2Id] = action.cardIds
|
||||
const updatedCards = state.gameCards.map((card) => {
|
||||
if (card.id === card1Id || card.id === card2Id) {
|
||||
return {
|
||||
...card,
|
||||
matched: true,
|
||||
matchedBy: state.currentPlayer,
|
||||
}
|
||||
}
|
||||
return card
|
||||
})
|
||||
|
||||
const newMatchedPairs = state.matchedPairs + 1
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
|
||||
}
|
||||
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const isGameComplete = newMatchedPairs === state.totalPairs
|
||||
|
||||
return {
|
||||
...state,
|
||||
gameCards: updatedCards,
|
||||
matchedPairs: newMatchedPairs,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
flippedCards: [],
|
||||
moves: state.moves + 1,
|
||||
lastMatchedPair: action.cardIds,
|
||||
gamePhase: isGameComplete ? 'results' : 'playing',
|
||||
gameEndTime: isGameComplete ? Date.now() : null,
|
||||
isProcessingMove: false,
|
||||
// Note: Player keeps turn after successful match in multiplayer mode
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FAILED': {
|
||||
// Player switching is now handled by passing activePlayerCount
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
moves: state.moves + 1,
|
||||
showMismatchFeedback: true,
|
||||
isProcessingMove: false,
|
||||
// currentPlayer will be updated by SWITCH_PLAYER action when needed
|
||||
}
|
||||
}
|
||||
|
||||
case 'SWITCH_PLAYER': {
|
||||
// Cycle through all active players
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Reset consecutive matches for the player who failed
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
}
|
||||
}
|
||||
|
||||
case 'ADD_CELEBRATION':
|
||||
return {
|
||||
...state,
|
||||
celebrationAnimations: [...state.celebrationAnimations, action.animation],
|
||||
}
|
||||
|
||||
case 'REMOVE_CELEBRATION':
|
||||
return {
|
||||
...state,
|
||||
celebrationAnimations: state.celebrationAnimations.filter(
|
||||
(anim) => anim.id !== action.animationId
|
||||
),
|
||||
}
|
||||
|
||||
case 'SET_PROCESSING':
|
||||
return {
|
||||
...state,
|
||||
isProcessingMove: action.processing,
|
||||
}
|
||||
|
||||
case 'SET_MISMATCH_FEEDBACK':
|
||||
return {
|
||||
...state,
|
||||
showMismatchFeedback: action.show,
|
||||
}
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
flippedCards: [],
|
||||
}
|
||||
|
||||
case 'RESET_GAME':
|
||||
return {
|
||||
...initialState,
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
totalPairs: state.difficulty,
|
||||
}
|
||||
|
||||
case 'UPDATE_TIMER':
|
||||
// This can be used for any timer-related updates
|
||||
return state
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Create context
|
||||
export const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
|
||||
// Provider component
|
||||
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs directly from GameModeContext
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Handle card matching logic when two cards are flipped
|
||||
useEffect(() => {
|
||||
if (state.flippedCards.length === 2 && !state.isProcessingMove) {
|
||||
dispatch({ type: 'SET_PROCESSING', processing: true })
|
||||
|
||||
const [card1, card2] = state.flippedCards
|
||||
const matchResult = validateMatch(card1, card2)
|
||||
|
||||
// Delay to allow card flip animation
|
||||
setTimeout(() => {
|
||||
if (matchResult.isValid) {
|
||||
dispatch({ type: 'MATCH_FOUND', cardIds: [card1.id, card2.id] })
|
||||
} else {
|
||||
dispatch({ type: 'MATCH_FAILED', cardIds: [card1.id, card2.id] })
|
||||
// Switch player only in multiplayer mode
|
||||
if (gameMode === 'multiplayer') {
|
||||
dispatch({ type: 'SWITCH_PLAYER' })
|
||||
}
|
||||
}
|
||||
}, 1000) // Give time to see both cards
|
||||
}
|
||||
}, [state.flippedCards, state.isProcessingMove, gameMode])
|
||||
|
||||
// Auto-hide mismatch feedback
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback) {
|
||||
const timeout = setTimeout(() => {
|
||||
dispatch({ type: 'SET_MISMATCH_FEEDBACK', show: false })
|
||||
}, 2000)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = (cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) return false
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) return false
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) return false
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const currentGameStatistics: GameStatistics = {
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}
|
||||
|
||||
// Action creators
|
||||
const startGame = () => {
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({ type: 'START_GAME', cards, activePlayers })
|
||||
}
|
||||
|
||||
const flipCard = (cardId: string) => {
|
||||
if (!canFlipCard(cardId)) return
|
||||
dispatch({ type: 'FLIP_CARD', cardId })
|
||||
}
|
||||
|
||||
const resetGame = () => {
|
||||
dispatch({ type: 'RESET_GAME' })
|
||||
}
|
||||
|
||||
// setGameMode removed - game mode is now derived from global context
|
||||
|
||||
const setGameType = (gameType: typeof state.gameType) => {
|
||||
dispatch({ type: 'SET_GAME_TYPE', gameType })
|
||||
}
|
||||
|
||||
const setDifficulty = (difficulty: typeof state.difficulty) => {
|
||||
dispatch({ type: 'SET_DIFFICULTY', difficulty })
|
||||
}
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: { ...state, gameMode }, // Add derived gameMode to state
|
||||
dispatch,
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
startGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
exitSession: () => {}, // No-op for non-arcade mode
|
||||
gameMode, // Expose derived gameMode
|
||||
activePlayers, // Expose active players
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
|
||||
// Hook to use the context
|
||||
export function useMemoryPairs(): MemoryPairsContextValue {
|
||||
const context = useContext(MemoryPairsContext)
|
||||
if (!context) {
|
||||
throw new Error('useMemoryPairs must be used within a MemoryPairsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Unit test for player ownership bug in RoomMemoryPairsProvider
|
||||
*
|
||||
* Bug: playerMetadata[playerId].userId is set to the LOCAL viewerId for ALL players,
|
||||
* including remote players from other room members. This causes "Your turn" to show
|
||||
* even when it's a remote player's turn.
|
||||
*
|
||||
* Fix: Use player.isLocal from GameModeContext to determine correct userId ownership.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Player Metadata userId Assignment', () => {
|
||||
it('should assign local userId to local players only', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const players = new Map([
|
||||
[
|
||||
'local-player-1',
|
||||
{
|
||||
id: 'local-player-1',
|
||||
name: 'Local Player',
|
||||
emoji: '😀',
|
||||
color: '#3b82f6',
|
||||
isLocal: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
'remote-player-1',
|
||||
{
|
||||
id: 'remote-player-1',
|
||||
name: 'Remote Player',
|
||||
emoji: '🤠',
|
||||
color: '#10b981',
|
||||
isLocal: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const activePlayers = ['local-player-1', 'remote-player-1']
|
||||
|
||||
// CURRENT BUGGY IMPLEMENTATION (from RoomMemoryPairsProvider.tsx:378-390)
|
||||
const buggyPlayerMetadata: Record<string, any> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
buggyPlayerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
userId: viewerId, // BUG: Always uses local viewerId!
|
||||
color: playerData.color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BUG MANIFESTATION: Both players have local userId
|
||||
expect(buggyPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
|
||||
expect(buggyPlayerMetadata['remote-player-1'].userId).toBe('local-user-id') // WRONG!
|
||||
|
||||
// CORRECT IMPLEMENTATION
|
||||
const correctPlayerMetadata: Record<string, any> = {}
|
||||
for (const playerId of activePlayers) {
|
||||
const playerData = players.get(playerId)
|
||||
if (playerData) {
|
||||
correctPlayerMetadata[playerId] = {
|
||||
id: playerId,
|
||||
name: playerData.name,
|
||||
emoji: playerData.emoji,
|
||||
// FIX: Only use local viewerId for local players
|
||||
// For remote players, we don't know their userId from this context,
|
||||
// but we can mark them as NOT belonging to local user
|
||||
userId: playerData.isLocal ? viewerId : `remote-user-${playerId}`,
|
||||
color: playerData.color,
|
||||
isLocal: playerData.isLocal, // Also include isLocal for clarity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECT BEHAVIOR: Each player has correct userId
|
||||
expect(correctPlayerMetadata['local-player-1'].userId).toBe('local-user-id')
|
||||
expect(correctPlayerMetadata['remote-player-1'].userId).not.toBe('local-user-id')
|
||||
})
|
||||
|
||||
it('reproduces "Your turn" bug when checking current player', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const currentPlayer = 'remote-player-1' // Remote player's turn
|
||||
|
||||
// Buggy playerMetadata (all players have local userId)
|
||||
const buggyPlayerMetadata = {
|
||||
'local-player-1': {
|
||||
id: 'local-player-1',
|
||||
userId: 'local-user-id',
|
||||
},
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'local-user-id', // BUG!
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerStatusBar logic (line 31 in PlayerStatusBar.tsx)
|
||||
const buggyIsLocalPlayer = buggyPlayerMetadata[currentPlayer]?.userId === viewerId
|
||||
|
||||
// BUG: Shows "Your turn" even though it's remote player's turn!
|
||||
expect(buggyIsLocalPlayer).toBe(true) // WRONG!
|
||||
expect(buggyIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Your turn') // WRONG!
|
||||
|
||||
// Correct playerMetadata (each player has correct userId)
|
||||
const correctPlayerMetadata = {
|
||||
'local-player-1': {
|
||||
id: 'local-player-1',
|
||||
userId: 'local-user-id',
|
||||
},
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'remote-user-id', // CORRECT!
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerStatusBar logic with correct data
|
||||
const correctIsLocalPlayer = correctPlayerMetadata[currentPlayer]?.userId === viewerId
|
||||
|
||||
// CORRECT: Shows "Their turn" because it's remote player's turn
|
||||
expect(correctIsLocalPlayer).toBe(false) // CORRECT!
|
||||
expect(correctIsLocalPlayer ? 'Your turn' : 'Their turn').toBe('Their turn') // CORRECT!
|
||||
})
|
||||
|
||||
it('reproduces hover avatar bug when filtering by current player', () => {
|
||||
const viewerId = 'local-user-id'
|
||||
const currentPlayer = 'remote-player-1' // Remote player's turn
|
||||
|
||||
// Buggy playerMetadata
|
||||
const buggyPlayerMetadata = {
|
||||
'remote-player-1': {
|
||||
id: 'remote-player-1',
|
||||
userId: 'local-user-id', // BUG!
|
||||
},
|
||||
}
|
||||
|
||||
// OLD WRONG logic from MemoryGrid.tsx (showed remote players)
|
||||
const oldWrongFilter = buggyPlayerMetadata[currentPlayer]?.userId !== viewerId
|
||||
expect(oldWrongFilter).toBe(false) // Would hide avatar incorrectly
|
||||
|
||||
// CURRENT logic in MemoryGrid.tsx (shows only current player)
|
||||
// This is actually correct - show avatar for whoever's turn it is
|
||||
const currentLogic = currentPlayer === 'remote-player-1'
|
||||
expect(currentLogic).toBe(true) // Shows avatar for current player
|
||||
|
||||
// The REAL issue is in PlayerStatusBar showing "Your turn"
|
||||
// when it should show "Their turn"
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Central export point for arcade matching game context
|
||||
* Re-exports the hook from the appropriate provider
|
||||
*/
|
||||
|
||||
// Export the hook (works with both local and room providers)
|
||||
export { useMemoryPairs } from './MemoryPairsContext'
|
||||
|
||||
// Export the room provider (networked multiplayer)
|
||||
export { RoomMemoryPairsProvider } from './RoomMemoryPairsProvider'
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
GameCard,
|
||||
GameMode,
|
||||
GamePhase,
|
||||
GameType,
|
||||
MemoryPairsState,
|
||||
MemoryPairsContextValue,
|
||||
} from './types'
|
||||
@@ -1,179 +0,0 @@
|
||||
// TypeScript interfaces for Memory Pairs Challenge game
|
||||
|
||||
export type GameMode = 'single' | 'multiplayer'
|
||||
export type GameType = 'abacus-numeral' | 'complement-pairs'
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
export type CardType = 'abacus' | 'number' | 'complement'
|
||||
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
|
||||
export type Player = string // Player ID (UUID)
|
||||
export type TargetSum = 5 | 10 | 20
|
||||
|
||||
export interface GameCard {
|
||||
id: string
|
||||
type: CardType
|
||||
number: number
|
||||
complement?: number // For complement pairs
|
||||
targetSum?: TargetSum // For complement pairs
|
||||
matched: boolean
|
||||
matchedBy?: Player // For two-player mode
|
||||
element?: HTMLElement | null // For animations
|
||||
}
|
||||
|
||||
export interface PlayerScore {
|
||||
[playerId: string]: number
|
||||
}
|
||||
|
||||
export interface CelebrationAnimation {
|
||||
id: string
|
||||
type: 'match' | 'win' | 'confetti'
|
||||
x: number
|
||||
y: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface GameStatistics {
|
||||
totalMoves: number
|
||||
matchedPairs: number
|
||||
totalPairs: number
|
||||
gameTime: number
|
||||
accuracy: number // Percentage of successful matches
|
||||
averageTimePerMove: number
|
||||
}
|
||||
|
||||
export interface MemoryPairsState {
|
||||
// Core game data
|
||||
cards: GameCard[]
|
||||
gameCards: GameCard[]
|
||||
flippedCards: GameCard[]
|
||||
|
||||
// Game configuration (gameMode removed - now derived from global context)
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number // Seconds for two-player mode
|
||||
|
||||
// Game progression
|
||||
gamePhase: GamePhase
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
totalPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
currentMoveStartTime: number | null
|
||||
timerInterval: NodeJS.Timeout | null
|
||||
|
||||
// UI state
|
||||
celebrationAnimations: CelebrationAnimation[]
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// PAUSE/RESUME: Paused game state
|
||||
originalConfig?: {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
pausedGamePhase?: GamePhase
|
||||
pausedGameState?: {
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: any }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// HOVER: Networked hover state
|
||||
playerHovers?: { [playerId: string]: string | null }
|
||||
}
|
||||
|
||||
export type MemoryPairsAction =
|
||||
| { type: 'SET_GAME_TYPE'; gameType: GameType }
|
||||
| { type: 'SET_DIFFICULTY'; difficulty: Difficulty }
|
||||
| { type: 'SET_TURN_TIMER'; timer: number }
|
||||
| { type: 'START_GAME'; cards: GameCard[]; activePlayers: Player[] }
|
||||
| { type: 'FLIP_CARD'; cardId: string }
|
||||
| { type: 'MATCH_FOUND'; cardIds: [string, string] }
|
||||
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
|
||||
| { type: 'SWITCH_PLAYER' }
|
||||
| { type: 'ADD_CELEBRATION'; animation: CelebrationAnimation }
|
||||
| { type: 'REMOVE_CELEBRATION'; animationId: string }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_GAME' }
|
||||
| { type: 'SET_PROCESSING'; processing: boolean }
|
||||
| { type: 'SET_MISMATCH_FEEDBACK'; show: boolean }
|
||||
| { type: 'UPDATE_TIMER' }
|
||||
|
||||
export interface MemoryPairsContextValue {
|
||||
state: MemoryPairsState & { gameMode: GameMode } // gameMode added as computed property
|
||||
dispatch: React.Dispatch<MemoryPairsAction>
|
||||
|
||||
// Computed values
|
||||
isGameActive: boolean
|
||||
canFlipCard: (cardId: string) => boolean
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
|
||||
// PAUSE/RESUME: Computed pause/resume values
|
||||
hasConfigChanged?: boolean
|
||||
canResumeGame?: boolean
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
flipCard: (cardId: string) => void
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer?: (timer: number) => void
|
||||
goToSetup?: () => void
|
||||
resumeGame?: () => void
|
||||
hoverCard?: (cardId: string | null) => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
// Utility types for component props
|
||||
export interface GameCardProps {
|
||||
card: GameCard
|
||||
isFlipped: boolean
|
||||
isMatched: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface PlayerIndicatorProps {
|
||||
player: Player
|
||||
isActive: boolean
|
||||
score: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface GameGridProps {
|
||||
cards: GameCard[]
|
||||
onCardClick: (cardId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// Configuration interfaces
|
||||
export interface GameConfiguration {
|
||||
gameMode: GameMode
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MatchValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
type: 'abacus-numeral' | 'complement' | 'invalid'
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { LocalMemoryPairsProvider } from './context/LocalMemoryPairsProvider'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<LocalMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</LocalMemoryPairsProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
import type { Difficulty, GameCard, GameType } from '../context/types'
|
||||
|
||||
// Utility function to generate unique random numbers
|
||||
function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] {
|
||||
const numbers = new Set<number>()
|
||||
const { min, max } = options
|
||||
|
||||
while (numbers.size < count) {
|
||||
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min
|
||||
numbers.add(randomNum)
|
||||
}
|
||||
|
||||
return Array.from(numbers)
|
||||
}
|
||||
|
||||
// Utility function to shuffle an array
|
||||
function shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
|
||||
// Generate cards for abacus-numeral game mode
|
||||
export function generateAbacusNumeralCards(pairs: Difficulty): GameCard[] {
|
||||
// Generate unique numbers based on difficulty
|
||||
// For easier games, use smaller numbers; for harder games, use larger ranges
|
||||
const numberRanges: Record<Difficulty, { min: number; max: number }> = {
|
||||
6: { min: 1, max: 50 }, // 6 pairs: 1-50
|
||||
8: { min: 1, max: 100 }, // 8 pairs: 1-100
|
||||
12: { min: 1, max: 200 }, // 12 pairs: 1-200
|
||||
15: { min: 1, max: 300 }, // 15 pairs: 1-300
|
||||
}
|
||||
|
||||
const range = numberRanges[pairs]
|
||||
const numbers = generateUniqueNumbers(pairs, range)
|
||||
|
||||
const cards: GameCard[] = []
|
||||
|
||||
numbers.forEach((number) => {
|
||||
// Abacus representation card
|
||||
cards.push({
|
||||
id: `abacus_${number}`,
|
||||
type: 'abacus',
|
||||
number,
|
||||
matched: false,
|
||||
})
|
||||
|
||||
// Numerical representation card
|
||||
cards.push({
|
||||
id: `number_${number}`,
|
||||
type: 'number',
|
||||
number,
|
||||
matched: false,
|
||||
})
|
||||
})
|
||||
|
||||
return shuffleArray(cards)
|
||||
}
|
||||
|
||||
// Generate cards for complement pairs game mode
|
||||
export function generateComplementCards(pairs: Difficulty): GameCard[] {
|
||||
// Define complement pairs for friends of 5 and friends of 10
|
||||
const complementPairs = [
|
||||
// Friends of 5
|
||||
{ pair: [0, 5], targetSum: 5 as const },
|
||||
{ pair: [1, 4], targetSum: 5 as const },
|
||||
{ pair: [2, 3], targetSum: 5 as const },
|
||||
|
||||
// Friends of 10
|
||||
{ pair: [0, 10], targetSum: 10 as const },
|
||||
{ pair: [1, 9], targetSum: 10 as const },
|
||||
{ pair: [2, 8], targetSum: 10 as const },
|
||||
{ pair: [3, 7], targetSum: 10 as const },
|
||||
{ pair: [4, 6], targetSum: 10 as const },
|
||||
{ pair: [5, 5], targetSum: 10 as const },
|
||||
|
||||
// Additional pairs for higher difficulties
|
||||
{ pair: [6, 4], targetSum: 10 as const },
|
||||
{ pair: [7, 3], targetSum: 10 as const },
|
||||
{ pair: [8, 2], targetSum: 10 as const },
|
||||
{ pair: [9, 1], targetSum: 10 as const },
|
||||
{ pair: [10, 0], targetSum: 10 as const },
|
||||
|
||||
// More challenging pairs (can be used for expert mode)
|
||||
{ pair: [11, 9], targetSum: 20 as const },
|
||||
{ pair: [12, 8], targetSum: 20 as const },
|
||||
]
|
||||
|
||||
// Select the required number of complement pairs
|
||||
const selectedPairs = complementPairs.slice(0, pairs)
|
||||
const cards: GameCard[] = []
|
||||
|
||||
selectedPairs.forEach(({ pair: [num1, num2], targetSum }, index) => {
|
||||
// First number in the pair
|
||||
cards.push({
|
||||
id: `comp1_${index}_${num1}`,
|
||||
type: 'complement',
|
||||
number: num1,
|
||||
complement: num2,
|
||||
targetSum,
|
||||
matched: false,
|
||||
})
|
||||
|
||||
// Second number in the pair
|
||||
cards.push({
|
||||
id: `comp2_${index}_${num2}`,
|
||||
type: 'complement',
|
||||
number: num2,
|
||||
complement: num1,
|
||||
targetSum,
|
||||
matched: false,
|
||||
})
|
||||
})
|
||||
|
||||
return shuffleArray(cards)
|
||||
}
|
||||
|
||||
// Main card generation function
|
||||
export function generateGameCards(gameType: GameType, difficulty: Difficulty): GameCard[] {
|
||||
switch (gameType) {
|
||||
case 'abacus-numeral':
|
||||
return generateAbacusNumeralCards(difficulty)
|
||||
|
||||
case 'complement-pairs':
|
||||
return generateComplementCards(difficulty)
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown game type: ${gameType}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to get responsive grid configuration based on difficulty and screen size
|
||||
export function getGridConfiguration(difficulty: Difficulty) {
|
||||
const configs: Record<
|
||||
Difficulty,
|
||||
{
|
||||
totalCards: 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,
|
||||
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(3, 1fr)',
|
||||
},
|
||||
8: {
|
||||
totalCards: 16,
|
||||
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(3, 1fr)',
|
||||
},
|
||||
12: {
|
||||
totalCards: 24,
|
||||
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(3, 1fr)',
|
||||
},
|
||||
15: {
|
||||
totalCards: 30,
|
||||
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(3, 1fr)',
|
||||
},
|
||||
}
|
||||
|
||||
return configs[difficulty]
|
||||
}
|
||||
|
||||
// Generate a unique ID for cards
|
||||
export function generateCardId(type: string, identifier: string | number): string {
|
||||
return `${type}_${identifier}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import type { GameStatistics, MemoryPairsState, Player } from '../context/types'
|
||||
|
||||
// Calculate final game score based on multiple factors
|
||||
export function calculateFinalScore(
|
||||
matchedPairs: number,
|
||||
totalPairs: number,
|
||||
moves: number,
|
||||
gameTime: number,
|
||||
difficulty: number,
|
||||
gameMode: 'single' | 'two-player'
|
||||
): number {
|
||||
// Base score for completing pairs
|
||||
const baseScore = matchedPairs * 100
|
||||
|
||||
// Efficiency bonus (fewer moves = higher bonus)
|
||||
const idealMoves = totalPairs * 2 // Perfect game would be 2 moves per pair
|
||||
const efficiency = idealMoves / Math.max(moves, idealMoves)
|
||||
const efficiencyBonus = Math.round(baseScore * efficiency * 0.5)
|
||||
|
||||
// Time bonus (faster completion = higher bonus)
|
||||
const timeInMinutes = gameTime / (1000 * 60)
|
||||
const timeBonus = Math.max(0, Math.round((1000 * difficulty) / timeInMinutes))
|
||||
|
||||
// Difficulty multiplier
|
||||
const difficultyMultiplier = 1 + (difficulty - 6) * 0.1
|
||||
|
||||
// Two-player mode bonus
|
||||
const modeMultiplier = gameMode === 'two-player' ? 1.2 : 1.0
|
||||
|
||||
const finalScore = Math.round(
|
||||
(baseScore + efficiencyBonus + timeBonus) * difficultyMultiplier * modeMultiplier
|
||||
)
|
||||
|
||||
return Math.max(0, finalScore)
|
||||
}
|
||||
|
||||
// Calculate star rating (1-5 stars) based on performance
|
||||
export function calculateStarRating(
|
||||
accuracy: number,
|
||||
efficiency: number,
|
||||
gameTime: number,
|
||||
difficulty: number
|
||||
): number {
|
||||
// Normalize time score (assuming reasonable time ranges)
|
||||
const expectedTime = difficulty * 30000 // 30 seconds per pair as baseline
|
||||
const timeScore = Math.max(0, Math.min(100, (expectedTime / gameTime) * 100))
|
||||
|
||||
// Weighted average of different factors
|
||||
const overallScore = accuracy * 0.4 + efficiency * 0.4 + timeScore * 0.2
|
||||
|
||||
// Convert to stars
|
||||
if (overallScore >= 90) return 5
|
||||
if (overallScore >= 80) return 4
|
||||
if (overallScore >= 70) return 3
|
||||
if (overallScore >= 60) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
// Get achievement badges based on performance
|
||||
export interface Achievement {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
earned: boolean
|
||||
}
|
||||
|
||||
export function getAchievements(
|
||||
state: MemoryPairsState,
|
||||
gameMode: 'single' | 'multiplayer'
|
||||
): Achievement[] {
|
||||
const { matchedPairs, totalPairs, moves, scores, gameStartTime, gameEndTime } = state
|
||||
const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
|
||||
const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
|
||||
const gameTimeInSeconds = gameTime / 1000
|
||||
|
||||
const achievements: Achievement[] = [
|
||||
{
|
||||
id: 'perfect_game',
|
||||
name: 'Perfect Memory',
|
||||
description: 'Complete a game with 100% accuracy',
|
||||
icon: '🧠',
|
||||
earned: matchedPairs === totalPairs && moves === totalPairs * 2,
|
||||
},
|
||||
{
|
||||
id: 'speed_demon',
|
||||
name: 'Speed Demon',
|
||||
description: 'Complete a game in under 2 minutes',
|
||||
icon: '⚡',
|
||||
earned: gameTimeInSeconds > 0 && gameTimeInSeconds < 120 && matchedPairs === totalPairs,
|
||||
},
|
||||
{
|
||||
id: 'accuracy_ace',
|
||||
name: 'Accuracy Ace',
|
||||
description: 'Achieve 90% accuracy or higher',
|
||||
icon: '🎯',
|
||||
earned: accuracy >= 90 && matchedPairs === totalPairs,
|
||||
},
|
||||
{
|
||||
id: 'marathon_master',
|
||||
name: 'Marathon Master',
|
||||
description: 'Complete the hardest difficulty (15 pairs)',
|
||||
icon: '🏃',
|
||||
earned: totalPairs === 15 && matchedPairs === totalPairs,
|
||||
},
|
||||
{
|
||||
id: 'complement_champion',
|
||||
name: 'Complement Champion',
|
||||
description: 'Master complement pairs mode',
|
||||
icon: '🤝',
|
||||
earned:
|
||||
state.gameType === 'complement-pairs' && matchedPairs === totalPairs && accuracy >= 85,
|
||||
},
|
||||
{
|
||||
id: 'two_player_triumph',
|
||||
name: 'Two-Player Triumph',
|
||||
description: 'Win a two-player game',
|
||||
icon: '👥',
|
||||
earned:
|
||||
gameMode === 'multiplayer' &&
|
||||
matchedPairs === totalPairs &&
|
||||
Object.keys(scores).length > 1 &&
|
||||
Math.max(...Object.values(scores)) > 0,
|
||||
},
|
||||
{
|
||||
id: 'shutout_victory',
|
||||
name: 'Shutout Victory',
|
||||
description: 'Win a two-player game without opponent scoring',
|
||||
icon: '🛡️',
|
||||
earned:
|
||||
gameMode === 'multiplayer' &&
|
||||
matchedPairs === totalPairs &&
|
||||
Object.values(scores).some((score) => score === totalPairs) &&
|
||||
Object.values(scores).some((score) => score === 0),
|
||||
},
|
||||
{
|
||||
id: 'comeback_kid',
|
||||
name: 'Comeback Kid',
|
||||
description: 'Win after being behind by 3+ points',
|
||||
icon: '🔄',
|
||||
earned: false, // This would need more complex tracking during the game
|
||||
},
|
||||
{
|
||||
id: 'first_timer',
|
||||
name: 'First Timer',
|
||||
description: 'Complete your first game',
|
||||
icon: '🌟',
|
||||
earned: matchedPairs === totalPairs,
|
||||
},
|
||||
{
|
||||
id: 'consistency_king',
|
||||
name: 'Consistency King',
|
||||
description: 'Achieve 80%+ accuracy in 5 consecutive games',
|
||||
icon: '👑',
|
||||
earned: false, // This would need persistent game history
|
||||
},
|
||||
]
|
||||
|
||||
return achievements
|
||||
}
|
||||
|
||||
// Get performance metrics and analysis
|
||||
export function getPerformanceAnalysis(state: MemoryPairsState): {
|
||||
statistics: GameStatistics
|
||||
grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F'
|
||||
strengths: string[]
|
||||
improvements: string[]
|
||||
starRating: number
|
||||
} {
|
||||
const { matchedPairs, totalPairs, moves, difficulty, gameStartTime, gameEndTime } = state
|
||||
const gameTime = gameStartTime && gameEndTime ? gameEndTime - gameStartTime : 0
|
||||
|
||||
// Calculate statistics
|
||||
const accuracy = moves > 0 ? (matchedPairs / moves) * 100 : 0
|
||||
const averageTimePerMove = moves > 0 ? gameTime / moves : 0
|
||||
const statistics: GameStatistics = {
|
||||
totalMoves: moves,
|
||||
matchedPairs,
|
||||
totalPairs,
|
||||
gameTime,
|
||||
accuracy,
|
||||
averageTimePerMove,
|
||||
}
|
||||
|
||||
// Calculate efficiency (ideal vs actual moves)
|
||||
const idealMoves = totalPairs * 2
|
||||
const efficiency = (idealMoves / Math.max(moves, idealMoves)) * 100
|
||||
|
||||
// Determine grade
|
||||
let grade: 'A+' | 'A' | 'B+' | 'B' | 'C+' | 'C' | 'D' | 'F' = 'F'
|
||||
if (accuracy >= 95 && efficiency >= 90) grade = 'A+'
|
||||
else if (accuracy >= 90 && efficiency >= 85) grade = 'A'
|
||||
else if (accuracy >= 85 && efficiency >= 80) grade = 'B+'
|
||||
else if (accuracy >= 80 && efficiency >= 75) grade = 'B'
|
||||
else if (accuracy >= 75 && efficiency >= 70) grade = 'C+'
|
||||
else if (accuracy >= 70 && efficiency >= 65) grade = 'C'
|
||||
else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
|
||||
|
||||
// Calculate star rating
|
||||
const starRating = calculateStarRating(accuracy, efficiency, gameTime, difficulty)
|
||||
|
||||
// Analyze strengths and areas for improvement
|
||||
const strengths: string[] = []
|
||||
const improvements: string[] = []
|
||||
|
||||
if (accuracy >= 90) {
|
||||
strengths.push('Excellent memory and pattern recognition')
|
||||
} else if (accuracy < 70) {
|
||||
improvements.push('Focus on remembering card positions more carefully')
|
||||
}
|
||||
|
||||
if (efficiency >= 85) {
|
||||
strengths.push('Very efficient with minimal unnecessary moves')
|
||||
} else if (efficiency < 60) {
|
||||
improvements.push('Try to reduce random guessing and use memory strategies')
|
||||
}
|
||||
|
||||
const avgTimePerMoveSeconds = averageTimePerMove / 1000
|
||||
if (avgTimePerMoveSeconds < 3) {
|
||||
strengths.push('Quick decision making')
|
||||
} else if (avgTimePerMoveSeconds > 8) {
|
||||
improvements.push('Practice to improve decision speed')
|
||||
}
|
||||
|
||||
if (difficulty >= 12) {
|
||||
strengths.push('Tackled challenging difficulty levels')
|
||||
}
|
||||
|
||||
if (state.gameType === 'complement-pairs' && accuracy >= 80) {
|
||||
strengths.push('Strong mathematical complement skills')
|
||||
}
|
||||
|
||||
// Fallback messages
|
||||
if (strengths.length === 0) {
|
||||
strengths.push('Keep practicing to improve your skills!')
|
||||
}
|
||||
if (improvements.length === 0) {
|
||||
improvements.push('Great job! Continue challenging yourself with harder difficulties.')
|
||||
}
|
||||
|
||||
return {
|
||||
statistics,
|
||||
grade,
|
||||
strengths,
|
||||
improvements,
|
||||
starRating,
|
||||
}
|
||||
}
|
||||
|
||||
// Format time duration for display
|
||||
export function formatGameTime(milliseconds: number): string {
|
||||
const seconds = Math.floor(milliseconds / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
return `${remainingSeconds}s`
|
||||
}
|
||||
|
||||
// Get two-player game winner
|
||||
// @deprecated Use getMultiplayerWinner instead which supports N players
|
||||
export function getTwoPlayerWinner(
|
||||
state: MemoryPairsState,
|
||||
activePlayers: Player[]
|
||||
): {
|
||||
winner: Player | 'tie'
|
||||
winnerScore: number
|
||||
loserScore: number
|
||||
margin: number
|
||||
} {
|
||||
const { scores } = state
|
||||
const [player1, player2] = activePlayers
|
||||
|
||||
if (!player1 || !player2) {
|
||||
throw new Error('getTwoPlayerWinner requires at least 2 active players')
|
||||
}
|
||||
|
||||
const score1 = scores[player1] || 0
|
||||
const score2 = scores[player2] || 0
|
||||
|
||||
if (score1 > score2) {
|
||||
return {
|
||||
winner: player1,
|
||||
winnerScore: score1,
|
||||
loserScore: score2,
|
||||
margin: score1 - score2,
|
||||
}
|
||||
} else if (score2 > score1) {
|
||||
return {
|
||||
winner: player2,
|
||||
winnerScore: score2,
|
||||
loserScore: score1,
|
||||
margin: score2 - score1,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
winner: 'tie',
|
||||
winnerScore: score1,
|
||||
loserScore: score2,
|
||||
margin: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get multiplayer game winner (supports N players)
|
||||
export function getMultiplayerWinner(
|
||||
state: MemoryPairsState,
|
||||
activePlayers: Player[]
|
||||
): {
|
||||
winners: Player[]
|
||||
winnerScore: number
|
||||
scores: { [playerId: string]: number }
|
||||
isTie: boolean
|
||||
} {
|
||||
const { scores } = state
|
||||
|
||||
// Find the highest score
|
||||
const maxScore = Math.max(...activePlayers.map((playerId) => scores[playerId] || 0))
|
||||
|
||||
// Find all players with the highest score
|
||||
const winners = activePlayers.filter((playerId) => (scores[playerId] || 0) === maxScore)
|
||||
|
||||
return {
|
||||
winners,
|
||||
winnerScore: maxScore,
|
||||
scores,
|
||||
isTie: winners.length > 1,
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
@@ -11,8 +9,8 @@ import { css } from '../../../../styled-system/css'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
// Note: "battle-arena" removed - now handled by game registry as "matching"
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'battle-arena': 'matching',
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
@@ -333,14 +331,7 @@ export default function RoomPage() {
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, etc.)
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
|
||||
@@ -1,792 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import emojiData from 'emojibase-data/en/data.json'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis'
|
||||
|
||||
// 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<string, { keywords: string[]; group: number }>()
|
||||
;(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<number | null>(null)
|
||||
const [hoveredEmoji, setHoveredEmoji] = useState<string | null>(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<number, number> = {}
|
||||
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?.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?.startsWith(searchTerm))
|
||||
const bStartsWithTerm = bKeywords.some((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?.includes(searchTerm)).length
|
||||
const bScore = bKeywords.filter((k) => k?.includes(searchTerm)).length
|
||||
|
||||
return bScore - aScore
|
||||
})
|
||||
|
||||
return sortedResults
|
||||
}, [searchFilter, isSearching, selectedCategory, isCategoryFiltered])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
padding: '20px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '24px',
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '800px',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'gray.100',
|
||||
paddingBottom: '12px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
margin: 0,
|
||||
})}
|
||||
>
|
||||
Choose Character for Player {playerNumber}
|
||||
</h3>
|
||||
<button
|
||||
className={css({
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: 'gray.500',
|
||||
_hover: { color: 'gray.700' },
|
||||
padding: '4px',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Current Selection & Search */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '16px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background:
|
||||
playerNumber === 1
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: playerNumber === 2
|
||||
? 'linear-gradient(135deg, #fd79a8, #e84393)'
|
||||
: playerNumber === 3
|
||||
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '24px' })}>{currentEmoji}</div>
|
||||
<div className={css({ fontSize: '12px', fontWeight: 'bold' })}>Current</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search: face, smart, heart, animal, food..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => 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 && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.600',
|
||||
flexShrink: 0,
|
||||
padding: '4px 8px',
|
||||
background: displayEmojis.length > 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'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
{!isSearching && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
overflowX: 'auto',
|
||||
paddingBottom: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#cbd5e1',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border: selectedCategory === null ? '2px solid #3b82f6' : '2px solid #e5e7eb',
|
||||
background: selectedCategory === null ? '#eff6ff' : 'white',
|
||||
color: selectedCategory === null ? '#1e40af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
background: selectedCategory === null ? '#dbeafe' : '#f9fafb',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✨ All
|
||||
</button>
|
||||
{availableCategories.map((groupId) => {
|
||||
const group = EMOJI_GROUPS[groupId as keyof typeof EMOJI_GROUPS]
|
||||
return (
|
||||
<button
|
||||
key={groupId}
|
||||
onClick={() => setSelectedCategory(Number(groupId))}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
border:
|
||||
selectedCategory === Number(groupId)
|
||||
? '2px solid #3b82f6'
|
||||
: '2px solid #e5e7eb',
|
||||
background: selectedCategory === Number(groupId) ? '#eff6ff' : 'white',
|
||||
color: selectedCategory === Number(groupId) ? '#1e40af' : '#6b7280',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'all 0.2s ease',
|
||||
_hover: {
|
||||
background: selectedCategory === Number(groupId) ? '#dbeafe' : '#f9fafb',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{group.icon} {group.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Mode Header */}
|
||||
{isSearching && displayEmojis.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'blue.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'blue.200',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'blue.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
🔍 Search Results for "{searchFilter}"
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: 'blue.600',
|
||||
})}
|
||||
>
|
||||
Showing {displayEmojis.length} of {PLAYER_EMOJIS.length} emojis • Clear search to see
|
||||
all
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default Mode Header */}
|
||||
{!isSearching && (
|
||||
<div
|
||||
className={css({
|
||||
padding: '8px 12px',
|
||||
background: 'gray.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
marginBottom: '4px',
|
||||
})}
|
||||
>
|
||||
{selectedCategory !== null
|
||||
? `${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].icon} ${EMOJI_GROUPS[selectedCategory as keyof typeof EMOJI_GROUPS].name}`
|
||||
: '📝 All Available Characters'}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{displayEmojis.length} emojis{' '}
|
||||
{selectedCategory !== null ? 'in category' : 'available'} • Use search to find
|
||||
specific emojis
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Emoji Grid - Only show when there are emojis to display */}
|
||||
{displayEmojis.length > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
minHeight: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f5f9',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#cbd5e1',
|
||||
borderRadius: '5px',
|
||||
'&:hover': {
|
||||
background: '#94a3b8',
|
||||
},
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(16, 1fr)',
|
||||
gap: '4px',
|
||||
padding: '4px',
|
||||
'@media (max-width: 1200px)': {
|
||||
gridTemplateColumns: 'repeat(14, 1fr)',
|
||||
},
|
||||
'@media (max-width: 1000px)': {
|
||||
gridTemplateColumns: 'repeat(12, 1fr)',
|
||||
},
|
||||
'@media (max-width: 800px)': {
|
||||
gridTemplateColumns: 'repeat(10, 1fr)',
|
||||
},
|
||||
'@media (max-width: 600px)': {
|
||||
gridTemplateColumns: 'repeat(8, 1fr)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{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 (
|
||||
<button
|
||||
key={emoji}
|
||||
className={css({
|
||||
aspectRatio: '1',
|
||||
background: getSelectedBg(),
|
||||
border: '2px solid',
|
||||
borderColor: getSelectedBorder(),
|
||||
borderRadius: '6px',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.1s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
background: getHoverBg(),
|
||||
transform: 'scale(1.15)',
|
||||
zIndex: 1,
|
||||
fontSize: '24px',
|
||||
},
|
||||
})}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setHoveredEmoji(emoji)
|
||||
setHoverPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => setHoveredEmoji(null)}
|
||||
onClick={() => {
|
||||
onEmojiSelect(emoji)
|
||||
}}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{isSearching && displayEmojis.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
textAlign: 'center',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '48px', marginBottom: '16px' })}>🔍</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
No emojis found for "{searchFilter}"
|
||||
</div>
|
||||
<div className={css({ fontSize: '14px', marginBottom: '12px' })}>
|
||||
Try searching for "face", "smart", "heart", "animal", "food", etc.
|
||||
</div>
|
||||
<button
|
||||
className={css({
|
||||
background: 'blue.500',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
_hover: { background: 'blue.600' },
|
||||
})}
|
||||
onClick={() => setSearchFilter('')}
|
||||
>
|
||||
Clear search to see all {PLAYER_EMOJIS.length} emojis
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick selection hint */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '8px',
|
||||
padding: '6px 12px',
|
||||
background: 'gray.50',
|
||||
borderRadius: '8px',
|
||||
fontSize: '11px',
|
||||
color: 'gray.600',
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
💡 Powered by emojibase-data • Try: "face", "smart", "heart", "animal", "food" • Click to
|
||||
select
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magnifying Glass Preview - SUPER POWERED! */}
|
||||
{hoveredEmoji && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${hoverPosition.x}px`,
|
||||
top: `${hoverPosition.y - 120}px`,
|
||||
transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10000,
|
||||
animation: 'magnifyIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
{/* Outer glow ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: '-20px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
|
||||
animation: 'pulseGlow 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main preview card */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)',
|
||||
borderRadius: '24px',
|
||||
padding: '20px',
|
||||
boxShadow:
|
||||
'0 20px 60px rgba(0, 0, 0, 0.4), 0 0 0 4px rgba(59, 130, 246, 0.6), inset 0 2px 4px rgba(255,255,255,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '120px',
|
||||
lineHeight: 1,
|
||||
minWidth: '160px',
|
||||
minHeight: '160px',
|
||||
position: 'relative',
|
||||
animation: 'emojiFloat 3s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{/* Sparkle effects */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
fontSize: '20px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0s',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '15px',
|
||||
left: '15px',
|
||||
fontSize: '16px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '0.5s',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '20px',
|
||||
left: '20px',
|
||||
fontSize: '12px',
|
||||
animation: 'sparkle 1.5s ease-in-out infinite',
|
||||
animationDelay: '1s',
|
||||
}}
|
||||
>
|
||||
✨
|
||||
</div>
|
||||
|
||||
{hoveredEmoji}
|
||||
</div>
|
||||
|
||||
{/* Arrow pointing down with glow */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '14px solid transparent',
|
||||
borderRight: '14px solid transparent',
|
||||
borderTop: '14px solid white',
|
||||
filter: 'drop-shadow(0 4px 8px rgba(0,0,0,0.3))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add magnifying animations */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes magnifyIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) scale(0.5);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
@keyframes emojiFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
@keyframes sparkle {
|
||||
0%, 100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.5) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(180deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add fade in animation
|
||||
const fadeInAnimation = `
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.9); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('emoji-picker-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'emoji-picker-animations'
|
||||
style.textContent = fadeInAnimation
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import type { GameCardProps } from '../context/types'
|
||||
|
||||
export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) {
|
||||
const appConfig = useAbacusConfig()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active players array for mapping numeric IDs to actual players
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
.map((id) => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
|
||||
const cardBackStyles = css({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
})
|
||||
|
||||
const cardFrontStyles = css({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backfaceVisibility: 'hidden',
|
||||
borderRadius: '12px',
|
||||
background: 'white',
|
||||
border: '3px solid',
|
||||
transform: 'rotateY(180deg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '8px',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
})
|
||||
|
||||
// Dynamic styling based on card type and state
|
||||
const getCardBackGradient = () => {
|
||||
if (isMatched) {
|
||||
// Player-specific colors for matched cards - use array index lookup
|
||||
const playerIndex = card.matchedBy
|
||||
? activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
: -1
|
||||
if (playerIndex === 0) {
|
||||
return 'linear-gradient(135deg, #74b9ff, #0984e3)' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return 'linear-gradient(135deg, #fd79a8, #e84393)' // Pink for second player
|
||||
}
|
||||
return 'linear-gradient(135deg, #48bb78, #38a169)' // Default green for single player
|
||||
}
|
||||
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return 'linear-gradient(135deg, #7b4397, #dc2430)'
|
||||
case 'number':
|
||||
return 'linear-gradient(135deg, #2E86AB, #A23B72)'
|
||||
case 'complement':
|
||||
return 'linear-gradient(135deg, #F18F01, #6A994E)'
|
||||
default:
|
||||
return 'linear-gradient(135deg, #667eea, #764ba2)'
|
||||
}
|
||||
}
|
||||
|
||||
const getCardBackIcon = () => {
|
||||
if (isMatched) {
|
||||
// Show player emoji for matched cards in multiplayer mode
|
||||
if (card.matchedBy) {
|
||||
const player = activePlayers.find((p) => p.id === card.matchedBy)
|
||||
return player?.emoji || '✓'
|
||||
}
|
||||
return '✓' // Default checkmark for single player
|
||||
}
|
||||
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return '🧮'
|
||||
case 'number':
|
||||
return '🔢'
|
||||
case 'complement':
|
||||
return '🤝'
|
||||
default:
|
||||
return '❓'
|
||||
}
|
||||
}
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (isMatched) {
|
||||
// Player-specific border colors for matched cards - use array index lookup
|
||||
const playerIndex = card.matchedBy
|
||||
? activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
: -1
|
||||
if (playerIndex === 0) {
|
||||
return '#74b9ff' // Blue for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return '#fd79a8' // Pink for second player
|
||||
}
|
||||
return '#48bb78' // Default green for single player
|
||||
}
|
||||
if (isFlipped) return '#667eea'
|
||||
return '#e2e8f0'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
perspective: '1000px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
cursor: disabled || isMatched ? 'default' : 'pointer',
|
||||
transition: 'transform 0.2s ease',
|
||||
_hover:
|
||||
disabled || isMatched
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
onClick={disabled || isMatched ? undefined : onClick}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.6s cubic-bezier(0.4, 0.0, 0.2, 1)',
|
||||
transformStyle: 'preserve-3d',
|
||||
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||
})}
|
||||
>
|
||||
{/* Card Back (hidden/face-down state) */}
|
||||
<div
|
||||
className={cardBackStyles}
|
||||
style={{
|
||||
background: getCardBackGradient(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '32px' })}>{getCardBackIcon()}</div>
|
||||
{isMatched && (
|
||||
<div className={css({ fontSize: '14px', opacity: 0.9 })}>
|
||||
{card.matchedBy ? 'Claimed!' : 'Matched!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Front (revealed/face-up state) */}
|
||||
<div
|
||||
className={cardFrontStyles}
|
||||
style={{
|
||||
borderColor: getBorderColor(),
|
||||
boxShadow: isMatched
|
||||
? (() => {
|
||||
const playerIndex = card.matchedBy
|
||||
? activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
: -1
|
||||
if (playerIndex === 0) {
|
||||
return '0 0 20px rgba(116, 185, 255, 0.4)' // Blue glow for first player
|
||||
} else if (playerIndex === 1) {
|
||||
return '0 0 20px rgba(253, 121, 168, 0.4)' // Pink glow for second player
|
||||
}
|
||||
return '0 0 20px rgba(72, 187, 120, 0.4)' // Default green glow
|
||||
})()
|
||||
: isFlipped
|
||||
? '0 0 15px rgba(102, 126, 234, 0.3)'
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Player Badge for matched cards */}
|
||||
{isMatched && card.matchedBy && (
|
||||
<>
|
||||
{/* Explosion Ring */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
border: '3px solid',
|
||||
borderColor: (() => {
|
||||
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
return playerIndex === 0 ? '#74b9ff' : '#fd79a8'
|
||||
})(),
|
||||
animation: 'explosionRing 0.6s ease-out',
|
||||
zIndex: 9,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Main Badge */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '6px',
|
||||
right: '6px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: (() => {
|
||||
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
return playerIndex === 0
|
||||
? 'linear-gradient(135deg, #74b9ff, #0984e3)'
|
||||
: 'linear-gradient(135deg, #fd79a8, #e84393)'
|
||||
})(),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
boxShadow: (() => {
|
||||
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
return playerIndex === 0
|
||||
? '0 0 20px rgba(116, 185, 255, 0.6), 0 0 40px rgba(116, 185, 255, 0.4)'
|
||||
: '0 0 20px rgba(253, 121, 168, 0.6), 0 0 40px rgba(253, 121, 168, 0.4)'
|
||||
})(),
|
||||
animation: 'epicClaim 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
zIndex: 10,
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
left: '-2px',
|
||||
right: '-2px',
|
||||
bottom: '-2px',
|
||||
borderRadius: '50%',
|
||||
background: (() => {
|
||||
const playerIndex = activePlayers.findIndex((p) => p.id === card.matchedBy)
|
||||
return playerIndex === 0
|
||||
? 'linear-gradient(45deg, #74b9ff, #a29bfe, #6c5ce7, #74b9ff)'
|
||||
: 'linear-gradient(45deg, #fd79a8, #fdcb6e, #e17055, #fd79a8)'
|
||||
})(),
|
||||
animation: 'spinningHalo 2s linear infinite',
|
||||
zIndex: -1,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
animation: 'emojiBlast 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55) 0.4s both',
|
||||
filter: 'drop-shadow(0 0 8px rgba(255,255,255,0.8))',
|
||||
})}
|
||||
>
|
||||
{activePlayers.find((p) => p.id === card.matchedBy)?.emoji || '✓'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Sparkle Effects */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '22px',
|
||||
right: '22px',
|
||||
width: '4px',
|
||||
height: '4px',
|
||||
background: '#ffeaa7',
|
||||
borderRadius: '50%',
|
||||
animation: `sparkle${i + 1} 1.5s ease-out`,
|
||||
zIndex: 8,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{card.type === 'abacus' ? (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& svg': {
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={card.number}
|
||||
columns="auto"
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
scaleFactor={0.8} // Smaller for card display
|
||||
interactive={false}
|
||||
showNumbers={false}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
) : card.type === 'number' ? (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '32px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
) : card.type === 'complement' ? (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '16px',
|
||||
color: 'gray.600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<span>{card.targetSum === 5 ? '✋' : '🔟'}</span>
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
{card.complement !== undefined && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '12px',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
+ {card.complement} = {card.targetSum}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match animation overlay */}
|
||||
{isMatched && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-5px',
|
||||
left: '-5px',
|
||||
right: '-5px',
|
||||
bottom: '-5px',
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(45deg, transparent, rgba(72, 187, 120, 0.3), transparent)',
|
||||
animation: 'pulse 2s infinite',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add global animation styles
|
||||
const globalCardAnimations = `
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes explosionRing {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epicClaim {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0) rotate(-360deg);
|
||||
}
|
||||
30% {
|
||||
opacity: 1;
|
||||
transform: scale(1.4) rotate(-180deg);
|
||||
}
|
||||
60% {
|
||||
transform: scale(0.8) rotate(-90deg);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.1) rotate(-30deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emojiBlast {
|
||||
0% {
|
||||
transform: scale(0) rotate(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.5) rotate(-10deg);
|
||||
opacity: 1;
|
||||
}
|
||||
85% {
|
||||
transform: scale(0.9) rotate(5deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinningHalo {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkle1 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(-20px, -15px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle2 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(15px, -20px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle3 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(-25px, 10px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle4 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(20px, 15px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle5 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(-10px, -25px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sparkle6 {
|
||||
0% { transform: translate(0, 0) scale(0); opacity: 1; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: translate(25px, -5px) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.3);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
70% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cardFlip {
|
||||
0% { transform: rotateY(0deg); }
|
||||
100% { transform: rotateY(180deg); }
|
||||
}
|
||||
|
||||
@keyframes matchSuccess {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes invalidMove {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-3px); }
|
||||
75% { transform: translateX(3px); }
|
||||
}
|
||||
`
|
||||
|
||||
// Inject global styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('memory-card-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'memory-card-animations'
|
||||
style.textContent = globalCardAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { MemoryGrid } from '@/components/matching/MemoryGrid'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
export function GamePhase() {
|
||||
const { state, flipCard } = useMemoryPairs()
|
||||
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Game header removed - game type and player info now shown in nav bar */}
|
||||
|
||||
{/* Memory Grid - The main game area */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<MemoryGrid
|
||||
state={state}
|
||||
gridConfig={gridConfig}
|
||||
flipCard={flipCard}
|
||||
enableMultiplayerPresence={false}
|
||||
renderCard={({ card, isFlipped, isMatched, onClick, disabled }) => (
|
||||
<GameCard
|
||||
card={card}
|
||||
isFlipped={isFlipped}
|
||||
isMatched={isMatched}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Tip - Only show when game is starting and on larger screens */}
|
||||
{state.moves === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(248, 250, 252, 0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.6)',
|
||||
display: { base: 'none', lg: 'block' },
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '13px',
|
||||
color: 'gray.600',
|
||||
margin: 0,
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
💡{' '}
|
||||
{state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus beads with numbers'
|
||||
: 'Find pairs that add to 5 or 10'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
const { state } = useMemoryPairs()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Register this component's main div as the fullscreen element
|
||||
if (gameRef.current) {
|
||||
console.log('🎯 MemoryPairsGame: Registering fullscreen element:', gameRef.current)
|
||||
setFullscreenElement(gameRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
// Determine nav title and emoji based on game type
|
||||
const navTitle = state.gameType === 'abacus-numeral' ? 'Abacus Match' : 'Complement Pairs'
|
||||
const navEmoji = state.gameType === 'abacus-numeral' ? '🧮' : '🤝'
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
gameName="matching"
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
playerStreaks={state.consecutiveMatches}
|
||||
>
|
||||
<StandardGameLayout>
|
||||
<div
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
|
||||
|
||||
<main
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <GamePhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</main>
|
||||
</div>
|
||||
</StandardGameLayout>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import type React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
|
||||
// Inject the celebration animations for Storybook
|
||||
const celebrationAnimations = `
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-sway {
|
||||
0%, 100% { transform: rotate(-2deg) scale(1); }
|
||||
50% { transform: rotate(2deg) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes turn-entrance {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-10deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes great-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12) translateY(-6px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epic-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legendary-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.2) translateY(-12px) rotate(5deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18) translateY(-10px) rotate(-3deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.22) translateY(-14px) rotate(3deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-1deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Component to inject animations
|
||||
const AnimationProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined' && !document.getElementById('celebration-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'celebration-animations'
|
||||
style.textContent = celebrationAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Games/Matching/PlayerStatusBar',
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The PlayerStatusBar component displays the current state of players in the matching game.
|
||||
It shows different layouts for single player vs multiplayer modes and includes escalating
|
||||
celebration effects for consecutive matching pairs.
|
||||
|
||||
## Features
|
||||
- Single player mode with epic styling
|
||||
- Multiplayer mode with competitive grid layout
|
||||
- Escalating celebration animations based on consecutive matches:
|
||||
- 2+ matches: Great celebration (green)
|
||||
- 3+ matches: Epic celebration (orange)
|
||||
- 5+ matches: Legendary celebration (purple with gold accents)
|
||||
- Real-time turn indicators
|
||||
- Score tracking and progress display
|
||||
- Responsive design for mobile and desktop
|
||||
|
||||
## Animation Preview
|
||||
The animations demonstrate different celebration levels that activate when players get consecutive matches.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AnimationProvider>
|
||||
<div
|
||||
className={css({
|
||||
width: '800px',
|
||||
maxWidth: '90vw',
|
||||
padding: '20px',
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
minHeight: '400px',
|
||||
})}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</AnimationProvider>
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Create a mock player card component that showcases the animations
|
||||
const MockPlayerCard = ({
|
||||
emoji,
|
||||
name,
|
||||
score,
|
||||
consecutiveMatches,
|
||||
isCurrentPlayer = true,
|
||||
celebrationLevel,
|
||||
}: {
|
||||
emoji: string
|
||||
name: string
|
||||
score: number
|
||||
consecutiveMatches: number
|
||||
isCurrentPlayer?: boolean
|
||||
celebrationLevel: 'normal' | 'great' | 'epic' | 'legendary'
|
||||
}) => {
|
||||
const playerColor =
|
||||
celebrationLevel === 'legendary'
|
||||
? '#a855f7'
|
||||
: celebrationLevel === 'epic'
|
||||
? '#f97316'
|
||||
: celebrationLevel === 'great'
|
||||
? '#22c55e'
|
||||
: '#3b82f6'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '3', md: '4' },
|
||||
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
|
||||
rounded: isCurrentPlayer ? '2xl' : 'lg',
|
||||
background: isCurrentPlayer
|
||||
? `linear-gradient(135deg, ${playerColor}15, ${playerColor}25, ${playerColor}15)`
|
||||
: 'white',
|
||||
border: isCurrentPlayer ? '4px solid' : '2px solid',
|
||||
borderColor: isCurrentPlayer ? playerColor : 'gray.200',
|
||||
boxShadow: isCurrentPlayer
|
||||
? `0 0 0 2px white, 0 0 0 6px ${playerColor}40, 0 12px 32px rgba(0,0,0,0.2)`
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
|
||||
zIndex: isCurrentPlayer ? 10 : 1,
|
||||
animation: isCurrentPlayer
|
||||
? celebrationLevel === 'legendary'
|
||||
? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'epic'
|
||||
? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'great'
|
||||
? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
|
||||
: 'turn-entrance 0.6s ease-out'
|
||||
: 'none',
|
||||
})}
|
||||
>
|
||||
{/* Player emoji */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
|
||||
flexShrink: 0,
|
||||
animation: isCurrentPlayer
|
||||
? 'float 3s ease-in-out infinite'
|
||||
: 'breathe 5s ease-in-out infinite',
|
||||
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</div>
|
||||
|
||||
{/* Player info */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
|
||||
fontWeight: 'black',
|
||||
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'md' } : { base: '2xs', md: 'xs' },
|
||||
color: isCurrentPlayer ? playerColor : 'gray.500',
|
||||
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
|
||||
})}
|
||||
>
|
||||
{gamePlurals.pair(score)}
|
||||
{isCurrentPlayer && (
|
||||
<span
|
||||
className={css({
|
||||
color: 'red.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
textShadow: '0 0 15px currentColor',
|
||||
})}
|
||||
>
|
||||
{' • Your turn'}
|
||||
</span>
|
||||
)}
|
||||
{consecutiveMatches > 1 && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
color:
|
||||
celebrationLevel === 'legendary'
|
||||
? 'purple.600'
|
||||
: celebrationLevel === 'epic'
|
||||
? 'orange.600'
|
||||
: celebrationLevel === 'great'
|
||||
? 'green.600'
|
||||
: 'gray.500',
|
||||
fontWeight: 'black',
|
||||
animation: isCurrentPlayer ? 'streak-pulse 1s ease-in-out infinite' : 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
🔥 {consecutiveMatches} streak!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Epic score display */}
|
||||
{isCurrentPlayer && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
|
||||
color: 'white',
|
||||
px: { base: '3', md: '4' },
|
||||
py: { base: '2', md: '3' },
|
||||
rounded: 'xl',
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
fontWeight: 'black',
|
||||
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
|
||||
animation: 'gentle-bounce 1.5s ease-in-out infinite',
|
||||
textShadow: '0 0 10px rgba(255,255,255,0.8)',
|
||||
})}
|
||||
>
|
||||
⚡{score}⚡
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Normal celebration level
|
||||
export const NormalPlayer: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🚀"
|
||||
name="Solo Champion"
|
||||
score={3}
|
||||
consecutiveMatches={0}
|
||||
celebrationLevel="normal"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// Great celebration level
|
||||
export const GreatStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🎯"
|
||||
name="Streak Master"
|
||||
score={5}
|
||||
consecutiveMatches={2}
|
||||
celebrationLevel="great"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// Epic celebration level
|
||||
export const EpicStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="🔥"
|
||||
name="Epic Matcher"
|
||||
score={7}
|
||||
consecutiveMatches={4}
|
||||
celebrationLevel="epic"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// Legendary celebration level
|
||||
export const LegendaryStreak: Story = {
|
||||
render: () => (
|
||||
<MockPlayerCard
|
||||
emoji="👑"
|
||||
name="Legend"
|
||||
score={8}
|
||||
consecutiveMatches={6}
|
||||
celebrationLevel="legendary"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// All levels showcase
|
||||
export const AllCelebrationLevels: Story = {
|
||||
render: () => (
|
||||
<div className={css({ display: 'flex', flexDirection: 'column', gap: '20px' })}>
|
||||
<h3
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
Consecutive Match Celebration Levels
|
||||
</h3>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))',
|
||||
gap: '20px',
|
||||
})}
|
||||
>
|
||||
{/* Normal */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '10px',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
Normal (0-1 matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🚀"
|
||||
name="Solo Champion"
|
||||
score={3}
|
||||
consecutiveMatches={0}
|
||||
celebrationLevel="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Great */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '10px',
|
||||
color: 'green.600',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
Great (2+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🎯"
|
||||
name="Streak Master"
|
||||
score={5}
|
||||
consecutiveMatches={2}
|
||||
celebrationLevel="great"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Epic */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '10px',
|
||||
color: 'orange.600',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
Epic (3+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="🔥"
|
||||
name="Epic Matcher"
|
||||
score={7}
|
||||
consecutiveMatches={4}
|
||||
celebrationLevel="epic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legendary */}
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: '10px',
|
||||
color: 'purple.600',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
Legendary (5+ matches)
|
||||
</h4>
|
||||
<MockPlayerCard
|
||||
emoji="👑"
|
||||
name="Legend"
|
||||
score={8}
|
||||
consecutiveMatches={6}
|
||||
celebrationLevel="legendary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '20px',
|
||||
padding: '16px',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: '14px', color: 'gray.700', margin: 0 })}>
|
||||
These animations trigger when a player gets consecutive matching pairs in the memory
|
||||
matching game. The celebrations get more intense as the streak grows, providing visual
|
||||
feedback and excitement!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
|
||||
interface PlayerStatusBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
const { state } = useMemoryPairs()
|
||||
|
||||
// Get active players array
|
||||
const activePlayersData = Array.from(activePlayerIds)
|
||||
.map((id) => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
|
||||
// Map active players to display data with scores
|
||||
// State uses UUID player IDs, so we map by player.id
|
||||
const activePlayers = activePlayersData.map((player) => ({
|
||||
...player,
|
||||
displayName: player.name,
|
||||
displayEmoji: player.emoji,
|
||||
score: state.scores[player.id] || 0,
|
||||
consecutiveMatches: state.consecutiveMatches?.[player.id] || 0,
|
||||
}))
|
||||
|
||||
// Get celebration level based on consecutive matches
|
||||
const getCelebrationLevel = (consecutiveMatches: number) => {
|
||||
if (consecutiveMatches >= 5) return 'legendary'
|
||||
if (consecutiveMatches >= 3) return 'epic'
|
||||
if (consecutiveMatches >= 2) return 'great'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
if (activePlayers.length <= 1) {
|
||||
// Simple single player indicator
|
||||
return (
|
||||
<div
|
||||
className={`${css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: 'white',
|
||||
rounded: 'lg',
|
||||
p: { base: '2', md: '3' },
|
||||
border: '2px solid',
|
||||
borderColor: 'blue.200',
|
||||
mb: { base: '2', md: '3' },
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
})} ${className || ''}`}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '2', md: '3' },
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
})}
|
||||
>
|
||||
{activePlayers[0]?.displayEmoji || '🚀'}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
{activePlayers[0]?.displayName || 'Player 1'}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: 'xs', md: 'sm' },
|
||||
color: 'blue.600',
|
||||
fontWeight: 'medium',
|
||||
})}
|
||||
>
|
||||
{gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} •{' '}
|
||||
{gamePlurals.move(state.moves)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// For multiplayer, show competitive status bar
|
||||
return (
|
||||
<div
|
||||
className={`${css({
|
||||
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
rounded: 'xl',
|
||||
p: { base: '2', md: '3' },
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.200',
|
||||
mb: { base: '3', md: '4' },
|
||||
})} ${className || ''}`}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns:
|
||||
activePlayers.length <= 2
|
||||
? 'repeat(2, 1fr)'
|
||||
: activePlayers.length === 3
|
||||
? 'repeat(3, 1fr)'
|
||||
: 'repeat(2, 1fr) repeat(2, 1fr)',
|
||||
gap: { base: '2', md: '3' },
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{activePlayers.map((player, _index) => {
|
||||
const isCurrentPlayer = player.id === state.currentPlayer
|
||||
const isLeading =
|
||||
player.score === Math.max(...activePlayers.map((p) => p.score)) && player.score > 0
|
||||
const celebrationLevel = getCelebrationLevel(player.consecutiveMatches)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '2', md: '3' },
|
||||
p: isCurrentPlayer ? { base: '3', md: '4' } : { base: '2', md: '2' },
|
||||
rounded: isCurrentPlayer ? '2xl' : 'lg',
|
||||
background: isCurrentPlayer
|
||||
? `linear-gradient(135deg, ${player.color || '#3b82f6'}15, ${player.color || '#3b82f6'}25, ${player.color || '#3b82f6'}15)`
|
||||
: 'white',
|
||||
border: isCurrentPlayer ? '4px solid' : '2px solid',
|
||||
borderColor: isCurrentPlayer ? player.color || '#3b82f6' : 'gray.200',
|
||||
boxShadow: isCurrentPlayer
|
||||
? '0 0 0 2px white, 0 0 0 6px ' +
|
||||
(player.color || '#3b82f6') +
|
||||
'40, 0 12px 32px rgba(0,0,0,0.2)'
|
||||
: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
position: 'relative',
|
||||
transform: isCurrentPlayer ? 'scale(1.08) translateY(-4px)' : 'scale(1)',
|
||||
zIndex: isCurrentPlayer ? 10 : 1,
|
||||
animation: isCurrentPlayer
|
||||
? celebrationLevel === 'legendary'
|
||||
? 'legendary-celebration 0.8s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'epic'
|
||||
? 'epic-celebration 0.7s ease-out, turn-entrance 0.6s ease-out'
|
||||
: celebrationLevel === 'great'
|
||||
? 'great-celebration 0.6s ease-out, turn-entrance 0.6s ease-out'
|
||||
: 'turn-entrance 0.6s ease-out'
|
||||
: 'none',
|
||||
})}
|
||||
>
|
||||
{/* Leading crown with sparkle */}
|
||||
{isLeading && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: isCurrentPlayer ? '-3' : '-1',
|
||||
right: isCurrentPlayer ? '-3' : '-1',
|
||||
background: 'linear-gradient(135deg, #ffd700, #ffaa00)',
|
||||
rounded: 'full',
|
||||
w: isCurrentPlayer ? '10' : '6',
|
||||
h: isCurrentPlayer ? '10' : '6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: isCurrentPlayer ? 'lg' : 'xs',
|
||||
zIndex: 10,
|
||||
animation: 'none',
|
||||
boxShadow: '0 0 20px rgba(255, 215, 0, 0.6)',
|
||||
})}
|
||||
>
|
||||
👑
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle turn indicator */}
|
||||
{isCurrentPlayer && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '-2',
|
||||
left: '-2',
|
||||
background: player.color || '#3b82f6',
|
||||
rounded: 'full',
|
||||
w: '4',
|
||||
h: '4',
|
||||
animation: 'gentle-sway 2s ease-in-out infinite',
|
||||
zIndex: 5,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Living, breathing player emoji */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: isCurrentPlayer ? { base: '2xl', md: '3xl' } : { base: 'lg', md: 'xl' },
|
||||
flexShrink: 0,
|
||||
animation: isCurrentPlayer
|
||||
? 'float 3s ease-in-out infinite'
|
||||
: 'breathe 5s ease-in-out infinite',
|
||||
transform: isCurrentPlayer ? 'scale(1.2)' : 'scale(1)',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
textShadow: isCurrentPlayer ? '0 0 20px currentColor' : 'none',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
transform: isCurrentPlayer ? 'scale(1.3)' : 'scale(1.1)',
|
||||
animation: 'gentle-sway 1s ease-in-out infinite',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{player.displayEmoji}
|
||||
</div>
|
||||
|
||||
{/* Enhanced player info */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: isCurrentPlayer ? { base: 'md', md: 'lg' } : { base: 'xs', md: 'sm' },
|
||||
fontWeight: 'black',
|
||||
color: isCurrentPlayer ? 'gray.900' : 'gray.700',
|
||||
animation: 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
{player.displayName}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: isCurrentPlayer
|
||||
? { base: 'sm', md: 'md' }
|
||||
: { base: '2xs', md: 'xs' },
|
||||
color: isCurrentPlayer ? player.color || '#3b82f6' : 'gray.500',
|
||||
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
|
||||
animation: 'none',
|
||||
})}
|
||||
>
|
||||
{gamePlurals.pair(player.score)}
|
||||
{isCurrentPlayer && (
|
||||
<span
|
||||
className={css({
|
||||
color: 'red.600',
|
||||
fontWeight: 'black',
|
||||
fontSize: isCurrentPlayer ? { base: 'sm', md: 'lg' } : 'inherit',
|
||||
animation: 'none',
|
||||
textShadow: '0 0 15px currentColor',
|
||||
})}
|
||||
>
|
||||
{' • Your turn'}
|
||||
</span>
|
||||
)}
|
||||
{player.consecutiveMatches > 1 && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '2xs', md: 'xs' },
|
||||
color:
|
||||
celebrationLevel === 'legendary'
|
||||
? 'purple.600'
|
||||
: celebrationLevel === 'epic'
|
||||
? 'orange.600'
|
||||
: celebrationLevel === 'great'
|
||||
? 'green.600'
|
||||
: 'gray.500',
|
||||
fontWeight: 'black',
|
||||
animation: isCurrentPlayer
|
||||
? 'streak-pulse 1s ease-in-out infinite'
|
||||
: 'none',
|
||||
textShadow: isCurrentPlayer ? '0 0 10px currentColor' : 'none',
|
||||
})}
|
||||
>
|
||||
🔥 {player.consecutiveMatches} streak!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple score display for current player */}
|
||||
{isCurrentPlayer && (
|
||||
<div
|
||||
className={css({
|
||||
background: 'blue.500',
|
||||
color: 'white',
|
||||
px: { base: '2', md: '3' },
|
||||
py: { base: '1', md: '2' },
|
||||
rounded: 'md',
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{player.score}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Epic animations for extreme emphasis
|
||||
const epicAnimations = `
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.3), 0 12px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.5), 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gentle-sway {
|
||||
0%, 100% { transform: rotate(-2deg) scale(1); }
|
||||
50% { transform: rotate(2deg) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
@keyframes turn-entrance {
|
||||
0% {
|
||||
transform: scale(0.8) rotate(-10deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) rotate(0deg);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes turn-exit {
|
||||
0% {
|
||||
transform: scale(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spotlight {
|
||||
0%, 100% {
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.3) 50%, transparent 70%);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
50% {
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.6) 50%, transparent 70%);
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes neon-flicker {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor, 0 0 15px currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 2px currentColor, 0 0 5px currentColor, 0 0 8px currentColor;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes crown-sparkle {
|
||||
0%, 100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
filter: brightness(1);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-5deg) scale(1.1);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(5deg) scale(1.1);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes streak-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes great-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.12) translateY(-6px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 8px #22c55e60, 0 15px 35px rgba(34,197,94,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #22c55e40, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes epic-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-2deg);
|
||||
box-shadow: 0 0 0 3px white, 0 0 0 10px #f97316, 0 18px 40px rgba(249,115,22,0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #f97316, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes legendary-celebration {
|
||||
0% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
20% {
|
||||
transform: scale(1.2) translateY(-12px) rotate(5deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.18) translateY(-10px) rotate(-3deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 10px #a855f7, 0 20px 45px rgba(168,85,247,0.4);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.22) translateY(-14px) rotate(3deg);
|
||||
box-shadow: 0 0 0 4px gold, 0 0 0 12px #a855f7, 0 25px 50px rgba(168,85,247,0.5);
|
||||
}
|
||||
80% {
|
||||
transform: scale(1.15) translateY(-8px) rotate(-1deg);
|
||||
box-shadow: 0 0 0 3px gold, 0 0 0 8px #a855f7, 0 18px 40px rgba(168,85,247,0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.08) translateY(-4px);
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 6px #a855f7, 0 12px 32px rgba(0,0,0,0.2);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('player-status-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'player-status-animations'
|
||||
style.textContent = epicAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const router = useRouter()
|
||||
const { state, resetGame, activePlayers, gameMode } = useMemoryPairs()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player data array
|
||||
const activePlayerData = Array.from(activePlayerIds)
|
||||
.map((id) => playerMap.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
.map((player) => ({
|
||||
...player,
|
||||
displayName: player.name,
|
||||
displayEmoji: player.emoji,
|
||||
}))
|
||||
|
||||
const gameTime =
|
||||
state.gameEndTime && state.gameStartTime ? state.gameEndTime - state.gameStartTime : 0
|
||||
|
||||
const analysis = getPerformanceAnalysis(state)
|
||||
const multiplayerResult =
|
||||
gameMode === 'multiplayer' ? getMultiplayerWinner(state, activePlayers) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: '40px 20px',
|
||||
})}
|
||||
>
|
||||
{/* Celebration Header */}
|
||||
<div
|
||||
className={css({
|
||||
marginBottom: '40px',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
color: 'green.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
🎉 Game Complete! 🎉
|
||||
</h2>
|
||||
|
||||
{gameMode === 'single' ? (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
color: 'gray.700',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
Congratulations on completing the memory challenge!
|
||||
</p>
|
||||
) : (
|
||||
multiplayerResult && (
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
{multiplayerResult.isTie ? (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
color: 'purple.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
🤝 It's a tie! All champions are memory masters!
|
||||
</p>
|
||||
) : multiplayerResult.winners.length === 1 ? (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
color: 'blue.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
🏆{' '}
|
||||
{activePlayerData.find((p) => p.id === multiplayerResult.winners[0])
|
||||
?.displayName || `Player ${multiplayerResult.winners[0]}`}{' '}
|
||||
Wins!
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
color: 'purple.600',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
🏆 {multiplayerResult.winners.length} Champions tied for victory!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Star Rating */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '32px',
|
||||
marginBottom: '20px',
|
||||
})}
|
||||
>
|
||||
{'⭐'.repeat(analysis.starRating)}
|
||||
{'☆'.repeat(5 - analysis.starRating)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: 'orange.600',
|
||||
})}
|
||||
>
|
||||
Grade: {analysis.grade}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Statistics */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '40px',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto 40px auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
color: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>{state.matchedPairs}</div>
|
||||
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Pairs Matched</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #a78bfa, #8b5cf6)',
|
||||
color: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>{state.moves}</div>
|
||||
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Total Moves</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
|
||||
color: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>
|
||||
{formatGameTime(gameTime)}
|
||||
</div>
|
||||
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Game Time</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #55a3ff, #003d82)',
|
||||
color: 'white',
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '32px', fontWeight: 'bold' })}>
|
||||
{Math.round(analysis.statistics.accuracy)}%
|
||||
</div>
|
||||
<div className={css({ fontSize: '16px', opacity: 0.9 })}>Accuracy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Scores */}
|
||||
{gameMode === 'multiplayer' && multiplayerResult && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '20px',
|
||||
marginBottom: '40px',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{activePlayerData.map((player) => {
|
||||
const score = multiplayerResult.scores[player.id] || 0
|
||||
const isWinner = multiplayerResult.winners.includes(player.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.id}
|
||||
className={css({
|
||||
background: isWinner
|
||||
? 'linear-gradient(135deg, #ffd700, #ff8c00)'
|
||||
: 'linear-gradient(135deg, #c0c0c0, #808080)',
|
||||
color: 'white',
|
||||
padding: '20px',
|
||||
borderRadius: '16px',
|
||||
textAlign: 'center',
|
||||
minWidth: '150px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '48px', marginBottom: '8px' })}>
|
||||
{player.displayEmoji}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
marginBottom: '4px',
|
||||
opacity: 0.9,
|
||||
})}
|
||||
>
|
||||
{player.displayName}
|
||||
</div>
|
||||
<div className={css({ fontSize: '36px', fontWeight: 'bold' })}>{score}</div>
|
||||
{isWinner && <div className={css({ fontSize: '24px' })}>👑</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Analysis */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'rgba(248, 250, 252, 0.8)',
|
||||
padding: '30px',
|
||||
borderRadius: '16px',
|
||||
marginBottom: '40px',
|
||||
border: '1px solid rgba(226, 232, 240, 0.8)',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto 40px auto',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '24px',
|
||||
marginBottom: '20px',
|
||||
color: 'gray.800',
|
||||
})}
|
||||
>
|
||||
Performance Analysis
|
||||
</h3>
|
||||
|
||||
{analysis.strengths.length > 0 && (
|
||||
<div className={css({ marginBottom: '20px' })}>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
color: 'green.600',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
✅ Strengths:
|
||||
</h4>
|
||||
<ul
|
||||
className={css({
|
||||
textAlign: 'left',
|
||||
color: 'gray.700',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
{analysis.strengths.map((strength, index) => (
|
||||
<li key={index}>{strength}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.improvements.length > 0 && (
|
||||
<div>
|
||||
<h4
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
color: 'orange.600',
|
||||
marginBottom: '8px',
|
||||
})}
|
||||
>
|
||||
💡 Areas for Improvement:
|
||||
</h4>
|
||||
<ul
|
||||
className={css({
|
||||
textAlign: 'left',
|
||||
color: 'gray.700',
|
||||
lineHeight: '1.6',
|
||||
})}
|
||||
>
|
||||
{analysis.improvements.map((improvement, index) => (
|
||||
<li key={index}>{improvement}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '20px',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #667eea, #764ba2)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '50px',
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 6px 20px rgba(102, 126, 234, 0.4)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(102, 126, 234, 0.6)',
|
||||
},
|
||||
})}
|
||||
onClick={resetGame}
|
||||
>
|
||||
🎮 Play Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #a78bfa, #8b5cf6)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '50px',
|
||||
padding: '16px 32px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
boxShadow: '0 6px 20px rgba(167, 139, 250, 0.4)',
|
||||
_hover: {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(167, 139, 250, 0.6)',
|
||||
},
|
||||
})}
|
||||
onClick={() => {
|
||||
console.log('🔄 ResultsPhase: Navigating to games with Next.js router (no page reload)')
|
||||
router.push('/games')
|
||||
}}
|
||||
>
|
||||
🏠 Back to Games
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
|
||||
// Add bounce animation for the start button
|
||||
const bounceAnimation = `
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject animation styles
|
||||
if (typeof document !== 'undefined' && !document.getElementById('setup-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'setup-animations'
|
||||
style.textContent = bounceAnimation
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
export function SetupPhase() {
|
||||
const { state, setGameType, setDifficulty, dispatch, activePlayers } = useMemoryPairs()
|
||||
|
||||
const { activePlayerCount, gameMode: globalGameMode } = useGameMode()
|
||||
|
||||
const handleStartGame = () => {
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({ type: 'START_GAME', cards, activePlayers })
|
||||
}
|
||||
|
||||
const getButtonStyles = (
|
||||
isSelected: boolean,
|
||||
variant: 'primary' | 'secondary' | 'difficulty' = 'primary'
|
||||
) => {
|
||||
const baseStyles = {
|
||||
border: 'none',
|
||||
borderRadius: { base: '12px', md: '16px' },
|
||||
padding: { base: '12px 16px', sm: '14px 20px', md: '16px 24px' },
|
||||
fontSize: { base: '14px', sm: '15px', md: '16px' },
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
minWidth: { base: '120px', sm: '140px', md: '160px' },
|
||||
textAlign: 'center' as const,
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden' as const,
|
||||
textShadow: isSelected ? '0 1px 2px rgba(0,0,0,0.2)' : 'none',
|
||||
transform: 'translateZ(0)', // Enable GPU acceleration
|
||||
}
|
||||
|
||||
if (variant === 'difficulty') {
|
||||
return css({
|
||||
...baseStyles,
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, #ff6b6b, #ee5a24)'
|
||||
: 'linear-gradient(135deg, #f8f9fa, #e9ecef)',
|
||||
color: isSelected ? 'white' : '#495057',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 25px rgba(255, 107, 107, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
_hover: {
|
||||
transform: 'translateY(-3px) scale(1.02)',
|
||||
boxShadow: isSelected
|
||||
? '0 12px 35px rgba(255, 107, 107, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(-1px) scale(1.01)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (variant === 'secondary') {
|
||||
return css({
|
||||
...baseStyles,
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, #a78bfa, #8b5cf6)'
|
||||
: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
|
||||
color: isSelected ? 'white' : '#475569',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 25px rgba(167, 139, 250, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
_hover: {
|
||||
transform: 'translateY(-3px) scale(1.02)',
|
||||
boxShadow: isSelected
|
||||
? '0 12px 35px rgba(167, 139, 250, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(-1px) scale(1.01)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Primary variant
|
||||
return css({
|
||||
...baseStyles,
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, #667eea, #764ba2)'
|
||||
: 'linear-gradient(135deg, #ffffff, #f1f5f9)',
|
||||
color: isSelected ? 'white' : '#334155',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 25px rgba(102, 126, 234, 0.4), inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: '0 2px 8px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
_hover: {
|
||||
transform: 'translateY(-3px) scale(1.02)',
|
||||
boxShadow: isSelected
|
||||
? '0 12px 35px rgba(102, 126, 234, 0.6), inset 0 1px 0 rgba(255,255,255,0.2)'
|
||||
: '0 8px 25px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.8)',
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(-1px) scale(1.01)',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
padding: { base: '12px 16px', sm: '16px 20px', md: '20px' },
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0, // Allow shrinking
|
||||
overflow: 'auto', // Enable scrolling if needed
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gap: { base: '8px', sm: '12px', md: '16px' },
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
minHeight: 0, // Allow shrinking
|
||||
})}
|
||||
>
|
||||
{/* Warning if no players */}
|
||||
{activePlayerCount === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
p: '4',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '2px solid',
|
||||
borderColor: 'red.300',
|
||||
rounded: 'xl',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
color: 'red.700',
|
||||
fontSize: { base: '14px', md: '16px' },
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
⚠️ Go back to the arcade to select players before starting the game
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Game Type Selection */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: { base: '16px', sm: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: { base: '12px', md: '16px' },
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Game Type
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
base: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
},
|
||||
gap: { base: '8px', sm: '10px', md: '12px' },
|
||||
justifyItems: 'stretch',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
className={getButtonStyles(state.gameType === 'abacus-numeral', 'secondary')}
|
||||
onClick={() => setGameType('abacus-numeral')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: { base: '4px', md: '6px' },
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '20px', sm: '24px', md: '28px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '4px', md: '8px' },
|
||||
})}
|
||||
>
|
||||
<span>🧮</span>
|
||||
<span className={css({ fontSize: { base: '16px', md: '20px' } })}>↔️</span>
|
||||
<span>🔢</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: { base: '12px', sm: '13px', md: '14px' },
|
||||
})}
|
||||
>
|
||||
Abacus-Numeral
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '10px', sm: '11px', md: '12px' },
|
||||
opacity: 0.8,
|
||||
textAlign: 'center',
|
||||
display: { base: 'none', sm: 'block' },
|
||||
})}
|
||||
>
|
||||
Match visual patterns
|
||||
<br />
|
||||
with numbers
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className={getButtonStyles(state.gameType === 'complement-pairs', 'secondary')}
|
||||
onClick={() => setGameType('complement-pairs')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: { base: '4px', md: '6px' },
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '20px', sm: '24px', md: '28px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '4px', md: '8px' },
|
||||
})}
|
||||
>
|
||||
<span>🤝</span>
|
||||
<span className={css({ fontSize: { base: '16px', md: '20px' } })}>➕</span>
|
||||
<span>🔟</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontWeight: 'bold',
|
||||
fontSize: { base: '12px', sm: '13px', md: '14px' },
|
||||
})}
|
||||
>
|
||||
Complement Pairs
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '10px', sm: '11px', md: '12px' },
|
||||
opacity: 0.8,
|
||||
textAlign: 'center',
|
||||
display: { base: 'none', sm: 'block' },
|
||||
})}
|
||||
>
|
||||
Find number friends
|
||||
<br />
|
||||
that add to 5 or 10
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: '12px', md: '14px' },
|
||||
color: 'gray.500',
|
||||
marginTop: { base: '6px', md: '8px' },
|
||||
textAlign: 'center',
|
||||
display: { base: 'none', sm: 'block' },
|
||||
})}
|
||||
>
|
||||
{state.gameType === 'abacus-numeral'
|
||||
? 'Match abacus representations with their numerical values'
|
||||
: 'Find pairs of numbers that add up to 5 or 10'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: { base: '16px', sm: '18px', md: '20px' },
|
||||
fontWeight: 'bold',
|
||||
marginBottom: { base: '12px', md: '16px' },
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Difficulty ({state.difficulty} pairs)
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
base: 'repeat(2, 1fr)',
|
||||
sm: 'repeat(4, 1fr)',
|
||||
},
|
||||
gap: { base: '8px', sm: '10px', md: '12px' },
|
||||
justifyItems: 'stretch',
|
||||
})}
|
||||
>
|
||||
{([6, 8, 12, 15] as const).map((difficulty) => {
|
||||
const difficultyInfo = {
|
||||
6: {
|
||||
icon: '🌱',
|
||||
label: 'Beginner',
|
||||
description: 'Perfect to start!',
|
||||
},
|
||||
8: {
|
||||
icon: '⚡',
|
||||
label: 'Medium',
|
||||
description: 'Getting spicy!',
|
||||
},
|
||||
12: {
|
||||
icon: '🔥',
|
||||
label: 'Hard',
|
||||
description: 'Serious challenge!',
|
||||
},
|
||||
15: {
|
||||
icon: '💀',
|
||||
label: 'Expert',
|
||||
description: 'Memory master!',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={difficulty}
|
||||
className={getButtonStyles(state.difficulty === difficulty, 'difficulty')}
|
||||
onClick={() => setDifficulty(difficulty)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '32px' })}>
|
||||
{difficultyInfo[difficulty].icon}
|
||||
</div>
|
||||
<div className={css({ fontSize: '18px', fontWeight: 'bold' })}>
|
||||
{difficulty} pairs
|
||||
</div>
|
||||
<div className={css({ fontSize: '14px', fontWeight: 'bold' })}>
|
||||
{difficultyInfo[difficulty].label}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '11px',
|
||||
opacity: 0.9,
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{difficultyInfo[difficulty].description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: 'gray.500',
|
||||
marginTop: '8px',
|
||||
})}
|
||||
>
|
||||
{state.difficulty} pairs = {state.difficulty * 2} cards total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Multi-Player Timer Setting */}
|
||||
{activePlayerCount > 1 && (
|
||||
<div>
|
||||
<label
|
||||
className={css({
|
||||
display: 'block',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '16px',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
Turn Timer
|
||||
</label>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{([15, 30, 45, 60] as const).map((timer) => {
|
||||
const timerInfo: Record<15 | 30 | 45 | 60, { icon: string; label: string }> = {
|
||||
15: { icon: '💨', label: 'Lightning' },
|
||||
30: { icon: '⚡', label: 'Quick' },
|
||||
45: { icon: '🏃', label: 'Standard' },
|
||||
60: { icon: '🧘', label: 'Relaxed' },
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={timer}
|
||||
className={getButtonStyles(state.turnTimer === timer, 'secondary')}
|
||||
onClick={() => dispatch({ type: 'SET_TURN_TIMER', timer })}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: '24px' })}>{timerInfo[timer].icon}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
})}
|
||||
>
|
||||
{timer}s
|
||||
</span>
|
||||
<span className={css({ fontSize: '12px', opacity: 0.8 })}>
|
||||
{timerInfo[timer].label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '14px',
|
||||
color: 'gray.500',
|
||||
marginTop: '8px',
|
||||
})}
|
||||
>
|
||||
Time limit for each player's turn
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Game Button - Sticky at bottom */}
|
||||
<div
|
||||
className={css({
|
||||
marginTop: 'auto', // Push to bottom
|
||||
paddingTop: { base: '12px', md: '16px' },
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
margin: '0 -16px -12px -16px', // Extend to edges
|
||||
padding: { base: '12px 16px', md: '16px' },
|
||||
})}
|
||||
>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: { base: '16px', sm: '20px', md: '24px' },
|
||||
padding: { base: '14px 28px', sm: '16px 32px', md: '18px 36px' },
|
||||
fontSize: { base: '16px', sm: '18px', md: '20px' },
|
||||
fontWeight: 'black',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background:
|
||||
'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
|
||||
transition: 'left 0.6s ease',
|
||||
},
|
||||
_hover: {
|
||||
transform: {
|
||||
base: 'translateY(-2px)',
|
||||
md: 'translateY(-3px) scale(1.02)',
|
||||
},
|
||||
boxShadow:
|
||||
'0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
|
||||
_before: {
|
||||
left: '100%',
|
||||
},
|
||||
},
|
||||
_active: {
|
||||
transform: 'translateY(-1px) scale(1.01)',
|
||||
},
|
||||
})}
|
||||
onClick={handleStartGame}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '6px', md: '8px' },
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
animation: 'bounce 2s infinite',
|
||||
})}
|
||||
>
|
||||
🚀
|
||||
</span>
|
||||
<span>START GAME</span>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
animation: 'bounce 2s infinite',
|
||||
animationDelay: '0.5s',
|
||||
})}
|
||||
>
|
||||
🎮
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { PLAYER_EMOJIS } from '../../../../../constants/playerEmojis'
|
||||
import { EmojiPicker } from '../EmojiPicker'
|
||||
|
||||
// Mock the emoji keywords function for testing
|
||||
vi.mock('emojibase-data/en/data.json', () => ({
|
||||
default: [
|
||||
{
|
||||
emoji: '🐱',
|
||||
label: 'cat face',
|
||||
tags: ['cat', 'animal', 'pet', 'cute'],
|
||||
emoticon: ':)',
|
||||
},
|
||||
{
|
||||
emoji: '🐯',
|
||||
label: 'tiger face',
|
||||
tags: ['tiger', 'animal', 'big cat', 'wild'],
|
||||
emoticon: null,
|
||||
},
|
||||
{
|
||||
emoji: '🤩',
|
||||
label: 'star-struck',
|
||||
tags: ['face', 'happy', 'excited', 'star'],
|
||||
emoticon: null,
|
||||
},
|
||||
{
|
||||
emoji: '🎭',
|
||||
label: 'performing arts',
|
||||
tags: ['theater', 'performance', 'drama', 'arts'],
|
||||
emoticon: null,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
describe('EmojiPicker Search Functionality', () => {
|
||||
const mockProps = {
|
||||
currentEmoji: '😀',
|
||||
onEmojiSelect: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
playerNumber: 1 as const,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('shows all emojis by default (no search)', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
// Should show default header
|
||||
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
|
||||
|
||||
// Should show emoji count
|
||||
expect(
|
||||
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Should show emoji grid
|
||||
const emojiButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
|
||||
})
|
||||
|
||||
test('shows search results when searching for "cat"', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
fireEvent.change(searchInput, { target: { value: 'cat' } })
|
||||
|
||||
// Should show search header
|
||||
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
|
||||
|
||||
// Should show results count
|
||||
expect(screen.getByText(/✓ \d+ found/)).toBeInTheDocument()
|
||||
|
||||
// Should only show cat-related emojis (🐱, 🐯)
|
||||
const emojiButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
|
||||
// Verify only cat emojis are shown
|
||||
const displayedEmojis = emojiButtons.map((btn) => btn.textContent)
|
||||
expect(displayedEmojis).toContain('🐱')
|
||||
expect(displayedEmojis).toContain('🐯')
|
||||
expect(displayedEmojis).not.toContain('🤩')
|
||||
expect(displayedEmojis).not.toContain('🎭')
|
||||
})
|
||||
|
||||
test('shows no results message when search has zero matches', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
|
||||
|
||||
// Should show no results indicator
|
||||
expect(screen.getByText('✗ No matches')).toBeInTheDocument()
|
||||
|
||||
// Should show no results message
|
||||
expect(screen.getByText(/No emojis found for "nonexistentterm"/)).toBeInTheDocument()
|
||||
|
||||
// Should NOT show any emoji buttons
|
||||
const emojiButtons = screen
|
||||
.queryAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
expect(emojiButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('returns to default view when clearing search', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
|
||||
// Search for something
|
||||
fireEvent.change(searchInput, { target: { value: 'cat' } })
|
||||
expect(screen.getByText(/🔍 Search Results for "cat"/)).toBeInTheDocument()
|
||||
|
||||
// Clear search
|
||||
fireEvent.change(searchInput, { target: { value: '' } })
|
||||
|
||||
// Should return to default view
|
||||
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(new RegExp(`${PLAYER_EMOJIS.length} characters available`))
|
||||
).toBeInTheDocument()
|
||||
|
||||
// Should show all emojis again
|
||||
const emojiButtons = screen
|
||||
.getAllByRole('button')
|
||||
.filter(
|
||||
(button) =>
|
||||
button.textContent &&
|
||||
/[\u{1F000}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(
|
||||
button.textContent
|
||||
)
|
||||
)
|
||||
expect(emojiButtons.length).toBe(PLAYER_EMOJIS.length)
|
||||
})
|
||||
|
||||
test('clear search button works from no results state', () => {
|
||||
render(<EmojiPicker {...mockProps} />)
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/Search:/)
|
||||
|
||||
// Search for something with no results
|
||||
fireEvent.change(searchInput, { target: { value: 'nonexistentterm' } })
|
||||
expect(screen.getByText(/No emojis found/)).toBeInTheDocument()
|
||||
|
||||
// Click clear search button
|
||||
const clearButton = screen.getByText(/Clear search to see all/)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// Should return to default view
|
||||
expect(searchInput).toHaveValue('')
|
||||
expect(screen.getByText('📝 All Available Characters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,382 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useReducer } from 'react'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { validateMatch } from '../utils/matchValidation'
|
||||
import type {
|
||||
GameStatistics,
|
||||
MemoryPairsAction,
|
||||
MemoryPairsContextValue,
|
||||
MemoryPairsState,
|
||||
PlayerScore,
|
||||
} from './types'
|
||||
|
||||
// Initial state (gameMode removed - now derived from global context)
|
||||
const initialState: MemoryPairsState = {
|
||||
// Core game data
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
|
||||
// Game configuration (gameMode removed)
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
|
||||
// Game progression
|
||||
gamePhase: 'setup',
|
||||
currentPlayer: '', // Will be set to first player ID on START_GAME
|
||||
matchedPairs: 0,
|
||||
totalPairs: 6,
|
||||
moves: 0,
|
||||
scores: {},
|
||||
activePlayers: [],
|
||||
consecutiveMatches: {},
|
||||
|
||||
// Timing
|
||||
gameStartTime: null,
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: null,
|
||||
timerInterval: null,
|
||||
|
||||
// UI state
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
|
||||
// Reducer function
|
||||
function memoryPairsReducer(state: MemoryPairsState, action: MemoryPairsAction): MemoryPairsState {
|
||||
switch (action.type) {
|
||||
// SET_GAME_MODE removed - game mode now derived from global context
|
||||
|
||||
case 'SET_GAME_TYPE':
|
||||
return {
|
||||
...state,
|
||||
gameType: action.gameType,
|
||||
}
|
||||
|
||||
case 'SET_DIFFICULTY':
|
||||
return {
|
||||
...state,
|
||||
difficulty: action.difficulty,
|
||||
totalPairs: action.difficulty,
|
||||
}
|
||||
|
||||
case 'SET_TURN_TIMER':
|
||||
return {
|
||||
...state,
|
||||
turnTimer: action.timer,
|
||||
}
|
||||
|
||||
case 'START_GAME': {
|
||||
// Initialize scores and consecutive matches for all active players
|
||||
const scores: PlayerScore = {}
|
||||
const consecutiveMatches: { [playerId: string]: number } = {}
|
||||
action.activePlayers.forEach((playerId) => {
|
||||
scores[playerId] = 0
|
||||
consecutiveMatches[playerId] = 0
|
||||
})
|
||||
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: action.cards,
|
||||
cards: action.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores,
|
||||
consecutiveMatches,
|
||||
activePlayers: action.activePlayers,
|
||||
currentPlayer: action.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
celebrationAnimations: [],
|
||||
isProcessingMove: false,
|
||||
showMismatchFeedback: false,
|
||||
lastMatchedPair: null,
|
||||
}
|
||||
}
|
||||
|
||||
case 'FLIP_CARD': {
|
||||
const cardToFlip = state.gameCards.find((card) => card.id === action.cardId)
|
||||
if (
|
||||
!cardToFlip ||
|
||||
cardToFlip.matched ||
|
||||
state.flippedCards.length >= 2 ||
|
||||
state.isProcessingMove
|
||||
) {
|
||||
return state
|
||||
}
|
||||
|
||||
const newFlippedCards = [...state.flippedCards, cardToFlip]
|
||||
const newMoveStartTime =
|
||||
state.flippedCards.length === 0 ? Date.now() : state.currentMoveStartTime
|
||||
|
||||
return {
|
||||
...state,
|
||||
flippedCards: newFlippedCards,
|
||||
currentMoveStartTime: newMoveStartTime,
|
||||
showMismatchFeedback: false,
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FOUND': {
|
||||
const [card1Id, card2Id] = action.cardIds
|
||||
const updatedCards = state.gameCards.map((card) => {
|
||||
if (card.id === card1Id || card.id === card2Id) {
|
||||
return {
|
||||
...card,
|
||||
matched: true,
|
||||
matchedBy: state.currentPlayer,
|
||||
}
|
||||
}
|
||||
return card
|
||||
})
|
||||
|
||||
const newMatchedPairs = state.matchedPairs + 1
|
||||
const newScores = {
|
||||
...state.scores,
|
||||
[state.currentPlayer]: (state.scores[state.currentPlayer] || 0) + 1,
|
||||
}
|
||||
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: (state.consecutiveMatches[state.currentPlayer] || 0) + 1,
|
||||
}
|
||||
|
||||
// Check if game is complete
|
||||
const isGameComplete = newMatchedPairs === state.totalPairs
|
||||
|
||||
return {
|
||||
...state,
|
||||
gameCards: updatedCards,
|
||||
matchedPairs: newMatchedPairs,
|
||||
scores: newScores,
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
flippedCards: [],
|
||||
moves: state.moves + 1,
|
||||
lastMatchedPair: action.cardIds,
|
||||
gamePhase: isGameComplete ? 'results' : 'playing',
|
||||
gameEndTime: isGameComplete ? Date.now() : null,
|
||||
isProcessingMove: false,
|
||||
// Note: Player keeps turn after successful match in multiplayer mode
|
||||
}
|
||||
}
|
||||
|
||||
case 'MATCH_FAILED': {
|
||||
// Player switching is now handled by passing activePlayerCount
|
||||
return {
|
||||
...state,
|
||||
flippedCards: [],
|
||||
moves: state.moves + 1,
|
||||
showMismatchFeedback: true,
|
||||
isProcessingMove: false,
|
||||
// currentPlayer will be updated by SWITCH_PLAYER action when needed
|
||||
}
|
||||
}
|
||||
|
||||
case 'SWITCH_PLAYER': {
|
||||
// Cycle through all active players
|
||||
const currentIndex = state.activePlayers.indexOf(state.currentPlayer)
|
||||
const nextIndex = (currentIndex + 1) % state.activePlayers.length
|
||||
|
||||
// Reset consecutive matches for the player who failed
|
||||
const newConsecutiveMatches = {
|
||||
...state.consecutiveMatches,
|
||||
[state.currentPlayer]: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentPlayer: state.activePlayers[nextIndex] || state.activePlayers[0],
|
||||
consecutiveMatches: newConsecutiveMatches,
|
||||
}
|
||||
}
|
||||
|
||||
case 'ADD_CELEBRATION':
|
||||
return {
|
||||
...state,
|
||||
celebrationAnimations: [...state.celebrationAnimations, action.animation],
|
||||
}
|
||||
|
||||
case 'REMOVE_CELEBRATION':
|
||||
return {
|
||||
...state,
|
||||
celebrationAnimations: state.celebrationAnimations.filter(
|
||||
(anim) => anim.id !== action.animationId
|
||||
),
|
||||
}
|
||||
|
||||
case 'SET_PROCESSING':
|
||||
return {
|
||||
...state,
|
||||
isProcessingMove: action.processing,
|
||||
}
|
||||
|
||||
case 'SET_MISMATCH_FEEDBACK':
|
||||
return {
|
||||
...state,
|
||||
showMismatchFeedback: action.show,
|
||||
}
|
||||
|
||||
case 'SHOW_RESULTS':
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'results',
|
||||
gameEndTime: Date.now(),
|
||||
flippedCards: [],
|
||||
}
|
||||
|
||||
case 'RESET_GAME':
|
||||
return {
|
||||
...initialState,
|
||||
gameType: state.gameType,
|
||||
difficulty: state.difficulty,
|
||||
turnTimer: state.turnTimer,
|
||||
totalPairs: state.difficulty,
|
||||
}
|
||||
|
||||
case 'UPDATE_TIMER':
|
||||
// This can be used for any timer-related updates
|
||||
return state
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
// Create context
|
||||
const MemoryPairsContext = createContext<MemoryPairsContextValue | null>(null)
|
||||
|
||||
// Provider component
|
||||
export function MemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(memoryPairsReducer, initialState)
|
||||
const { activePlayerCount, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
|
||||
// Handle card matching logic when two cards are flipped
|
||||
useEffect(() => {
|
||||
if (state.flippedCards.length === 2 && !state.isProcessingMove) {
|
||||
dispatch({ type: 'SET_PROCESSING', processing: true })
|
||||
|
||||
const [card1, card2] = state.flippedCards
|
||||
const matchResult = validateMatch(card1, card2)
|
||||
|
||||
// Delay to allow card flip animation
|
||||
setTimeout(() => {
|
||||
if (matchResult.isValid) {
|
||||
dispatch({ type: 'MATCH_FOUND', cardIds: [card1.id, card2.id] })
|
||||
} else {
|
||||
dispatch({ type: 'MATCH_FAILED', cardIds: [card1.id, card2.id] })
|
||||
// Switch player only in multiplayer mode
|
||||
if (gameMode === 'multiplayer') {
|
||||
dispatch({ type: 'SWITCH_PLAYER' })
|
||||
}
|
||||
}
|
||||
}, 1000) // Give time to see both cards
|
||||
}
|
||||
}, [state.flippedCards, state.isProcessingMove, gameMode])
|
||||
|
||||
// Auto-hide mismatch feedback
|
||||
useEffect(() => {
|
||||
if (state.showMismatchFeedback) {
|
||||
const timeout = setTimeout(() => {
|
||||
dispatch({ type: 'SET_MISMATCH_FEEDBACK', show: false })
|
||||
}, 2000)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [state.showMismatchFeedback])
|
||||
|
||||
// Computed values
|
||||
const isGameActive = state.gamePhase === 'playing'
|
||||
|
||||
const canFlipCard = (cardId: string): boolean => {
|
||||
if (!isGameActive || state.isProcessingMove) return false
|
||||
|
||||
const card = state.gameCards.find((c) => c.id === cardId)
|
||||
if (!card || card.matched) return false
|
||||
|
||||
// Can't flip if already flipped
|
||||
if (state.flippedCards.some((c) => c.id === cardId)) return false
|
||||
|
||||
// Can't flip more than 2 cards
|
||||
if (state.flippedCards.length >= 2) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const currentGameStatistics: GameStatistics = {
|
||||
totalMoves: state.moves,
|
||||
matchedPairs: state.matchedPairs,
|
||||
totalPairs: state.totalPairs,
|
||||
gameTime: state.gameStartTime ? (state.gameEndTime || Date.now()) - state.gameStartTime : 0,
|
||||
accuracy: state.moves > 0 ? (state.matchedPairs / state.moves) * 100 : 0,
|
||||
averageTimePerMove:
|
||||
state.moves > 0 && state.gameStartTime
|
||||
? ((state.gameEndTime || Date.now()) - state.gameStartTime) / state.moves
|
||||
: 0,
|
||||
}
|
||||
|
||||
// Action creators
|
||||
const startGame = () => {
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
dispatch({ type: 'START_GAME', cards, activePlayers })
|
||||
}
|
||||
|
||||
const flipCard = (cardId: string) => {
|
||||
if (!canFlipCard(cardId)) return
|
||||
dispatch({ type: 'FLIP_CARD', cardId })
|
||||
}
|
||||
|
||||
const resetGame = () => {
|
||||
dispatch({ type: 'RESET_GAME' })
|
||||
}
|
||||
|
||||
// setGameMode removed - game mode is now derived from global context
|
||||
|
||||
const setGameType = (gameType: typeof state.gameType) => {
|
||||
dispatch({ type: 'SET_GAME_TYPE', gameType })
|
||||
}
|
||||
|
||||
const setDifficulty = (difficulty: typeof state.difficulty) => {
|
||||
dispatch({ type: 'SET_DIFFICULTY', difficulty })
|
||||
}
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
state: { ...state, gameMode }, // Add derived gameMode to state
|
||||
dispatch,
|
||||
isGameActive,
|
||||
canFlipCard,
|
||||
currentGameStatistics,
|
||||
startGame,
|
||||
flipCard,
|
||||
resetGame,
|
||||
setGameType,
|
||||
setDifficulty,
|
||||
exitSession: () => {}, // No-op for non-arcade mode
|
||||
gameMode, // Expose derived gameMode
|
||||
activePlayers, // Expose active players
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
}
|
||||
|
||||
// Hook to use the context
|
||||
export function useMemoryPairs(): MemoryPairsContextValue {
|
||||
const context = useContext(MemoryPairsContext)
|
||||
if (!context) {
|
||||
throw new Error('useMemoryPairs must be used within a MemoryPairsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
// TypeScript interfaces for Memory Pairs Challenge game
|
||||
|
||||
export type GameMode = 'single' | 'multiplayer'
|
||||
export type GameType = 'abacus-numeral' | 'complement-pairs'
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
export type CardType = 'abacus' | 'number' | 'complement'
|
||||
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
|
||||
export type Player = string // Player ID (UUID)
|
||||
export type TargetSum = 5 | 10 | 20
|
||||
|
||||
export interface GameCard {
|
||||
id: string
|
||||
type: CardType
|
||||
number: number
|
||||
complement?: number // For complement pairs
|
||||
targetSum?: TargetSum // For complement pairs
|
||||
matched: boolean
|
||||
matchedBy?: Player // For two-player mode
|
||||
element?: HTMLElement | null // For animations
|
||||
}
|
||||
|
||||
export interface PlayerScore {
|
||||
[playerId: string]: number
|
||||
}
|
||||
|
||||
export interface CelebrationAnimation {
|
||||
id: string
|
||||
type: 'match' | 'win' | 'confetti'
|
||||
x: number
|
||||
y: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface GameStatistics {
|
||||
totalMoves: number
|
||||
matchedPairs: number
|
||||
totalPairs: number
|
||||
gameTime: number
|
||||
accuracy: number // Percentage of successful matches
|
||||
averageTimePerMove: number
|
||||
}
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string // Which user owns this player
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface GameConfiguration {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
export interface MemoryPairsState {
|
||||
// Core game data
|
||||
cards: GameCard[]
|
||||
gameCards: GameCard[]
|
||||
flippedCards: GameCard[]
|
||||
|
||||
// Game configuration (gameMode removed - now derived from global context)
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number // Seconds for two-player mode
|
||||
|
||||
// Paused game state - for Resume functionality
|
||||
originalConfig?: GameConfiguration // Config when game started - used to detect changes
|
||||
pausedGamePhase?: 'playing' | 'results' // Set when GO_TO_SETUP called from active game
|
||||
pausedGameState?: {
|
||||
// Snapshot of game state when paused
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata }
|
||||
consecutiveMatches: { [playerId: string]: number }
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// Game progression
|
||||
gamePhase: GamePhase
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
totalPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata: { [playerId: string]: PlayerMetadata } // Player metadata snapshot for cross-user visibility
|
||||
consecutiveMatches: { [playerId: string]: number } // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
currentMoveStartTime: number | null
|
||||
timerInterval: NodeJS.Timeout | null
|
||||
|
||||
// UI state
|
||||
celebrationAnimations: CelebrationAnimation[]
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// Hover state for networked presence
|
||||
playerHovers: { [playerId: string]: string | null } // playerId -> cardId (or null if not hovering)
|
||||
}
|
||||
|
||||
export type MemoryPairsAction =
|
||||
| { type: 'SET_GAME_TYPE'; gameType: GameType }
|
||||
| { type: 'SET_DIFFICULTY'; difficulty: Difficulty }
|
||||
| { type: 'SET_TURN_TIMER'; timer: number }
|
||||
| { type: 'START_GAME'; cards: GameCard[]; activePlayers: Player[] }
|
||||
| { type: 'FLIP_CARD'; cardId: string }
|
||||
| { type: 'MATCH_FOUND'; cardIds: [string, string] }
|
||||
| { type: 'MATCH_FAILED'; cardIds: [string, string] }
|
||||
| { type: 'SWITCH_PLAYER' }
|
||||
| { type: 'ADD_CELEBRATION'; animation: CelebrationAnimation }
|
||||
| { type: 'REMOVE_CELEBRATION'; animationId: string }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_GAME' }
|
||||
| { type: 'SET_PROCESSING'; processing: boolean }
|
||||
| { type: 'SET_MISMATCH_FEEDBACK'; show: boolean }
|
||||
| { type: 'UPDATE_TIMER' }
|
||||
|
||||
export interface MemoryPairsContextValue {
|
||||
state: MemoryPairsState & { gameMode: GameMode } // gameMode added as computed property
|
||||
dispatch: React.Dispatch<MemoryPairsAction>
|
||||
|
||||
// Computed values
|
||||
isGameActive: boolean
|
||||
canFlipCard: (cardId: string) => boolean
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode // Derived from global context
|
||||
activePlayers: Player[] // Active player IDs from arena
|
||||
hasConfigChanged: boolean // True if current config differs from originalConfig
|
||||
canResumeGame: boolean // True if there's a paused game and config hasn't changed
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
resumeGame: () => void
|
||||
flipCard: (cardId: string) => void
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer: (timer: number) => void
|
||||
hoverCard: (cardId: string | null) => void // Send hover state for networked presence
|
||||
goToSetup: () => void
|
||||
exitSession: () => void // Exit arcade session (no-op for non-arcade mode)
|
||||
}
|
||||
|
||||
// Utility types for component props
|
||||
export interface GameCardProps {
|
||||
card: GameCard
|
||||
isFlipped: boolean
|
||||
isMatched: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface PlayerIndicatorProps {
|
||||
player: Player
|
||||
isActive: boolean
|
||||
score: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface GameGridProps {
|
||||
cards: GameCard[]
|
||||
onCardClick: (cardId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface MatchValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
type: 'abacus-numeral' | 'complement' | 'invalid'
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { MemoryPairsProvider } from './context/MemoryPairsContext'
|
||||
|
||||
export default function MatchingPage() {
|
||||
return (
|
||||
<MemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</MemoryPairsProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import type { GameCard, MatchValidationResult } from '../context/types'
|
||||
|
||||
// Validate abacus-numeral match (abacus card matches with number card of same value)
|
||||
export function validateAbacusNumeralMatch(
|
||||
card1: GameCard,
|
||||
card2: GameCard
|
||||
): MatchValidationResult {
|
||||
// Both cards must have the same number
|
||||
if (card1.number !== card2.number) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Numbers do not match',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Cards must be different types (one abacus, one number)
|
||||
if (card1.type === card2.type) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Both cards are the same type',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// One must be abacus, one must be number
|
||||
const hasAbacus = card1.type === 'abacus' || card2.type === 'abacus'
|
||||
const hasNumber = card1.type === 'number' || card2.type === 'number'
|
||||
|
||||
if (!hasAbacus || !hasNumber) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Must match abacus with number representation',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Neither should be complement type for this game mode
|
||||
if (card1.type === 'complement' || card2.type === 'complement') {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Complement cards not valid in abacus-numeral mode',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
type: 'abacus-numeral',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate complement match (two numbers that add up to target sum)
|
||||
export function validateComplementMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
|
||||
// Both cards must be complement type
|
||||
if (card1.type !== 'complement' || card2.type !== 'complement') {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Both cards must be complement type',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Both cards must have the same target sum
|
||||
if (card1.targetSum !== card2.targetSum) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cards have different target sums',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the numbers are actually complements
|
||||
if (!card1.complement || !card2.complement) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Complement information missing',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the complement relationship
|
||||
if (card1.number !== card2.complement || card2.number !== card1.complement) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Numbers are not complements of each other',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the sum equals the target
|
||||
const sum = card1.number + card2.number
|
||||
if (sum !== card1.targetSum) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: `Sum ${sum} does not equal target ${card1.targetSum}`,
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
type: 'complement',
|
||||
}
|
||||
}
|
||||
|
||||
// Main validation function that determines which validation to use
|
||||
export function validateMatch(card1: GameCard, card2: GameCard): MatchValidationResult {
|
||||
// Cannot match the same card with itself
|
||||
if (card1.id === card2.id) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cannot match card with itself',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot match already matched cards
|
||||
if (card1.matched || card2.matched) {
|
||||
return {
|
||||
isValid: false,
|
||||
reason: 'Cannot match already matched cards',
|
||||
type: 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which type of match to validate based on card types
|
||||
const hasComplement = card1.type === 'complement' || card2.type === 'complement'
|
||||
|
||||
if (hasComplement) {
|
||||
// If either card is complement type, use complement validation
|
||||
return validateComplementMatch(card1, card2)
|
||||
} else {
|
||||
// Otherwise, use abacus-numeral validation
|
||||
return validateAbacusNumeralMatch(card1, card2)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a card can be flipped
|
||||
export function canFlipCard(
|
||||
card: GameCard,
|
||||
flippedCards: GameCard[],
|
||||
isProcessingMove: boolean
|
||||
): boolean {
|
||||
// Cannot flip if processing a move
|
||||
if (isProcessingMove) return false
|
||||
|
||||
// Cannot flip already matched cards
|
||||
if (card.matched) return false
|
||||
|
||||
// Cannot flip if already flipped
|
||||
if (flippedCards.some((c) => c.id === card.id)) return false
|
||||
|
||||
// Cannot flip if two cards are already flipped
|
||||
if (flippedCards.length >= 2) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Get hint for what kind of match the player should look for
|
||||
export function getMatchHint(card: GameCard): string {
|
||||
switch (card.type) {
|
||||
case 'abacus':
|
||||
return `Find the number ${card.number}`
|
||||
|
||||
case 'number':
|
||||
return `Find the abacus showing ${card.number}`
|
||||
|
||||
case 'complement':
|
||||
if (card.complement !== undefined && card.targetSum !== undefined) {
|
||||
return `Find ${card.complement} to make ${card.targetSum}`
|
||||
}
|
||||
return 'Find the matching complement'
|
||||
|
||||
default:
|
||||
return 'Find the matching card'
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate match score based on difficulty and time
|
||||
export function calculateMatchScore(
|
||||
difficulty: number,
|
||||
timeForMatch: number,
|
||||
isComplementMatch: boolean
|
||||
): number {
|
||||
const baseScore = isComplementMatch ? 15 : 10 // Complement matches worth more
|
||||
const difficultyMultiplier = difficulty / 6 // Scale with difficulty
|
||||
const timeBonus = Math.max(0, (10000 - timeForMatch) / 1000) // Bonus for speed
|
||||
|
||||
return Math.round(baseScore * difficultyMultiplier + timeBonus)
|
||||
}
|
||||
|
||||
// Analyze game performance
|
||||
export function analyzeGamePerformance(
|
||||
totalMoves: number,
|
||||
matchedPairs: number,
|
||||
totalPairs: number,
|
||||
gameTime: number
|
||||
): {
|
||||
accuracy: number
|
||||
efficiency: number
|
||||
averageTimePerMove: number
|
||||
grade: 'A' | 'B' | 'C' | 'D' | 'F'
|
||||
} {
|
||||
const accuracy = totalMoves > 0 ? (matchedPairs / totalMoves) * 100 : 0
|
||||
const efficiency = totalPairs > 0 ? (matchedPairs / (totalPairs * 2)) * 100 : 0 // Ideal is 100% (each pair found in 2 moves)
|
||||
const averageTimePerMove = totalMoves > 0 ? gameTime / totalMoves : 0
|
||||
|
||||
// Calculate grade based on accuracy and efficiency
|
||||
let grade: 'A' | 'B' | 'C' | 'D' | 'F' = 'F'
|
||||
if (accuracy >= 90 && efficiency >= 80) grade = 'A'
|
||||
else if (accuracy >= 80 && efficiency >= 70) grade = 'B'
|
||||
else if (accuracy >= 70 && efficiency >= 60) grade = 'C'
|
||||
else if (accuracy >= 60 && efficiency >= 50) grade = 'D'
|
||||
|
||||
return {
|
||||
accuracy,
|
||||
efficiency,
|
||||
averageTimePerMove,
|
||||
grade,
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,10 @@ function GamesPageContent() {
|
||||
|
||||
const _handleGameClick = (gameType: string) => {
|
||||
// Navigate directly to games using the centralized game mode with Next.js router
|
||||
// Note: battle-arena has been removed - now handled by game registry as "matching"
|
||||
console.log('🔄 GamesPage: Navigating with Next.js router (no page reload)')
|
||||
if (gameType === 'memory-quiz') {
|
||||
router.push('/games/memory-quiz')
|
||||
} else if (gameType === 'battle-arena') {
|
||||
router.push('/games/matching')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useMemo } from 'react'
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, createContext, useContext } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
@@ -9,13 +9,15 @@ import {
|
||||
buildPlayerOwnershipFromRoomData,
|
||||
} from '@/lib/arcade/player-ownership.client'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { generateGameCards } from '../utils/cardGeneration'
|
||||
import { MemoryPairsContext } from './MemoryPairsContext'
|
||||
import type { GameMode, GameStatistics, MemoryPairsContextValue, MemoryPairsState } from './types'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { generateGameCards } from './utils/cardGeneration'
|
||||
import type { GameMode, GameStatistics, MatchingContextValue, MatchingState, MatchingMove } from './types'
|
||||
|
||||
// Create context for Matching game
|
||||
const MatchingContext = createContext<MatchingContextValue | null>(null)
|
||||
|
||||
// Initial state
|
||||
const initialState: MemoryPairsState = {
|
||||
const initialState: MatchingState = {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
flippedCards: [],
|
||||
@@ -51,26 +53,27 @@ const initialState: MemoryPairsState = {
|
||||
* Optimistic move application (client-side prediction)
|
||||
* The server will validate and send back the authoritative state
|
||||
*/
|
||||
function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): MemoryPairsState {
|
||||
switch (move.type) {
|
||||
function applyMoveOptimistically(state: MatchingState, move: GameMove): MatchingState {
|
||||
const typedMove = move as MatchingMove
|
||||
switch (typedMove.type) {
|
||||
case 'START_GAME':
|
||||
// Generate cards and initialize game
|
||||
return {
|
||||
...state,
|
||||
gamePhase: 'playing',
|
||||
gameCards: move.data.cards,
|
||||
cards: move.data.cards,
|
||||
gameCards: typedMove.data.cards,
|
||||
cards: typedMove.data.cards,
|
||||
flippedCards: [],
|
||||
matchedPairs: 0,
|
||||
moves: 0,
|
||||
scores: move.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: move.data.activePlayers.reduce(
|
||||
scores: typedMove.data.activePlayers.reduce((acc: any, p: string) => ({ ...acc, [p]: 0 }), {}),
|
||||
consecutiveMatches: typedMove.data.activePlayers.reduce(
|
||||
(acc: any, p: string) => ({ ...acc, [p]: 0 }),
|
||||
{}
|
||||
),
|
||||
activePlayers: move.data.activePlayers,
|
||||
playerMetadata: move.data.playerMetadata || {}, // Include player metadata
|
||||
currentPlayer: move.data.activePlayers[0] || '',
|
||||
activePlayers: typedMove.data.activePlayers,
|
||||
playerMetadata: typedMove.data.playerMetadata || {}, // Include player metadata
|
||||
currentPlayer: typedMove.data.activePlayers[0] || '',
|
||||
gameStartTime: Date.now(),
|
||||
gameEndTime: null,
|
||||
currentMoveStartTime: Date.now(),
|
||||
@@ -94,7 +97,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
const gameCards = state.gameCards || []
|
||||
const flippedCards = state.flippedCards || []
|
||||
|
||||
const card = gameCards.find((c) => c.id === move.data.cardId)
|
||||
const card = gameCards.find((c) => c.id === typedMove.data.cardId)
|
||||
if (!card) return state
|
||||
|
||||
const newFlippedCards = [...flippedCards, card]
|
||||
@@ -173,7 +176,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
|
||||
case 'SET_CONFIG': {
|
||||
// Update configuration field optimistically
|
||||
const { field, value } = move.data as { field: string; value: any }
|
||||
const { field, value } = typedMove.data
|
||||
const clearPausedGame = !!state.pausedGamePhase
|
||||
|
||||
return {
|
||||
@@ -223,7 +226,7 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
...state,
|
||||
playerHovers: {
|
||||
...state.playerHovers,
|
||||
[move.playerId]: move.data.cardId,
|
||||
[typedMove.playerId]: typedMove.data.cardId,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -236,14 +239,14 @@ function applyMoveOptimistically(state: MemoryPairsState, move: GameMove): Memor
|
||||
// Provider component for ROOM-BASED play (with network sync)
|
||||
// NOTE: This provider should ONLY be used for room-based multiplayer games.
|
||||
// For arcade sessions without rooms, use LocalMemoryPairsProvider instead.
|
||||
export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
export function MatchingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // Fetch room data for room-based play
|
||||
const { activePlayerCount, activePlayers: activePlayerIds, players } = useGameMode()
|
||||
const { mutate: updateGameConfig } = useUpdateGameConfig()
|
||||
|
||||
// Get active player IDs directly as strings (UUIDs)
|
||||
const activePlayers = Array.from(activePlayerIds)
|
||||
const activePlayers = Array.from(activePlayerIds) as string[]
|
||||
|
||||
// Derive game mode from active player count
|
||||
const gameMode = activePlayerCount > 1 ? 'multiplayer' : 'single'
|
||||
@@ -251,7 +254,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
// Track roomData.gameConfig changes
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] roomData.gameConfig changed:',
|
||||
'[MatchingProvider] roomData.gameConfig changed:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameConfig: roomData?.gameConfig,
|
||||
@@ -269,7 +272,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const mergedInitialState = useMemo(() => {
|
||||
const gameConfig = roomData?.gameConfig as Record<string, any> | null | undefined
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Loading settings from database:',
|
||||
'[MatchingProvider] Loading settings from database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameConfig,
|
||||
@@ -281,19 +284,19 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomMemoryPairsProvider] No gameConfig, using initialState')
|
||||
console.log('[MatchingProvider] No gameConfig, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
// Get settings for this specific game (matching)
|
||||
const savedConfig = gameConfig.matching as Record<string, any> | null | undefined
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saved config for matching:',
|
||||
'[MatchingProvider] Saved config for matching:',
|
||||
JSON.stringify(savedConfig, null, 2)
|
||||
)
|
||||
|
||||
if (!savedConfig) {
|
||||
console.log('[RoomMemoryPairsProvider] No saved config for matching, using initialState')
|
||||
console.log('[MatchingProvider] No saved config for matching, using initialState')
|
||||
return initialState
|
||||
}
|
||||
|
||||
@@ -305,7 +308,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
turnTimer: savedConfig.turnTimer ?? initialState.turnTimer,
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Merged state:',
|
||||
'[MatchingProvider] Merged state:',
|
||||
JSON.stringify(
|
||||
{
|
||||
gameType: merged.gameType,
|
||||
@@ -326,7 +329,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
sendMove,
|
||||
connected: _connected,
|
||||
exitSession,
|
||||
} = useArcadeSession<MemoryPairsState>({
|
||||
} = useArcadeSession<MatchingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // CRITICAL: Pass roomId for network sync across room members
|
||||
initialState: mergedInitialState,
|
||||
@@ -479,7 +482,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
|
||||
|
||||
// Use centralized utility to build metadata
|
||||
return buildPlayerMetadataUtil(playerIds, playerOwnership, players, viewerId)
|
||||
return buildPlayerMetadataUtil(playerIds, playerOwnership, players, viewerId ?? undefined)
|
||||
},
|
||||
[players, roomData, viewerId]
|
||||
)
|
||||
@@ -488,7 +491,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const startGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[RoomMemoryPairs] Cannot start game without active players')
|
||||
console.error('[MatchingProvider] Cannot start game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -499,7 +502,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
const firstPlayer = activePlayers[0] as string
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
@@ -543,7 +546,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const resetGame = useCallback(() => {
|
||||
// Must have at least one active player
|
||||
if (activePlayers.length === 0) {
|
||||
console.error('[RoomMemoryPairs] Cannot reset game without active players')
|
||||
console.error('[MatchingProvider] Cannot reset game without active players')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -553,7 +556,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
// Use current session state configuration (no local state!)
|
||||
const cards = generateGameCards(state.gameType, state.difficulty)
|
||||
// Use first active player as playerId for START_GAME move
|
||||
const firstPlayer = activePlayers[0]
|
||||
const firstPlayer = activePlayers[0] as string
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: firstPlayer,
|
||||
@@ -568,10 +571,10 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setGameType = useCallback(
|
||||
(gameType: typeof state.gameType) => {
|
||||
console.log('[RoomMemoryPairsProvider] setGameType called:', gameType)
|
||||
console.log('[MatchingProvider] setGameType called:', gameType)
|
||||
|
||||
// Use first active player as playerId, or empty string if none
|
||||
const playerId = activePlayers[0] || ''
|
||||
const playerId = (activePlayers[0] as string) || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
@@ -592,7 +595,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving gameType to database:',
|
||||
'[MatchingProvider] Saving gameType to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
@@ -607,7 +610,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save gameType - no roomData.id')
|
||||
console.warn('[MatchingProvider] Cannot save gameType - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
@@ -615,9 +618,9 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setDifficulty = useCallback(
|
||||
(difficulty: typeof state.difficulty) => {
|
||||
console.log('[RoomMemoryPairsProvider] setDifficulty called:', difficulty)
|
||||
console.log('[MatchingProvider] setDifficulty called:', difficulty)
|
||||
|
||||
const playerId = activePlayers[0] || ''
|
||||
const playerId = (activePlayers[0] as string) || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
@@ -638,7 +641,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving difficulty to database:',
|
||||
'[MatchingProvider] Saving difficulty to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
@@ -653,7 +656,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save difficulty - no roomData.id')
|
||||
console.warn('[MatchingProvider] Cannot save difficulty - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
@@ -661,9 +664,9 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const setTurnTimer = useCallback(
|
||||
(turnTimer: typeof state.turnTimer) => {
|
||||
console.log('[RoomMemoryPairsProvider] setTurnTimer called:', turnTimer)
|
||||
console.log('[MatchingProvider] setTurnTimer called:', turnTimer)
|
||||
|
||||
const playerId = activePlayers[0] || ''
|
||||
const playerId = (activePlayers[0] as string) || ''
|
||||
sendMove({
|
||||
type: 'SET_CONFIG',
|
||||
playerId,
|
||||
@@ -684,7 +687,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
}
|
||||
console.log(
|
||||
'[RoomMemoryPairsProvider] Saving turnTimer to database:',
|
||||
'[MatchingProvider] Saving turnTimer to database:',
|
||||
JSON.stringify(
|
||||
{
|
||||
roomId: roomData.id,
|
||||
@@ -699,7 +702,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
gameConfig: updatedConfig,
|
||||
})
|
||||
} else {
|
||||
console.warn('[RoomMemoryPairsProvider] Cannot save turnTimer - no roomData.id')
|
||||
console.warn('[MatchingProvider] Cannot save turnTimer - no roomData.id')
|
||||
}
|
||||
},
|
||||
[activePlayers, sendMove, viewerId, roomData?.id, roomData?.gameConfig, updateGameConfig]
|
||||
@@ -707,7 +710,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const goToSetup = useCallback(() => {
|
||||
// Send GO_TO_SETUP move - synchronized across all room members
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
const playerId = (activePlayers[0] as string) || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'GO_TO_SETUP',
|
||||
playerId,
|
||||
@@ -719,11 +722,11 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
const resumeGame = useCallback(() => {
|
||||
// PAUSE/RESUME: Resume paused game if config unchanged
|
||||
if (!canResumeGame) {
|
||||
console.warn('[RoomMemoryPairs] Cannot resume - no paused game or config changed')
|
||||
console.warn('[MatchingProvider] Cannot resume - no paused game or config changed')
|
||||
return
|
||||
}
|
||||
|
||||
const playerId = activePlayers[0] || state.currentPlayer || ''
|
||||
const playerId = (activePlayers[0] as string) || state.currentPlayer || ''
|
||||
sendMove({
|
||||
type: 'RESUME_GAME',
|
||||
playerId,
|
||||
@@ -736,7 +739,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
(cardId: string | null) => {
|
||||
// HOVER: Send hover state for networked presence
|
||||
// Use current player as the one hovering
|
||||
const playerId = state.currentPlayer || activePlayers[0] || ''
|
||||
const playerId = state.currentPlayer || (activePlayers[0] as string) || ''
|
||||
if (!playerId) return // No active player to send hover for
|
||||
|
||||
sendMove({
|
||||
@@ -750,7 +753,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
|
||||
// NO MORE effectiveState merging! Just use session state directly with gameMode added
|
||||
const effectiveState = { ...state, gameMode } as MemoryPairsState & {
|
||||
const effectiveState = { ...state, gameMode } as MatchingState & {
|
||||
gameMode: GameMode
|
||||
}
|
||||
|
||||
@@ -848,7 +851,7 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
const contextValue: MemoryPairsContextValue = {
|
||||
const contextValue: MatchingContextValue = {
|
||||
state: effectiveState,
|
||||
dispatch: () => {
|
||||
// No-op - replaced with sendMove
|
||||
@@ -873,8 +876,14 @@ export function RoomMemoryPairsProvider({ children }: { children: ReactNode }) {
|
||||
activePlayers,
|
||||
}
|
||||
|
||||
return <MemoryPairsContext.Provider value={contextValue}>{children}</MemoryPairsContext.Provider>
|
||||
return <MatchingContext.Provider value={contextValue}>{children}</MatchingContext.Provider>
|
||||
}
|
||||
|
||||
// Export the hook for this provider
|
||||
export { useMemoryPairs } from './MemoryPairsContext'
|
||||
export function useMatching() {
|
||||
const context = useContext(MatchingContext)
|
||||
if (!context) {
|
||||
throw new Error('useMatching must be used within MatchingProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -3,16 +3,21 @@
|
||||
* Validates all game moves and state transitions
|
||||
*/
|
||||
|
||||
import type { GameCard, MemoryPairsState, Player } from '@/app/games/matching/context/types'
|
||||
import { generateGameCards } from '@/app/games/matching/utils/cardGeneration'
|
||||
import { canFlipCard, validateMatch } from '@/app/games/matching/utils/matchValidation'
|
||||
import type { MatchingGameConfig } from '@/lib/arcade/game-configs'
|
||||
import type { GameValidator, MatchingGameMove, ValidationResult } from './types'
|
||||
import type {
|
||||
GameCard,
|
||||
MatchingConfig,
|
||||
MatchingMove,
|
||||
MatchingState,
|
||||
Player,
|
||||
} from './types'
|
||||
import { generateGameCards } from './utils/cardGeneration'
|
||||
import { canFlipCard, validateMatch } from './utils/matchValidation'
|
||||
import type { GameValidator, ValidationResult } from '@/lib/arcade/validation/types'
|
||||
|
||||
export class MatchingGameValidator implements GameValidator<MemoryPairsState, MatchingGameMove> {
|
||||
export class MatchingGameValidator implements GameValidator<MatchingState, MatchingMove> {
|
||||
validateMove(
|
||||
state: MemoryPairsState,
|
||||
move: MatchingGameMove,
|
||||
state: MatchingState,
|
||||
move: MatchingMove,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
): ValidationResult {
|
||||
switch (move.type) {
|
||||
@@ -51,7 +56,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
|
||||
private validateFlipCard(
|
||||
state: MemoryPairsState,
|
||||
state: MatchingState,
|
||||
cardId: string,
|
||||
playerId: string,
|
||||
context?: { userId?: string; playerOwnership?: Record<string, string> }
|
||||
@@ -198,7 +203,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
|
||||
private validateStartGame(
|
||||
state: MemoryPairsState,
|
||||
state: MatchingState,
|
||||
activePlayers: Player[],
|
||||
cards?: GameCard[],
|
||||
playerMetadata?: { [playerId: string]: any }
|
||||
@@ -216,7 +221,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
// Use provided cards or generate new ones
|
||||
const gameCards = cards || generateGameCards(state.gameType, state.difficulty)
|
||||
|
||||
const newState: MemoryPairsState = {
|
||||
const newState: MatchingState = {
|
||||
...state,
|
||||
gameCards,
|
||||
cards: gameCards,
|
||||
@@ -249,7 +254,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
private validateClearMismatch(state: MemoryPairsState): ValidationResult {
|
||||
private validateClearMismatch(state: MatchingState): ValidationResult {
|
||||
// Only clear if there's actually a mismatch showing
|
||||
// This prevents race conditions where CLEAR_MISMATCH arrives after cards have already been cleared
|
||||
if (!state.showMismatchFeedback || state.flippedCards.length === 0) {
|
||||
@@ -303,7 +308,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
* - Saves game state for resume if coming from active game
|
||||
* - Resets game progression state (scores, cards, etc.)
|
||||
*/
|
||||
private validateGoToSetup(state: MemoryPairsState): ValidationResult {
|
||||
private validateGoToSetup(state: MatchingState): ValidationResult {
|
||||
// Determine if we're pausing an active game (for Resume functionality)
|
||||
const isPausingGame = state.gamePhase === 'playing' || state.gamePhase === 'results'
|
||||
|
||||
@@ -374,7 +379,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
* @param value New value for the field
|
||||
*/
|
||||
private validateSetConfig(
|
||||
state: MemoryPairsState,
|
||||
state: MatchingState,
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer',
|
||||
value: any
|
||||
): ValidationResult {
|
||||
@@ -446,7 +451,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
* - Restores game state and phase
|
||||
* - Clears paused game state
|
||||
*/
|
||||
private validateResumeGame(state: MemoryPairsState): ValidationResult {
|
||||
private validateResumeGame(state: MatchingState): ValidationResult {
|
||||
// Must be in setup phase
|
||||
if (state.gamePhase !== 'setup') {
|
||||
return {
|
||||
@@ -509,7 +514,7 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
* which card a player is hovering over for UI feedback to other players.
|
||||
*/
|
||||
private validateHoverCard(
|
||||
state: MemoryPairsState,
|
||||
state: MatchingState,
|
||||
cardId: string | null,
|
||||
playerId: string
|
||||
): ValidationResult {
|
||||
@@ -527,11 +532,11 @@ export class MatchingGameValidator implements GameValidator<MemoryPairsState, Ma
|
||||
}
|
||||
}
|
||||
|
||||
isGameComplete(state: MemoryPairsState): boolean {
|
||||
isGameComplete(state: MatchingState): boolean {
|
||||
return state.gamePhase === 'results' || state.matchedPairs === state.totalPairs
|
||||
}
|
||||
|
||||
getInitialState(config: MatchingGameConfig): MemoryPairsState {
|
||||
getInitialState(config: MatchingConfig): MatchingState {
|
||||
return {
|
||||
cards: [],
|
||||
gameCards: [],
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import emojiData from 'emojibase-data/en/data.json'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { PLAYER_EMOJIS } from '../../../../constants/playerEmojis'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { PLAYER_EMOJIS } from '@/constants/playerEmojis'
|
||||
|
||||
// Proper TypeScript interface for emojibase-data structure
|
||||
interface EmojibaseEmoji {
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import type { GameCardProps } from '../context/types'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import type { GameCardProps } from '../types'
|
||||
|
||||
export function GameCard({ card, isFlipped, isMatched, onClick, disabled = false }: GameCardProps) {
|
||||
const appConfig = useAbacusConfig()
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { MemoryGrid } from '@/components/matching/MemoryGrid'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useMatching } from '../Provider'
|
||||
import { getGridConfiguration } from '../utils/cardGeneration'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
export function GamePhase() {
|
||||
const { state, flipCard, hoverCard, gameMode } = useMemoryPairs()
|
||||
const { state, flipCard, hoverCard, gameMode } = useMatching()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
const gridConfig = useMemo(() => getGridConfiguration(state.difficulty), [state.difficulty])
|
||||
@@ -3,17 +3,17 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { StandardGameLayout } from '@/components/StandardGameLayout'
|
||||
import { useFullscreen } from '@/contexts/FullscreenContext'
|
||||
import { useMatching } from '../Provider'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, resetGame, goToSetup } = useMemoryPairs()
|
||||
const { state, exitSession, resetGame, goToSetup } = useMatching()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { gamePlurals } from '../../../../utils/pluralization'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { gamePlurals } from '@/utils/pluralization'
|
||||
import { useMatching } from '../Provider'
|
||||
|
||||
interface PlayerStatusBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
|
||||
const { state } = useMemoryPairs()
|
||||
const { state } = useMatching()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
// Get active players from game state (not GameModeContext)
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useMatching } from '../Provider'
|
||||
import { formatGameTime, getMultiplayerWinner, getPerformanceAnalysis } from '../utils/gameScoring'
|
||||
|
||||
export function ResultsPhase() {
|
||||
const router = useRouter()
|
||||
const { state, resetGame, activePlayers, gameMode, exitSession } = useMemoryPairs()
|
||||
const { state, resetGame, activePlayers, gameMode, exitSession } = useMatching()
|
||||
const { players: playerMap, activePlayers: activePlayerIds } = useGameMode()
|
||||
|
||||
// Get active player data array
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
import { useGameMode } from '../../../../contexts/GameModeContext'
|
||||
import { useMemoryPairs } from '../context/MemoryPairsContext'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useGameMode } from '@/contexts/GameModeContext'
|
||||
import { useMatching } from '../Provider'
|
||||
|
||||
// Add bounce animation for the start button
|
||||
const bounceAnimation = `
|
||||
@@ -39,7 +39,7 @@ export function SetupPhase() {
|
||||
canResumeGame,
|
||||
hasConfigChanged,
|
||||
activePlayers: _activePlayers,
|
||||
} = useMemoryPairs()
|
||||
} = useMatching()
|
||||
|
||||
const { activePlayerCount, gameMode: _globalGameMode } = useGameMode()
|
||||
|
||||
11
apps/web/src/arcade-games/matching/components/index.ts
Normal file
11
apps/web/src/arcade-games/matching/components/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Matching Pairs Battle - Components
|
||||
*/
|
||||
|
||||
export { MemoryPairsGame } from './MemoryPairsGame'
|
||||
export { SetupPhase } from './SetupPhase'
|
||||
export { GamePhase } from './GamePhase'
|
||||
export { ResultsPhase } from './ResultsPhase'
|
||||
export { GameCard } from './GameCard'
|
||||
export { PlayerStatusBar } from './PlayerStatusBar'
|
||||
export { EmojiPicker } from './EmojiPicker'
|
||||
76
apps/web/src/arcade-games/matching/index.ts
Normal file
76
apps/web/src/arcade-games/matching/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Matching Pairs Battle Game Definition
|
||||
*
|
||||
* A turn-based multiplayer memory game where players flip cards to find matching pairs.
|
||||
* Supports both abacus-numeral matching and complement pairs modes.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { MatchingProvider } from './Provider'
|
||||
import type { MatchingConfig, MatchingMove, MatchingState } from './types'
|
||||
import { matchingGameValidator } from './Validator'
|
||||
|
||||
const manifest: GameManifest = {
|
||||
name: 'matching',
|
||||
displayName: 'Matching Pairs Battle',
|
||||
icon: '⚔️',
|
||||
description: 'Multiplayer memory battle with friends',
|
||||
longDescription:
|
||||
'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience. ' +
|
||||
'Choose between abacus-numeral matching or complement pairs mode. Strategic thinking and quick memory are key to victory!',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
|
||||
borderColor: 'purple.200',
|
||||
available: true,
|
||||
}
|
||||
|
||||
const defaultConfig: MatchingConfig = {
|
||||
gameType: 'abacus-numeral',
|
||||
difficulty: 6,
|
||||
turnTimer: 30,
|
||||
}
|
||||
|
||||
// Config validation function
|
||||
function validateMatchingConfig(config: unknown): config is MatchingConfig {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const c = config as any
|
||||
|
||||
// Validate gameType
|
||||
if (!('gameType' in c) || !['abacus-numeral', 'complement-pairs'].includes(c.gameType)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate difficulty (number of pairs)
|
||||
if (!('difficulty' in c) || ![6, 8, 12, 15].includes(c.difficulty)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Validate turnTimer
|
||||
if (
|
||||
!('turnTimer' in c) ||
|
||||
typeof c.turnTimer !== 'number' ||
|
||||
c.turnTimer < 5 ||
|
||||
c.turnTimer > 300
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const matchingGame = defineGame<MatchingConfig, MatchingState, MatchingMove>({
|
||||
manifest,
|
||||
Provider: MatchingProvider,
|
||||
GameComponent: MemoryPairsGame,
|
||||
validator: matchingGameValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateMatchingConfig,
|
||||
})
|
||||
286
apps/web/src/arcade-games/matching/types.ts
Normal file
286
apps/web/src/arcade-games/matching/types.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Matching Pairs Battle - Type Definitions
|
||||
*
|
||||
* SDK-compatible types for the matching game.
|
||||
*/
|
||||
|
||||
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
|
||||
|
||||
// ============================================================================
|
||||
// Core Types
|
||||
// ============================================================================
|
||||
|
||||
export type GameMode = 'single' | 'multiplayer'
|
||||
export type GameType = 'abacus-numeral' | 'complement-pairs'
|
||||
export type GamePhase = 'setup' | 'playing' | 'results'
|
||||
export type CardType = 'abacus' | 'number' | 'complement'
|
||||
export type Difficulty = 6 | 8 | 12 | 15 // Number of pairs
|
||||
export type Player = string // Player ID (UUID)
|
||||
export type TargetSum = 5 | 10 | 20
|
||||
|
||||
// ============================================================================
|
||||
// Game Configuration (SDK-compatible)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for matching game
|
||||
* Extends GameConfig for SDK compatibility
|
||||
*/
|
||||
export interface MatchingConfig extends GameConfig {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Entities
|
||||
// ============================================================================
|
||||
|
||||
export interface GameCard {
|
||||
id: string
|
||||
type: CardType
|
||||
number: number
|
||||
complement?: number // For complement pairs
|
||||
targetSum?: TargetSum // For complement pairs
|
||||
matched: boolean
|
||||
matchedBy?: Player // For two-player mode
|
||||
element?: HTMLElement | null // For animations
|
||||
}
|
||||
|
||||
export interface PlayerMetadata {
|
||||
id: string // Player ID (UUID)
|
||||
name: string
|
||||
emoji: string
|
||||
userId: string // Which user owns this player
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface PlayerScore {
|
||||
[playerId: string]: number
|
||||
}
|
||||
|
||||
export interface CelebrationAnimation {
|
||||
id: string
|
||||
type: 'match' | 'win' | 'confetti'
|
||||
x: number
|
||||
y: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface GameStatistics {
|
||||
totalMoves: number
|
||||
matchedPairs: number
|
||||
totalPairs: number
|
||||
gameTime: number
|
||||
accuracy: number // Percentage of successful matches
|
||||
averageTimePerMove: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game State (SDK-compatible)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Main game state for matching pairs battle
|
||||
* Extends GameState for SDK compatibility
|
||||
*/
|
||||
export interface MatchingState extends GameState {
|
||||
// Core game data
|
||||
cards: GameCard[]
|
||||
gameCards: GameCard[]
|
||||
flippedCards: GameCard[]
|
||||
|
||||
// Game configuration
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number // Seconds for turn timer
|
||||
|
||||
// Game progression
|
||||
gamePhase: GamePhase
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
totalPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[] // Track active player IDs
|
||||
playerMetadata: Record<string, PlayerMetadata> // Player metadata for cross-user visibility
|
||||
consecutiveMatches: Record<string, number> // Track consecutive matches per player
|
||||
|
||||
// Timing
|
||||
gameStartTime: number | null
|
||||
gameEndTime: number | null
|
||||
currentMoveStartTime: number | null
|
||||
timerInterval: NodeJS.Timeout | null
|
||||
|
||||
// UI state
|
||||
celebrationAnimations: CelebrationAnimation[]
|
||||
isProcessingMove: boolean
|
||||
showMismatchFeedback: boolean
|
||||
lastMatchedPair: [string, string] | null
|
||||
|
||||
// PAUSE/RESUME: Paused game state
|
||||
originalConfig?: {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
pausedGamePhase?: GamePhase
|
||||
pausedGameState?: {
|
||||
gameCards: GameCard[]
|
||||
currentPlayer: Player
|
||||
matchedPairs: number
|
||||
moves: number
|
||||
scores: PlayerScore
|
||||
activePlayers: Player[]
|
||||
playerMetadata: Record<string, PlayerMetadata>
|
||||
consecutiveMatches: Record<string, number>
|
||||
gameStartTime: number | null
|
||||
}
|
||||
|
||||
// HOVER: Networked hover state
|
||||
playerHovers: Record<string, string | null> // playerId -> cardId (or null if not hovering)
|
||||
}
|
||||
|
||||
// For backwards compatibility with existing code
|
||||
export type MemoryPairsState = MatchingState
|
||||
|
||||
// ============================================================================
|
||||
// Context Value
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Context value for the matching game provider
|
||||
* Exposes state and action creators to components
|
||||
*/
|
||||
export interface MatchingContextValue {
|
||||
state: MatchingState & { gameMode: GameMode }
|
||||
dispatch: React.Dispatch<any> // Deprecated - use action creators instead
|
||||
|
||||
// Computed values
|
||||
isGameActive: boolean
|
||||
canFlipCard: (cardId: string) => boolean
|
||||
currentGameStatistics: GameStatistics
|
||||
gameMode: GameMode
|
||||
activePlayers: Player[]
|
||||
|
||||
// Pause/Resume
|
||||
hasConfigChanged: boolean
|
||||
canResumeGame: boolean
|
||||
|
||||
// Actions
|
||||
startGame: () => void
|
||||
flipCard: (cardId: string) => void
|
||||
resetGame: () => void
|
||||
setGameType: (type: GameType) => void
|
||||
setDifficulty: (difficulty: Difficulty) => void
|
||||
setTurnTimer: (timer: number) => void
|
||||
goToSetup: () => void
|
||||
resumeGame: () => void
|
||||
hoverCard: (cardId: string | null) => void
|
||||
exitSession: () => void
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Game Moves (SDK-compatible)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* All possible moves in the matching game
|
||||
* These match the move types validated by MatchingGameValidator
|
||||
*/
|
||||
export type MatchingMove =
|
||||
| {
|
||||
type: 'FLIP_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cardId: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'START_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cards: GameCard[]
|
||||
activePlayers: string[]
|
||||
playerMetadata: Record<string, PlayerMetadata>
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'CLEAR_MISMATCH'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'GO_TO_SETUP'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'SET_CONFIG'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'RESUME_GAME'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: Record<string, never>
|
||||
}
|
||||
| {
|
||||
type: 'HOVER_CARD'
|
||||
playerId: string
|
||||
userId: string
|
||||
timestamp: number
|
||||
data: {
|
||||
cardId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export interface GameCardProps {
|
||||
card: GameCard
|
||||
isFlipped: boolean
|
||||
isMatched: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface PlayerIndicatorProps {
|
||||
player: Player
|
||||
isActive: boolean
|
||||
score: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface GameGridProps {
|
||||
cards: GameCard[]
|
||||
onCardClick: (cardId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation
|
||||
// ============================================================================
|
||||
|
||||
export interface MatchValidationResult {
|
||||
isValid: boolean
|
||||
reason?: string
|
||||
type: 'abacus-numeral' | 'complement' | 'invalid'
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Difficulty, GameCard, GameType } from '../context/types'
|
||||
import type { Difficulty, GameCard, GameType } from '../types'
|
||||
|
||||
// Utility function to generate unique random numbers
|
||||
function generateUniqueNumbers(count: number, options: { min: number; max: number }): number[] {
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameStatistics, MemoryPairsState, Player } from '../context/types'
|
||||
import type { GameStatistics, MemoryPairsState, Player } from '../types'
|
||||
|
||||
// Calculate final game score based on multiple factors
|
||||
export function calculateFinalScore(
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameCard, MatchValidationResult } from '../context/types'
|
||||
import type { GameCard, MatchValidationResult } from '../types'
|
||||
|
||||
// Validate abacus-numeral match (abacus card matches with number card of same value)
|
||||
export function validateAbacusNumeralMatch(
|
||||
@@ -7,22 +7,8 @@ import { getAllGames } from '../lib/arcade/game-registry'
|
||||
import { GameCard } from './GameCard'
|
||||
|
||||
// Game configuration defining player limits
|
||||
// Note: "matching" (formerly "battle-arena") has been migrated to the modular game system
|
||||
export const GAMES_CONFIG = {
|
||||
'battle-arena': {
|
||||
name: 'Matching Pairs Battle',
|
||||
fullName: 'Matching Pairs Battle ⚔️',
|
||||
maxPlayers: 4,
|
||||
description: 'Multiplayer memory battle with friends',
|
||||
longDescription:
|
||||
'Battle friends in epic memory challenges. Match pairs faster than your opponents in this exciting multiplayer experience.',
|
||||
url: '/arcade/matching',
|
||||
icon: '⚔️',
|
||||
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
|
||||
borderColor: 'purple.200',
|
||||
difficulty: 'Intermediate',
|
||||
},
|
||||
'complement-race': {
|
||||
name: 'Speed Complement Race',
|
||||
fullName: 'Speed Complement Race 🏁',
|
||||
|
||||
@@ -18,32 +18,29 @@ interface StandardGameLayoutProps {
|
||||
export function StandardGameLayout({ children, className }: StandardGameLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
{
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
className={`${css({
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
|
||||
// Safe area for navigation (fixed at top: 4px, right: 4px)
|
||||
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
|
||||
paddingTop: '80px',
|
||||
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '4px',
|
||||
// Safe area for navigation (fixed at top: 4px, right: 4px)
|
||||
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
|
||||
paddingTop: '80px',
|
||||
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '4px',
|
||||
|
||||
// Box sizing to include padding in dimensions
|
||||
boxSizing: 'border-box',
|
||||
// Box sizing to include padding in dimensions
|
||||
boxSizing: 'border-box',
|
||||
|
||||
// Flex container for game content
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Flex container for game content
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
// Transparent background - themes will be applied at nav level
|
||||
background: 'transparent',
|
||||
},
|
||||
className
|
||||
)}
|
||||
// Transparent background - themes will be applied at nav level
|
||||
background: 'transparent',
|
||||
})} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -319,7 +319,7 @@ export function MemoryGrid<
|
||||
.map(([playerId, cardId]) => {
|
||||
const playerInfo = getPlayerHoverInfo(playerId)
|
||||
// Get card element if player is hovering (cardId might be null)
|
||||
const cardElement = cardId ? cardRefs.current.get(cardId) : null
|
||||
const cardElement = cardId ? (cardRefs.current.get(cardId) ?? null) : null
|
||||
// Check if it's this player's turn
|
||||
const isPlayersTurn = state.currentPlayer === playerId
|
||||
// Check if the card being hovered is flipped
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
import { EmojiPicker } from '@/arcade-games/matching/components/EmojiPicker'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
import { generateUniquePlayerName } from '../../utils/playerNames'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { MemoryPairsState } from '@/app/games/matching/context/types'
|
||||
import type { MemoryPairsState } from '@/arcade-games/matching/types'
|
||||
import { db, schema } from '@/db'
|
||||
import {
|
||||
applyGameMove,
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Shared game configuration types
|
||||
*
|
||||
* ARCHITECTURE: Phase 3 - Type Inference
|
||||
* - Modern games (number-guesser, math-sprint, memory-quiz): Types inferred from game definitions
|
||||
* - Legacy games (matching, complement-race): Manual types until migrated
|
||||
* - Modern games (number-guesser, math-sprint, memory-quiz, matching): Types inferred from game definitions
|
||||
* - Legacy games (complement-race): Manual types until migrated
|
||||
*
|
||||
* These types are used across:
|
||||
* - Database storage (room_game_configs table)
|
||||
@@ -12,12 +12,11 @@
|
||||
* - Helper functions (reading/writing configs)
|
||||
*/
|
||||
|
||||
import type { Difficulty, GameType } from '@/app/games/matching/context/types'
|
||||
|
||||
// Type-only imports (won't load React components at runtime)
|
||||
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import type { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
|
||||
import type { matchingGame } from '@/arcade-games/matching'
|
||||
|
||||
/**
|
||||
* Utility type: Extract config type from a game definition
|
||||
@@ -47,20 +46,17 @@ export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
|
||||
*/
|
||||
export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
|
||||
|
||||
/**
|
||||
* Configuration for matching (memory pairs battle) game
|
||||
* INFERRED from matchingGame.defaultConfig
|
||||
*/
|
||||
export type MatchingGameConfig = InferGameConfig<typeof matchingGame>
|
||||
|
||||
// ============================================================================
|
||||
// Legacy Games (Manual Type Definitions)
|
||||
// TODO: Migrate these games to the modular system for type inference
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for matching (memory pairs) game
|
||||
*/
|
||||
export interface MatchingGameConfig {
|
||||
gameType: GameType
|
||||
difficulty: Difficulty
|
||||
turnTimer: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for complement-race game
|
||||
* TODO: Define when implementing complement-race settings
|
||||
@@ -83,9 +79,9 @@ export type GameConfigByName = {
|
||||
'number-guesser': NumberGuesserGameConfig
|
||||
'math-sprint': MathSprintGameConfig
|
||||
'memory-quiz': MemoryQuizGameConfig
|
||||
matching: MatchingGameConfig
|
||||
|
||||
// Legacy games (manual types)
|
||||
matching: MatchingGameConfig
|
||||
'complement-race': ComplementRaceGameConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,9 @@ export function clearRegistry(): void {
|
||||
import { numberGuesserGame } from '@/arcade-games/number-guesser'
|
||||
import { mathSprintGame } from '@/arcade-games/math-sprint'
|
||||
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
|
||||
import { matchingGame } from '@/arcade-games/matching'
|
||||
|
||||
registerGame(numberGuesserGame)
|
||||
registerGame(mathSprintGame)
|
||||
registerGame(memoryQuizGame)
|
||||
registerGame(matchingGame)
|
||||
|
||||
@@ -7,15 +7,18 @@ import type { ReactNode } from 'react'
|
||||
import type { GameManifest } from '../manifest-schema'
|
||||
import type {
|
||||
GameMove as BaseGameMove,
|
||||
GameValidator as BaseGameValidator,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
GameValidator,
|
||||
} from '../validation/types'
|
||||
|
||||
/**
|
||||
* Re-export base validation types from arcade system
|
||||
*/
|
||||
export type { GameMove, ValidationContext, ValidationResult } from '../validation/types'
|
||||
export type {
|
||||
GameMove,
|
||||
GameValidator,
|
||||
ValidationContext,
|
||||
ValidationResult,
|
||||
} from '../validation/types'
|
||||
export { TEAM_MOVE } from '../validation/types'
|
||||
export type { TeamMoveSentinel } from '../validation/types'
|
||||
|
||||
@@ -31,17 +34,6 @@ export type GameConfig = Record<string, unknown>
|
||||
*/
|
||||
export type GameState = Record<string, unknown>
|
||||
|
||||
/**
|
||||
* Game validator interface
|
||||
* Games must implement this to validate moves server-side
|
||||
*/
|
||||
export interface GameValidator<TState = GameState, TMove extends BaseGameMove = BaseGameMove>
|
||||
extends BaseGameValidator<TState, TMove> {
|
||||
validateMove(state: TState, move: TMove, context?: ValidationContext): ValidationResult
|
||||
isGameComplete(state: TState): boolean
|
||||
getInitialState(config: unknown): TState
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component interface
|
||||
* Each game provides a React context provider that wraps the game UI
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Used on both client and server for arcade session validation
|
||||
*/
|
||||
|
||||
import type { MemoryPairsState } from '@/app/games/matching/context/types'
|
||||
import type { MemoryPairsState } from '@/arcade-games/matching/types'
|
||||
import type { MemoryQuizState as SorobanQuizState } from '@/arcade-games/memory-quiz/types'
|
||||
|
||||
/**
|
||||
@@ -33,130 +33,25 @@ export interface GameMove {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
// Matching game specific moves
|
||||
export interface MatchingFlipCardMove extends GameMove {
|
||||
type: 'FLIP_CARD'
|
||||
data: {
|
||||
cardId: string
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Re-export game-specific move types from their respective modules
|
||||
* This maintains a single source of truth (game types) while providing
|
||||
* convenient access for validation code.
|
||||
*/
|
||||
export type { MatchingMove } from '@/arcade-games/matching/types'
|
||||
export type { MemoryQuizMove } from '@/arcade-games/memory-quiz/types'
|
||||
export type { NumberGuesserMove } from '@/arcade-games/number-guesser/types'
|
||||
export type { MathSprintMove } from '@/arcade-games/math-sprint/types'
|
||||
|
||||
export interface MatchingStartGameMove extends GameMove {
|
||||
type: 'START_GAME'
|
||||
data: {
|
||||
activePlayers: string[] // Player IDs (UUIDs)
|
||||
cards?: any[] // GameCard type from context
|
||||
playerMetadata?: { [playerId: string]: any } // Player metadata for cross-user visibility
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Re-export game-specific state types from their respective modules
|
||||
*/
|
||||
export type { MatchingState } from '@/arcade-games/matching/types'
|
||||
export type { MemoryQuizState } from '@/arcade-games/memory-quiz/types'
|
||||
export type { NumberGuesserState } from '@/arcade-games/number-guesser/types'
|
||||
export type { MathSprintState } from '@/arcade-games/math-sprint/types'
|
||||
|
||||
export interface MatchingClearMismatchMove extends GameMove {
|
||||
type: 'CLEAR_MISMATCH'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
// Standard setup moves - pattern for all arcade games
|
||||
export interface MatchingGoToSetupMove extends GameMove {
|
||||
type: 'GO_TO_SETUP'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MatchingSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'gameType' | 'difficulty' | 'turnTimer'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface MatchingResumeGameMove extends GameMove {
|
||||
type: 'RESUME_GAME'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MatchingHoverCardMove extends GameMove {
|
||||
type: 'HOVER_CARD'
|
||||
data: {
|
||||
cardId: string | null // null when mouse leaves card
|
||||
}
|
||||
}
|
||||
|
||||
export type MatchingGameMove =
|
||||
| MatchingFlipCardMove
|
||||
| MatchingStartGameMove
|
||||
| MatchingClearMismatchMove
|
||||
| MatchingGoToSetupMove
|
||||
| MatchingSetConfigMove
|
||||
| MatchingResumeGameMove
|
||||
| MatchingHoverCardMove
|
||||
|
||||
// Memory Quiz game specific moves
|
||||
export interface MemoryQuizStartQuizMove extends GameMove {
|
||||
type: 'START_QUIZ'
|
||||
data: {
|
||||
quizCards: any[] // QuizCard type from memory-quiz types
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizNextCardMove extends GameMove {
|
||||
type: 'NEXT_CARD'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizShowInputPhaseMove extends GameMove {
|
||||
type: 'SHOW_INPUT_PHASE'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizAcceptNumberMove extends GameMove {
|
||||
type: 'ACCEPT_NUMBER'
|
||||
data: {
|
||||
number: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizRejectNumberMove extends GameMove {
|
||||
type: 'REJECT_NUMBER'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizSetInputMove extends GameMove {
|
||||
type: 'SET_INPUT'
|
||||
data: {
|
||||
input: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface MemoryQuizShowResultsMove extends GameMove {
|
||||
type: 'SHOW_RESULTS'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizResetQuizMove extends GameMove {
|
||||
type: 'RESET_QUIZ'
|
||||
data: Record<string, never>
|
||||
}
|
||||
|
||||
export interface MemoryQuizSetConfigMove extends GameMove {
|
||||
type: 'SET_CONFIG'
|
||||
data: {
|
||||
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
|
||||
value: any
|
||||
}
|
||||
}
|
||||
|
||||
export type MemoryQuizGameMove =
|
||||
| MemoryQuizStartQuizMove
|
||||
| MemoryQuizNextCardMove
|
||||
| MemoryQuizShowInputPhaseMove
|
||||
| MemoryQuizAcceptNumberMove
|
||||
| MemoryQuizRejectNumberMove
|
||||
| MemoryQuizSetInputMove
|
||||
| MemoryQuizShowResultsMove
|
||||
| MemoryQuizResetQuizMove
|
||||
| MemoryQuizSetConfigMove
|
||||
|
||||
// Generic game state union
|
||||
// Generic game state union (for backwards compatibility)
|
||||
export type GameState = MemoryPairsState | SorobanQuizState // Add other game states as union later
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* 3. GameName type will auto-update
|
||||
*/
|
||||
|
||||
import { matchingGameValidator } from './validation/MatchingGameValidator'
|
||||
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
|
||||
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
|
||||
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
|
||||
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function getSocketIO(): Promise<SocketIOServerType | null> {
|
||||
if (!socketServerModule) {
|
||||
try {
|
||||
// Dynamic import to avoid bundling issues
|
||||
socketServerModule = await import('../../socket-server')
|
||||
socketServerModule = await import('../socket-server')
|
||||
} catch (error) {
|
||||
console.error('[Socket IO] Failed to load socket server:', error)
|
||||
return null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user