Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb682ed79e | ||
|
|
4ab093a9d8 | ||
|
|
aafba77d62 | ||
|
|
feecda78d0 | ||
|
|
1eb6ceca19 | ||
|
|
e72839e0f3 | ||
|
|
bb59c61638 | ||
|
|
593aed81cc | ||
|
|
0f55909533 | ||
|
|
c92d7d9d89 | ||
|
|
34a377d91b | ||
|
|
3dcdfb4986 | ||
|
|
0209975af6 | ||
|
|
c9b7e92f39 | ||
|
|
c56a47cb60 | ||
|
|
bdb84f5d90 | ||
|
|
33838b7fa7 | ||
|
|
33e9ad2f79 | ||
|
|
db62519f9b | ||
|
|
ec978de0b3 | ||
|
|
d9a7694031 | ||
|
|
42dcbff857 | ||
|
|
5923d341a0 | ||
|
|
cd4796024e | ||
|
|
cff948708f | ||
|
|
ea10c16811 | ||
|
|
474d31576f | ||
|
|
73ff32c243 | ||
|
|
0a50c733b0 | ||
|
|
1386378ca1 | ||
|
|
30f48ab897 | ||
|
|
d2f6b8b46c | ||
|
|
247377fca3 | ||
|
|
be39401716 | ||
|
|
d2a3b7ae2e | ||
|
|
39ab605279 | ||
|
|
cf9d893f3f | ||
|
|
e6d0bd4953 | ||
|
|
1b57f6ddec | ||
|
|
d38ea312a7 | ||
|
|
06aca986ac | ||
|
|
a126466037 | ||
|
|
9a53d7e5db | ||
|
|
d2d8f7740f | ||
|
|
29af265958 | ||
|
|
291bcc581d | ||
|
|
26edec1bbf | ||
|
|
da4fdc90e0 | ||
|
|
ee6c4f2f4f | ||
|
|
9b9f0cdbcb | ||
|
|
e14ffe44d6 | ||
|
|
d5bc0bb27c | ||
|
|
0790074ffc | ||
|
|
1a44daf2ce | ||
|
|
9679d68154 | ||
|
|
80ba94203d | ||
|
|
87631af678 |
192
CHANGELOG.md
192
CHANGELOG.md
@@ -1,3 +1,195 @@
|
||||
## [4.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.15...v4.14.0) (2025-10-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **card-sorting:** add spectator mode UX enhancements ([4ab093a](https://github.com/antialias/soroban-abacus-flashcards/commit/4ab093a9d8ba5b290da44aaa6aa71ad7d7149b32))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **arcade:** add comprehensive spectator mode documentation ([1eb6cec](https://github.com/antialias/soroban-abacus-flashcards/commit/1eb6ceca19805be53dfe3194e07e68002b2d09a7))
|
||||
* **arcade:** spec spectator mode UX enhancements for card sorting ([aafba77](https://github.com/antialias/soroban-abacus-flashcards/commit/aafba77d62b47403f21f5dd72859d6a6fbb1efac))
|
||||
|
||||
## [4.13.15](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.14...v4.13.15) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** bypass PEP 668 externally-managed-environment error ([bb59c61](https://github.com/antialias/soroban-abacus-flashcards/commit/bb59c61638e60b0678043e954e044d9390f88e7f))
|
||||
|
||||
## [4.13.14](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.13...v4.13.14) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** install py3-pip for Python dependency installation ([0f55909](https://github.com/antialias/soroban-abacus-flashcards/commit/0f55909533414bdc07f113b93bb8bfa21367959b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add Panda CSS styling framework documentation ([c92d7d9](https://github.com/antialias/soroban-abacus-flashcards/commit/c92d7d9d89a72e012c30fc5ac88fa96e7a526f83))
|
||||
* **arcade:** fix incorrect Tailwind references - use Panda CSS ([34a377d](https://github.com/antialias/soroban-abacus-flashcards/commit/34a377d91b37ad47968b85aedd112f9fcf72ad63))
|
||||
|
||||
## [4.13.13](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.12...v4.13.13) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **docker:** install Python dependencies for flashcard generation ([c9b7e92](https://github.com/antialias/soroban-abacus-flashcards/commit/c9b7e92f39ee7aa7f13606c2836763144df102e7))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** standardize game card themes with preset system ([0209975](https://github.com/antialias/soroban-abacus-flashcards/commit/0209975af642944cc5a434c0b44205a87e634e7e)), closes [#99f6e4](https://github.com/antialias/soroban-abacus-flashcards/issues/99f6e4) [#5eead4](https://github.com/antialias/soroban-abacus-flashcards/issues/5eead4)
|
||||
|
||||
## [4.13.12](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.11...v4.13.12) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** use blue gradient matching other game cards ([bdb84f5](https://github.com/antialias/soroban-abacus-flashcards/commit/bdb84f5d909542060fa886a83a5af62c4a785a98))
|
||||
|
||||
## [4.13.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.10...v4.13.11) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** match game selector background to other games ([db62519](https://github.com/antialias/soroban-abacus-flashcards/commit/db62519f9beb0b4bc6120e1fd5ec251cfde5c3c1)), closes [#ccfbf1](https://github.com/antialias/soroban-abacus-flashcards/issues/ccfbf1) [#99f6e4](https://github.com/antialias/soroban-abacus-flashcards/issues/99f6e4)
|
||||
* **docker:** copy core package with Python scripts to production image ([33e9ad2](https://github.com/antialias/soroban-abacus-flashcards/commit/33e9ad2f79b591f1c5ee57a6691e1bcf48420859))
|
||||
|
||||
## [4.13.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.9...v4.13.10) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add Typst to Docker image for flashcard generation ([d9a7694](https://github.com/antialias/soroban-abacus-flashcards/commit/d9a769403187bf70fb069be7ffe77417a62271a5))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove 'Complete Soroban Learning Platform' section ([42dcbff](https://github.com/antialias/soroban-abacus-flashcards/commit/42dcbff85708ad378550634cbf7a3345eccb578e))
|
||||
|
||||
## [4.13.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.8...v4.13.9) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set color on abacus container div for numeral visibility ([cd47960](https://github.com/antialias/soroban-abacus-flashcards/commit/cd4796024e41f731ae5d83c82f6582e19d6eaf99)), closes [#1f2937](https://github.com/antialias/soroban-abacus-flashcards/issues/1f2937)
|
||||
|
||||
## [4.13.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.7...v4.13.8) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use color instead of fill for numeral styling ([ea10c16](https://github.com/antialias/soroban-abacus-flashcards/commit/ea10c16811eb969b9963417079c330ae9ff295ba))
|
||||
|
||||
## [4.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.6...v4.13.7) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add dark color for abacus numerals ([73ff32c](https://github.com/antialias/soroban-abacus-flashcards/commit/73ff32c2432beb62710e57aa8b3b4793eca43fda)), closes [#1f2937](https://github.com/antialias/soroban-abacus-flashcards/issues/1f2937)
|
||||
* use app-wide abacus config and remove instruction text ([0a50c73](https://github.com/antialias/soroban-abacus-flashcards/commit/0a50c733b089c7c341f0fdef47da78d1c61a3cb5))
|
||||
|
||||
## [4.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.5...v4.13.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* simplify abacus pane with light background ([30f48ab](https://github.com/antialias/soroban-abacus-flashcards/commit/30f48ab8976976688e089b07ece7fdae6d7ada79))
|
||||
|
||||
## [4.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.4...v4.13.5) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct AbacusReact API usage and add structural styling ([247377f](https://github.com/antialias/soroban-abacus-flashcards/commit/247377fca35ee3433e02ad594ecc1c4f391f0143)), closes [#fbbf24](https://github.com/antialias/soroban-abacus-flashcards/issues/fbbf24) [#a78](https://github.com/antialias/soroban-abacus-flashcards/issues/a78)
|
||||
|
||||
## [4.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.3...v4.13.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** increase card tile sizes to contain abacuses ([d2a3b7a](https://github.com/antialias/soroban-abacus-flashcards/commit/d2a3b7ae2e3f6819b8d9ace32be22f04f748d1bc))
|
||||
|
||||
## [4.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.2...v4.13.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** increase SVG size to fill card containers ([cf9d893](https://github.com/antialias/soroban-abacus-flashcards/commit/cf9d893f3fdbef6e91cd0ba283d602b9215569f1))
|
||||
|
||||
## [4.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.1...v4.13.2) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* show initial value and improve numeral contrast ([1b57f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/1b57f6ddecf3a118f2e4fadd1a91be1256f5a034)), closes [#fbbf24](https://github.com/antialias/soroban-abacus-flashcards/issues/fbbf24)
|
||||
|
||||
## [4.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.13.0...v4.13.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use defaultValue for interactive abacus control ([06aca98](https://github.com/antialias/soroban-abacus-flashcards/commit/06aca986ace4d76b70f2fd2f5e57f66758185b38))
|
||||
|
||||
## [4.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.12.0...v4.13.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* make home page abacus interactive with audio ([9a53d7e](https://github.com/antialias/soroban-abacus-flashcards/commit/9a53d7e5db18853aca4e2e0c7abc799217feaecf))
|
||||
|
||||
## [4.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.11.1...v4.12.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* redesign home page with component showcase ([29af265](https://github.com/antialias/soroban-abacus-flashcards/commit/29af265958f9fdab0253b92e153c01575840454d))
|
||||
|
||||
## [4.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.11.0...v4.11.1) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** center AbacusReact SVGs in card tiles ([26edec1](https://github.com/antialias/soroban-abacus-flashcards/commit/26edec1bbf038264405ec9d161edcd18f67a6fc6))
|
||||
|
||||
## [4.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.6...v4.11.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **home:** redesign home page to showcase complete platform ([ee6c4f2](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6c4f2f4f39e3b30f59c54866c3857c218fb80f))
|
||||
|
||||
## [4.10.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.5...v4.10.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** position slots flow horizontally with wrap ([e14ffe4](https://github.com/antialias/soroban-abacus-flashcards/commit/e14ffe44d66d0c97bc0cc4e0c255698e88ce723a))
|
||||
|
||||
## [4.10.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.4...v4.10.5) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** merge /arcade/room into /arcade route ([0790074](https://github.com/antialias/soroban-abacus-flashcards/commit/0790074ffc5008bce9a162fe0ddbd1d5c214c4f7))
|
||||
|
||||
## [4.10.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.3...v4.10.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** match Python card layout with flex wrap ([9679d68](https://github.com/antialias/soroban-abacus-flashcards/commit/9679d68154ac8b6a2f905ec7d17a34b39bc00237))
|
||||
|
||||
## [4.10.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.2...v4.10.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** remove broken query param from game URLs ([87631af](https://github.com/antialias/soroban-abacus-flashcards/commit/87631af6788bd7b42e671374e55ec0ad8435900c))
|
||||
|
||||
## [4.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.1...v4.10.2) (2025-10-18)
|
||||
|
||||
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -33,8 +33,8 @@ RUN turbo build --filter=@soroban/web
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python and build tools for better-sqlite3 (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-setuptools make g++
|
||||
# Install Python, pip, build tools for better-sqlite3, and Typst (needed at runtime)
|
||||
RUN apk add --no-cache python3 py3-pip py3-setuptools make g++ typst
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
@@ -55,6 +55,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizz
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
# Copy core package (needed for Python flashcard generation scripts)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/core ./packages/core
|
||||
|
||||
# Install Python dependencies for flashcard generation
|
||||
RUN pip3 install --no-cache-dir --break-system-packages -r packages/core/requirements.txt
|
||||
|
||||
# Copy package.json files for module resolution
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./apps/web/
|
||||
|
||||
@@ -16,6 +16,7 @@ Following `docs/terminology-user-player-room.md`:
|
||||
- **PLAYER ROSTER** - All PLAYERS belonging to a USER (can have many)
|
||||
- **ACTIVE PLAYERS** - PLAYERS where `isActive = true` - these are the ones that actually participate in games
|
||||
- **ROOM MEMBER** - A USER's participation in a multiplayer room (tracked in `room_members` table)
|
||||
- **SPECTATOR** - A room member who watches another player's game without participating (see Spectator Mode section)
|
||||
|
||||
**Important:** A USER can have many PLAYERS in their roster, but only the ACTIVE PLAYERS (where `isActive = true`) participate in games. This enables "hot-potato" style local multiplayer where multiple people share the same device. This is LOCAL play (not networked), even though multiple PLAYERS participate.
|
||||
|
||||
@@ -27,25 +28,46 @@ In arcade sessions:
|
||||
|
||||
## Critical Architectural Requirements
|
||||
|
||||
### 1. Mode Isolation (MUST ENFORCE)
|
||||
### 1. Game Synchronization Modes
|
||||
|
||||
**Local Play** (`/arcade/[game-name]`)
|
||||
The arcade system supports three synchronization patterns:
|
||||
|
||||
#### Local Play (No Network Sync)
|
||||
**Route**: Custom route or dedicated local page
|
||||
**Use Case**: Practice, offline play, or games that should never be visible to others
|
||||
|
||||
- MUST NOT sync game state across the network
|
||||
- MUST NOT use room data, even if the USER is currently a member of an active room
|
||||
- MUST create isolated, per-USER game sessions
|
||||
- MUST pass `roomId: undefined` to `useArcadeSession`
|
||||
- Game state lives only in the current browser tab/session
|
||||
- CAN have multiple ACTIVE PLAYERS from the same USER (local multiplayer / hot-potato)
|
||||
- State is NOT shared across the network, only within the browser session
|
||||
|
||||
**Room-Based Play** (`/arcade/room`)
|
||||
#### Room-Based with Spectator Mode (RECOMMENDED PATTERN)
|
||||
**Route**: `/arcade/room` (or use room context anywhere)
|
||||
**Use Case**: Most arcade games - enables spectating even for single-player games
|
||||
|
||||
- MUST sync game state across all room members via network
|
||||
- MUST use the USER's current active room
|
||||
- MUST coordinate moves via server WebSocket
|
||||
- SYNCS game state across all room members via network
|
||||
- Uses the USER's current active room (`roomId: roomData?.id`)
|
||||
- Coordinates moves via server WebSocket
|
||||
- Game state is shared across all ACTIVE PLAYERS from all USERS in the room
|
||||
- When a PLAYER makes a move, all room members see it in real-time
|
||||
- CAN ALSO have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
|
||||
- **Non-playing room members become SPECTATORS** (see Spectator Mode section)
|
||||
- When a PLAYER makes a move, all room members see it in real-time (players + spectators)
|
||||
- CAN have multiple ACTIVE PLAYERS per USER (networked + local multiplayer combined)
|
||||
|
||||
**✅ This is the PREFERRED pattern** - even for single-player games like Card Sorting, because:
|
||||
- Enables spectator mode automatically
|
||||
- Creates social experience ("watch me solve this!")
|
||||
- No extra code needed
|
||||
- Works seamlessly with multiplayer games too
|
||||
|
||||
#### Pure Multiplayer (Room-Only)
|
||||
**Route**: `/arcade/room` with validation
|
||||
**Use Case**: Games that REQUIRE multiple players (e.g., competitive battles)
|
||||
|
||||
- Same as Room-Based with Spectator Mode
|
||||
- Plus: Validates minimum player count before starting
|
||||
- Plus: May prevent game start if `activePlayers.length < minPlayers`
|
||||
|
||||
### 2. Room ID Usage Rules
|
||||
|
||||
@@ -258,6 +280,327 @@ sendMove({
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- 5 total active PLAYERS across 2 devices, all synced over network
|
||||
|
||||
5. **Single-player game with spectators (Card Sorting):**
|
||||
- USER A: "guest_abc"
|
||||
- Active PLAYERS: ["player_001"]
|
||||
- Playing Card Sorting Challenge
|
||||
- USER B: "guest_def"
|
||||
- Active PLAYERS: [] (none selected)
|
||||
- Spectating USER A's game
|
||||
- USER C: "guest_ghi"
|
||||
- Active PLAYERS: ["player_005"]
|
||||
- Spectating USER A's game (could play after USER A finishes)
|
||||
- Mode: Room-based play (roomId: "room_xyz")
|
||||
- All room members see USER A's card placements in real-time
|
||||
- Spectators cannot interact with the game state
|
||||
|
||||
## Spectator Mode
|
||||
|
||||
### Overview
|
||||
|
||||
Spectator mode is automatically enabled when using room-based sync (`roomId: roomData?.id`). Any room member who is not actively playing becomes a spectator and can watch the game in real-time.
|
||||
|
||||
**Key Benefits**:
|
||||
- Creates social/collaborative experience even for single-player games
|
||||
- "Watch me solve this!" engagement
|
||||
- Learning by observation
|
||||
- Cheering/coaching opportunity
|
||||
- No extra implementation needed
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Automatic Role Assignment**:
|
||||
- Room members with active PLAYERs in the game → **Players**
|
||||
- Room members without active PLAYERs in the game → **Spectators**
|
||||
|
||||
2. **State Synchronization**:
|
||||
- All game state updates broadcast to entire room via `game:${roomId}` socket room
|
||||
- Spectators receive same state updates as players
|
||||
- Spectators see game in real-time as it happens
|
||||
|
||||
3. **Interaction Control**:
|
||||
- Players can make moves (send move actions)
|
||||
- Spectators can only observe (no move actions permitted)
|
||||
- Server validates PLAYER ownership before accepting moves
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
**Provider** (Room-Based with Spectator Support):
|
||||
|
||||
```typescript
|
||||
export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // ✅ Fetch room data
|
||||
const { activePlayers, players } = useGameMode()
|
||||
|
||||
// Find local player (if any)
|
||||
const localPlayerId = useMemo(() => {
|
||||
return Array.from(activePlayers).find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal !== false
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // ✅ Enable spectator mode
|
||||
initialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Actions check if local player exists before allowing moves
|
||||
const startGame = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
console.warn('[CardSorting] No local player - spectating only')
|
||||
return // ✅ Spectators cannot start game
|
||||
}
|
||||
sendMove({ type: 'START_GAME', playerId: localPlayerId, ... })
|
||||
}, [localPlayerId, sendMove])
|
||||
|
||||
// ... rest of provider
|
||||
}
|
||||
```
|
||||
|
||||
**Key Implementation Points**:
|
||||
- Always check `if (!localPlayerId)` before allowing moves
|
||||
- Return early or show "Spectating..." message
|
||||
- Don't throw errors - spectating is a valid state
|
||||
- UI should indicate spectator vs player role
|
||||
|
||||
### UI/UX Considerations
|
||||
|
||||
#### 1. Spectator Indicators
|
||||
|
||||
Show visual feedback when user is spectating:
|
||||
|
||||
```typescript
|
||||
{!localPlayerId && state.gamePhase === 'playing' && (
|
||||
<div className={spectatorBannerStyles}>
|
||||
👀 Spectating {state.playerMetadata.name}'s game
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 2. Disabled Controls
|
||||
|
||||
Disable interactive elements for spectators:
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={placeCard}
|
||||
disabled={!localPlayerId} // Spectators can't interact
|
||||
className={css({
|
||||
opacity: !localPlayerId ? 0.5 : 1,
|
||||
cursor: !localPlayerId ? 'not-allowed' : 'pointer',
|
||||
})}
|
||||
>
|
||||
Place Card
|
||||
</button>
|
||||
```
|
||||
|
||||
#### 3. Join Prompt
|
||||
|
||||
For games that support multiple players, show "Join Game" option:
|
||||
|
||||
```typescript
|
||||
{!localPlayerId && state.gamePhase === 'setup' && (
|
||||
<button onClick={selectPlayerAndJoin}>
|
||||
Join as Player
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
#### 4. Real-Time Updates
|
||||
|
||||
Ensure spectators see smooth updates:
|
||||
- Use optimistic UI updates (same as players)
|
||||
- Show animations for state changes
|
||||
- Display current player's moves as they happen
|
||||
|
||||
### When to Use Spectator Mode
|
||||
|
||||
**✅ Use Spectator Mode (room-based sync) For**:
|
||||
- Single-player puzzle games (Card Sorting, Sudoku, etc.)
|
||||
- Turn-based competitive games (Matching Pairs Battle)
|
||||
- Cooperative games (Memory Lightning)
|
||||
- Any game where watching is educational/entertaining
|
||||
- Social/family game nights
|
||||
- Classroom settings (teacher demonstrates, students watch)
|
||||
|
||||
**❌ Avoid Spectator Mode (use local-only) For**:
|
||||
- Private practice sessions
|
||||
- Timed competitive games where watching gives unfair advantage
|
||||
- Games with personal/sensitive content
|
||||
- Offline/no-network scenarios
|
||||
- Performance-critical games (reduce network overhead)
|
||||
|
||||
### Example Scenarios
|
||||
|
||||
#### Scenario 1: Family Game Night - Card Sorting
|
||||
|
||||
```
|
||||
Room: "Smith Family Game Night"
|
||||
|
||||
USER A (Dad): Playing Card Sorting
|
||||
- Active PLAYER: "Dad 👨"
|
||||
- State: Placing cards, 6/8 complete
|
||||
- Can interact with game
|
||||
|
||||
USER B (Mom): Spectating
|
||||
- Active PLAYERS: [] (none selected)
|
||||
- State: Sees Dad's card placements in real-time
|
||||
- Cannot place cards
|
||||
- Can cheer and help
|
||||
|
||||
USER C (Kid): Spectating
|
||||
- Active PLAYER: "Emma 👧" (selected but not in this game)
|
||||
- State: Watching to learn strategy
|
||||
- Will play next round
|
||||
|
||||
Flow:
|
||||
1. Dad starts Card Sorting
|
||||
2. Mom and Kid see setup phase
|
||||
3. Dad places cards one by one
|
||||
4. Mom/Kid see each placement instantly
|
||||
5. Dad checks solution
|
||||
6. Everyone sees the score together
|
||||
7. Kid says "My turn!" and starts their own game
|
||||
8. Dad and Mom become spectators
|
||||
```
|
||||
|
||||
#### Scenario 2: Classroom - Memory Lightning
|
||||
|
||||
```
|
||||
Room: "Ms. Johnson's 3rd Grade"
|
||||
|
||||
USER A (Teacher): Playing cooperatively with 2 students
|
||||
- Active PLAYERS: ["Teacher 👩🏫", "Student 1 👦"]
|
||||
- State: Memorizing cards
|
||||
- Both can participate
|
||||
|
||||
USER B-F (5 other students): Spectating
|
||||
- Watching demonstration
|
||||
- Learning the rules
|
||||
- Will join next round
|
||||
|
||||
Flow:
|
||||
1. Teacher demonstrates with 2 students
|
||||
2. Other students watch and learn
|
||||
3. Round ends
|
||||
4. Teacher sets up new round
|
||||
5. New students join as players
|
||||
6. Previous players become spectators
|
||||
```
|
||||
|
||||
### Server-Side Handling
|
||||
|
||||
The server must handle spectators correctly:
|
||||
|
||||
```typescript
|
||||
// Validate move ownership
|
||||
socket.on('game-move', ({ move, roomId }) => {
|
||||
const session = getSession(roomId)
|
||||
|
||||
// Check if PLAYER making move is in the active players list
|
||||
if (!session.activePlayers.includes(move.playerId)) {
|
||||
return {
|
||||
error: 'PLAYER not in game - spectators cannot make moves'
|
||||
}
|
||||
}
|
||||
|
||||
// Check if USER owns this PLAYER
|
||||
const playerOwner = getPlayerOwner(move.playerId)
|
||||
if (playerOwner !== socket.userId) {
|
||||
return {
|
||||
error: 'USER does not own this PLAYER'
|
||||
}
|
||||
}
|
||||
|
||||
// Valid move - apply and broadcast
|
||||
const newState = validator.validateMove(session.state, move)
|
||||
io.to(`game:${roomId}`).emit('state-update', newState) // ALL room members get update
|
||||
})
|
||||
```
|
||||
|
||||
**Key Server Logic**:
|
||||
- Validate PLAYER is in `session.activePlayers`
|
||||
- Validate USER owns PLAYER
|
||||
- Broadcast to entire room (players + spectators)
|
||||
- Spectators receive updates but cannot send moves
|
||||
|
||||
### Testing Spectator Mode
|
||||
|
||||
```typescript
|
||||
describe('Spectator Mode', () => {
|
||||
it('should allow room members to spectate single-player games', () => {
|
||||
// Setup: USER A and USER B in same room
|
||||
// Action: USER A starts Card Sorting (single-player)
|
||||
// Assert: USER B receives game state updates
|
||||
// Assert: USER B cannot make moves
|
||||
// Assert: USER B sees USER A's card placements in real-time
|
||||
})
|
||||
|
||||
it('should prevent spectators from making moves', () => {
|
||||
// Setup: USER A playing, USER B spectating
|
||||
// Action: USER B attempts to place a card
|
||||
// Assert: Server rejects move (PLAYER not in activePlayers)
|
||||
// Assert: Client UI disables controls for USER B
|
||||
})
|
||||
|
||||
it('should show spectator indicator in UI', () => {
|
||||
// Setup: USER B spectating USER A's game
|
||||
// Assert: UI shows "Spectating [Player Name]" banner
|
||||
// Assert: Interactive controls are disabled
|
||||
// Assert: Game state is visible
|
||||
})
|
||||
|
||||
it('should allow spectator to join next round', () => {
|
||||
// Setup: USER B spectating USER A's Card Sorting game
|
||||
// Action: USER A finishes game, returns to setup
|
||||
// Action: USER B starts new game
|
||||
// Assert: USER A becomes spectator
|
||||
// Assert: USER B becomes active player
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Migration Path
|
||||
|
||||
**For existing games**:
|
||||
|
||||
If your game currently uses `roomId: roomData?.id`, it already supports spectator mode! You just need to:
|
||||
|
||||
1. ✅ Check for `!localPlayerId` before allowing moves
|
||||
2. ✅ Add spectator UI indicators
|
||||
3. ✅ Disable controls when spectating
|
||||
4. ✅ Test spectator experience
|
||||
|
||||
**Example Fix**:
|
||||
|
||||
```typescript
|
||||
// Before (will crash for spectators)
|
||||
const placeCard = useCallback((cardId, position) => {
|
||||
sendMove({
|
||||
type: 'PLACE_CARD',
|
||||
playerId: localPlayerId, // ❌ Will be undefined for spectators!
|
||||
...
|
||||
})
|
||||
}, [localPlayerId, sendMove])
|
||||
|
||||
// After (spectator-safe)
|
||||
const placeCard = useCallback((cardId, position) => {
|
||||
if (!localPlayerId) {
|
||||
console.warn('Spectators cannot place cards')
|
||||
return // ✅ Spectators blocked from moving
|
||||
}
|
||||
sendMove({
|
||||
type: 'PLACE_CARD',
|
||||
playerId: localPlayerId,
|
||||
...
|
||||
})
|
||||
}, [localPlayerId, sendMove])
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### Mistake 1: Conditional Room Usage
|
||||
|
||||
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# Arcade Routing Architecture - Complete Overview
|
||||
|
||||
## 1. Current /arcade Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/page.tsx` (lines 1-129)
|
||||
|
||||
**Purpose:** The main arcade landing page - displays the "Champion Arena"
|
||||
|
||||
**Key Components:**
|
||||
- `ArcadeContent()` - Renders the main arcade interface
|
||||
- Uses `EnhancedChampionArena` component which contains `GameSelector`
|
||||
- The `GameSelector` displays all available games as cards
|
||||
- `GameSelector` includes both legacy games and registry games
|
||||
|
||||
**Current Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. Page renders `FullscreenProvider` wrapper
|
||||
3. Displays `PageWithNav` with title "🏟️ Champion Arena"
|
||||
4. Content area shows `EnhancedChampionArena` → `GameSelector`
|
||||
5. `GameSelector` renders `GameCard` components for each game
|
||||
6. When user clicks a game card, `GameCard` calls `router.push(config.url)`
|
||||
7. For registry games, `config.url` is `/arcade/room?game={gameName}`
|
||||
8. For legacy games, URL would be direct to their page
|
||||
|
||||
**State Management:**
|
||||
- `GameModeContext` provides player selection (emoji, name, color)
|
||||
- `PageWithNav` wraps content and provides mini-nav with:
|
||||
- Active player list
|
||||
- Add player button
|
||||
- Game mode indicator (single/battle/tournament)
|
||||
- Exit session handler
|
||||
|
||||
## 2. Current /arcade/room Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/room/page.tsx` (lines 1-359)
|
||||
|
||||
**Purpose:** "Magical place" that shows either a game OR the game chooser, driven by room state
|
||||
|
||||
**Three States:**
|
||||
|
||||
### State 1: Loading
|
||||
- Shows "Loading room..." message
|
||||
- Waits for `useRoomData()` hook to resolve
|
||||
|
||||
### State 2: Game Selection UI (when `!roomData.gameName`)
|
||||
- Shows large game selection buttons
|
||||
- User clicks to select a game
|
||||
- Calls `setRoomGame()` mutation to save selection to room
|
||||
- Invokes `handleGameSelect()` which:
|
||||
1. Checks if game exists in registry via `hasGame(gameType)`
|
||||
2. If registry game: calls `setRoomGame({roomId, gameName: gameType})`
|
||||
3. If legacy game: maps to internal name via `GAME_TYPE_TO_NAME`, then calls `setRoomGame()`
|
||||
4. Game selection is persisted to the room database
|
||||
|
||||
### State 3: Game Display (when `roomData.gameName` is set)
|
||||
- Checks game registry first via `hasGame(roomData.gameName)`
|
||||
- If registry game:
|
||||
- Gets game definition via `getGame(roomData.gameName)`
|
||||
- Renders: `<Provider><GameComponent /></Provider>`
|
||||
- Provider and GameComponent come from game registry definition
|
||||
- If legacy game:
|
||||
- Switch statement with TODO for individual games
|
||||
- Currently only shows "Game not yet supported"
|
||||
|
||||
**Key Hook:**
|
||||
- `useRoomData()` - Fetches current room from API and subscribes to socket updates
|
||||
- Returns `roomData` with fields: `id`, `name`, `code`, `gameName`, `gameConfig`, `members`, `memberPlayers`
|
||||
- Also returns `isLoading` boolean
|
||||
|
||||
**Navigation Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. `GameCard` onClick calls `router.push('/arcade/room?game={gameName}')`
|
||||
3. User arrives at `/arcade/room`
|
||||
4. If NOT in a room yet: Shows error with link back to `/arcade`
|
||||
5. If in room but no game selected: Shows game selection UI
|
||||
6. If game selected: Loads and displays game
|
||||
|
||||
## 3. The "Mini App Nav" - GameContextNav Component
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/nav/GameContextNav.tsx` (lines 1-372)
|
||||
|
||||
**What It Is:**
|
||||
The "mini app nav" is actually a sophisticated component within the `PageWithNav` wrapper that intelligently shows different UI based on context:
|
||||
|
||||
**Components & Props:**
|
||||
- `navTitle` - Current page title (e.g., "Champion Arena", "Choose Game", "Speed Complement Race")
|
||||
- `navEmoji` - Icon emoji for current page
|
||||
- `gameMode` - Computed from active player count: 'none' | 'single' | 'battle' | 'tournament'
|
||||
- `activePlayers` - Array of selected players
|
||||
- `inactivePlayers` - Array of available but unselected players
|
||||
- `shouldEmphasize` - Boolean to emphasize player selection
|
||||
- `showFullscreenSelection` - Boolean to show fullscreen mode for player selection
|
||||
- `roomInfo` - Optional arcade room data (roomId, roomName, gameName, playerCount, joinCode)
|
||||
- `networkPlayers` - Remote players from room members
|
||||
|
||||
**Three Display Modes:**
|
||||
|
||||
### Mode 1: Fullscreen Player Selection
|
||||
- When `showFullscreenSelection === true`
|
||||
- Displays:
|
||||
- Large title with emoji
|
||||
- Game mode indicator
|
||||
- Fullscreen player selection UI
|
||||
- Shows all inactive players for selection
|
||||
|
||||
### Mode 2: Solo Mode (NOT in room)
|
||||
- When `roomInfo` is undefined
|
||||
- Shows:
|
||||
- **Game Title Section** (left side):
|
||||
- `GameTitleMenu` with game title and emoji
|
||||
- Menu options: Setup, New Game, Quit
|
||||
- `GameModeIndicator`
|
||||
- **Player Section** (right side):
|
||||
- `ActivePlayersList` - shows selected players
|
||||
- `AddPlayerButton` - add more players
|
||||
|
||||
### Mode 3: Room Mode (IN a room)
|
||||
- When `roomInfo` is defined
|
||||
- Shows:
|
||||
- **Hidden:** Game title section (display: none)
|
||||
- **Room Info Pane** (left side):
|
||||
- `RoomInfo` component with room details
|
||||
- Game mode indicator with color/emoji
|
||||
- Room name, player count, join code
|
||||
- `NetworkPlayerIndicator` components for remote players
|
||||
- **Player Section** (may be hidden):
|
||||
- Shows local active players
|
||||
- Add player button (for local players only)
|
||||
|
||||
**Key Sub-Components:**
|
||||
- `GameTitleMenu` - Menu for game options (setup, new game, quit)
|
||||
- `GameModeIndicator` - Shows 🎯 Solo, ⚔️ Battle, 🏆 Tournament, 👥 Select
|
||||
- `RoomInfo` - Displays room metadata
|
||||
- `NetworkPlayerIndicator` - Shows remote players with scores/streaks
|
||||
- `ActivePlayersList` - List of selected players
|
||||
- `AddPlayerButton` - Button to add more players with popover
|
||||
- `FullscreenPlayerSelection` - Large player picker for fullscreen mode
|
||||
- `PendingInvitations` - Banner for room invitations
|
||||
|
||||
**State Management:**
|
||||
- Lifted from `PageWithNav` to preserve state across remounts:
|
||||
- `showPopover` / `setShowPopover` - AddPlayerButton popover state
|
||||
- `activeTab` / `setActiveTab` - 'add' or 'invite' tab selection
|
||||
|
||||
## 4. Navigation Flow
|
||||
|
||||
### Flow 1: Solo Player → Game Selection → Room Creation → Game Start
|
||||
|
||||
```
|
||||
/arcade (Champion Arena)
|
||||
↓ [Select players - updates GameModeContext]
|
||||
↓ [Click game card - GameCard.onClick → router.push]
|
||||
/arcade/room (if not in room, shows game selector)
|
||||
↓ [Select game - calls setRoomGame mutation]
|
||||
↓ [Room created, gameName saved to roomData]
|
||||
↓ [useRoomData refetch updates roomData.gameName]
|
||||
/arcade/room (now displays the game)
|
||||
↓ [Game Provider and Component render]
|
||||
```
|
||||
|
||||
### Flow 2: Multiplayer - Room Invitation
|
||||
|
||||
```
|
||||
User A: Creates room via Champion Arena
|
||||
User B: Receives invitation
|
||||
User B: Joins room via /arcade/room
|
||||
User B: Sees same game selection (if set) or game selector (if not set)
|
||||
```
|
||||
|
||||
### Flow 3: Exit Game
|
||||
|
||||
```
|
||||
/arcade/room (in-game)
|
||||
↓ [Click "Quit" or "Exit Session" in GameContextNav]
|
||||
↓ [onExitSession callback → router.push('/arcade')]
|
||||
/arcade (back to champion arena)
|
||||
↓ Player selection reset by GameModeContext
|
||||
```
|
||||
|
||||
## 5. Game Chooser / Game Selection System
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**How It Works:**
|
||||
1. `GameSelector` component gets all games from both sources:
|
||||
- Legacy `GAMES_CONFIG` (currently empty)
|
||||
- Registry games via `getAllGames()`
|
||||
|
||||
2. For each game, creates `GameCard` component with configuration including `url` field
|
||||
|
||||
3. Game Cards rendered in 2-column grid (responsive)
|
||||
|
||||
4. When card clicked:
|
||||
- `GameCard` checks `activePlayerCount` against game's `maxPlayers`
|
||||
- If valid: calls `router.push(config.url)` - client-side navigation via Next.js
|
||||
- If invalid: blocks navigation with warning
|
||||
|
||||
**Two Game Systems:**
|
||||
|
||||
### Registry Games (NEW - Modular)
|
||||
- Location: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/arcade-games/`
|
||||
- File: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/lib/arcade/game-registry.ts`
|
||||
- Examples: `complement-race`, `memory-quiz`, `matching`
|
||||
- Each game has: `manifest` (metadata), `Provider` (context), `GameComponent` (UI)
|
||||
- Games registered globally via `registerGame()` function
|
||||
|
||||
### Legacy Games (OLD)
|
||||
- Location: Directly in `/app/arcade/` directory
|
||||
- Examples: `/app/arcade/complement-race/page.tsx`
|
||||
- Currently, only complement-race is partially migrated
|
||||
- Direct URL structure: `/arcade/{gameName}/page.tsx`
|
||||
|
||||
**Game Config Structure (for display):**
|
||||
```javascript
|
||||
{
|
||||
name: string, // Display name
|
||||
fullName?: string, // Longer name for detailed view
|
||||
description: string, // Short description
|
||||
longDescription?: string, // Detailed description
|
||||
icon: emoji, // Game icon emoji
|
||||
gradient: css gradient, // Background gradient
|
||||
borderColor: css color, // Border color for availability
|
||||
maxPlayers: number, // Player limit for validation
|
||||
chips?: string[], // Feature labels
|
||||
color?: 'green'|'purple'|'blue', // Color theme
|
||||
difficulty?: string, // Difficulty level
|
||||
available: boolean, // Is game available
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Key Components Summary
|
||||
|
||||
### PageWithNav - Main Layout Wrapper
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/PageWithNav.tsx` (lines 1-192)
|
||||
|
||||
**Responsibilities:**
|
||||
- Wraps all game/arcade pages
|
||||
- Manages GameContextNav state (mini-nav)
|
||||
- Handles player configuration dialog
|
||||
- Shows moderation notifications
|
||||
- Renders top navigation bar via `AppNavBar`
|
||||
|
||||
**Key Props:**
|
||||
- `navTitle` - Passed to GameContextNav
|
||||
- `navEmoji` - Passed to GameContextNav
|
||||
- `gameName` - Internal game name for API
|
||||
- `emphasizePlayerSelection` - Highlight player controls
|
||||
- `onExitSession` - Callback when user exits
|
||||
- `onSetup`, `onNewGame` - Game-specific callbacks
|
||||
- `children` - Page content
|
||||
|
||||
### AppNavBar - Top Navigation Bar
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/AppNavBar.tsx` (lines 1-625)
|
||||
|
||||
**Variants:**
|
||||
- `full` - Standard navigation (default for non-game pages)
|
||||
- `minimal` - Game navigation (auto-selected for `/arcade` and `/games`)
|
||||
|
||||
**Minimal Nav Features:**
|
||||
- Hamburger menu (left) with:
|
||||
- Site navigation (Home, Create, Guide, Games)
|
||||
- Controls (Fullscreen, Exit Arcade)
|
||||
- Abacus style dropdown
|
||||
- Centered game context (navSlot)
|
||||
- Fullscreen indicator badge
|
||||
|
||||
### EnhancedChampionArena - Main Arcade Display
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/EnhancedChampionArena.tsx` (lines 1-40)
|
||||
|
||||
**Responsibilities:**
|
||||
- Container for game selector
|
||||
- Full-height flex layout
|
||||
- Passes configuration to `GameSelector`
|
||||
|
||||
### GameSelector - Game Grid
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**Responsibilities:**
|
||||
- Fetches all games from registry
|
||||
- Arranges in responsive grid
|
||||
- Shows header "🎮 Available Games"
|
||||
- Renders GameCard for each game
|
||||
|
||||
### GameCard - Individual Game Button
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameCard.tsx` (lines 1-241)
|
||||
|
||||
**Responsibilities:**
|
||||
- Displays game with icon, name, description
|
||||
- Shows feature chips and player count indicator
|
||||
- Validates player count against game requirements
|
||||
- Handles click to navigate to game
|
||||
- Two variants: compact and detailed
|
||||
|
||||
## 7. State Management
|
||||
|
||||
### GameModeContext
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameModeContext.tsx` (lines 1-325)
|
||||
|
||||
**Manages:**
|
||||
- Local players (Map<string, Player>)
|
||||
- Active players (Set<string>)
|
||||
- Game mode (computed from active player count)
|
||||
- Player CRUD operations (add, update, remove)
|
||||
|
||||
**Key Features:**
|
||||
- Fetches players from user's local DB via `useUserPlayers()`
|
||||
- Creates 4 default players if none exist
|
||||
- When in room: merges room members' players (marked as isLocal: false)
|
||||
- Syncs to room members via `notifyRoomOfPlayerUpdate()`
|
||||
|
||||
**Computed Values:**
|
||||
- `activePlayerCount` - Size of activePlayers set
|
||||
- `gameMode`:
|
||||
- 1 player → 'single'
|
||||
- 2 players → 'battle'
|
||||
- 3+ players → 'tournament'
|
||||
|
||||
### useRoomData Hook
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/useRoomData.ts` (lines 1-450+)
|
||||
|
||||
**Manages:**
|
||||
- Current room fetching via TanStack Query
|
||||
- Socket.io real-time updates
|
||||
- Room state (members, players, game name)
|
||||
- Moderation events (kicked, banned, invitations)
|
||||
|
||||
**Key Operations:**
|
||||
- `fetchCurrentRoom()` - GET `/api/arcade/rooms/current`
|
||||
- `createRoomApi()` - POST `/api/arcade/rooms`
|
||||
- `joinRoomApi()` - POST `/api/arcade/rooms/{id}/join`
|
||||
- `leaveRoomApi()` - POST `/api/arcade/rooms/{id}/leave`
|
||||
- `setRoomGame()` - Updates room's gameName and gameConfig
|
||||
|
||||
**Socket Events:**
|
||||
- `join-user-channel` - Personal notifications
|
||||
- `join-room` - Subscribe to room updates
|
||||
- `room-joined` - Refresh when entering room
|
||||
- `member-joined` - When player joins
|
||||
- `member-left` - When player leaves
|
||||
- `room-players-updated` - When players change
|
||||
- Moderation events (kicked, banned, etc.)
|
||||
|
||||
## 8. Routing Summary
|
||||
|
||||
**Current URL Structure:**
|
||||
|
||||
```
|
||||
/ → Home page (Soroban Generator)
|
||||
/create → Create flashcards
|
||||
/guide → Tutorial guide
|
||||
/games → Games library (external game pages)
|
||||
/arcade → Champion Arena (main landing with game selector)
|
||||
/arcade/room → Active game display or game selection UI
|
||||
/arcade/room?game={name} → Query param for game selection (optional)
|
||||
/arcade/complement-race → OLD: Direct complement-race page (legacy)
|
||||
/arcade/complement-race/practice → Complement-race practice mode
|
||||
/arcade/complement-race/sprint → Complement-race sprint mode
|
||||
/arcade/complement-race/survival → Complement-race survival mode
|
||||
/arcade/memory-quiz → Memory quiz game page (legacy structure)
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `/arcade/room?game={gameName}` - Optional game selection (parsed by GameCard)
|
||||
|
||||
## 9. Key Differences: /arcade vs /arcade/room
|
||||
|
||||
| Aspect | /arcade | /arcade/room |
|
||||
|--------|---------|--------------|
|
||||
| **Purpose** | Game selection hub | Active game display or selection within room |
|
||||
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
|
||||
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
|
||||
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
|
||||
| **GameContextNav** | Shows player selector | Shows room info when joined |
|
||||
| **Player State** | Local only | Local + remote (room members) |
|
||||
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
|
||||
| **Socket Connection** | Optional | Always connected (in room) |
|
||||
| **Page Transition** | User controls | Driven by room state updates |
|
||||
|
||||
## 10. Planning the Merge (/arcade/room → /arcade)
|
||||
|
||||
**Challenges to Consider:**
|
||||
|
||||
1. **URL Consolidation:**
|
||||
- `/arcade/room` would become a sub-path or handled by `/arcade` with state
|
||||
- Query param `?game={name}` could drive game selection
|
||||
- Current: `/arcade/room?game=complement-race`
|
||||
- Could become: `/arcade?game=complement-race&mode=play`
|
||||
|
||||
2. **Route Disambiguation:**
|
||||
- `/arcade` needs to handle: game selection display, game display, game loading
|
||||
- Same page different modes based on state
|
||||
- Or: Sub-routes like `/arcade/select`, `/arcade/play`
|
||||
|
||||
3. **State Layering:**
|
||||
- Local game mode (solo player, GameModeContext)
|
||||
- Room state (multiplayer, useRoomData)
|
||||
- Both need to coexist
|
||||
|
||||
4. **Navigation Preservation:**
|
||||
- Currently: `GameCard` → `router.push('/arcade/room?game=X')`
|
||||
- After merge: Would need new logic
|
||||
- Fullscreen state must persist (uses Next.js router, not reload)
|
||||
|
||||
5. **PageWithNav Behavior:**
|
||||
- Mini-nav shows game selection UI vs room info
|
||||
- Currently determined by `roomInfo` presence
|
||||
- After merge: Need same logic but one route
|
||||
|
||||
6. **Game Display:**
|
||||
- Currently: `/arcade/room` fetches game from registry
|
||||
- New: `/arcade` would need same game registry lookup
|
||||
- Game Provider/Component rendering must work identically
|
||||
|
||||
**Merge Strategy Options:**
|
||||
|
||||
### Option A: Single Route with Modes
|
||||
```
|
||||
/arcade
|
||||
├── Mode: browse (default, show GameSelector)
|
||||
├── Mode: select (game selected, show GameSelector for confirmation)
|
||||
└── Mode: play (in-game, show game display)
|
||||
```
|
||||
|
||||
### Option B: Sub-routes
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (selector)
|
||||
├── /arcade/play (game display)
|
||||
└── /arcade/configure (player config)
|
||||
```
|
||||
|
||||
### Option C: Query-Parameter Driven
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (default - selector)
|
||||
├── /arcade?game=X (game loading)
|
||||
└── /arcade?game=X&playing=true (in-game)
|
||||
```
|
||||
|
||||
**Recommendation:** Option C (Query-driven) is closest to current architecture and requires minimal changes to existing logic.
|
||||
404
apps/web/.claude/CARD_SORTING_AUDIT.md
Normal file
404
apps/web/.claude/CARD_SORTING_AUDIT.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Card Sorting Challenge - Arcade Architecture Audit
|
||||
|
||||
**Date**: 2025-10-18
|
||||
**Auditor**: Claude Code
|
||||
**Reference**: `.claude/ARCADE_ARCHITECTURE.md`
|
||||
**Update**: 2025-10-18 - Spectator mode recognized as intentional feature
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The Card Sorting Challenge game was audited against the Arcade Architecture requirements documented in `.claude/ARCADE_ARCHITECTURE.md`. The initial audit identified the room-based sync pattern as a potential issue, but this was later recognized as an **intentional spectator mode feature**.
|
||||
|
||||
**Overall Status**: ✅ **CORRECT IMPLEMENTATION** (with spectator mode enabled)
|
||||
|
||||
---
|
||||
|
||||
## Spectator Mode Feature (Initially Flagged as Issue)
|
||||
|
||||
### ✅ Room-Based Sync Enables Spectator Mode (INTENTIONAL FEATURE)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/Provider.tsx` lines 286, 312
|
||||
|
||||
**Initial Assessment**: The provider **ALWAYS** calls `useRoomData()` and **ALWAYS** passes `roomId: roomData?.id` to `useArcadeSession`. This was initially flagged as a mode isolation violation.
|
||||
|
||||
```typescript
|
||||
const { roomData } = useRoomData() // Line 286
|
||||
...
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // Line 312 - Room-based sync
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
```
|
||||
|
||||
**Actual Behavior (CORRECT)**:
|
||||
- ✅ When a USER plays Card Sorting in a room, the game state SYNCS ACROSS THE ROOM NETWORK
|
||||
- ✅ This enables **spectator mode** - other room members can watch the game in real-time
|
||||
- ✅ Card Sorting is single-player (`maxPlayers: 1`), but spectators can watch and cheer
|
||||
- ✅ Room members without active players become spectators automatically
|
||||
- ✅ Creates social/collaborative experience ("Watch me solve this!")
|
||||
|
||||
**Supported By Architecture** (ARCADE_ARCHITECTURE.md, Spectator Mode section):
|
||||
> Spectator mode is automatically enabled when using room-based sync (`roomId: roomData?.id`).
|
||||
> Any room member who is not actively playing becomes a spectator and can watch the game in real-time.
|
||||
>
|
||||
> **✅ This is the PREFERRED pattern** - even for single-player games like Card Sorting, because:
|
||||
> - Enables spectator mode automatically
|
||||
> - Creates social experience ("watch me solve this!")
|
||||
> - No extra code needed
|
||||
> - Works seamlessly with multiplayer games too
|
||||
|
||||
**Pattern is CORRECT**:
|
||||
|
||||
```typescript
|
||||
// For single-player games WITH spectator mode support:
|
||||
export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
const { data: viewerId } = useViewerId()
|
||||
const { roomData } = useRoomData() // ✅ Fetch room data for spectator mode
|
||||
|
||||
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
|
||||
userId: viewerId || '',
|
||||
roomId: roomData?.id, // ✅ Enable spectator mode - room members can watch
|
||||
initialState: mergedInitialState,
|
||||
applyMove: applyMoveOptimistically,
|
||||
})
|
||||
|
||||
// Actions check for localPlayerId - spectators won't have one
|
||||
const startGame = useCallback(() => {
|
||||
if (!localPlayerId) {
|
||||
console.warn('[CardSorting] No local player - spectating only')
|
||||
return // ✅ Spectators blocked from starting game
|
||||
}
|
||||
// ... send move
|
||||
}, [localPlayerId, sendMove])
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Pattern is Used**:
|
||||
This enables spectator mode as a first-class user experience. Room members can:
|
||||
- Watch other players solve puzzles
|
||||
- Learn strategies by observation
|
||||
- Cheer and coach
|
||||
- Take turns (finish watching, then play yourself)
|
||||
|
||||
**Status**: ✅ CORRECT IMPLEMENTATION
|
||||
**Priority**: N/A - No changes needed
|
||||
|
||||
---
|
||||
|
||||
## Scope of Spectator Mode
|
||||
|
||||
This same room-based sync pattern exists in **ALL** arcade games currently:
|
||||
|
||||
```bash
|
||||
$ grep -A 2 "useRoomData" /path/to/arcade-games/*/Provider.tsx
|
||||
|
||||
card-sorting/Provider.tsx: const { roomData } = useRoomData()
|
||||
complement-race/Provider.tsx: const { roomData } = useRoomData()
|
||||
matching/Provider.tsx: const { roomData } = useRoomData()
|
||||
memory-quiz/Provider.tsx: const { roomData } = useRoomData()
|
||||
```
|
||||
|
||||
All providers pass `roomId: roomData?.id` to `useArcadeSession`. This means:
|
||||
- ✅ **All games** support spectator mode automatically
|
||||
- ✅ **Single-player games** (card-sorting) enable "watch me play" experience
|
||||
- ✅ **Multiplayer games** (matching, memory-quiz, complement-race) support both players and spectators
|
||||
|
||||
**Status**: This is the recommended pattern for social/family gaming experiences.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Correct Implementations
|
||||
|
||||
### 1. Active Players Handling (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/Provider.tsx` lines 287, 294-299
|
||||
|
||||
The provider correctly uses `useGameMode()` to access active players:
|
||||
|
||||
```typescript
|
||||
const { activePlayers, players } = useGameMode()
|
||||
|
||||
const localPlayerId = useMemo(() => {
|
||||
return Array.from(activePlayers).find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal !== false
|
||||
})
|
||||
}, [activePlayers, players])
|
||||
```
|
||||
|
||||
✅ Only includes players with `isActive = true`
|
||||
✅ Finds the first local player for this single-player game
|
||||
✅ Follows architecture pattern correctly
|
||||
|
||||
---
|
||||
|
||||
### 2. Player ID vs User ID (CORRECT)
|
||||
|
||||
**Location**: Provider.tsx lines 383-491 (all move creators)
|
||||
|
||||
All moves correctly use:
|
||||
- `playerId: localPlayerId` (PLAYER makes the move)
|
||||
- `userId: viewerId || ''` (USER owns the session)
|
||||
|
||||
```typescript
|
||||
// Example from startGame (lines 383-391)
|
||||
sendMove({
|
||||
type: 'START_GAME',
|
||||
playerId: localPlayerId, // ✅ PLAYER ID
|
||||
userId: viewerId || '', // ✅ USER ID
|
||||
data: { playerMetadata, selectedCards },
|
||||
})
|
||||
```
|
||||
|
||||
✅ Follows USER/PLAYER distinction correctly
|
||||
✅ Server can validate PLAYER ownership
|
||||
✅ Matches architecture requirements
|
||||
|
||||
---
|
||||
|
||||
### 3. Validator Implementation (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/Validator.ts`
|
||||
|
||||
The validator correctly implements all required methods:
|
||||
|
||||
```typescript
|
||||
export class CardSortingValidator implements GameValidator<CardSortingState, CardSortingMove> {
|
||||
validateMove(state, move, context): ValidationResult { ... }
|
||||
isGameComplete(state): boolean { ... }
|
||||
getInitialState(config: CardSortingConfig): CardSortingState { ... }
|
||||
}
|
||||
```
|
||||
|
||||
✅ All move types have validation
|
||||
✅ `getInitialState()` accepts full config
|
||||
✅ Proper error messages
|
||||
✅ Server-side score calculation
|
||||
✅ State transitions validated
|
||||
|
||||
---
|
||||
|
||||
### 4. Game Registration (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/index.ts`
|
||||
|
||||
Uses the modular game system correctly:
|
||||
|
||||
```typescript
|
||||
export const cardSortingGame = defineGame<CardSortingConfig, CardSortingState, CardSortingMove>({
|
||||
manifest,
|
||||
Provider: CardSortingProvider,
|
||||
GameComponent,
|
||||
validator: cardSortingValidator,
|
||||
defaultConfig,
|
||||
validateConfig: validateCardSortingConfig,
|
||||
})
|
||||
```
|
||||
|
||||
✅ Proper TypeScript generics
|
||||
✅ Manifest includes all required fields
|
||||
✅ Config validation function provided
|
||||
✅ Uses `getGameTheme()` for consistent styling
|
||||
|
||||
---
|
||||
|
||||
### 5. Type Definitions (CORRECT)
|
||||
|
||||
**Location**: `/src/arcade-games/card-sorting/types.ts`
|
||||
|
||||
State and move types properly extend base types:
|
||||
|
||||
```typescript
|
||||
export interface CardSortingState extends GameState { ... }
|
||||
export interface CardSortingConfig extends GameConfig { ... }
|
||||
export type CardSortingMove =
|
||||
| { type: 'START_GAME', playerId: string, userId: string, ... }
|
||||
| { type: 'PLACE_CARD', playerId: string, userId: string, ... }
|
||||
...
|
||||
```
|
||||
|
||||
✅ All moves include `playerId` and `userId`
|
||||
✅ Extends SDK base types
|
||||
✅ Proper TypeScript structure
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Add Spectator UI Indicators (Enhancement)
|
||||
|
||||
The current implementation correctly enables spectator mode, but could be enhanced with better UI/UX:
|
||||
|
||||
**Action**: Add spectator indicators to `GameComponent.tsx`:
|
||||
|
||||
```typescript
|
||||
export function GameComponent() {
|
||||
const { state, localPlayerId } = useCardSorting()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!localPlayerId && state.gamePhase === 'playing' && (
|
||||
<div className={spectatorBannerStyles}>
|
||||
👀 Spectating {state.playerMetadata?.name || 'player'}'s game
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable controls when spectating */}
|
||||
<button
|
||||
onClick={placeCard}
|
||||
disabled={!localPlayerId}
|
||||
className={css({
|
||||
opacity: !localPlayerId ? 0.5 : 1,
|
||||
cursor: !localPlayerId ? 'not-allowed' : 'pointer',
|
||||
})}
|
||||
>
|
||||
Place Card
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Also Consider**:
|
||||
- Show "Join Game" prompt during setup phase for spectators
|
||||
- Display spectator count ("2 people watching")
|
||||
- Add smooth real-time animations for spectators
|
||||
|
||||
---
|
||||
|
||||
### 2. Document Other Games
|
||||
|
||||
All arcade games currently support spectator mode. Consider documenting this in each game's README:
|
||||
|
||||
**Games with Spectator Mode**:
|
||||
- ✅ `card-sorting` - Single-player puzzle with spectators
|
||||
- ✅ `matching` - Multiplayer battle with spectators
|
||||
- ✅ `memory-quiz` - Cooperative with spectators
|
||||
- ✅ `complement-race` - Competitive with spectators
|
||||
|
||||
**Documentation to Add**:
|
||||
- How spectator mode works in each game
|
||||
- Example scenarios (family game night, classroom)
|
||||
- Best practices for spectator experience
|
||||
|
||||
---
|
||||
|
||||
### 3. Add Spectator Mode Tests
|
||||
|
||||
Following ARCADE_ARCHITECTURE.md Spectator Mode section, add tests:
|
||||
|
||||
```typescript
|
||||
describe('Card Sorting - Spectator Mode', () => {
|
||||
it('should sync state to spectators when USER plays in a room', async () => {
|
||||
// Setup: USER A and USER B in same room
|
||||
// Action: USER A plays Card Sorting
|
||||
// Assert: USER B (spectator) sees card placements in real-time
|
||||
// Assert: USER B cannot place cards (no localPlayerId)
|
||||
})
|
||||
|
||||
it('should prevent spectators from making moves', () => {
|
||||
// Setup: USER A playing, USER B spectating
|
||||
// Action: USER B attempts to place card
|
||||
// Assert: Action blocked (localPlayerId check)
|
||||
// Assert: Server rejects if somehow sent
|
||||
})
|
||||
|
||||
it('should allow spectator to play after current player finishes', () => {
|
||||
// Setup: USER A playing, USER B spectating
|
||||
// Action: USER A finishes, USER B starts new game
|
||||
// Assert: USER B becomes player
|
||||
// Assert: USER A becomes spectator
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Architecture Documentation
|
||||
|
||||
**✅ COMPLETED**: ARCADE_ARCHITECTURE.md has been updated with comprehensive spectator mode documentation:
|
||||
- Added "SPECTATOR" to core terminology
|
||||
- Documented three synchronization modes (Local, Room-Based with Spectator, Pure Multiplayer)
|
||||
- Complete "Spectator Mode" section with:
|
||||
- Implementation patterns
|
||||
- UI/UX considerations
|
||||
- Example scenarios (Family Game Night, Classroom)
|
||||
- Server-side validation
|
||||
- Testing requirements
|
||||
- Migration path
|
||||
|
||||
**No further documentation needed** - Card Sorting follows the recommended pattern
|
||||
|
||||
---
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
Based on ARCADE_ARCHITECTURE.md Spectator Mode Pattern:
|
||||
|
||||
- [x] ✅ **Provider uses room-based sync to enable spectator mode**
|
||||
- Calls `useRoomData()` and passes `roomId: roomData?.id`
|
||||
- [x] ✅ Provider uses `useGameMode()` to get active players
|
||||
- [x] ✅ Provider finds `localPlayerId` to distinguish player vs spectator
|
||||
- [x] ✅ Game components correctly use PLAYER IDs (not USER IDs) for moves
|
||||
- [x] ✅ Move actions check `localPlayerId` before sending
|
||||
- Spectators without `localPlayerId` cannot make moves
|
||||
- [x] ✅ Game supports multiple active PLAYERS from same USER
|
||||
- Implementation allows it (finds first local player)
|
||||
- [x] ✅ Inactive PLAYERS are never included in game sessions
|
||||
- Uses `activePlayers` which filters to `isActive = true`
|
||||
- [ ] ⚠️ **UI shows spectator indicator**
|
||||
- Could be enhanced (see Recommendations #1)
|
||||
- [ ] ⚠️ **UI disables controls for spectators**
|
||||
- Could be enhanced (see Recommendations #1)
|
||||
- [ ] ⚠️ Tests verify spectator mode
|
||||
- No tests found (see Recommendations #3)
|
||||
- [ ] ⚠️ Tests verify PLAYER ownership validation
|
||||
- No tests found
|
||||
- [x] ✅ Validator implements all required methods
|
||||
- [x] ✅ Game registered with modular system
|
||||
|
||||
**Overall Compliance**: 9/13 ✅ (Core features complete, enhancements recommended)
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Card Sorting Challenge game is **correctly implemented** with:
|
||||
- ✅ Active players (only `isActive = true` players participate)
|
||||
- ✅ Player ID vs User ID distinction
|
||||
- ✅ Validator pattern
|
||||
- ✅ Game registration
|
||||
- ✅ Type safety
|
||||
- ✅ **Spectator mode enabled** (room-based sync pattern)
|
||||
|
||||
**Architecture Pattern**: Room-Based with Spectator Mode (RECOMMENDED)
|
||||
|
||||
✅ **CORRECT**: Room sync enables spectator mode as a first-class feature
|
||||
|
||||
The `roomId: roomData?.id` pattern is **intentional and correct**:
|
||||
1. ✅ Enables spectator mode automatically
|
||||
2. ✅ Room members can watch games in real-time
|
||||
3. ✅ Creates social/collaborative experience
|
||||
4. ✅ Spectators blocked from making moves (via `localPlayerId` check)
|
||||
5. ✅ Follows ARCADE_ARCHITECTURE.md recommended pattern
|
||||
|
||||
**Recommended Enhancements** (not critical):
|
||||
1. Add spectator UI indicators ("👀 Spectating...")
|
||||
2. Disable controls visually for spectators
|
||||
3. Add spectator mode tests
|
||||
|
||||
**Priority**: LOW (enhancements only - core implementation is correct)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Optional Enhancements)
|
||||
|
||||
1. ✅ **Architecture documentation** - COMPLETED (ARCADE_ARCHITECTURE.md updated with spectator mode)
|
||||
2. Add spectator UI indicators to GameComponent (banner, disabled controls)
|
||||
3. Add spectator mode tests
|
||||
4. Document spectator mode in other arcade games
|
||||
5. Consider adding spectator count display ("2 watching")
|
||||
|
||||
**Note**: Card Sorting is production-ready as-is. Enhancements are for improved UX only.
|
||||
1206
apps/web/.claude/CARD_SORTING_SPECTATOR_UX.md
Normal file
1206
apps/web/.claude/CARD_SORTING_SPECTATOR_UX.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,37 @@ npm run check # Biome check (format + lint + organize imports)
|
||||
|
||||
**Remember: Always run `npm run pre-commit` before creating commits.**
|
||||
|
||||
## Styling Framework
|
||||
|
||||
**CRITICAL: This project uses Panda CSS, NOT Tailwind CSS.**
|
||||
|
||||
- All styling is done with Panda CSS (`@pandacss/dev`)
|
||||
- Configuration: `/panda.config.ts`
|
||||
- Generated system: `/styled-system/`
|
||||
- Import styles using: `import { css } from '../../styled-system/css'`
|
||||
- Token syntax: `color: 'blue.200'`, `borderColor: 'gray.300'`, etc.
|
||||
|
||||
**Common Mistakes to Avoid:**
|
||||
- ❌ Don't reference "Tailwind" in code, comments, or documentation
|
||||
- ❌ Don't use Tailwind utility classes (e.g., `className="bg-blue-500"`)
|
||||
- ✅ Use Panda CSS `css()` function for all styling
|
||||
- ✅ Use Panda's token system (defined in `panda.config.ts`)
|
||||
|
||||
**Color Tokens:**
|
||||
```typescript
|
||||
// Correct (Panda CSS)
|
||||
css({
|
||||
bg: 'blue.200',
|
||||
borderColor: 'gray.300',
|
||||
color: 'brand.600'
|
||||
})
|
||||
|
||||
// Incorrect (Tailwind)
|
||||
className="bg-blue-200 border-gray-300 text-brand-600"
|
||||
```
|
||||
|
||||
See `.claude/GAME_THEMES.md` for standardized color theme usage in arcade games.
|
||||
|
||||
## Known Issues
|
||||
|
||||
### @soroban/abacus-react TypeScript Module Resolution
|
||||
|
||||
154
apps/web/.claude/GAME_THEMES.md
Normal file
154
apps/web/.claude/GAME_THEMES.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Game Theme Standardization
|
||||
|
||||
## Problem
|
||||
|
||||
Previously, each game manually specified `color`, `gradient`, and `borderColor` in their manifest. This led to:
|
||||
- Inconsistent appearance across game cards
|
||||
- No guidance on what colors/gradients to use
|
||||
- Easy to choose saturated colors that don't match the pastel style
|
||||
- Duplication and maintenance burden
|
||||
|
||||
## Solution
|
||||
|
||||
**Standard theme presets** in `/src/lib/arcade/game-themes.ts`
|
||||
|
||||
All games now use predefined color themes that ensure consistent, professional appearance.
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Import from the Game SDK
|
||||
|
||||
```typescript
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
```
|
||||
|
||||
### 2. Use the Theme Spread Operator
|
||||
|
||||
```typescript
|
||||
const manifest: GameManifest = {
|
||||
name: 'my-game',
|
||||
displayName: 'My Awesome Game',
|
||||
icon: '🎮',
|
||||
description: 'A fun game',
|
||||
longDescription: 'More details...',
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['🎯 Feature 1', '⚡ Feature 2'],
|
||||
...getGameTheme('blue'), // ← Just add this!
|
||||
available: true,
|
||||
}
|
||||
```
|
||||
|
||||
That's it! The theme automatically provides:
|
||||
- `color: 'blue'`
|
||||
- `gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)'`
|
||||
- `borderColor: 'blue.200'`
|
||||
|
||||
## Available Themes
|
||||
|
||||
All themes use Panda CSS's 100-200 color range for soft pastel appearance:
|
||||
|
||||
| Theme | Color Range | Use Case |
|
||||
|-------|-------------|----------|
|
||||
| `blue` | blue-100 to blue-200 | Memory, puzzle games |
|
||||
| `purple` | purple-100 to purple-200 | Strategic, battle games |
|
||||
| `green` | green-100 to green-200 | Growth, achievement games |
|
||||
| `teal` | teal-100 to teal-200 | Creative, sorting games |
|
||||
| `indigo` | indigo-100 to indigo-200 | Deep thinking games |
|
||||
| `pink` | pink-100 to pink-200 | Fun, casual games |
|
||||
| `orange` | orange-100 to orange-200 | Speed, energy games |
|
||||
| `yellow` | yellow-100 to yellow-200 | Bright, happy games |
|
||||
| `red` | red-100 to red-200 | Competition, challenge |
|
||||
| `gray` | gray-100 to gray-200 | Neutral games |
|
||||
|
||||
## Examples
|
||||
|
||||
### Current Games
|
||||
|
||||
```typescript
|
||||
// Memory Lightning - blue theme
|
||||
...getGameTheme('blue')
|
||||
|
||||
// Matching Pairs Battle - purple theme
|
||||
...getGameTheme('purple')
|
||||
|
||||
// Card Sorting Challenge - teal theme
|
||||
...getGameTheme('teal')
|
||||
|
||||
// Speed Complement Race - blue theme
|
||||
...getGameTheme('blue')
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
✅ **Consistency** - All games have the same professional pastel look
|
||||
✅ **Simple** - One line instead of three properties
|
||||
✅ **Maintainable** - Update all games by changing the theme definition
|
||||
✅ **Discoverable** - TypeScript autocomplete shows available themes
|
||||
✅ **No mistakes** - Can't accidentally use wrong color values
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
If you need to inspect or customize a theme:
|
||||
|
||||
```typescript
|
||||
import { GAME_THEMES } from '@/lib/arcade/game-sdk'
|
||||
import type { GameTheme } from '@/lib/arcade/game-sdk'
|
||||
|
||||
// Access a specific theme
|
||||
const blueTheme: GameTheme = GAME_THEMES.blue
|
||||
|
||||
// Use it
|
||||
const manifest: GameManifest = {
|
||||
// ... other fields
|
||||
...blueTheme,
|
||||
// Or customize:
|
||||
color: blueTheme.color,
|
||||
gradient: 'linear-gradient(135deg, #custom, #values)', // override
|
||||
borderColor: blueTheme.borderColor,
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Themes
|
||||
|
||||
To add a new theme, edit `/src/lib/arcade/game-themes.ts`:
|
||||
|
||||
```typescript
|
||||
export const GAME_THEMES = {
|
||||
// ... existing themes
|
||||
mycolor: {
|
||||
color: 'mycolor',
|
||||
gradient: 'linear-gradient(135deg, #lighter, #darker)', // Use Panda CSS 100-200 range
|
||||
borderColor: 'mycolor.200',
|
||||
},
|
||||
} as const satisfies Record<string, GameTheme>
|
||||
```
|
||||
|
||||
Then update the TypeScript type:
|
||||
```typescript
|
||||
export type GameThemeName = keyof typeof GAME_THEMES
|
||||
```
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
When creating a new game:
|
||||
|
||||
- [x] Import `getGameTheme` from `@/lib/arcade/game-sdk`
|
||||
- [x] Use `...getGameTheme('theme-name')` in manifest
|
||||
- [x] Remove manual `color`, `gradient`, `borderColor` properties
|
||||
- [x] Choose a theme that matches your game's vibe
|
||||
|
||||
## Summary
|
||||
|
||||
**Old way** (error-prone, inconsistent):
|
||||
```typescript
|
||||
color: 'teal',
|
||||
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)', // Too saturated!
|
||||
borderColor: 'teal.200',
|
||||
```
|
||||
|
||||
**New way** (simple, consistent):
|
||||
```typescript
|
||||
...getGameTheme('teal')
|
||||
```
|
||||
@@ -97,7 +97,11 @@
|
||||
"Bash(pnpm exec turbo build --filter=@soroban/web)",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')"
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"WebFetch(domain:abaci.one)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run ID: \\(.databaseId)\"\"')",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - Run \\(.databaseId)\"\"')"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function RoomDetailPage() {
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
|
||||
@@ -1,128 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
|
||||
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
function ArcadeContent() {
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const arcadeRef = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* /arcade - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We show a friendly message with a link if no room exists to avoid navigation loops.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
useEffect(() => {
|
||||
// Register this component's main div as the fullscreen element
|
||||
if (arcadeRef.current) {
|
||||
setFullscreenElement(arcadeRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={arcadeRef}
|
||||
className={css({
|
||||
minHeight: 'calc(100vh - 80px)', // Account for mini nav height
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: { base: '4', md: '6' },
|
||||
})}
|
||||
>
|
||||
{/* Animated background elements */}
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `
|
||||
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%)
|
||||
`,
|
||||
animation: 'arcadeFloat 20s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Main Champion Arena - takes remaining space */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
style={{
|
||||
display: 'flex',
|
||||
px: { base: '2', md: '4' },
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: 0, // Important for flex children
|
||||
})}
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<EnhancedChampionArena
|
||||
onConfigurePlayer={() => {}}
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// All games are now in the registry
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]: [string, any]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
export default function ArcadePage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<ArcadePageWithRedirect />
|
||||
</FullscreenProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Arcade-specific animations
|
||||
const arcadeAnimations = `
|
||||
@keyframes arcadeFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
opacity: 0.7;
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-20px) rotate(1deg);
|
||||
opacity: 1;
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-10px) rotate(-1deg);
|
||||
opacity: 0.8;
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arcadePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(96, 165, 250, 0.6);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject arcade animations
|
||||
if (typeof document !== 'undefined' && !document.getElementById('arcade-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'arcade-animations'
|
||||
style.textContent = arcadeAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
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> = {
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// Check if it's a registry game first
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType, // Use the game name directly for registry games
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy game handling
|
||||
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: 'available' in gameConfig ? gameConfig.available : true,
|
||||
})
|
||||
|
||||
if ('available' in gameConfig && gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
console.log('[RoomPage] Mapping:', {
|
||||
gameType,
|
||||
internalGameName,
|
||||
mappingExists: !!internalGameName,
|
||||
})
|
||||
|
||||
console.log('[RoomPage] Calling setRoomGame with:', {
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
preservingGameConfig: true,
|
||||
})
|
||||
|
||||
// Don't pass gameConfig - we want to preserve existing settings for all games
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
password: roomPassword,
|
||||
})
|
||||
// Navigate to the game
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsJoining(false)
|
||||
@@ -261,7 +261,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
|
||||
// If user is already in this exact room, just navigate to game
|
||||
if (roomData && roomData.id === room.id) {
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push('/arcade/room') // Stay in current room
|
||||
router.push('/arcade') // Stay in current room
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
|
||||
@@ -1,185 +1,465 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack, stack } from '../../styled-system/patterns'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
|
||||
export default function HomePage() {
|
||||
const [abacusValue, setAbacusValue] = useState(1234567)
|
||||
const appConfig = useAbacusConfig()
|
||||
return (
|
||||
<PageWithNav navTitle="Soroban Flashcards" navEmoji="🧮">
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gradient-to-br from-brand.50 to-brand.100',
|
||||
})}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<main className={container({ maxW: '6xl', px: '4' })}>
|
||||
<PageWithNav navTitle="Soroban Mastery Platform" navEmoji="🧮">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
|
||||
{/* Hero with Large Abacus */}
|
||||
<div
|
||||
className={css({
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(88, 28, 135, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
|
||||
color: 'white',
|
||||
py: { base: '12', md: '20' },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={stack({
|
||||
gap: '12',
|
||||
py: '16',
|
||||
align: 'center',
|
||||
textAlign: 'center',
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
>
|
||||
{/* Hero Content */}
|
||||
<div className={stack({ gap: '6', maxW: '4xl' })}>
|
||||
/>
|
||||
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
|
||||
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl' },
|
||||
fontSize: { base: '3xl', md: '5xl', lg: '6xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '6',
|
||||
lineHeight: 'tight',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
Beautiful Soroban <span className={css({ color: 'brand.600' })}>Flashcards</span>
|
||||
Master the Soroban
|
||||
</h1>
|
||||
|
||||
{/* Large Featured Abacus */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
my: '10',
|
||||
p: '8',
|
||||
bg: 'white',
|
||||
borderRadius: '2xl',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.500/30',
|
||||
boxShadow: '0 25px 50px -12px rgba(139, 92, 246, 0.25)',
|
||||
color: '#1f2937',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={abacusValue}
|
||||
columns={7}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
interactive={true}
|
||||
animated={true}
|
||||
soundEnabled={true}
|
||||
soundVolume={0.4}
|
||||
scaleFactor={2.2}
|
||||
showNumbers={true}
|
||||
onValueChange={(newValue: number) => setAbacusValue(newValue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
color: 'gray.600',
|
||||
maxW: '2xl',
|
||||
color: 'gray.300',
|
||||
mb: '8',
|
||||
maxW: '3xl',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
Create stunning, educational flashcards with authentic Japanese abacus
|
||||
representations. Perfect for teachers, students, and mental math enthusiasts.
|
||||
Interactive tutorials, multiplayer games, and beautiful flashcards—your complete
|
||||
soroban learning ecosystem
|
||||
</p>
|
||||
|
||||
<div className={hstack({ gap: '4', justify: 'center', mt: '8' })}>
|
||||
<div className={hstack({ gap: '4', justify: 'center', flexWrap: 'wrap' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
href="/games"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
bg: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
color: 'gray.900',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
transition: 'all',
|
||||
shadow: '0 10px 40px rgba(251, 191, 36, 0.3)',
|
||||
_hover: {
|
||||
bg: 'brand.700',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'modal',
|
||||
shadow: '0 20px 50px rgba(251, 191, 36, 0.4)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
✨ Start Creating →
|
||||
🎮 Play Games
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'white',
|
||||
color: 'brand.700',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.200',
|
||||
transition: 'all',
|
||||
borderColor: 'purple.500',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
bg: 'rgba(139, 92, 246, 0.3)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
📚 Learn Soroban
|
||||
📚 Learn
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'rgba(139, 92, 246, 0.2)',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'lg',
|
||||
rounded: 'xl',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.500',
|
||||
_hover: {
|
||||
bg: 'rgba(139, 92, 246, 0.3)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
})}
|
||||
>
|
||||
🎨 Create
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1', md: '3' },
|
||||
gap: '8',
|
||||
mt: '16',
|
||||
w: 'full',
|
||||
})}
|
||||
>
|
||||
<FeatureCard
|
||||
icon="🎨"
|
||||
title="Beautiful Design"
|
||||
description="Vector graphics, color schemes, authentic bead positioning"
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
{/* Arcade Games Section */}
|
||||
<section className={stack({ gap: '6', mt: '16' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
<div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
🕹️ Multiplayer Arcade
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md' })}>
|
||||
Compete with friends in real-time soroban games
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/games"
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
color: 'yellow.400',
|
||||
fontWeight: 'semibold',
|
||||
_hover: { color: 'yellow.300' },
|
||||
display: { base: 'none', md: 'block' },
|
||||
})}
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '5' })}>
|
||||
<GameCard
|
||||
icon="🧠"
|
||||
title="Memory Lightning"
|
||||
description="Memorize soroban numbers"
|
||||
players="1-8 players"
|
||||
tags={['Co-op', 'Competitive']}
|
||||
gradient="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚡"
|
||||
title="Instant Generation"
|
||||
description="Create PDFs, interactive HTML, PNGs, and SVGs in seconds"
|
||||
<GameCard
|
||||
icon="⚔️"
|
||||
title="Matching Pairs"
|
||||
description="Turn-based card battles"
|
||||
players="1-4 players"
|
||||
tags={['Pattern Recognition']}
|
||||
gradient="linear-gradient(135deg, #f093fb 0%, #f5576c 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🎯"
|
||||
title="Educational Focus"
|
||||
description="Perfect for teachers, students, and soroban enthusiasts"
|
||||
<GameCard
|
||||
icon="🏁"
|
||||
title="Speed Race"
|
||||
description="Race AI with complements"
|
||||
players="1-4 players + AI"
|
||||
tags={['Practice', 'Sprint', 'Survival']}
|
||||
gradient="linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
<GameCard
|
||||
icon="🔢"
|
||||
title="Card Sorting"
|
||||
description="Arrange cards visually"
|
||||
players="Solo challenge"
|
||||
tags={['Visual Literacy']}
|
||||
gradient="linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)"
|
||||
href="/games"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Interactive Learning & Flashcard Creator */}
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8', mt: '16' })}>
|
||||
<FeaturePanel
|
||||
icon="📚"
|
||||
title="Interactive Learning"
|
||||
description="Master soroban through hands-on guided tutorials"
|
||||
features={[
|
||||
'Visual tutorials on reading bead positions',
|
||||
'Step-by-step arithmetic operations',
|
||||
'Interactive exercises with instant feedback',
|
||||
]}
|
||||
ctaText="Start Learning →"
|
||||
ctaHref="/guide"
|
||||
accentColor="purple"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="🎨"
|
||||
title="Flashcard Creator"
|
||||
description="Design beautiful soroban flashcards for any purpose"
|
||||
features={[
|
||||
'Multiple export formats: PDF, PNG, SVG, HTML',
|
||||
'Custom bead shapes, colors, and layouts',
|
||||
'All paper sizes: A3, A4, A5, US Letter',
|
||||
]}
|
||||
ctaText="Create Flashcards →"
|
||||
ctaHref="/create"
|
||||
accentColor="blue"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
function ColorSchemeCard({
|
||||
title,
|
||||
description,
|
||||
colorScheme,
|
||||
value,
|
||||
beadShape,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
colorScheme: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'
|
||||
value: number
|
||||
beadShape: 'diamond' | 'circle' | 'square'
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
p: '8',
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
textAlign: 'center',
|
||||
transition: 'all',
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
border: '2px solid',
|
||||
borderColor: 'gray.700',
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: {
|
||||
borderColor: 'purple.500',
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: 'modal',
|
||||
boxShadow: '0 20px 40px rgba(139, 92, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '3',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.400', mb: '4' })}>{description}</p>
|
||||
<div
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
lineHeight: 'relaxed',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
p: '4',
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
rounded: 'lg',
|
||||
})}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={3}
|
||||
beadShape={beadShape}
|
||||
colorScheme={colorScheme}
|
||||
hideInactiveBeads={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GameCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
players,
|
||||
tags,
|
||||
gradient,
|
||||
href,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
players: string
|
||||
tags: string[]
|
||||
gradient: string
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<div
|
||||
className={css({
|
||||
background: gradient,
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'lg',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
transform: 'translateY(-6px) scale(1.02)',
|
||||
shadow: '0 25px 50px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '3xl', mb: '3' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'white', mb: '2' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={css({ fontSize: 'sm', color: 'rgba(255, 255, 255, 0.9)', mb: '2' })}>
|
||||
{description}
|
||||
</p>
|
||||
<p className={css({ fontSize: 'xs', color: 'rgba(255, 255, 255, 0.7)', mb: '3' })}>
|
||||
{players}
|
||||
</p>
|
||||
<div className={hstack({ gap: '2', flexWrap: 'wrap' })}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
px: '2',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
rounded: 'full',
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturePanel({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
features,
|
||||
ctaText,
|
||||
ctaHref,
|
||||
accentColor,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
features: string[]
|
||||
ctaText: string
|
||||
ctaHref: string
|
||||
accentColor: 'purple' | 'blue'
|
||||
}) {
|
||||
const borderColor = accentColor === 'purple' ? 'purple.500/30' : 'blue.500/30'
|
||||
const bgColor = accentColor === 'purple' ? 'purple.500/10' : 'blue.500/10'
|
||||
const hoverBg = accentColor === 'purple' ? 'purple.500/20' : 'blue.500/20'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
border: '2px solid',
|
||||
borderColor,
|
||||
})}
|
||||
>
|
||||
<div className={hstack({ gap: '3', mb: '4' })}>
|
||||
<span className={css({ fontSize: '3xl' })}>{icon}</span>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white' })}>{title}</h2>
|
||||
</div>
|
||||
<p className={css({ fontSize: 'md', color: 'gray.300', mb: '6' })}>{description}</p>
|
||||
<div className={stack({ gap: '3', mb: '6' })}>
|
||||
{features.map((feature, i) => (
|
||||
<div key={i} className={hstack({ gap: '3' })}>
|
||||
<span className={css({ color: 'yellow.400', fontSize: 'lg' })}>✓</span>
|
||||
<span className={css({ color: 'gray.300', fontSize: 'sm' })}>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className={css({
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
py: '3',
|
||||
px: '6',
|
||||
bg: bgColor,
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor,
|
||||
_hover: { bg: hoverBg },
|
||||
transition: 'all 0.2s ease',
|
||||
})}
|
||||
>
|
||||
{ctaText}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
@@ -40,6 +33,9 @@ interface CardSortingContextValue {
|
||||
// UI state
|
||||
selectedCardId: string | null
|
||||
selectCard: (cardId: string | null) => void
|
||||
// Spectator mode
|
||||
localPlayerId: string | undefined
|
||||
isSpectating: boolean
|
||||
}
|
||||
|
||||
// Create context
|
||||
@@ -553,6 +549,9 @@ export function CardSortingProvider({ children }: { children: ReactNode }) {
|
||||
// UI state
|
||||
selectedCardId,
|
||||
selectCard: setSelectedCardId,
|
||||
// Spectator mode
|
||||
localPlayerId,
|
||||
isSpectating: !localPlayerId,
|
||||
}
|
||||
|
||||
return <CardSortingContext.Provider value={contextValue}>{children}</CardSortingContext.Provider>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ResultsPhase } from './ResultsPhase'
|
||||
|
||||
export function GameComponent() {
|
||||
const router = useRouter()
|
||||
const { state, exitSession, startGame, goToSetup } = useCardSorting()
|
||||
const { state, exitSession, startGame, goToSetup, isSpectating } = useCardSorting()
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const gameRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -57,6 +57,34 @@ export function GameComponent() {
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Spectator Mode Banner */}
|
||||
{isSpectating && state.gamePhase !== 'setup' && (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
|
||||
borderRadius: { base: '8px', md: '12px' },
|
||||
padding: { base: '12px', md: '16px' },
|
||||
marginBottom: { base: '12px', md: '16px' },
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
fontWeight: '600',
|
||||
color: '#92400e',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
<span role="img" aria-label="watching">
|
||||
👀
|
||||
</span>
|
||||
<span>Spectating {state.playerMetadata?.name || 'player'}'s game</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main
|
||||
className={css({
|
||||
width: '100%',
|
||||
|
||||
@@ -18,6 +18,7 @@ export function PlayingPhase() {
|
||||
canCheckSolution,
|
||||
placedCount,
|
||||
elapsedTime,
|
||||
isSpectating,
|
||||
} = useCardSorting()
|
||||
|
||||
// Status message (mimics Python updateSortingStatus)
|
||||
@@ -64,6 +65,7 @@ export function PlayingPhase() {
|
||||
}
|
||||
|
||||
const handleCardClick = (cardId: string) => {
|
||||
if (isSpectating) return // Spectators cannot interact
|
||||
if (selectedCardId === cardId) {
|
||||
selectCard(null) // Deselect
|
||||
} else {
|
||||
@@ -72,6 +74,7 @@ export function PlayingPhase() {
|
||||
}
|
||||
|
||||
const handleSlotClick = (position: number) => {
|
||||
if (isSpectating) return // Spectators cannot interact
|
||||
if (!selectedCardId) {
|
||||
// No card selected - if slot has a card, move it back and auto-select
|
||||
if (state.placedCards[position]) {
|
||||
@@ -89,6 +92,7 @@ export function PlayingPhase() {
|
||||
}
|
||||
|
||||
const handleInsertClick = (insertPosition: number) => {
|
||||
if (isSpectating) return // Spectators cannot interact
|
||||
if (!selectedCardId) {
|
||||
setStatusMessage('Please select a card first, then click where to insert it.')
|
||||
return
|
||||
@@ -181,17 +185,19 @@ export function PlayingPhase() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={revealNumbers}
|
||||
disabled={isSpectating}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'orange.500',
|
||||
background: isSpectating ? 'gray.300' : 'orange.500',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
_hover: {
|
||||
background: 'orange.600',
|
||||
background: isSpectating ? 'gray.300' : 'orange.600',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -201,19 +207,19 @@ export function PlayingPhase() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkSolution}
|
||||
disabled={!canCheckSolution}
|
||||
disabled={!canCheckSolution || isSpectating}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: canCheckSolution ? 'teal.600' : 'gray.300',
|
||||
background: canCheckSolution && !isSpectating ? 'teal.600' : 'gray.300',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: canCheckSolution ? 'pointer' : 'not-allowed',
|
||||
opacity: canCheckSolution ? 1 : 0.6,
|
||||
cursor: canCheckSolution && !isSpectating ? 'pointer' : 'not-allowed',
|
||||
opacity: canCheckSolution && !isSpectating ? 1 : 0.5,
|
||||
_hover: {
|
||||
background: canCheckSolution ? 'teal.700' : 'gray.300',
|
||||
background: canCheckSolution && !isSpectating ? 'teal.700' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -222,17 +228,19 @@ export function PlayingPhase() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSetup}
|
||||
disabled={isSpectating}
|
||||
className={css({
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'gray.600',
|
||||
background: isSpectating ? 'gray.400' : 'gray.600',
|
||||
color: 'white',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
_hover: {
|
||||
background: 'gray.700',
|
||||
background: isSpectating ? 'gray.400' : 'gray.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -264,13 +272,15 @@ export function PlayingPhase() {
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
base: '1',
|
||||
sm: '2',
|
||||
md: '3',
|
||||
},
|
||||
gap: '0.75rem',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
borderRadius: '8px',
|
||||
minHeight: '120px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{state.availableCards.map((card) => (
|
||||
@@ -278,39 +288,68 @@ export function PlayingPhase() {
|
||||
key={card.id}
|
||||
onClick={() => handleCardClick(card.id)}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
width: '140px',
|
||||
height: '140px',
|
||||
padding: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: selectedCardId === card.id ? 'blue.500' : 'gray.300',
|
||||
borderRadius: '0.5rem',
|
||||
background: selectedCardId === card.id ? 'blue.50' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
transform: selectedCardId === card.id ? 'scale(1.05)' : 'scale(1)',
|
||||
_hover: {
|
||||
transform: 'scale(1.05)',
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
},
|
||||
borderColor: selectedCardId === card.id ? '#1976d2' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
background: selectedCardId === card.id ? '#e3f2fd' : 'white',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||
borderColor: '#2c5f76',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
selectedCardId === card.id
|
||||
? {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: card.svgContent }}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{state.numbersRevealed && (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '0.5rem',
|
||||
fontSize: 'lg',
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
background: '#ffc107',
|
||||
color: '#333',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
@@ -336,35 +375,43 @@ export function PlayingPhase() {
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{/* Insert button before first position */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(0)}
|
||||
disabled={!selectedCardId}
|
||||
disabled={!selectedCardId || isSpectating}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '50px',
|
||||
background: selectedCardId ? '#1976d2' : '#2c5f76',
|
||||
background: selectedCardId && !isSpectating ? '#1976d2' : '#2c5f76',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
cursor: selectedCardId ? 'pointer' : 'default',
|
||||
opacity: selectedCardId ? 1 : 0.3,
|
||||
cursor: selectedCardId && !isSpectating ? 'pointer' : 'not-allowed',
|
||||
opacity: selectedCardId && !isSpectating ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
@@ -376,17 +423,19 @@ export function PlayingPhase() {
|
||||
const isEmpty = card === null
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<>
|
||||
{/* Position slot */}
|
||||
<div
|
||||
key={`slot-${index}`}
|
||||
onClick={() => handleSlotClick(index)}
|
||||
className={css({
|
||||
width: '90px',
|
||||
height: '110px',
|
||||
width: '140px',
|
||||
height: '160px',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid',
|
||||
cursor: 'pointer',
|
||||
cursor: isSpectating ? 'not-allowed' : 'pointer',
|
||||
opacity: isSpectating ? 0.5 : 1,
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -394,20 +443,23 @@ export function PlayingPhase() {
|
||||
justifyContent: 'center',
|
||||
gap: '0.25rem',
|
||||
position: 'relative',
|
||||
_hover: {
|
||||
transform: selectedCardId && isEmpty ? 'scale(1.05)' : 'none',
|
||||
boxShadow:
|
||||
selectedCardId && isEmpty ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
|
||||
},
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
transform: selectedCardId && isEmpty ? 'scale(1.05)' : 'none',
|
||||
boxShadow:
|
||||
selectedCardId && isEmpty ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
isEmpty
|
||||
? {
|
||||
...gradientStyle,
|
||||
// Active state: add slight glow when card is selected
|
||||
boxShadow: selectedCardId
|
||||
? '0 0 0 2px #1976d2, 0 2px 8px rgba(25, 118, 210, 0.3)'
|
||||
: 'none',
|
||||
boxShadow:
|
||||
selectedCardId && !isSpectating
|
||||
? '0 0 0 2px #1976d2, 0 2px 8px rgba(25, 118, 210, 0.3)'
|
||||
: 'none',
|
||||
}
|
||||
: {
|
||||
background: '#fff',
|
||||
@@ -423,10 +475,16 @@ export function PlayingPhase() {
|
||||
__html: card.svgContent,
|
||||
}}
|
||||
className={css({
|
||||
width: '70px',
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
@@ -457,35 +515,37 @@ export function PlayingPhase() {
|
||||
|
||||
{/* Insert button after this position */}
|
||||
<button
|
||||
key={`insert-${index + 1}`}
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(index + 1)}
|
||||
disabled={!selectedCardId}
|
||||
disabled={!selectedCardId || isSpectating}
|
||||
className={css({
|
||||
width: '32px',
|
||||
height: '50px',
|
||||
background: selectedCardId ? '#1976d2' : '#2c5f76',
|
||||
background: selectedCardId && !isSpectating ? '#1976d2' : '#2c5f76',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
cursor: selectedCardId ? 'pointer' : 'default',
|
||||
opacity: selectedCardId ? 1 : 0.3,
|
||||
cursor: selectedCardId && !isSpectating ? 'pointer' : 'not-allowed',
|
||||
opacity: selectedCardId && !isSpectating ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
marginTop: '0.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
opacity: 1,
|
||||
background: '#1976d2',
|
||||
transform: selectedCardId ? 'scale(1.1)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* in ascending order using only visual patterns (no numbers shown).
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { GameComponent } from './components/GameComponent'
|
||||
import { CardSortingProvider } from './Provider'
|
||||
@@ -24,9 +24,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 1, // Single player only
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
|
||||
color: 'teal',
|
||||
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)',
|
||||
borderColor: 'teal.200',
|
||||
...getGameTheme('teal'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Complete integration into the arcade system with multiplayer support
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { complementRaceValidator } from './Validator'
|
||||
import { ComplementRaceProvider } from './Provider'
|
||||
@@ -20,9 +20,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 4,
|
||||
icon: '🏁',
|
||||
chips: ['👥 1-4 Players', '🚂 Sprint Mode', '🤖 AI Opponents', '🔥 Speed Challenge'],
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
|
||||
borderColor: 'blue.200',
|
||||
...getGameTheme('blue'),
|
||||
difficulty: 'Intermediate',
|
||||
available: true,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Supports both abacus-numeral matching and complement pairs modes.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { MemoryPairsGame } from './components/MemoryPairsGame'
|
||||
import { MatchingProvider } from './Provider'
|
||||
@@ -23,9 +23,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 4,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['👥 Multiplayer', '🎯 Strategic', '🏆 Competitive'],
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)',
|
||||
borderColor: 'purple.200',
|
||||
...getGameTheme('purple'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Supports both cooperative and competitive multiplayer modes.
|
||||
*/
|
||||
|
||||
import { defineGame } from '@/lib/arcade/game-sdk'
|
||||
import { defineGame, getGameTheme } from '@/lib/arcade/game-sdk'
|
||||
import type { GameManifest } from '@/lib/arcade/game-sdk'
|
||||
import { MemoryQuizGame } from './components/MemoryQuizGame'
|
||||
import { MemoryQuizProvider } from './Provider'
|
||||
@@ -23,9 +23,7 @@ const manifest: GameManifest = {
|
||||
maxPlayers: 8,
|
||||
difficulty: 'Intermediate',
|
||||
chips: ['👥 Multiplayer', '🧠 Memory', '🧮 Soroban'],
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)',
|
||||
borderColor: 'blue.200',
|
||||
...getGameTheme('blue'),
|
||||
available: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ function getAllGameConfigs() {
|
||||
maxPlayers: gameDef.manifest.maxPlayers,
|
||||
description: gameDef.manifest.description,
|
||||
longDescription: gameDef.manifest.longDescription,
|
||||
url: `/arcade/room?game=${gameDef.manifest.name}`, // Registry games load in room
|
||||
url: '/arcade', // Arcade page handles game selection through UI
|
||||
icon: gameDef.manifest.icon,
|
||||
chips: gameDef.manifest.chips,
|
||||
color: gameDef.manifest.color,
|
||||
|
||||
@@ -80,7 +80,7 @@ export function AddPlayerButton({
|
||||
})
|
||||
// Close popover and navigate to room to choose game
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create room:', error)
|
||||
@@ -111,7 +111,7 @@ export function AddPlayerButton({
|
||||
}
|
||||
// Close popover and navigate to room
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -833,7 +833,7 @@ export function ModerationNotifications({
|
||||
// Close the modal
|
||||
onClose()
|
||||
// Navigate to the room
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
showError(
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
|
||||
// Join the room
|
||||
await joinRoom({ roomId: invitation.roomId })
|
||||
// Navigate to the room
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
// Refresh invitations
|
||||
await fetchInvitations()
|
||||
onInvitationChange?.()
|
||||
|
||||
@@ -82,6 +82,13 @@ export { loadManifest } from './load-manifest'
|
||||
*/
|
||||
export { defineGame } from './define-game'
|
||||
|
||||
/**
|
||||
* Standard color themes for game cards
|
||||
* Use these to ensure consistent appearance across all games
|
||||
*/
|
||||
export { getGameTheme, GAME_THEMES } from '../game-themes'
|
||||
export type { GameTheme, GameThemeName } from '../game-themes'
|
||||
|
||||
// ============================================================================
|
||||
// Re-exports for convenience
|
||||
// ============================================================================
|
||||
|
||||
88
apps/web/src/lib/arcade/game-themes.ts
Normal file
88
apps/web/src/lib/arcade/game-themes.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Standard color themes for arcade game cards
|
||||
*
|
||||
* Use these presets to ensure consistent, professional appearance
|
||||
* across all game cards on the /arcade game chooser.
|
||||
*
|
||||
* All gradients use Panda CSS's 100-200 color range for soft pastel appearance.
|
||||
*/
|
||||
|
||||
export interface GameTheme {
|
||||
color: string
|
||||
gradient: string
|
||||
borderColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard theme presets
|
||||
* These use Panda CSS's color system and provide consistent styling
|
||||
*/
|
||||
export const GAME_THEMES = {
|
||||
blue: {
|
||||
color: 'blue',
|
||||
gradient: 'linear-gradient(135deg, #dbeafe, #bfdbfe)', // blue.100 to blue.200
|
||||
borderColor: 'blue.200',
|
||||
},
|
||||
purple: {
|
||||
color: 'purple',
|
||||
gradient: 'linear-gradient(135deg, #e9d5ff, #ddd6fe)', // purple.100 to purple.200
|
||||
borderColor: 'purple.200',
|
||||
},
|
||||
green: {
|
||||
color: 'green',
|
||||
gradient: 'linear-gradient(135deg, #d1fae5, #a7f3d0)', // green.100 to green.200
|
||||
borderColor: 'green.200',
|
||||
},
|
||||
teal: {
|
||||
color: 'teal',
|
||||
gradient: 'linear-gradient(135deg, #ccfbf1, #99f6e4)', // teal.100 to teal.200
|
||||
borderColor: 'teal.200',
|
||||
},
|
||||
indigo: {
|
||||
color: 'indigo',
|
||||
gradient: 'linear-gradient(135deg, #e0e7ff, #c7d2fe)', // indigo.100 to indigo.200
|
||||
borderColor: 'indigo.200',
|
||||
},
|
||||
pink: {
|
||||
color: 'pink',
|
||||
gradient: 'linear-gradient(135deg, #fce7f3, #fbcfe8)', // pink.100 to pink.200
|
||||
borderColor: 'pink.200',
|
||||
},
|
||||
orange: {
|
||||
color: 'orange',
|
||||
gradient: 'linear-gradient(135deg, #ffedd5, #fed7aa)', // orange.100 to orange.200
|
||||
borderColor: 'orange.200',
|
||||
},
|
||||
yellow: {
|
||||
color: 'yellow',
|
||||
gradient: 'linear-gradient(135deg, #fef3c7, #fde68a)', // yellow.100 to yellow.200
|
||||
borderColor: 'yellow.200',
|
||||
},
|
||||
red: {
|
||||
color: 'red',
|
||||
gradient: 'linear-gradient(135deg, #fee2e2, #fecaca)', // red.100 to red.200
|
||||
borderColor: 'red.200',
|
||||
},
|
||||
gray: {
|
||||
color: 'gray',
|
||||
gradient: 'linear-gradient(135deg, #f3f4f6, #e5e7eb)', // gray.100 to gray.200
|
||||
borderColor: 'gray.200',
|
||||
},
|
||||
} as const satisfies Record<string, GameTheme>
|
||||
|
||||
export type GameThemeName = keyof typeof GAME_THEMES
|
||||
|
||||
/**
|
||||
* Get a standard theme by name
|
||||
* Use this in your game manifest instead of hardcoding gradients
|
||||
*
|
||||
* @example
|
||||
* const manifest: GameManifest = {
|
||||
* name: 'my-game',
|
||||
* // ... other fields
|
||||
* ...getGameTheme('blue')
|
||||
* }
|
||||
*/
|
||||
export function getGameTheme(themeName: GameThemeName): GameTheme {
|
||||
return GAME_THEMES[themeName]
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
session = await createArcadeSession({
|
||||
userId,
|
||||
gameName: room.gameName as GameName,
|
||||
gameUrl: '/arcade/room',
|
||||
gameUrl: '/arcade',
|
||||
initialState,
|
||||
activePlayers: roomPlayerIds, // Include all room members' active players
|
||||
roomId: room.id,
|
||||
@@ -173,7 +173,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
await createArcadeSession({
|
||||
userId: data.userId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
gameUrl: '/arcade', // Room-based sessions use /arcade
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.10.2",
|
||||
"version": "4.14.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user