Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f8e411b92 | ||
|
|
4b04e43ff8 | ||
|
|
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 |
10
.github/workflows/deploy.yml
vendored
10
.github/workflows/deploy.yml
vendored
@@ -57,6 +57,10 @@ jobs:
|
||||
type=ref,event=pr
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Get short commit SHA
|
||||
id: vars
|
||||
run: echo "short_sha=$(echo ${{ github.sha }} | cut -c1-8)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -64,3 +68,9 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
GIT_COMMIT_SHORT=${{ steps.vars.outputs.short_sha }}
|
||||
GIT_BRANCH=${{ github.ref_name }}
|
||||
GIT_TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }}
|
||||
GIT_DIRTY=false
|
||||
|
||||
136
CHANGELOG.md
136
CHANGELOG.md
@@ -1,3 +1,139 @@
|
||||
## [4.14.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.14.0...v4.14.1) (2025-10-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deployment:** pass git info to Docker build for deployment info modal ([4b04e43](https://github.com/antialias/soroban-abacus-flashcards/commit/4b04e43ff8c9e9f239d7f5e306aab338b535296f))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -21,6 +21,21 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Builder stage
|
||||
FROM base AS builder
|
||||
|
||||
# Accept git information as build arguments
|
||||
ARG GIT_COMMIT
|
||||
ARG GIT_COMMIT_SHORT
|
||||
ARG GIT_BRANCH
|
||||
ARG GIT_TAG
|
||||
ARG GIT_DIRTY
|
||||
|
||||
# Set as environment variables for build scripts
|
||||
ENV GIT_COMMIT=${GIT_COMMIT}
|
||||
ENV GIT_COMMIT_SHORT=${GIT_COMMIT_SHORT}
|
||||
ENV GIT_BRANCH=${GIT_BRANCH}
|
||||
ENV GIT_TAG=${GIT_TAG}
|
||||
ENV GIT_DIRTY=${GIT_DIRTY}
|
||||
|
||||
COPY . .
|
||||
|
||||
# Generate Panda CSS styled-system before building
|
||||
@@ -33,8 +48,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 +70,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
|
||||
|
||||
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')
|
||||
```
|
||||
@@ -98,7 +98,10 @@
|
||||
"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\"\")\"\"')",
|
||||
"WebFetch(domain:abaci.one)"
|
||||
"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": []
|
||||
|
||||
@@ -18,11 +18,13 @@ function exec(command) {
|
||||
}
|
||||
|
||||
function getBuildInfo() {
|
||||
const gitCommit = exec('git rev-parse HEAD')
|
||||
const gitCommitShort = exec('git rev-parse --short HEAD')
|
||||
const gitBranch = exec('git rev-parse --abbrev-ref HEAD')
|
||||
const gitTag = exec('git describe --tags --exact-match 2>/dev/null')
|
||||
const gitDirty = exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
// Try to get git info from environment variables first (for Docker builds)
|
||||
// Fall back to git commands (for local development)
|
||||
const gitCommit = process.env.GIT_COMMIT || exec('git rev-parse HEAD')
|
||||
const gitCommitShort = process.env.GIT_COMMIT_SHORT || exec('git rev-parse --short HEAD')
|
||||
const gitBranch = process.env.GIT_BRANCH || exec('git rev-parse --abbrev-ref HEAD')
|
||||
const gitTag = process.env.GIT_TAG || exec('git describe --tags --exact-match 2>/dev/null')
|
||||
const gitDirty = process.env.GIT_DIRTY === 'true' || exec('git diff --quiet || echo "dirty"') === 'dirty'
|
||||
|
||||
const packageJson = require('../package.json')
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
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 Mastery Platform" navEmoji="🧮">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh' })}>
|
||||
@@ -53,55 +54,31 @@ export default function HomePage() {
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4',
|
||||
justifyContent: 'center',
|
||||
my: '10',
|
||||
p: '8',
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
bg: 'white',
|
||||
borderRadius: '2xl',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.500/30',
|
||||
boxShadow: '0 25px 50px -12px rgba(139, 92, 246, 0.25)',
|
||||
color: '#1f2937',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
color: 'gray.300',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Click the beads to interact!
|
||||
</div>
|
||||
<AbacusReact
|
||||
defaultValue={1234567}
|
||||
value={abacusValue}
|
||||
columns={7}
|
||||
beadShape="diamond"
|
||||
colorScheme="place-value"
|
||||
hideInactiveBeads={false}
|
||||
beadShape={appConfig.beadShape}
|
||||
colorScheme={appConfig.colorScheme}
|
||||
hideInactiveBeads={appConfig.hideInactiveBeads}
|
||||
interactive={true}
|
||||
animated={true}
|
||||
soundEnabled={true}
|
||||
soundVolume={0.4}
|
||||
scaleFactor={2.2}
|
||||
showNumbers={true}
|
||||
callbacks={{
|
||||
onValueChange: (newValue: number) => setAbacusValue(newValue),
|
||||
}}
|
||||
onValueChange={(newValue: number) => setAbacusValue(newValue)}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'yellow.400',
|
||||
fontFamily: 'mono',
|
||||
letterSpacing: 'wide',
|
||||
})}
|
||||
>
|
||||
{abacusValue.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
@@ -185,57 +162,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Scheme Showcase */}
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
<section className={stack({ gap: '8' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
Beautiful Color Schemes
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'lg' })}>
|
||||
Choose from multiple visual styles to enhance learning
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '6' })}>
|
||||
<ColorSchemeCard
|
||||
title="Monochrome"
|
||||
description="Classic, minimalist design"
|
||||
colorScheme="monochrome"
|
||||
value={42}
|
||||
beadShape="diamond"
|
||||
/>
|
||||
<ColorSchemeCard
|
||||
title="Place Value"
|
||||
description="Each column has its own color"
|
||||
colorScheme="place-value"
|
||||
value={789}
|
||||
beadShape="circle"
|
||||
/>
|
||||
<ColorSchemeCard
|
||||
title="Heaven & Earth"
|
||||
description="Distinct heaven and earth beads"
|
||||
colorScheme="heaven-earth"
|
||||
value={156}
|
||||
beadShape="square"
|
||||
/>
|
||||
<ColorSchemeCard
|
||||
title="Alternating"
|
||||
description="Alternating column colors"
|
||||
colorScheme="alternating"
|
||||
value={234}
|
||||
beadShape="diamond"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Arcade Games Section */}
|
||||
<section className={stack({ gap: '6', mt: '16' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
@@ -336,36 +264,6 @@ export default function HomePage() {
|
||||
accentColor="blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Banner */}
|
||||
<section
|
||||
className={css({
|
||||
bg: 'linear-gradient(135deg, rgba(139, 92, 246, 0.2) 0%, rgba(59, 130, 246, 0.2) 100%)',
|
||||
rounded: '2xl',
|
||||
p: '10',
|
||||
mt: '16',
|
||||
border: '2px solid',
|
||||
borderColor: 'purple.500/20',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'bold',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
Complete Soroban Learning Platform
|
||||
</h2>
|
||||
<div className={grid({ columns: { base: 2, md: 4 }, gap: '8', textAlign: 'center' })}>
|
||||
<StatItem number="4" label="Arcade Games" />
|
||||
<StatItem number="8" label="Max Players" />
|
||||
<StatItem number="3" label="Learning Modes" />
|
||||
<StatItem number="4+" label="Export Formats" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
@@ -565,23 +463,3 @@ function FeaturePanel({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatItem({ number, label }: { number: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: { base: '3xl', md: '4xl' },
|
||||
fontWeight: 'bold',
|
||||
mb: '2',
|
||||
background: 'linear-gradient(135deg, #fbbf24, #f59e0b)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
})}
|
||||
>
|
||||
{number}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.300' })}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ interface CardSortingContextValue {
|
||||
// UI state
|
||||
selectedCardId: string | null
|
||||
selectCard: (cardId: string | null) => void
|
||||
// Spectator mode
|
||||
localPlayerId: string | undefined
|
||||
isSpectating: boolean
|
||||
}
|
||||
|
||||
// Create context
|
||||
@@ -546,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',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -280,14 +288,15 @@ export function PlayingPhase() {
|
||||
key={card.id}
|
||||
onClick={() => handleCardClick(card.id)}
|
||||
className={css({
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
width: '140px',
|
||||
height: '140px',
|
||||
padding: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: selectedCardId === card.id ? '#1976d2' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
background: selectedCardId === card.id ? '#e3f2fd' : 'white',
|
||||
cursor: 'pointer',
|
||||
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',
|
||||
@@ -296,11 +305,13 @@ export function PlayingPhase() {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||
borderColor: '#2c5f76',
|
||||
},
|
||||
_hover: isSpectating
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||
borderColor: '#2c5f76',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
selectedCardId === card.id
|
||||
@@ -314,8 +325,8 @@ export function PlayingPhase() {
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: card.svgContent }}
|
||||
className={css({
|
||||
width: '74px',
|
||||
height: '74px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -378,27 +389,29 @@ export function PlayingPhase() {
|
||||
<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',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
@@ -416,12 +429,13 @@ export function PlayingPhase() {
|
||||
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',
|
||||
@@ -429,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',
|
||||
@@ -458,13 +475,12 @@ export function PlayingPhase() {
|
||||
__html: card.svgContent,
|
||||
}}
|
||||
className={css({
|
||||
width: '70px',
|
||||
height: '70px',
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
margin: '0 auto',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -502,27 +518,29 @@ export function PlayingPhase() {
|
||||
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',
|
||||
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',
|
||||
},
|
||||
})}
|
||||
>
|
||||
+
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.13.1",
|
||||
"version": "4.14.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user