fix: board rotation now properly fills height in portrait mode
Fixed board rotation issues when guide is docked: - Board now prioritizes HEIGHT when in portrait orientation - Removed CSS constraints (maxWidth, maxHeight, aspectRatio) that were interfering with explicit dimensions during rotation - Board properly fills container whether guide is docked or not - Smooth transition between portrait and landscape orientations Technical changes: - Set maxWidth/maxHeight to 'none' when svgDimensions are explicit - Set aspectRatio to 'auto' when rotating to prevent override - Cleaned up debug logging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
943
apps/web/.claude/PLATFORM_INTEGRATION_ROADMAP.md
Normal file
943
apps/web/.claude/PLATFORM_INTEGRATION_ROADMAP.md
Normal file
@@ -0,0 +1,943 @@
|
||||
# Platform Integration Roadmap
|
||||
|
||||
**Project:** Soroban Abacus Flashcards - Arcade Games Platform
|
||||
**Focus Areas:** Educational Platform SSO & Game Distribution Channels
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** 2025-11-02
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This roadmap outlines integration strategies for four major platform categories to expand reach and player acquisition:
|
||||
|
||||
1. **Google Classroom Integration** - Direct access to teachers and students
|
||||
2. **Clever/ClassLink SSO** - K-12 institution single sign-on (massive reach)
|
||||
3. **Game Distribution Portals** - CrazyGames, Poki, Kongregate (casual player discovery)
|
||||
4. **Steam Distribution** - Educational game marketplace with social features
|
||||
|
||||
**Estimated Total Timeline:** 6-9 months for full implementation
|
||||
**Estimated Total Cost:** $600-1,500 (app fees + hosting)
|
||||
**Expected Impact:** 10x-100x increase in player discovery reach
|
||||
|
||||
---
|
||||
|
||||
## Platform #1: Google Classroom Integration
|
||||
|
||||
### Overview
|
||||
|
||||
Google Classroom API enables teachers to:
|
||||
- Import game rooms directly into their classes
|
||||
- Auto-create student accounts with SSO
|
||||
- Track student progress and scores
|
||||
- Assign abacus practice as homework
|
||||
|
||||
**Target Audience:** 150+ million students and teachers using Google Classroom globally
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
**Prerequisites:**
|
||||
- Google Cloud Platform (GCP) project
|
||||
- OAuth 2.0 implementation
|
||||
- Google Classroom API access
|
||||
- HTTPS endpoints
|
||||
|
||||
**API Capabilities Needed:**
|
||||
- Courses API (read class rosters)
|
||||
- Coursework API (create assignments)
|
||||
- Students API (manage student accounts)
|
||||
- Submissions API (track game completion)
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Basic OAuth & SSO (2-3 weeks)
|
||||
**Tasks:**
|
||||
1. Set up GCP project and enable Classroom API
|
||||
2. Implement OAuth 2.0 "Sign in with Google" flow
|
||||
3. Add Classroom scope permissions (`classroom.courses.readonly`, `classroom.rosters.readonly`)
|
||||
4. Create user account mapping (Google ID → Internal User ID)
|
||||
5. Test with sandbox Google Workspace account
|
||||
|
||||
**Deliverables:**
|
||||
- "Sign in with Google" button on login page
|
||||
- Auto-account creation for Google users
|
||||
- Basic profile sync (name, email, photo)
|
||||
|
||||
**Effort:** 40-60 hours
|
||||
**Cost:** $0 (Google API is free for educational use)
|
||||
|
||||
#### Phase 2: Class Import & Roster Sync (3-4 weeks)
|
||||
**Tasks:**
|
||||
1. Build UI for teachers to import Classroom rosters
|
||||
2. Implement Courses API integration (list teacher's classes)
|
||||
3. Create room auto-provisioning from class data
|
||||
4. Set up automatic roster synchronization (daily sync)
|
||||
5. Handle student account creation/deactivation
|
||||
6. Add class management dashboard for teachers
|
||||
|
||||
**Deliverables:**
|
||||
- "Import from Google Classroom" feature
|
||||
- Teacher dashboard showing all imported classes
|
||||
- Automatic student account provisioning
|
||||
- Daily roster sync (new students, removed students)
|
||||
|
||||
**Effort:** 80-100 hours
|
||||
**Cost:** $0
|
||||
|
||||
#### Phase 3: Assignment Integration (3-4 weeks)
|
||||
**Tasks:**
|
||||
1. Build "Assign to Classroom" feature for game rooms
|
||||
2. Implement Coursework API integration
|
||||
3. Create assignment templates for each game type
|
||||
4. Build grade sync (game scores → Classroom grades)
|
||||
5. Add completion tracking and submission API integration
|
||||
6. Create teacher analytics dashboard
|
||||
|
||||
**Deliverables:**
|
||||
- Teachers can push game assignments to Classroom
|
||||
- Students see assignments in Classroom feed
|
||||
- Automatic grade passback on game completion
|
||||
- Teacher analytics (who played, scores, time spent)
|
||||
|
||||
**Effort:** 100-120 hours
|
||||
**Cost:** $0
|
||||
|
||||
#### Phase 4: Deep Integration & Polish (2-3 weeks)
|
||||
**Tasks:**
|
||||
1. Add "Share Turn" notifications (via Classroom API announcements)
|
||||
2. Implement progress tracking dashboard
|
||||
3. Create teacher resource library (lesson plans, tutorials)
|
||||
4. Add student portfolio (history of games played)
|
||||
5. Build reporting exports (CSV, PDF)
|
||||
6. Classroom API webhook integration for real-time updates
|
||||
|
||||
**Deliverables:**
|
||||
- Comprehensive teacher dashboard
|
||||
- Student progress portfolios
|
||||
- Automated reporting
|
||||
- Real-time roster updates
|
||||
|
||||
**Effort:** 60-80 hours
|
||||
**Cost:** $0
|
||||
|
||||
### Total Timeline: 10-14 weeks
|
||||
### Total Effort: 280-360 hours
|
||||
### Total Cost: $0
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing user authentication system (✓ NextAuth in place)
|
||||
- Room management system (✓ exists)
|
||||
- Score/progress tracking (✓ exists per game)
|
||||
- Email notification system (partially implemented)
|
||||
|
||||
### Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| OAuth complexity | Medium | Medium | Use official Google client libraries |
|
||||
| API rate limits | Low | Low | Implement caching and batch requests |
|
||||
| Grade sync errors | High | Medium | Add retry logic and error notifications |
|
||||
| Teacher adoption | High | High | Create detailed onboarding videos |
|
||||
| Privacy compliance (COPPA) | Critical | Medium | Implement parent consent workflows |
|
||||
|
||||
### Success Metrics
|
||||
|
||||
- **Adoption:** 100+ teachers in first 3 months
|
||||
- **Usage:** 50+ classes imported
|
||||
- **Retention:** 70%+ of teachers assign games monthly
|
||||
- **Referrals:** 20% teacher-to-teacher referrals
|
||||
|
||||
---
|
||||
|
||||
## Platform #2: Clever & ClassLink SSO
|
||||
|
||||
### Overview
|
||||
|
||||
**Clever:** SSO platform serving 75% of U.S. K-12 schools (13,000+ districts)
|
||||
**ClassLink:** SSO platform serving 20+ million students globally
|
||||
|
||||
Both provide instant access to school rosters without manual setup.
|
||||
|
||||
**Key Benefits:**
|
||||
- Zero teacher setup (IT manages access)
|
||||
- Automatic roster sync
|
||||
- Instant student login (no passwords)
|
||||
- District-wide deployment capability
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
**Clever Requirements:**
|
||||
- OAuth 2.0 implementation
|
||||
- REST API integration
|
||||
- HTTPS endpoints
|
||||
- District SSO certification
|
||||
|
||||
**ClassLink Requirements:**
|
||||
- SAML 2.0 support (or OAuth/OpenID Connect)
|
||||
- OneRoster API integration (for roster sync)
|
||||
- ClassLink Management Console access
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Clever District SSO (4-5 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Register as Clever developer (dev.clever.com)
|
||||
2. Implement OAuth 2.0 authorization grant flow
|
||||
3. Add "Log in with Clever" button
|
||||
4. Integrate Clever Data API v3.1
|
||||
- Districts API
|
||||
- Schools API
|
||||
- Students/Teachers API
|
||||
- Sections API (classes)
|
||||
5. Build user account mapping (Clever ID → Internal ID)
|
||||
6. Create development environment with sandbox district
|
||||
7. Implement district admin dashboard
|
||||
8. Apply for District SSO certification
|
||||
|
||||
**API Scopes Required:**
|
||||
- `read:district_admins_basic`
|
||||
- `read:school_admins_basic`
|
||||
- `read:students_basic`
|
||||
- `read:teachers_basic`
|
||||
- `read:user_id`
|
||||
- `read:sis` (for full roster data)
|
||||
|
||||
**Deliverables:**
|
||||
- "Log in with Clever" SSO
|
||||
- Automatic account creation
|
||||
- District/school/class hierarchy sync
|
||||
- Certified for live district connections
|
||||
|
||||
**Effort:** 100-120 hours
|
||||
**Cost:** $0 (Clever is free for developers)
|
||||
|
||||
#### Phase 2: ClassLink SAML Integration (3-4 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Set up SAML 2.0 service provider
|
||||
2. Register with ClassLink SSO Library
|
||||
3. Implement SAML authentication flow
|
||||
4. Add "Log in with ClassLink" button
|
||||
5. Integrate OneRoster API for roster sync
|
||||
6. Build automated roster update system
|
||||
7. Create ClassLink admin dashboard
|
||||
8. Submit to ClassLink SSO Library (6,000+ app directory)
|
||||
|
||||
**Deliverables:**
|
||||
- "Log in with ClassLink" SSO
|
||||
- OneRoster-based roster sync
|
||||
- Listed in ClassLink SSO Library
|
||||
|
||||
**Effort:** 80-100 hours
|
||||
**Cost:** $0
|
||||
|
||||
#### Phase 3: Advanced Features (2-3 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Implement shared device support (session override)
|
||||
2. Add UTF-8 character support for names
|
||||
3. Build district admin analytics dashboard
|
||||
4. Create automated onboarding emails for admins
|
||||
5. Add Clever/ClassLink badge to marketing site
|
||||
6. Build compliance documentation (FERPA, COPPA)
|
||||
|
||||
**Deliverables:**
|
||||
- Production-ready SSO for both platforms
|
||||
- District admin tools
|
||||
- Compliance documentation
|
||||
|
||||
**Effort:** 60-80 hours
|
||||
**Cost:** $0
|
||||
|
||||
### Total Timeline: 9-12 weeks
|
||||
### Total Effort: 240-300 hours
|
||||
### Total Cost: $0
|
||||
|
||||
### Dependencies
|
||||
|
||||
- SAML library (e.g., `passport-saml` for Node.js)
|
||||
- OAuth 2.0 implementation (can reuse from Google Classroom)
|
||||
- Secure session management
|
||||
- Database schema for district/school/class hierarchy
|
||||
|
||||
### Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Certification delays | High | Medium | Start certification process early |
|
||||
| SAML complexity | Medium | Medium | Use established libraries (passport-saml) |
|
||||
| District adoption | High | Medium | Partner with early adopter districts |
|
||||
| Privacy regulations | Critical | Medium | Legal review of terms/privacy policy |
|
||||
| Competing apps | Medium | High | Emphasize unique abacus/math focus |
|
||||
|
||||
### Success Metrics
|
||||
|
||||
- **Listings:** Published in both app directories
|
||||
- **Certification:** Clever District SSO certified
|
||||
- **Districts:** 10+ districts in first 6 months
|
||||
- **Students:** 5,000+ student logins in first year
|
||||
- **Retention:** 60%+ district renewal rate
|
||||
|
||||
---
|
||||
|
||||
## Platform #3: Game Distribution Portals
|
||||
|
||||
### Overview
|
||||
|
||||
Three major browser game portals with complementary audiences:
|
||||
|
||||
1. **CrazyGames:** Open platform, 35M+ monthly users, SDK monetization
|
||||
2. **Poki:** Curated platform, 50M+ monthly users, 50/50 revenue share, web exclusivity
|
||||
3. **Kongregate:** Legacy platform, 20M+ monthly users, now requires pre-approval
|
||||
|
||||
**Target Audience:** Casual gamers discovering educational games through play
|
||||
|
||||
### Platform Comparison
|
||||
|
||||
| Feature | CrazyGames | Poki | Kongregate |
|
||||
|---------|------------|------|------------|
|
||||
| **Selectivity** | Moderate | High (curated) | High (email approval) |
|
||||
| **File Size** | Strict limits | <8MB initial | ~1100x700px max |
|
||||
| **Exclusivity** | None | Web exclusive | None |
|
||||
| **Revenue** | SDK ads | 50/50 split | Ads + Kreds |
|
||||
| **SDK Required** | Yes | Yes | Yes |
|
||||
| **Approval Time** | 1-2 weeks | 2-4 weeks | Varies |
|
||||
|
||||
### Technical Requirements (Common)
|
||||
|
||||
**All Platforms:**
|
||||
- HTML5/WebGL build
|
||||
- Save system (localStorage/cloud)
|
||||
- Ad integration (SDK provided)
|
||||
- Mobile responsive design
|
||||
- HTTPS hosting
|
||||
- No external monetization UI
|
||||
|
||||
**CrazyGames Specific:**
|
||||
- CrazyGames SDK integration
|
||||
- File size optimization
|
||||
- Ad placements (banner, interstitial, rewarded)
|
||||
- Leaderboard integration
|
||||
|
||||
**Poki Specific:**
|
||||
- Poki SDK integration
|
||||
- <8MB initial download (critical!)
|
||||
- Auto-detect mobile devices
|
||||
- Remove any IAP UI elements
|
||||
- Web exclusivity agreement
|
||||
|
||||
**Kongregate Specific:**
|
||||
- Pre-approval via BD@kongregate.com
|
||||
- Kongregate API integration
|
||||
- Badge system integration
|
||||
- Kreds (virtual currency) support optional
|
||||
- "initialized" stat tracking
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Game Optimization & SDK Prep (3-4 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Audit current build sizes (check all games)
|
||||
2. Implement code splitting and lazy loading
|
||||
3. Optimize assets (image compression, audio formats)
|
||||
4. Create lightweight "portal build" configuration
|
||||
5. Build asset CDN strategy
|
||||
6. Implement progress save system (if not present)
|
||||
7. Add mobile detection and responsive layouts
|
||||
8. Create build pipeline for portal releases
|
||||
|
||||
**Target Build Sizes:**
|
||||
- Initial load: <5MB (to meet Poki's <8MB requirement)
|
||||
- Full game: <20MB
|
||||
- Individual game assets: lazy loaded
|
||||
|
||||
**Deliverables:**
|
||||
- Optimized builds for each game
|
||||
- Build pipeline for portal releases
|
||||
- Mobile-responsive UI for all games
|
||||
|
||||
**Effort:** 80-100 hours
|
||||
**Cost:** $0
|
||||
|
||||
#### Phase 2: CrazyGames Integration (2-3 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Register at CrazyGames developer portal
|
||||
2. Integrate CrazyGames SDK
|
||||
- Ad placements (banner, interstitial, rewarded)
|
||||
- Game analytics
|
||||
- User progression tracking
|
||||
3. Submit best-performing games:
|
||||
- Matching Pairs Battle (most casual-friendly)
|
||||
- Complement Race (fast-paced)
|
||||
- Card Sorting (quick sessions)
|
||||
4. Create game pages with descriptions/screenshots
|
||||
5. Pass technical review and QA
|
||||
6. Launch and monitor metrics
|
||||
|
||||
**Deliverables:**
|
||||
- 3 games live on CrazyGames
|
||||
- SDK integration complete
|
||||
- Ad monetization active
|
||||
|
||||
**Effort:** 60-80 hours
|
||||
**Cost:** $0
|
||||
|
||||
#### Phase 3: Poki Integration (3-4 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Submit application via Poki game submission form
|
||||
2. Wait for curator review (2-4 weeks)
|
||||
3. If approved, integrate Poki SDK
|
||||
- Ad integration (Poki manages monetization)
|
||||
- Analytics tracking
|
||||
- Commerce API (if applicable)
|
||||
4. Agree to web exclusivity terms
|
||||
5. Optimize games to <8MB initial load (critical!)
|
||||
6. Remove any competing monetization UI
|
||||
7. Submit games for final review
|
||||
8. Launch on Poki
|
||||
|
||||
**Deliverables:**
|
||||
- 2-3 games live on Poki (start with best performers)
|
||||
- Poki SDK integration
|
||||
- Revenue share active (50/50 split)
|
||||
|
||||
**Effort:** 80-100 hours
|
||||
**Cost:** $0
|
||||
**Note:** Approval not guaranteed - Poki is highly selective
|
||||
|
||||
#### Phase 4: Kongregate Pre-Approval (2-3 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Email BD@kongregate.com with game portfolio
|
||||
2. Prepare pitch deck:
|
||||
- Game trailers/screenshots
|
||||
- Unique value proposition (abacus education + multiplayer)
|
||||
- Target audience data
|
||||
- Existing player metrics
|
||||
3. Wait for response (varies)
|
||||
4. If approved, integrate Kongregate API
|
||||
- Stats and achievements (badges)
|
||||
- Optional: Kreds integration
|
||||
5. Submit games for review
|
||||
6. Launch on Kongregate
|
||||
|
||||
**Deliverables:**
|
||||
- Pre-approval from Kongregate
|
||||
- 1-2 games live on platform
|
||||
- Badge achievements implemented
|
||||
|
||||
**Effort:** 60-80 hours
|
||||
**Cost:** $0
|
||||
**Note:** Approval required before development
|
||||
|
||||
### Total Timeline: 10-14 weeks
|
||||
### Total Effort: 280-360 hours
|
||||
### Total Cost: $0
|
||||
|
||||
### Game Prioritization for Submission
|
||||
|
||||
**Tier 1 (Submit first):**
|
||||
1. **Matching Pairs Battle** - Most casual-friendly, clear mechanics
|
||||
2. **Complement Race** - Fast-paced, competitive, good for short sessions
|
||||
|
||||
**Tier 2 (Submit if Tier 1 succeeds):**
|
||||
3. **Card Sorting** - Simple, quick gameplay
|
||||
4. **Memory Quiz** - Educational but approachable
|
||||
|
||||
**Tier 3 (Hold back):**
|
||||
5. **Rithmomachia** - Too complex for casual portals, better for Steam
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Current games must be playable without login (guest mode)
|
||||
- Mobile responsive design
|
||||
- Build optimization pipeline
|
||||
- CDN for asset hosting
|
||||
- Analytics integration
|
||||
|
||||
### Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Poki rejection | Medium | High | Focus on CrazyGames first, apply with best metrics |
|
||||
| File size requirements | High | Medium | Aggressive asset optimization, code splitting |
|
||||
| Ad integration conflicts | Medium | Medium | Separate builds per platform |
|
||||
| Revenue lower than expected | Medium | High | Treat as marketing channel, not primary revenue |
|
||||
| Web exclusivity limits Steam | High | Low | Don't submit Rithmomachia to Poki |
|
||||
| Multiplayer sync issues | High | Medium | Add offline/single-player modes |
|
||||
|
||||
### Success Metrics
|
||||
|
||||
**CrazyGames:**
|
||||
- **Plays:** 10,000+ plays/month per game
|
||||
- **Retention:** 30%+ day-1 return rate
|
||||
- **Revenue:** $200+ monthly from ads
|
||||
|
||||
**Poki:**
|
||||
- **Plays:** 50,000+ plays/month per game
|
||||
- **Revenue:** $500+ monthly (50/50 split)
|
||||
- **Rating:** 4.0+ stars
|
||||
|
||||
**Kongregate:**
|
||||
- **Plays:** 5,000+ plays/month
|
||||
- **Badges:** 3+ achievements per game
|
||||
- **Revenue:** $100+ monthly
|
||||
|
||||
---
|
||||
|
||||
## Platform #4: Steam Distribution
|
||||
|
||||
### Overview
|
||||
|
||||
**Why Steam for Educational Games:**
|
||||
- 120+ million active users
|
||||
- Strong social features (friends, achievements, leaderboards)
|
||||
- Educational games perform well (Kerbal Space Program, Human Resource Machine, etc.)
|
||||
- One-time purchase model fits premium educational content
|
||||
- Community features drive multiplayer engagement
|
||||
|
||||
**Package Concept:** "Soroban Academy: Mathematical Strategy Games"
|
||||
- Bundle all games into single Steam app
|
||||
- Include exclusive Steam features (achievements, leaderboards, cloud saves)
|
||||
- Highlight Rithmomachia as flagship historical strategy game
|
||||
- Price point: $9.99-14.99
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
**Platform:**
|
||||
- Desktop application (Windows, macOS, Linux)
|
||||
- No native browser game support
|
||||
|
||||
**Packaging Options:**
|
||||
1. **Electron** (most common for web games)
|
||||
- Pros: Easy conversion, full Chromium
|
||||
- Cons: Large file size (~100-200MB), memory intensive
|
||||
|
||||
2. **Tauri** (modern alternative)
|
||||
- Pros: Lightweight (~10MB), uses system WebView
|
||||
- Cons: Newer, less Steam integration examples
|
||||
|
||||
3. **NW.js** (alternative to Electron)
|
||||
- Pros: Similar to Electron, slightly smaller
|
||||
- Cons: Less popular, smaller community
|
||||
|
||||
**Recommended:** Start with Electron (most proven path for HTML5 → Steam)
|
||||
|
||||
**Steamworks Integration:**
|
||||
- Steam Authentication API
|
||||
- Steam Achievements API
|
||||
- Steam Leaderboards API
|
||||
- Steam Cloud (save sync)
|
||||
- Steam Friends API (invite friends to games)
|
||||
- Steam Overlay support
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Desktop Packaging with Electron (4-5 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Set up Electron project
|
||||
2. Package Next.js app for Electron
|
||||
3. Configure build pipeline:
|
||||
- Windows (x64)
|
||||
- macOS (Apple Silicon + Intel)
|
||||
- Linux (x64)
|
||||
4. Handle offline mode and local Socket.io server
|
||||
5. Implement native window controls
|
||||
6. Add auto-updater support
|
||||
7. Test on all platforms
|
||||
8. Optimize bundle size (<500MB total)
|
||||
|
||||
**Deliverables:**
|
||||
- Desktop app for Windows/macOS/Linux
|
||||
- Standalone builds (no separate server needed)
|
||||
- Native app experience
|
||||
|
||||
**Effort:** 120-150 hours
|
||||
**Cost:** $0
|
||||
|
||||
#### Phase 2: Steamworks SDK Integration (3-4 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Register as Steamworks developer (pay $100 fee)
|
||||
2. Create Steam app page
|
||||
3. Integrate Steamworks SDK:
|
||||
- Install Greenworks (Electron + Steamworks bridge)
|
||||
- Implement Steam Authentication
|
||||
- Replace internal auth with Steam accounts
|
||||
4. Build achievement system:
|
||||
- Define 20-30 achievements per game
|
||||
- Integrate Steamworks Achievements API
|
||||
- Create achievement icons (64x64px)
|
||||
5. Implement Steam Leaderboards:
|
||||
- Per-game leaderboards
|
||||
- Global cross-game leaderboard
|
||||
6. Add Steam Cloud saves:
|
||||
- Sync game progress across devices
|
||||
- Handle conflict resolution
|
||||
7. Integrate Steam Friends API:
|
||||
- "Invite friend to game room" button
|
||||
- Show online friends in lobby
|
||||
8. Test Steam Overlay functionality
|
||||
|
||||
**Deliverables:**
|
||||
- Full Steamworks integration
|
||||
- Achievement system (20-30 achievements)
|
||||
- Steam Leaderboards
|
||||
- Cloud saves
|
||||
- Friend invites
|
||||
|
||||
**Effort:** 100-120 hours
|
||||
**Cost:** $100 (Steam Direct fee, one-time, recoupable)
|
||||
|
||||
#### Phase 3: Store Page & Marketing Assets (2-3 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Create Steam store page:
|
||||
- Title: "Soroban Academy: Mathematical Strategy Games"
|
||||
- Capsule images (multiple sizes)
|
||||
- Hero image / header
|
||||
- 5-10 screenshots per game
|
||||
- 60-90 second trailer (show all games)
|
||||
- Detailed description with educational benefits
|
||||
- System requirements
|
||||
- Price: $9.99-14.99
|
||||
2. Write compelling marketing copy
|
||||
3. Create game trailer (video editing)
|
||||
4. Design Steam library assets
|
||||
5. Set up community hub
|
||||
6. Configure Steam tags:
|
||||
- Education, Strategy, Multiplayer, Casual, Math, Board Game
|
||||
7. Prepare press kit and media
|
||||
|
||||
**Deliverables:**
|
||||
- Complete Steam store page
|
||||
- Marketing trailer
|
||||
- Press kit
|
||||
- Community hub setup
|
||||
|
||||
**Effort:** 60-80 hours
|
||||
**Cost:** $0-300 (if hiring video editor)
|
||||
|
||||
#### Phase 4: Steam Review & Launch (2-3 weeks)
|
||||
|
||||
**Tasks:**
|
||||
1. Upload build to Steamworks
|
||||
2. Set release date (2+ weeks out)
|
||||
3. Make store page public
|
||||
4. Submit for Steam review (1-5 days)
|
||||
5. Address any review feedback
|
||||
6. Prepare launch marketing:
|
||||
- Email existing users
|
||||
- Social media campaign
|
||||
- Press outreach
|
||||
- Reddit/forum posts
|
||||
- Product Hunt launch
|
||||
7. Launch on Steam
|
||||
8. Monitor reviews and feedback
|
||||
9. Respond to community
|
||||
10. Plan post-launch updates
|
||||
|
||||
**Deliverables:**
|
||||
- Live on Steam
|
||||
- Launch marketing campaign
|
||||
- Community management process
|
||||
|
||||
**Effort:** 60-80 hours
|
||||
**Cost:** $0-500 (marketing budget)
|
||||
|
||||
#### Phase 5: Post-Launch Support (Ongoing)
|
||||
|
||||
**Tasks:**
|
||||
- Weekly bug fixes and patches
|
||||
- Monthly content updates
|
||||
- Seasonal events
|
||||
- New achievements
|
||||
- Community engagement
|
||||
- Steam Sale participation
|
||||
- User feedback integration
|
||||
|
||||
**Deliverables:**
|
||||
- Regular updates
|
||||
- Active community
|
||||
- Growing review score
|
||||
|
||||
**Effort:** 20-40 hours/month ongoing
|
||||
**Cost:** $0
|
||||
|
||||
### Total Timeline: 11-15 weeks
|
||||
### Total Effort: 340-430 hours
|
||||
### Total Cost: $100-900
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Functioning multiplayer system (✓ exists)
|
||||
- All games stable and bug-free
|
||||
- Marketing materials (screenshots, videos)
|
||||
- Legal documentation (EULA, privacy policy)
|
||||
- Support email/forum setup
|
||||
|
||||
### Risks & Mitigation
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|------|--------|------------|------------|
|
||||
| Electron bundle too large | Medium | Medium | Optimize assets, use lazy loading |
|
||||
| Steamworks integration bugs | High | Medium | Extensive testing, use Greenworks |
|
||||
| Low sales | High | High | Strong marketing, community building |
|
||||
| Review bombing | Medium | Low | Active community management |
|
||||
| Multiplayer server costs | Medium | Medium | P2P option or Steam networking |
|
||||
| Steam review rejection | High | Low | Follow guidelines strictly |
|
||||
| Competition from free web version | High | High | Add Steam-exclusive features |
|
||||
|
||||
### Pricing Strategy
|
||||
|
||||
**Options:**
|
||||
1. **Premium ($9.99-14.99):** Full game bundle with Steam features
|
||||
2. **Freemium:** Free base + DLC for premium games
|
||||
3. **Early Access ($7.99):** Launch at lower price, increase at 1.0
|
||||
|
||||
**Recommendation:** Premium $12.99 with frequent sales
|
||||
- Perceived value: 6 games = ~$2 each
|
||||
- Recoup $100 fee after 8 sales
|
||||
- Sales can drive volume (50% off = $6.49)
|
||||
|
||||
### Marketing Angles for Steam
|
||||
|
||||
1. **Rithmomachia Focus:** "Play the 1,000-year-old medieval chess"
|
||||
2. **Educational Value:** "Math learning disguised as strategy gaming"
|
||||
3. **Multiplayer Fun:** "Challenge friends in mathematical duels"
|
||||
4. **Historical Gaming:** "Rediscover forgotten board games"
|
||||
5. **Family Friendly:** "Safe, educational gaming for all ages"
|
||||
|
||||
### Success Metrics
|
||||
|
||||
**Launch Goals:**
|
||||
- **Wishlist:** 1,000+ before launch
|
||||
- **Sales:** 500+ units in first month
|
||||
- **Reviews:** 50+ reviews, 85%+ positive
|
||||
- **Revenue:** $5,000+ in first quarter
|
||||
|
||||
**Long-term Goals:**
|
||||
- **Sales:** 5,000+ units in first year
|
||||
- **Reviews:** 200+ reviews, 90%+ positive
|
||||
- **Revenue:** $30,000+ annually
|
||||
- **Community:** Active Discord/forums with 1,000+ members
|
||||
|
||||
---
|
||||
|
||||
## Prioritization & Recommended Timeline
|
||||
|
||||
### Recommended Order of Execution
|
||||
|
||||
**Phase A: Quick Wins (First 3 months)**
|
||||
1. **Google Classroom - Phase 1 (OAuth/SSO)** - Immediate teacher value
|
||||
2. **CrazyGames submission** - Fast player discovery
|
||||
3. **Google Classroom - Phase 2 (Class Import)** - Core teacher feature
|
||||
|
||||
**Phase B: Institution Access (Months 4-6)**
|
||||
4. **Clever SSO integration** - Scale to districts
|
||||
5. **ClassLink SSO integration** - Additional district reach
|
||||
6. **Google Classroom - Phase 3 (Assignments)** - Deep integration
|
||||
|
||||
**Phase C: Casual Discovery (Months 6-9)**
|
||||
7. **Poki submission** - Largest casual audience
|
||||
8. **Kongregate pre-approval + submission** - Niche community
|
||||
|
||||
**Phase D: Premium Market (Months 9-12)**
|
||||
9. **Steam development** - Desktop packaging + Steamworks
|
||||
10. **Steam launch** - Premium educational game market
|
||||
|
||||
### Timeline Gantt Overview
|
||||
|
||||
```
|
||||
Month 1-2: [Google OAuth/SSO] [CrazyGames Prep]
|
||||
Month 2-3: [Google Class Import] [CrazyGames Launch]
|
||||
Month 4-5: [Clever SSO] [ClassLink SSO]
|
||||
Month 6-7: [Google Assignments] [Poki Prep]
|
||||
Month 7-8: [Poki Launch] [Kongregate]
|
||||
Month 9-10: [Steam Electron Build]
|
||||
Month 10-11: [Steam Steamworks Integration]
|
||||
Month 11-12: [Steam Store Page & Launch]
|
||||
```
|
||||
|
||||
### Total Cost Summary
|
||||
|
||||
| Platform | One-time Cost | Ongoing Cost | Notes |
|
||||
|----------|--------------|--------------|-------|
|
||||
| Google Classroom | $0 | $0 | Free for education |
|
||||
| Clever | $0 | $0 | Free for developers |
|
||||
| ClassLink | $0 | $0 | Free for developers |
|
||||
| CrazyGames | $0 | $0 | Revenue share via ads |
|
||||
| Poki | $0 | $0 | 50/50 revenue share |
|
||||
| Kongregate | $0 | $0 | Revenue share via ads |
|
||||
| Steam | $100 | $0-200/mo | Server costs optional |
|
||||
| **TOTAL** | **$100** | **$0-200/mo** | Very affordable |
|
||||
|
||||
### Resource Requirements
|
||||
|
||||
**Developer Time:**
|
||||
- **Total effort:** 1,140-1,450 hours across all platforms
|
||||
- **Timeline:** 9-12 months (with 1-2 developers)
|
||||
- **Ongoing:** 20-60 hours/month maintenance
|
||||
|
||||
**External Costs:**
|
||||
- Steam Direct fee: $100
|
||||
- Optional video editing: $0-500
|
||||
- Optional marketing budget: $0-1,000
|
||||
- Server costs (if needed): $50-200/month
|
||||
|
||||
**Skills Needed:**
|
||||
- OAuth/SAML implementation
|
||||
- REST API integration
|
||||
- Electron/desktop app packaging
|
||||
- Steamworks SDK integration
|
||||
- Marketing/community management
|
||||
|
||||
---
|
||||
|
||||
## Risk Management
|
||||
|
||||
### Top 5 Risks Across All Platforms
|
||||
|
||||
1. **Privacy Compliance (COPPA, FERPA, GDPR)**
|
||||
- **Impact:** Critical (could block educational adoption)
|
||||
- **Mitigation:** Legal review, implement consent systems, privacy policy updates
|
||||
|
||||
2. **Platform Rejection (Poki, Kongregate)**
|
||||
- **Impact:** High (wasted development time)
|
||||
- **Mitigation:** Apply early, start with less selective platforms, gather metrics
|
||||
|
||||
3. **Low Adoption/Sales**
|
||||
- **Impact:** High (ROI concern)
|
||||
- **Mitigation:** Strong marketing, community building, teacher outreach
|
||||
|
||||
4. **Technical Integration Complexity**
|
||||
- **Impact:** Medium (timeline delays)
|
||||
- **Mitigation:** Use established libraries, start simple, iterate
|
||||
|
||||
5. **Server Costs for Multiplayer**
|
||||
- **Impact:** Medium (ongoing expenses)
|
||||
- **Mitigation:** Optimize server efficiency, consider P2P, price accordingly
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics Dashboard
|
||||
|
||||
### Key Performance Indicators (KPIs)
|
||||
|
||||
**User Acquisition:**
|
||||
- New users per month (by source)
|
||||
- Sign-up conversion rate
|
||||
- Platform-specific downloads/plays
|
||||
|
||||
**Engagement:**
|
||||
- Daily/Monthly Active Users (DAU/MAU)
|
||||
- Average session length
|
||||
- Games per session
|
||||
- Return rate (D1, D7, D30)
|
||||
|
||||
**Education Impact:**
|
||||
- Teachers registered
|
||||
- Classes created
|
||||
- Assignments completed
|
||||
- Student progress metrics
|
||||
|
||||
**Revenue (Steam):**
|
||||
- Units sold
|
||||
- Revenue per month
|
||||
- Refund rate
|
||||
- Review score
|
||||
|
||||
**Platform-Specific:**
|
||||
- **Google Classroom:** Classes imported, assignments created
|
||||
- **Clever/ClassLink:** Districts connected, SSO logins
|
||||
- **Game Portals:** Plays per game, ad revenue, ratings
|
||||
- **Steam:** Sales, reviews, concurrent players
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Immediate Actions (This Week)
|
||||
|
||||
1. **Decide on priority order** - Which platform to tackle first?
|
||||
2. **Set up project tracking** - Dedicate repository/board for integration work
|
||||
3. **Legal review** - Review privacy policy and terms for educational compliance
|
||||
4. **Create developer accounts:**
|
||||
- Google Cloud Platform
|
||||
- Clever Developer Portal
|
||||
- CrazyGames Developer Portal
|
||||
- Poki submission prep
|
||||
5. **Audit current codebase** - Check readiness for each integration
|
||||
|
||||
### Quick Wins to Start
|
||||
|
||||
**Option 1: Google Classroom OAuth (Fastest Impact)**
|
||||
- 2-3 weeks to launch
|
||||
- Immediate teacher value
|
||||
- No approval needed
|
||||
|
||||
**Option 2: CrazyGames (Fastest Player Growth)**
|
||||
- 3-4 weeks to launch
|
||||
- Immediate player discovery
|
||||
- Open platform (high acceptance rate)
|
||||
|
||||
**Recommendation:** Start both in parallel if resources allow
|
||||
|
||||
---
|
||||
|
||||
## Appendix
|
||||
|
||||
### Useful Links
|
||||
|
||||
**Google Classroom:**
|
||||
- Developer Docs: https://developers.google.com/classroom
|
||||
- API Reference: https://developers.google.com/classroom/reference
|
||||
- OAuth Guide: https://developers.google.com/identity/protocols/oauth2
|
||||
|
||||
**Clever:**
|
||||
- Developer Portal: https://dev.clever.com
|
||||
- SSO Guide: https://dev.clever.com/docs/getting-started-with-clever-sso
|
||||
- API Docs: https://dev.clever.com/docs/api-overview
|
||||
|
||||
**ClassLink:**
|
||||
- SSO Library: https://www.classlink.com/resources/sso-search
|
||||
- OneRoster: https://www.imsglobal.org/activity/onerosterlis
|
||||
|
||||
**Game Portals:**
|
||||
- CrazyGames: https://docs.crazygames.com/
|
||||
- Poki: https://developers.poki.com/
|
||||
- Kongregate: BD@kongregate.com
|
||||
|
||||
**Steam:**
|
||||
- Steamworks: https://partner.steamgames.com/
|
||||
- Electron: https://www.electronjs.org/
|
||||
- Greenworks: https://github.com/greenheartgames/greenworks
|
||||
|
||||
### Code Library Recommendations
|
||||
|
||||
**OAuth/SSO:**
|
||||
- `next-auth` (already in use)
|
||||
- `passport-saml` (for ClassLink SAML)
|
||||
- `@googleapis/classroom` (official Google Classroom client)
|
||||
|
||||
**Electron:**
|
||||
- `electron-builder` (packaging)
|
||||
- `electron-updater` (auto-updates)
|
||||
- `greenworks` (Steamworks bridge)
|
||||
|
||||
**Game Portal SDKs:**
|
||||
- CrazyGames SDK (provided)
|
||||
- Poki SDK (provided)
|
||||
- Kongregate API (provided)
|
||||
|
||||
---
|
||||
|
||||
**Document End**
|
||||
|
||||
*For questions or updates to this roadmap, contact the development team.*
|
||||
@@ -140,7 +140,9 @@
|
||||
"Bash(src/arcade-games/rithmomachia/components/guide-sections/VictorySection.tsx)",
|
||||
"Bash(pnpm remove:*)",
|
||||
"Bash(__NEW_LINE__ sed -n '68,73p' CaptureSection.tsx.bak)",
|
||||
"WebFetch(domain:hub.docker.com)"
|
||||
"WebFetch(domain:hub.docker.com)",
|
||||
"Bash(gcloud auth:*)",
|
||||
"Bash(gcloud config list:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
235
apps/web/scripts/setup-google-classroom.sh
Normal file
235
apps/web/scripts/setup-google-classroom.sh
Normal file
@@ -0,0 +1,235 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Google Classroom API Setup Script
|
||||
# This script automates GCP project setup from the command line
|
||||
#
|
||||
# Prerequisites:
|
||||
# - gcloud CLI installed (brew install google-cloud-sdk)
|
||||
# - Valid Google account
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-google-classroom.sh
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE}Google Classroom API Setup${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Configuration
|
||||
PROJECT_ID="soroban-abacus-$(date +%s)" # Unique project ID with timestamp
|
||||
PROJECT_NAME="Soroban Abacus Flashcards"
|
||||
BILLING_ACCOUNT="" # Will prompt user if needed
|
||||
REDIRECT_URIS="http://localhost:3000/api/auth/callback/google,https://abaci.one/api/auth/callback/google"
|
||||
|
||||
echo -e "${YELLOW}Project ID:${NC} $PROJECT_ID"
|
||||
echo ""
|
||||
|
||||
# Step 1: Check if gcloud is installed
|
||||
echo -e "${BLUE}[1/9] Checking gcloud installation...${NC}"
|
||||
if ! command -v gcloud &> /dev/null; then
|
||||
echo -e "${RED}Error: gcloud CLI not found${NC}"
|
||||
echo "Install it with: brew install google-cloud-sdk"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ gcloud CLI found${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 2: Authenticate with Google
|
||||
echo -e "${BLUE}[2/9] Authenticating with Google...${NC}"
|
||||
CURRENT_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null || echo "")
|
||||
if [ -z "$CURRENT_ACCOUNT" ]; then
|
||||
echo "No active account found. Opening browser to authenticate..."
|
||||
gcloud auth login
|
||||
CURRENT_ACCOUNT=$(gcloud auth list --filter=status:ACTIVE --format="value(account)")
|
||||
fi
|
||||
echo -e "${GREEN}✓ Authenticated as: $CURRENT_ACCOUNT${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 3: Create GCP project
|
||||
echo -e "${BLUE}[3/9] Creating GCP project...${NC}"
|
||||
echo "Creating project: $PROJECT_ID"
|
||||
gcloud projects create "$PROJECT_ID" --name="$PROJECT_NAME" 2>/dev/null || {
|
||||
echo -e "${YELLOW}Project might already exist, continuing...${NC}"
|
||||
}
|
||||
gcloud config set project "$PROJECT_ID"
|
||||
echo -e "${GREEN}✓ Project created/selected: $PROJECT_ID${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 4: Check billing (required for APIs)
|
||||
echo -e "${BLUE}[4/9] Checking billing account...${NC}"
|
||||
echo -e "${YELLOW}Note: Google Classroom API requires a billing account, but it's FREE for educational use.${NC}"
|
||||
echo -e "${YELLOW}You won't be charged unless you explicitly enable paid services.${NC}"
|
||||
echo ""
|
||||
|
||||
# List available billing accounts
|
||||
BILLING_ACCOUNTS=$(gcloud billing accounts list --format="value(name)" 2>/dev/null || echo "")
|
||||
if [ -z "$BILLING_ACCOUNTS" ]; then
|
||||
echo -e "${YELLOW}No billing accounts found.${NC}"
|
||||
echo -e "${YELLOW}You'll need to create one at: https://console.cloud.google.com/billing${NC}"
|
||||
echo -e "${YELLOW}Press Enter after creating a billing account, or Ctrl+C to exit${NC}"
|
||||
read -r
|
||||
BILLING_ACCOUNTS=$(gcloud billing accounts list --format="value(name)")
|
||||
fi
|
||||
|
||||
# If multiple accounts, let user choose
|
||||
BILLING_COUNT=$(echo "$BILLING_ACCOUNTS" | wc -l | tr -d ' ')
|
||||
if [ "$BILLING_COUNT" -eq 1 ]; then
|
||||
BILLING_ACCOUNT="$BILLING_ACCOUNTS"
|
||||
else
|
||||
echo "Available billing accounts:"
|
||||
gcloud billing accounts list
|
||||
echo ""
|
||||
echo -n "Enter billing account ID (e.g., 012345-ABCDEF-678901): "
|
||||
read -r BILLING_ACCOUNT
|
||||
fi
|
||||
|
||||
# Link billing account to project
|
||||
echo "Linking billing account to project..."
|
||||
gcloud billing projects link "$PROJECT_ID" --billing-account="$BILLING_ACCOUNT"
|
||||
echo -e "${GREEN}✓ Billing account linked${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 5: Enable required APIs
|
||||
echo -e "${BLUE}[5/9] Enabling required APIs...${NC}"
|
||||
echo "This may take 1-2 minutes..."
|
||||
gcloud services enable classroom.googleapis.com --project="$PROJECT_ID"
|
||||
gcloud services enable people.googleapis.com --project="$PROJECT_ID"
|
||||
echo -e "${GREEN}✓ APIs enabled:${NC}"
|
||||
echo " - Google Classroom API"
|
||||
echo " - Google People API (for profile info)"
|
||||
echo ""
|
||||
|
||||
# Step 6: Create OAuth 2.0 credentials
|
||||
echo -e "${BLUE}[6/9] Creating OAuth 2.0 credentials...${NC}"
|
||||
|
||||
# First check if credentials already exist
|
||||
EXISTING_CREDS=$(gcloud auth application-default print-access-token &>/dev/null && \
|
||||
curl -s -H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
|
||||
"https://oauth2.googleapis.com/v1/projects/${PROJECT_ID}/oauthClients" 2>/dev/null | \
|
||||
grep -c "clientId" || echo "0")
|
||||
|
||||
if [ "$EXISTING_CREDS" -gt 0 ]; then
|
||||
echo -e "${YELLOW}OAuth credentials already exist for this project${NC}"
|
||||
echo "Skipping credential creation..."
|
||||
else
|
||||
# Create OAuth client
|
||||
# Note: This creates a "Web application" type OAuth client
|
||||
echo "Creating OAuth 2.0 client..."
|
||||
|
||||
# Unfortunately, gcloud doesn't have a direct command for this
|
||||
# We need to use the REST API
|
||||
echo -e "${YELLOW}Note: OAuth client creation requires using the web console${NC}"
|
||||
echo -e "${YELLOW}Opening the OAuth credentials page...${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Please follow these steps in the browser:${NC}"
|
||||
echo "1. Click 'Create Credentials' → 'OAuth client ID'"
|
||||
echo "2. Application type: 'Web application'"
|
||||
echo "3. Name: 'Soroban Abacus Web'"
|
||||
echo "4. Authorized JavaScript origins:"
|
||||
echo " - http://localhost:3000"
|
||||
echo " - https://abaci.one"
|
||||
echo "5. Authorized redirect URIs:"
|
||||
echo " - http://localhost:3000/api/auth/callback/google"
|
||||
echo " - https://abaci.one/api/auth/callback/google"
|
||||
echo "6. Click 'Create'"
|
||||
echo "7. Copy the Client ID and Client Secret"
|
||||
echo ""
|
||||
|
||||
# Open browser to credentials page
|
||||
open "https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID" 2>/dev/null || \
|
||||
echo "Open this URL: https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
|
||||
|
||||
echo -n "Press Enter after creating the OAuth client..."
|
||||
read -r
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ OAuth credentials configured${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 7: Configure OAuth Consent Screen
|
||||
echo -e "${BLUE}[7/9] Configuring OAuth consent screen...${NC}"
|
||||
echo -e "${YELLOW}The OAuth consent screen requires web console configuration${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}Please follow these steps:${NC}"
|
||||
echo "1. User Type: 'External' (unless you have Google Workspace)"
|
||||
echo "2. App name: 'Soroban Abacus Flashcards'"
|
||||
echo "3. User support email: Your email"
|
||||
echo "4. Developer contact: Your email"
|
||||
echo "5. Scopes: Click 'Add or Remove Scopes' and add:"
|
||||
echo " - .../auth/userinfo.email"
|
||||
echo " - .../auth/userinfo.profile"
|
||||
echo " - .../auth/classroom.courses.readonly"
|
||||
echo " - .../auth/classroom.rosters.readonly"
|
||||
echo "6. Test users: Add your email for testing"
|
||||
echo "7. Save and continue"
|
||||
echo ""
|
||||
|
||||
# Open OAuth consent screen configuration
|
||||
open "https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID" 2>/dev/null || \
|
||||
echo "Open this URL: https://console.cloud.google.com/apis/credentials/consent?project=$PROJECT_ID"
|
||||
|
||||
echo -n "Press Enter after configuring the consent screen..."
|
||||
read -r
|
||||
|
||||
echo -e "${GREEN}✓ OAuth consent screen configured${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 8: Create .env.local file
|
||||
echo -e "${BLUE}[8/9] Creating environment configuration...${NC}"
|
||||
echo ""
|
||||
echo "Please enter your OAuth credentials from the previous step:"
|
||||
echo -n "Client ID: "
|
||||
read -r CLIENT_ID
|
||||
echo -n "Client Secret: "
|
||||
read -r -s CLIENT_SECRET
|
||||
echo ""
|
||||
|
||||
# Create or update .env.local
|
||||
ENV_FILE=".env.local"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
echo "Backing up existing $ENV_FILE to ${ENV_FILE}.backup"
|
||||
cp "$ENV_FILE" "${ENV_FILE}.backup"
|
||||
fi
|
||||
|
||||
# Add Google OAuth credentials
|
||||
echo "" >> "$ENV_FILE"
|
||||
echo "# Google OAuth (Generated by setup-google-classroom.sh)" >> "$ENV_FILE"
|
||||
echo "GOOGLE_CLIENT_ID=\"$CLIENT_ID\"" >> "$ENV_FILE"
|
||||
echo "GOOGLE_CLIENT_SECRET=\"$CLIENT_SECRET\"" >> "$ENV_FILE"
|
||||
echo "" >> "$ENV_FILE"
|
||||
|
||||
echo -e "${GREEN}✓ Environment variables added to $ENV_FILE${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 9: Summary
|
||||
echo -e "${BLUE}[9/9] Setup Complete!${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}Setup Summary${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Project ID: ${BLUE}$PROJECT_ID${NC}"
|
||||
echo -e "Project Name: ${BLUE}$PROJECT_NAME${NC}"
|
||||
echo -e "Billing Account: ${BLUE}$BILLING_ACCOUNT${NC}"
|
||||
echo -e "APIs Enabled: ${BLUE}Classroom, People${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next Steps:${NC}"
|
||||
echo "1. Add Google provider to NextAuth configuration"
|
||||
echo "2. Test login with 'Sign in with Google'"
|
||||
echo "3. Verify Classroom API access"
|
||||
echo ""
|
||||
echo -e "${BLUE}Useful Commands:${NC}"
|
||||
echo " View project: gcloud projects describe $PROJECT_ID"
|
||||
echo " List APIs: gcloud services list --enabled --project=$PROJECT_ID"
|
||||
echo " View quota: gcloud quotas describe classroom.googleapis.com --project=$PROJECT_ID"
|
||||
echo ""
|
||||
echo -e "${GREEN}Setup script complete!${NC}"
|
||||
@@ -280,21 +280,6 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
|
||||
capture: NonNullable<Extract<RithmomachiaMove, { type: 'MOVE' }>['data']['capture']>,
|
||||
pyramidFaceUsed?: number | null
|
||||
): ValidationResult {
|
||||
// Get mover value
|
||||
let moverValue: number
|
||||
if (mover.type === 'P') {
|
||||
if (!pyramidFaceUsed) {
|
||||
return { valid: false, error: 'Pyramid must choose a face for capture' }
|
||||
}
|
||||
// Validate face is valid
|
||||
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
|
||||
return { valid: false, error: 'Invalid pyramid face' }
|
||||
}
|
||||
moverValue = pyramidFaceUsed
|
||||
} else {
|
||||
moverValue = mover.value!
|
||||
}
|
||||
|
||||
// Get target value
|
||||
const targetValue = getEffectiveValue(target)
|
||||
if (targetValue === null) {
|
||||
@@ -334,14 +319,49 @@ export class RithmomachiaValidator implements GameValidator<RithmomachiaState, R
|
||||
helperValue = getEffectiveValue(helperPiece) ?? undefined
|
||||
}
|
||||
|
||||
// Check the relation
|
||||
const relationCheck = checkRelation(capture.relation, moverValue, targetValue, helperValue)
|
||||
// Get mover value(s) - for pyramids, try all faces if not specified
|
||||
if (mover.type === 'P') {
|
||||
if (pyramidFaceUsed) {
|
||||
// Specific face provided - validate it
|
||||
if (!mover.pyramidFaces?.some((f) => f === pyramidFaceUsed)) {
|
||||
return { valid: false, error: 'Invalid pyramid face' }
|
||||
}
|
||||
const relationCheck = checkRelation(
|
||||
capture.relation,
|
||||
pyramidFaceUsed,
|
||||
targetValue,
|
||||
helperValue
|
||||
)
|
||||
if (!relationCheck.valid) {
|
||||
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
|
||||
}
|
||||
return { valid: true }
|
||||
} else {
|
||||
// No face specified - try all faces and accept if ANY works
|
||||
if (!mover.pyramidFaces || mover.pyramidFaces.length !== 4) {
|
||||
return { valid: false, error: 'Pyramid must have 4 faces' }
|
||||
}
|
||||
|
||||
if (!relationCheck.valid) {
|
||||
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
|
||||
for (const faceValue of mover.pyramidFaces) {
|
||||
const relationCheck = checkRelation(capture.relation, faceValue, targetValue, helperValue)
|
||||
if (relationCheck.valid) {
|
||||
// At least one face works - capture is valid
|
||||
return { valid: true }
|
||||
}
|
||||
}
|
||||
|
||||
// None of the faces worked
|
||||
return { valid: false, error: 'No pyramid face satisfies the relation' }
|
||||
}
|
||||
} else {
|
||||
// Non-pyramid piece
|
||||
const moverValue = mover.value!
|
||||
const relationCheck = checkRelation(capture.relation, moverValue, targetValue, helperValue)
|
||||
if (!relationCheck.valid) {
|
||||
return { valid: false, error: relationCheck.explanation || 'Relation check failed' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PieceRendererProps {
|
||||
useNativeAbacusNumbers?: boolean
|
||||
selected?: boolean
|
||||
pyramidFaces?: number[]
|
||||
shouldRotate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,7 @@ export function PieceRenderer({
|
||||
useNativeAbacusNumbers = false,
|
||||
selected = false,
|
||||
pyramidFaces = [],
|
||||
shouldRotate = false,
|
||||
}: PieceRendererProps) {
|
||||
const isDark = color === 'B'
|
||||
const { config } = useAbacusDisplay()
|
||||
@@ -242,244 +244,249 @@ export function PieceRenderer({
|
||||
|
||||
{/* Pyramid face numbers - show when selected */}
|
||||
{type === 'P' && selected && pyramidFaces.length === 4 && (
|
||||
<animated.g
|
||||
style={{
|
||||
opacity: pyramidNumbersSpring.opacity,
|
||||
transform: pyramidNumbersSpring.scale.to((s) => `scale(${s})`),
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Filter for strong drop shadow */}
|
||||
<defs>
|
||||
<filter id={`face-shadow-${color}`} x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="0"
|
||||
stdDeviation="3"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.9"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform={shouldRotate ? `rotate(90, ${size / 2}, ${size / 2})` : undefined}>
|
||||
<animated.g
|
||||
style={{
|
||||
opacity: pyramidNumbersSpring.opacity,
|
||||
transform: pyramidNumbersSpring.scale.to((s) => `scale(${s})`),
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Filter for strong drop shadow */}
|
||||
<defs>
|
||||
<filter id={`face-shadow-${color}`} x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="0"
|
||||
stdDeviation="3"
|
||||
floodColor="#000000"
|
||||
floodOpacity="0.9"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Top face */}
|
||||
{/* Outline/stroke for contrast */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[0]}
|
||||
</text>
|
||||
{/* Main text with shadow and vibrant color */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[0]}
|
||||
</text>
|
||||
{/* Top face */}
|
||||
{/* Outline/stroke for contrast */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[0]}
|
||||
</text>
|
||||
{/* Main text with shadow and vibrant color */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[0]}
|
||||
</text>
|
||||
|
||||
{/* Right face */}
|
||||
<text
|
||||
x={size * 0.88}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[1]}
|
||||
</text>
|
||||
<text
|
||||
x={size * 0.88}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[1]}
|
||||
</text>
|
||||
{/* Right face */}
|
||||
<text
|
||||
x={size * 0.88}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[1]}
|
||||
</text>
|
||||
<text
|
||||
x={size * 0.88}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[1]}
|
||||
</text>
|
||||
|
||||
{/* Bottom face */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.88}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[2]}
|
||||
</text>
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.88}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[2]}
|
||||
</text>
|
||||
{/* Bottom face */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.88}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[2]}
|
||||
</text>
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size * 0.88}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[2]}
|
||||
</text>
|
||||
|
||||
{/* Left face */}
|
||||
<text
|
||||
x={size * 0.12}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[3]}
|
||||
</text>
|
||||
<text
|
||||
x={size * 0.12}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[3]}
|
||||
</text>
|
||||
</animated.g>
|
||||
{/* Left face */}
|
||||
<text
|
||||
x={size * 0.12}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="none"
|
||||
stroke={isDark ? '#000000' : '#ffffff'}
|
||||
strokeWidth={size * 0.05}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
>
|
||||
{pyramidFaces[3]}
|
||||
</text>
|
||||
<text
|
||||
x={size * 0.12}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={isDark ? '#fbbf24' : '#b45309'}
|
||||
fontSize={size * 0.35}
|
||||
fontWeight="900"
|
||||
fontFamily="Arial Black, Arial, sans-serif"
|
||||
filter={`url(#face-shadow-${color})`}
|
||||
style={{ transition: 'all 0.2s ease' }}
|
||||
>
|
||||
{pyramidFaces[3]}
|
||||
</text>
|
||||
</animated.g>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Other pieces show numbers normally */}
|
||||
{type !== 'P' &&
|
||||
(useNativeAbacusNumbers && typeof value === 'number' ? (
|
||||
// Render mini abacus
|
||||
<foreignObject
|
||||
x={size * 0.1}
|
||||
y={size * 0.1}
|
||||
width={size * 0.8}
|
||||
height={size * 0.8}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
{type !== 'P' && (
|
||||
<g transform={shouldRotate ? `rotate(90, ${size / 2}, ${size / 2})` : undefined}>
|
||||
{useNativeAbacusNumbers && typeof value === 'number' ? (
|
||||
// Render mini abacus
|
||||
<foreignObject
|
||||
x={size * 0.1}
|
||||
y={size * 0.1}
|
||||
width={size * 0.8}
|
||||
height={size * 0.8}
|
||||
style={{ overflow: 'visible' }}
|
||||
>
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={Math.max(1, Math.ceil(Math.log10(value + 1)))}
|
||||
scaleFactor={0.35}
|
||||
showNumbers={false}
|
||||
beadShape={config.beadShape}
|
||||
colorScheme={config.colorScheme}
|
||||
hideInactiveBeads={config.hideInactiveBeads}
|
||||
customStyles={{
|
||||
columnPosts: {
|
||||
fill: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
|
||||
stroke: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.2)',
|
||||
stroke: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
) : (
|
||||
// Render traditional text number
|
||||
<g>
|
||||
{/* Outer glow/shadow for emphasis */}
|
||||
{isDark ? (
|
||||
>
|
||||
<AbacusReact
|
||||
value={value}
|
||||
columns={Math.max(1, Math.ceil(Math.log10(value + 1)))}
|
||||
scaleFactor={0.35}
|
||||
showNumbers={false}
|
||||
beadShape={config.beadShape}
|
||||
colorScheme={config.colorScheme}
|
||||
hideInactiveBeads={config.hideInactiveBeads}
|
||||
customStyles={{
|
||||
columnPosts: {
|
||||
fill: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
|
||||
stroke: isDark ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: isDark ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.2)',
|
||||
stroke: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.15)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
) : (
|
||||
// Render traditional text number
|
||||
<g>
|
||||
{/* Outer glow/shadow for emphasis */}
|
||||
{isDark ? (
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.4)"
|
||||
strokeWidth={fontSize * 0.2}
|
||||
fontSize={fontSize}
|
||||
fontWeight="900"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.95)"
|
||||
strokeWidth={fontSize * 0.25}
|
||||
fontSize={fontSize}
|
||||
fontWeight="900"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
)}
|
||||
{/* Main text */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.4)"
|
||||
strokeWidth={fontSize * 0.2}
|
||||
fill={textColor}
|
||||
fontSize={fontSize}
|
||||
fontWeight="900"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
filter={isDark ? `url(#text-shadow-${color})` : undefined}
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
) : (
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.95)"
|
||||
strokeWidth={fontSize * 0.25}
|
||||
fontSize={fontSize}
|
||||
fontWeight="900"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
)}
|
||||
{/* Main text */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fill={textColor}
|
||||
fontSize={fontSize}
|
||||
fontWeight="900"
|
||||
fontFamily="Georgia, 'Times New Roman', serif"
|
||||
filter={isDark ? `url(#text-shadow-${color})` : undefined}
|
||||
>
|
||||
{value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ export function PlayingGuideModal({
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [dockPreview, setDockPreview] = useState<'left' | 'right' | null>(null)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const hasUndockedRef = useRef(false) // Track if we've undocked during current drag
|
||||
const undockPositionRef = useRef<{ x: number; y: number } | null>(null) // Position at moment of undocking
|
||||
const [dragTransform, setDragTransform] = useState<{ x: number; y: number } | null>(null) // Visual transform while dragging from dock
|
||||
|
||||
// Save position to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
@@ -114,7 +117,7 @@ export function PlayingGuideModal({
|
||||
// Handle dragging
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
console.log(
|
||||
'[GUIDE_DRAG] handleMouseDown - windowWidth: ' +
|
||||
'[GUIDE_DRAG] === MOUSE DOWN === windowWidth: ' +
|
||||
window.innerWidth +
|
||||
', standalone: ' +
|
||||
standalone +
|
||||
@@ -125,8 +128,18 @@ export function PlayingGuideModal({
|
||||
console.log('[GUIDE_DRAG] Skipping drag - mobile or standalone')
|
||||
return // No dragging on mobile or standalone
|
||||
}
|
||||
console.log('[GUIDE_DRAG] Starting drag - docked: ' + docked)
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Starting drag - docked: ' +
|
||||
docked +
|
||||
', position: ' +
|
||||
JSON.stringify(position) +
|
||||
', size: ' +
|
||||
JSON.stringify(size)
|
||||
)
|
||||
setIsDragging(true)
|
||||
hasUndockedRef.current = false // Reset undock tracking for new drag
|
||||
undockPositionRef.current = null // Clear undock position
|
||||
setDragTransform(null) // Clear any previous transform
|
||||
|
||||
// When docked, we need to track the initial mouse position for undocking
|
||||
if (docked) {
|
||||
@@ -141,7 +154,12 @@ export function PlayingGuideModal({
|
||||
y: e.clientY,
|
||||
})
|
||||
} else {
|
||||
console.log('[GUIDE_DRAG] Not docked - setting dragStart offset')
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Not docked - setting dragStart offset: ' +
|
||||
(e.clientX - position.x) +
|
||||
', ' +
|
||||
(e.clientY - position.y)
|
||||
)
|
||||
setDragStart({
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
@@ -172,34 +190,61 @@ export function PlayingGuideModal({
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
// When docked, check if we've dragged far enough away to undock
|
||||
if (docked && onUndock) {
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Mouse move - clientX: ' +
|
||||
e.clientX +
|
||||
', clientY: ' +
|
||||
e.clientY +
|
||||
', docked: ' +
|
||||
docked +
|
||||
', hasUndocked: ' +
|
||||
hasUndockedRef.current
|
||||
)
|
||||
|
||||
// When docked and haven't undocked yet, check if we've dragged far enough away to undock
|
||||
if (docked && onUndock && !hasUndockedRef.current) {
|
||||
const UNDOCK_THRESHOLD = 50 // pixels to drag before undocking
|
||||
const dragDistance = Math.sqrt(
|
||||
(e.clientX - dragStart.x) ** 2 + (e.clientY - dragStart.y) ** 2
|
||||
)
|
||||
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Dragging while docked - distance: ' +
|
||||
'[GUIDE_DRAG] Checking threshold - distance: ' +
|
||||
dragDistance +
|
||||
', threshold: ' +
|
||||
UNDOCK_THRESHOLD
|
||||
UNDOCK_THRESHOLD +
|
||||
', dragStart: ' +
|
||||
JSON.stringify(dragStart)
|
||||
)
|
||||
|
||||
if (dragDistance > UNDOCK_THRESHOLD) {
|
||||
console.log('[GUIDE_DRAG] Threshold exceeded - calling onUndock()')
|
||||
onUndock()
|
||||
// After undocking, keep modal at current visual position
|
||||
// and set dragStart as offset from position to cursor for smooth continued dragging
|
||||
console.log('[GUIDE_DRAG] === THRESHOLD EXCEEDED === Marking as virtually undocked')
|
||||
hasUndockedRef.current = true
|
||||
// Don't call onUndock() yet - wait until mouse up to avoid unmounting during drag
|
||||
// After undocking, set dragStart as offset from position to cursor for smooth continued dragging
|
||||
if (modalRef.current) {
|
||||
const rect = modalRef.current.getBoundingClientRect()
|
||||
console.log('[GUIDE_DRAG] Modal rect - left: ' + rect.left + ', top: ' + rect.top)
|
||||
// Keep modal at its current screen position (no jump)
|
||||
setPosition({
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Modal rect - left: ' +
|
||||
rect.left +
|
||||
', top: ' +
|
||||
rect.top +
|
||||
', width: ' +
|
||||
rect.width +
|
||||
', height: ' +
|
||||
rect.height
|
||||
)
|
||||
|
||||
// Store the undock position in ref for immediate access
|
||||
undockPositionRef.current = {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
})
|
||||
console.log('[GUIDE_DRAG] New position set to: ' + rect.left + ', ' + rect.top)
|
||||
}
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Stored undock position in ref: ' +
|
||||
JSON.stringify(undockPositionRef.current)
|
||||
)
|
||||
|
||||
// Set dragStart as offset from current position to cursor
|
||||
const newDragStartX = e.clientX - rect.left
|
||||
const newDragStartY = e.clientY - rect.top
|
||||
@@ -210,18 +255,74 @@ export function PlayingGuideModal({
|
||||
x: newDragStartX,
|
||||
y: newDragStartY,
|
||||
})
|
||||
// Also store the position for state (used when actually undocking)
|
||||
setPosition({
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log('[GUIDE_DRAG] Below threshold - returning early')
|
||||
// Still below threshold - don't apply any transform yet
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Normal floating modal dragging
|
||||
setPosition({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
})
|
||||
}
|
||||
|
||||
// Check if we're near edges for docking preview
|
||||
if (onDock && onDockPreview && !docked) {
|
||||
// Virtually undocked or already floating - update position
|
||||
if (hasUndockedRef.current || !docked) {
|
||||
const newX = e.clientX - dragStart.x
|
||||
const newY = e.clientY - dragStart.y
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Calculating position - newX: ' +
|
||||
newX +
|
||||
', newY: ' +
|
||||
newY +
|
||||
', dragStart: ' +
|
||||
JSON.stringify(dragStart)
|
||||
)
|
||||
|
||||
if (hasUndockedRef.current && docked) {
|
||||
// Still docked but virtually undocked - use transform for visual movement
|
||||
// Use undockPositionRef instead of position state to avoid stale closure
|
||||
if (undockPositionRef.current) {
|
||||
const transformX = newX - undockPositionRef.current.x
|
||||
const transformY = newY - undockPositionRef.current.y
|
||||
console.log(
|
||||
'[GUIDE_DRAG] === SETTING TRANSFORM === x: ' +
|
||||
transformX +
|
||||
', y: ' +
|
||||
transformY +
|
||||
', undockPosition: ' +
|
||||
JSON.stringify(undockPositionRef.current)
|
||||
)
|
||||
setDragTransform({ x: transformX, y: transformY })
|
||||
}
|
||||
} else {
|
||||
// Actually floating - use position
|
||||
console.log('[GUIDE_DRAG] Floating - setting position: ' + newX + ', ' + newY)
|
||||
setPosition({
|
||||
x: newX,
|
||||
y: newY,
|
||||
})
|
||||
}
|
||||
|
||||
// Check if we're near edges for docking preview (works for floating or virtually undocked)
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Checking docking preview - onDock: ' +
|
||||
(onDock ? 'defined' : 'undefined') +
|
||||
', onDockPreview: ' +
|
||||
(onDockPreview ? 'defined' : 'undefined') +
|
||||
', docked: ' +
|
||||
docked +
|
||||
', hasUndocked: ' +
|
||||
hasUndockedRef.current
|
||||
)
|
||||
if (onDock && onDockPreview && (!docked || hasUndockedRef.current)) {
|
||||
const DOCK_THRESHOLD = 100
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Docking preview condition passed - checking edges, clientX: ' +
|
||||
e.clientX
|
||||
)
|
||||
if (e.clientX < DOCK_THRESHOLD) {
|
||||
setDockPreview('left')
|
||||
onDockPreview('left')
|
||||
@@ -280,21 +381,77 @@ export function PlayingGuideModal({
|
||||
}
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
// Check for docking when releasing drag
|
||||
if (isDragging && onDock && !docked) {
|
||||
console.log(
|
||||
'[GUIDE_DRAG] === MOUSE UP === clientX: ' +
|
||||
e.clientX +
|
||||
', docked: ' +
|
||||
docked +
|
||||
', hasUndocked: ' +
|
||||
hasUndockedRef.current +
|
||||
', isDragging: ' +
|
||||
isDragging +
|
||||
', onDock: ' +
|
||||
(onDock ? 'defined' : 'undefined')
|
||||
)
|
||||
|
||||
// Check for docking when releasing drag (works for floating or virtually undocked)
|
||||
if (isDragging && onDock && (!docked || hasUndockedRef.current)) {
|
||||
const DOCK_THRESHOLD = 100 // pixels from edge to trigger docking
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Checking for dock - clientX: ' +
|
||||
e.clientX +
|
||||
', threshold: ' +
|
||||
DOCK_THRESHOLD +
|
||||
', windowWidth: ' +
|
||||
window.innerWidth
|
||||
)
|
||||
|
||||
if (e.clientX < DOCK_THRESHOLD) {
|
||||
console.log('[GUIDE_DRAG] Mouse up - near left edge, calling onDock(left)')
|
||||
onDock('left')
|
||||
// Don't call onUndock if we're re-docking
|
||||
setIsDragging(false)
|
||||
setIsResizing(false)
|
||||
setResizeDirection('')
|
||||
setDockPreview(null)
|
||||
setDragTransform(null)
|
||||
if (onDockPreview) {
|
||||
onDockPreview(null)
|
||||
}
|
||||
console.log('[GUIDE_DRAG] Cleared state after re-dock to left')
|
||||
return
|
||||
} else if (e.clientX > window.innerWidth - DOCK_THRESHOLD) {
|
||||
console.log('[GUIDE_DRAG] Mouse up - near right edge, calling onDock(right)')
|
||||
onDock('right')
|
||||
// Don't call onUndock if we're re-docking
|
||||
setIsDragging(false)
|
||||
setIsResizing(false)
|
||||
setResizeDirection('')
|
||||
setDockPreview(null)
|
||||
setDragTransform(null)
|
||||
if (onDockPreview) {
|
||||
onDockPreview(null)
|
||||
}
|
||||
console.log('[GUIDE_DRAG] Cleared state after re-dock to right')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If we virtually undocked during this drag and didn't re-dock, now actually undock
|
||||
if (hasUndockedRef.current && docked && onUndock) {
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Mouse up - calling deferred onUndock() with final position: ' +
|
||||
JSON.stringify(position)
|
||||
)
|
||||
onUndock()
|
||||
}
|
||||
|
||||
console.log('[GUIDE_DRAG] Mouse up - clearing all drag state')
|
||||
setIsDragging(false)
|
||||
setIsResizing(false)
|
||||
setResizeDirection('')
|
||||
setDockPreview(null) // Clear dock preview when drag ends
|
||||
setDragTransform(null) // Clear drag transform
|
||||
if (onDockPreview) {
|
||||
onDockPreview(null) // Clear parent preview state
|
||||
}
|
||||
@@ -466,81 +623,138 @@ export function PlayingGuideModal({
|
||||
)
|
||||
}
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
ref={modalRef}
|
||||
data-component="playing-guide-modal"
|
||||
style={{
|
||||
position: docked ? 'relative' : 'fixed',
|
||||
background: 'white',
|
||||
borderRadius: standalone || docked ? 0 : isVeryNarrow ? '8px' : '12px',
|
||||
boxShadow: standalone || docked ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
border: standalone || docked ? 'none' : '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
...(docked
|
||||
? { width: '100%', height: '100%' }
|
||||
const modalContent = (() => {
|
||||
const styleConfig: React.CSSProperties = {
|
||||
// When virtually undocked (dragTransform present), use fixed positioning to break out of Panel
|
||||
position: dragTransform || !docked ? 'fixed' : 'relative',
|
||||
background: 'white',
|
||||
borderRadius: standalone || (docked && !dragTransform) ? 0 : isVeryNarrow ? '8px' : '12px',
|
||||
boxShadow:
|
||||
standalone || (docked && !dragTransform) ? 'none' : '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
border: standalone || (docked && !dragTransform) ? 'none' : '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
overflow: 'hidden',
|
||||
...(dragTransform && undockPositionRef.current
|
||||
? // Virtually undocked - show at drag position using ref position
|
||||
{
|
||||
left: `${undockPositionRef.current.x + dragTransform.x}px`,
|
||||
top: `${undockPositionRef.current.y + dragTransform.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
}
|
||||
: docked
|
||||
? // Still docked
|
||||
{ width: '100%', height: '100%' }
|
||||
: standalone
|
||||
? { top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
|
||||
: {
|
||||
? // Standalone mode
|
||||
{ top: 0, left: 0, width: '100vw', height: '100vh', zIndex: 1 }
|
||||
: // Actually floating
|
||||
{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${size.width}px`,
|
||||
height: `${size.height}px`,
|
||||
zIndex: Z_INDEX.MODAL,
|
||||
}),
|
||||
// 80% opacity when showing dock preview or when not hovered on desktop
|
||||
opacity:
|
||||
dockPreview !== null
|
||||
// 80% opacity when showing dock preview or when not hovered on desktop
|
||||
opacity:
|
||||
dockPreview !== null
|
||||
? 0.8
|
||||
: !standalone && !docked && window.innerWidth >= 768 && !isHovered
|
||||
? 0.8
|
||||
: !standalone && !docked && window.innerWidth >= 768 && !isHovered
|
||||
? 0.8
|
||||
: 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{!docked && renderResizeHandles()}
|
||||
: 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}
|
||||
|
||||
{/* Header */}
|
||||
console.log(
|
||||
'[GUIDE_DRAG] Rendering with style - position: ' +
|
||||
styleConfig.position +
|
||||
', left: ' +
|
||||
(styleConfig.left ?? 'none') +
|
||||
', top: ' +
|
||||
(styleConfig.top ?? 'none') +
|
||||
', dragTransform: ' +
|
||||
JSON.stringify(dragTransform) +
|
||||
', docked: ' +
|
||||
docked
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-element="modal-header"
|
||||
className={css({
|
||||
bg: '#f9fafb',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
})}
|
||||
style={{
|
||||
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
|
||||
cursor: isDragging
|
||||
? 'grabbing'
|
||||
: !standalone && window.innerWidth >= 768
|
||||
? 'grab'
|
||||
: 'default',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
ref={modalRef}
|
||||
data-component="playing-guide-modal"
|
||||
style={styleConfig}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* Close and utility buttons - top right */}
|
||||
{!docked && renderResizeHandles()}
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
data-element="modal-header"
|
||||
className={css({
|
||||
bg: '#f9fafb',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
})}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: isVeryNarrow ? '4px' : '8px',
|
||||
right: isVeryNarrow ? '4px' : '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isVeryNarrow ? '4px' : '8px',
|
||||
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
|
||||
cursor: isDragging
|
||||
? 'grabbing'
|
||||
: !standalone && window.innerWidth >= 768
|
||||
? 'grab'
|
||||
: 'default',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* Bust-out button (only if not already standalone/docked and not very narrow) */}
|
||||
{!standalone && !docked && !isVeryNarrow && (
|
||||
{/* Close and utility buttons - top right */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: isVeryNarrow ? '4px' : '8px',
|
||||
right: isVeryNarrow ? '4px' : '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: isVeryNarrow ? '4px' : '8px',
|
||||
}}
|
||||
>
|
||||
{/* Bust-out button (only if not already standalone/docked and not very narrow) */}
|
||||
{!standalone && !docked && !isVeryNarrow && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="bust-out-guide"
|
||||
onClick={handleBustOut}
|
||||
style={{
|
||||
background: '#e5e7eb',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: isVeryNarrow ? '4px' : '6px',
|
||||
width: isVeryNarrow ? '24px' : '32px',
|
||||
height: isVeryNarrow ? '24px' : '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: isVeryNarrow ? '12px' : '16px',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
|
||||
title={t('bustOut')}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="bust-out-guide"
|
||||
onClick={handleBustOut}
|
||||
data-action="close-guide"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: '#e5e7eb',
|
||||
color: '#374151',
|
||||
@@ -552,170 +766,144 @@ export function PlayingGuideModal({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: isVeryNarrow ? '12px' : '16px',
|
||||
fontSize: isVeryNarrow ? '14px' : '18px',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
|
||||
title={t('bustOut')}
|
||||
>
|
||||
↗
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-guide"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: '#e5e7eb',
|
||||
color: '#374151',
|
||||
border: 'none',
|
||||
borderRadius: isVeryNarrow ? '4px' : '6px',
|
||||
width: isVeryNarrow ? '24px' : '32px',
|
||||
height: isVeryNarrow ? '24px' : '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: isVeryNarrow ? '14px' : '18px',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#d1d5db')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#e5e7eb')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Centered title and subtitle - hide when very narrow */}
|
||||
{!isVeryNarrow && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: isNarrow ? '16px' : isMedium ? '20px' : '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
marginBottom: isNarrow ? '4px' : '8px',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{t('title')}
|
||||
</h1>
|
||||
{!isNarrow && (
|
||||
<p
|
||||
{/* Centered title and subtitle - hide when very narrow */}
|
||||
{!isVeryNarrow && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: isMedium ? '12px' : '16px',
|
||||
color: '#6b7280',
|
||||
marginBottom: isMedium ? '8px' : '16px',
|
||||
lineHeight: 1.3,
|
||||
fontSize: isNarrow ? '16px' : isMedium ? '20px' : '28px',
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
marginBottom: isNarrow ? '4px' : '8px',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{t('title')}
|
||||
</h1>
|
||||
{!isNarrow && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: isMedium ? '12px' : '16px',
|
||||
color: '#6b7280',
|
||||
marginBottom: isMedium ? '8px' : '16px',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs - fully responsive, always fit in available width */}
|
||||
<div
|
||||
data-element="guide-nav"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
background: '#f9fafb',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
data-action={`navigate-${section.id}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
style={{
|
||||
flex: '1 1 0', // Equal width tabs
|
||||
minWidth: 0, // Allow shrinking below content size
|
||||
padding: isVeryNarrow ? '10px 6px' : isNarrow ? '10px 8px' : '14px 20px',
|
||||
fontSize: isVeryNarrow ? '16px' : isNarrow ? '12px' : '14px',
|
||||
fontWeight: activeSection === section.id ? 'bold' : '500',
|
||||
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
|
||||
background: activeSection === section.id ? 'white' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
border: 'none',
|
||||
borderBottom: `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: isVeryNarrow ? '0' : isNarrow ? '4px' : '6px',
|
||||
lineHeight: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeSection !== section.id) {
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeSection !== section.id) {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}
|
||||
}}
|
||||
title={section.label}
|
||||
>
|
||||
<span style={{ fontSize: isVeryNarrow ? '18px' : 'inherit', flexShrink: 0 }}>
|
||||
{section.icon}
|
||||
</span>
|
||||
{!isVeryNarrow && (
|
||||
<Textfit
|
||||
mode="single"
|
||||
min={8}
|
||||
max={isNarrow ? 12 : 14}
|
||||
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{section.label}
|
||||
</Textfit>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Navigation Tabs - fully responsive, always fit in available width */}
|
||||
<div
|
||||
data-element="guide-nav"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
background: '#f9fafb',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
data-action={`navigate-${section.id}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
style={{
|
||||
flex: '1 1 0', // Equal width tabs
|
||||
minWidth: 0, // Allow shrinking below content size
|
||||
padding: isVeryNarrow ? '10px 6px' : isNarrow ? '10px 8px' : '14px 20px',
|
||||
fontSize: isVeryNarrow ? '16px' : isNarrow ? '12px' : '14px',
|
||||
fontWeight: activeSection === section.id ? 'bold' : '500',
|
||||
color: activeSection === section.id ? '#7c2d12' : '#6b7280',
|
||||
background: activeSection === section.id ? 'white' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
border: 'none',
|
||||
borderBottom: `3px solid ${activeSection === section.id ? '#7c2d12' : 'transparent'}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: isVeryNarrow ? '0' : isNarrow ? '4px' : '6px',
|
||||
lineHeight: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (activeSection !== section.id) {
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (activeSection !== section.id) {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
}
|
||||
}}
|
||||
title={section.label}
|
||||
>
|
||||
<span style={{ fontSize: isVeryNarrow ? '18px' : 'inherit', flexShrink: 0 }}>
|
||||
{section.icon}
|
||||
</span>
|
||||
{!isVeryNarrow && (
|
||||
<Textfit
|
||||
mode="single"
|
||||
min={8}
|
||||
max={isNarrow ? 12 : 14}
|
||||
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{section.label}
|
||||
</Textfit>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
data-element="guide-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
|
||||
fontSize: isVeryNarrow ? '12px' : isNarrow ? '13px' : '14px',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{activeSection === 'overview' && (
|
||||
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'pieces' && (
|
||||
<PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'capture' && (
|
||||
<CaptureSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'strategy' && (
|
||||
<StrategySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'harmony' && (
|
||||
<HarmonySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'victory' && (
|
||||
<VictorySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{/* Content */}
|
||||
<div
|
||||
data-element="guide-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
|
||||
fontSize: isVeryNarrow ? '12px' : isNarrow ? '13px' : '14px',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{activeSection === 'overview' && (
|
||||
<OverviewSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'pieces' && (
|
||||
<PiecesSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'capture' && (
|
||||
<CaptureSection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'strategy' && (
|
||||
<StrategySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'harmony' && (
|
||||
<HarmonySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
{activeSection === 'victory' && (
|
||||
<VictorySection useNativeAbacusNumbers={useNativeAbacusNumbers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
})() // Invoke the IIFE
|
||||
|
||||
// If standalone, just render the content without Dialog wrapper
|
||||
if (standalone) {
|
||||
|
||||
@@ -173,9 +173,11 @@ export function RithmomachiaGame() {
|
||||
padding: guideDocked ? 0 : { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
alignItems: guideDocked ? 'stretch' : 'center',
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
// When docked, ensure we fill panel height
|
||||
height: guideDocked ? '100%' : 'auto',
|
||||
})}
|
||||
>
|
||||
<main
|
||||
@@ -191,7 +193,8 @@ export function RithmomachiaGame() {
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
// Ensure main fills parent height
|
||||
minHeight: 0,
|
||||
})}
|
||||
>
|
||||
{state.gamePhase === 'setup' && (
|
||||
@@ -245,6 +248,7 @@ export function RithmomachiaGame() {
|
||||
onClose={() => setIsGuideOpen(false)}
|
||||
docked={true} // Always render as docked when in panel
|
||||
onUndock={guideDocked ? handleUndock : undefined} // Only show undock button when truly docked
|
||||
onDock={handleDock} // Allow re-docking during virtual undock
|
||||
onDockPreview={handleDockPreview}
|
||||
/>
|
||||
</Panel>
|
||||
@@ -284,6 +288,7 @@ export function RithmomachiaGame() {
|
||||
onClose={() => setIsGuideOpen(false)}
|
||||
docked={true} // Always render as docked when in panel
|
||||
onUndock={guideDocked ? handleUndock : undefined} // Only show undock button when truly docked
|
||||
onDock={handleDock} // Allow re-docking during virtual undock
|
||||
onDockPreview={handleDockPreview}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
@@ -183,40 +183,87 @@ export function BoardDisplay() {
|
||||
return
|
||||
}
|
||||
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
const targetValue = getEffectiveValue(targetPiece)
|
||||
|
||||
console.log('[handleCaptureWithRelation] moverValue:', moverValue)
|
||||
console.log('[handleCaptureWithRelation] targetValue:', targetValue)
|
||||
|
||||
if (targetValue === undefined || targetValue === null) {
|
||||
console.log('[handleCaptureWithRelation] Undefined/null target value, returning')
|
||||
return
|
||||
}
|
||||
|
||||
// For pyramids, find helpers using all faces
|
||||
if (
|
||||
moverValue === undefined ||
|
||||
moverValue === null ||
|
||||
targetValue === undefined ||
|
||||
targetValue === null
|
||||
moverPiece.type === 'P' &&
|
||||
moverPiece.pyramidFaces &&
|
||||
moverPiece.pyramidFaces.length === 4
|
||||
) {
|
||||
console.log('[handleCaptureWithRelation] Undefined/null value, returning')
|
||||
return
|
||||
console.log(
|
||||
'[handleCaptureWithRelation] Pyramid - checking all faces for helpers: ' +
|
||||
moverPiece.pyramidFaces.join(', ')
|
||||
)
|
||||
|
||||
// Collect all valid helpers from all pyramid faces
|
||||
const allValidHelpers = new Map<string, Piece>()
|
||||
|
||||
for (const faceValue of moverPiece.pyramidFaces) {
|
||||
const helpers = findValidHelpers(faceValue, targetValue, relation)
|
||||
console.log(
|
||||
'[handleCaptureWithRelation] Face ' +
|
||||
faceValue +
|
||||
' found ' +
|
||||
helpers.length +
|
||||
' helpers'
|
||||
)
|
||||
for (const helper of helpers) {
|
||||
allValidHelpers.set(helper.id, helper)
|
||||
}
|
||||
}
|
||||
|
||||
const validHelpers = Array.from(allValidHelpers.values())
|
||||
console.log('[handleCaptureWithRelation] Total unique helpers: ' + validHelpers.length)
|
||||
|
||||
if (validHelpers.length === 0) {
|
||||
console.log('[handleCaptureWithRelation] No valid helpers found for any pyramid face')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[handleCaptureWithRelation] Auto-selecting first helper:', validHelpers[0])
|
||||
setSelectedRelation(relation)
|
||||
setSelectedHelper({
|
||||
helperPiece: validHelpers[0],
|
||||
moverPiece,
|
||||
targetPiece,
|
||||
})
|
||||
} else {
|
||||
// Non-pyramid logic
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
console.log('[handleCaptureWithRelation] moverValue:', moverValue)
|
||||
|
||||
if (moverValue === undefined || moverValue === null) {
|
||||
console.log('[handleCaptureWithRelation] Undefined/null mover value, returning')
|
||||
return
|
||||
}
|
||||
|
||||
// Find valid helpers
|
||||
const validHelpers = findValidHelpers(moverValue, targetValue, relation)
|
||||
console.log('[handleCaptureWithRelation] validHelpers:', validHelpers)
|
||||
|
||||
if (validHelpers.length === 0) {
|
||||
// No valid helpers - relation is impossible
|
||||
console.log('[handleCaptureWithRelation] No valid helpers found')
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically select the first valid helper (skip helper selection UI)
|
||||
console.log('[handleCaptureWithRelation] Auto-selecting first helper:', validHelpers[0])
|
||||
setSelectedRelation(relation)
|
||||
setSelectedHelper({
|
||||
helperPiece: validHelpers[0],
|
||||
moverPiece,
|
||||
targetPiece,
|
||||
})
|
||||
}
|
||||
|
||||
// Find valid helpers
|
||||
const validHelpers = findValidHelpers(moverValue, targetValue, relation)
|
||||
console.log('[handleCaptureWithRelation] validHelpers:', validHelpers)
|
||||
|
||||
if (validHelpers.length === 0) {
|
||||
// No valid helpers - relation is impossible
|
||||
console.log('[handleCaptureWithRelation] No valid helpers found')
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically select the first valid helper (skip helper selection UI)
|
||||
console.log('[handleCaptureWithRelation] Auto-selecting first helper:', validHelpers[0])
|
||||
setSelectedRelation(relation)
|
||||
setSelectedHelper({
|
||||
helperPiece: validHelpers[0],
|
||||
moverPiece,
|
||||
targetPiece,
|
||||
})
|
||||
} else {
|
||||
console.log('[handleCaptureWithRelation] No helper needed, executing capture immediately')
|
||||
// No helper needed - execute capture immediately
|
||||
@@ -388,19 +435,36 @@ export function BoardDisplay() {
|
||||
return []
|
||||
}
|
||||
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
const targetValue = getEffectiveValue(targetPiece)
|
||||
|
||||
if (
|
||||
moverValue === undefined ||
|
||||
moverValue === null ||
|
||||
targetValue === undefined ||
|
||||
targetValue === null
|
||||
) {
|
||||
if (targetValue === undefined || targetValue === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
const validHelpers = findValidHelpers(moverValue, targetValue, selectedRelation)
|
||||
// For pyramids, collect helpers from all faces
|
||||
let validHelpers: Piece[]
|
||||
if (
|
||||
moverPiece.type === 'P' &&
|
||||
moverPiece.pyramidFaces &&
|
||||
moverPiece.pyramidFaces.length === 4
|
||||
) {
|
||||
const allValidHelpers = new Map<string, Piece>()
|
||||
|
||||
for (const faceValue of moverPiece.pyramidFaces) {
|
||||
const helpers = findValidHelpers(faceValue, targetValue, selectedRelation)
|
||||
for (const helper of helpers) {
|
||||
allValidHelpers.set(helper.id, helper)
|
||||
}
|
||||
}
|
||||
|
||||
validHelpers = Array.from(allValidHelpers.values())
|
||||
} else {
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
if (moverValue === undefined || moverValue === null) {
|
||||
return []
|
||||
}
|
||||
validHelpers = findValidHelpers(moverValue, targetValue, selectedRelation)
|
||||
}
|
||||
|
||||
const helpersWithPos = validHelpers.map((piece) => {
|
||||
const basePos = getSquarePosition(piece.square, layout)
|
||||
@@ -413,9 +477,9 @@ export function BoardDisplay() {
|
||||
return helpersWithPos
|
||||
})()
|
||||
|
||||
// Calculate available relations for this capture
|
||||
const availableRelations = (() => {
|
||||
if (!captureTarget) return []
|
||||
// Calculate available relations and pyramid face values for this capture
|
||||
const { availableRelations, pyramidFaceValues } = (() => {
|
||||
if (!captureTarget) return { availableRelations: [], pyramidFaceValues: null }
|
||||
|
||||
const moverPiece = Object.values(state.pieces).find(
|
||||
(p) => p.id === captureTarget.pieceId && !p.captured
|
||||
@@ -424,20 +488,60 @@ export function BoardDisplay() {
|
||||
(p) => p.square === captureTarget.to && !p.captured
|
||||
)
|
||||
|
||||
if (!moverPiece || !targetPiece) return []
|
||||
if (!moverPiece || !targetPiece) return { availableRelations: [], pyramidFaceValues: null }
|
||||
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
const targetValue = getEffectiveValue(targetPiece)
|
||||
|
||||
if (
|
||||
moverValue === undefined ||
|
||||
moverValue === null ||
|
||||
targetValue === undefined ||
|
||||
targetValue === null
|
||||
)
|
||||
return []
|
||||
if (targetValue === undefined || targetValue === null)
|
||||
return { availableRelations: [], pyramidFaceValues: null }
|
||||
|
||||
return findAvailableRelations(moverValue, targetValue)
|
||||
// For pyramids, collect ALL relations from ALL faces and track which face to use
|
||||
if (
|
||||
moverPiece.type === 'P' &&
|
||||
moverPiece.pyramidFaces &&
|
||||
moverPiece.pyramidFaces.length === 4
|
||||
) {
|
||||
console.log(
|
||||
'[availableRelations] Checking pyramid with faces: ' + moverPiece.pyramidFaces.join(', ')
|
||||
)
|
||||
|
||||
const allRelations = new Set<RelationKind>()
|
||||
const faceMap = new Map<RelationKind, number>()
|
||||
|
||||
// Check each face and collect all available relations, storing first face that works for each relation
|
||||
for (const faceValue of moverPiece.pyramidFaces) {
|
||||
const relations = findAvailableRelations(faceValue, targetValue)
|
||||
console.log(
|
||||
'[availableRelations] Face ' + faceValue + ' relations: ' + relations.join(', ')
|
||||
)
|
||||
for (const rel of relations) {
|
||||
if (!faceMap.has(rel)) {
|
||||
faceMap.set(rel, faceValue)
|
||||
}
|
||||
allRelations.add(rel)
|
||||
}
|
||||
}
|
||||
|
||||
const result = Array.from(allRelations)
|
||||
console.log('[availableRelations] All pyramid relations: ' + result.join(', '))
|
||||
console.log(
|
||||
'[availableRelations] Pyramid face map: ' +
|
||||
Array.from(faceMap.entries())
|
||||
.map(([r, v]) => r + '=' + v)
|
||||
.join(', ')
|
||||
)
|
||||
return { availableRelations: result, pyramidFaceValues: faceMap }
|
||||
}
|
||||
|
||||
// For non-pyramid pieces, use standard logic
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
if (moverValue === undefined || moverValue === null)
|
||||
return { availableRelations: [], pyramidFaceValues: null }
|
||||
|
||||
return {
|
||||
availableRelations: findAvailableRelations(moverValue, targetValue),
|
||||
pyramidFaceValues: null,
|
||||
}
|
||||
})()
|
||||
|
||||
// Calculate if hovered square shows error (for hover preview)
|
||||
@@ -542,24 +646,126 @@ export function BoardDisplay() {
|
||||
return { x, y }
|
||||
})()
|
||||
|
||||
// Detect portrait vs landscape and calculate explicit dimensions for rotation
|
||||
const boardAspectRatio = boardWidth / boardHeight // ~2:1 for 16x8 board
|
||||
const [containerAspectRatio, setContainerAspectRatio] = useState(1)
|
||||
const [svgDimensions, setSvgDimensions] = useState<{ width: string; height: string } | null>(null)
|
||||
const [updateCounter, setUpdateCounter] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.querySelector('[data-component="board-container"]')
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
// Watch the parent element - that's what gets resized by the panel
|
||||
const parentContainer = container.parentElement
|
||||
if (!parentContainer) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateDimensions = () => {
|
||||
// Measure the PARENT container, not the wrapper itself
|
||||
const rect = parentContainer.getBoundingClientRect()
|
||||
const containerAspect = rect.width / rect.height
|
||||
const shouldRotateBoard = containerAspect < 1 && boardAspectRatio > 1
|
||||
|
||||
if (shouldRotateBoard) {
|
||||
// When rotating -90°, the board's dimensions swap visually:
|
||||
// - pre-rotation width → visual height
|
||||
// - pre-rotation height → visual width
|
||||
//
|
||||
// In portrait mode (tall container), prioritize filling HEIGHT.
|
||||
|
||||
// Start by filling container height
|
||||
let visualHeight = rect.height
|
||||
let visualWidth = visualHeight / boardAspectRatio
|
||||
|
||||
// If visual width exceeds container width, scale down PROPORTIONALLY
|
||||
// (don't switch to filling width - keep height priority)
|
||||
if (visualWidth > rect.width) {
|
||||
const scale = rect.width / visualWidth
|
||||
visualWidth = rect.width
|
||||
visualHeight = visualHeight * scale
|
||||
}
|
||||
|
||||
// Convert visual dimensions back to pre-rotation SVG dimensions
|
||||
const preRotationWidth = visualHeight
|
||||
const preRotationHeight = visualWidth
|
||||
|
||||
setSvgDimensions({
|
||||
width: `${preRotationWidth}px`,
|
||||
height: `${preRotationHeight}px`,
|
||||
})
|
||||
} else {
|
||||
// Normal landscape: use percentage sizing
|
||||
setSvgDimensions(null)
|
||||
}
|
||||
|
||||
setContainerAspectRatio(containerAspect)
|
||||
}
|
||||
|
||||
// Initial check - use requestAnimationFrame to ensure layout is painted
|
||||
const initialUpdate = () => {
|
||||
requestAnimationFrame(() => {
|
||||
updateDimensions()
|
||||
})
|
||||
}
|
||||
initialUpdate()
|
||||
|
||||
// Use ResizeObserver to detect panel resizing (window resize doesn't catch panel changes!)
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
// Use requestAnimationFrame to ensure we measure after layout
|
||||
requestAnimationFrame(() => {
|
||||
updateDimensions()
|
||||
})
|
||||
})
|
||||
// Watch the parent container - that's what gets resized by the panel
|
||||
resizeObserver.observe(parentContainer)
|
||||
|
||||
// Also listen to window resize for when the whole window changes
|
||||
window.addEventListener('resize', updateDimensions)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', updateDimensions)
|
||||
}
|
||||
}, [boardAspectRatio])
|
||||
|
||||
// Rotate board if container is portrait and board is landscape
|
||||
const shouldRotate = containerAspectRatio < 1 && boardAspectRatio > 1
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="board-container"
|
||||
className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
zIndex: Z_INDEX.GAME.OVERLAY,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Unified SVG Board */}
|
||||
<svg
|
||||
viewBox={`0 0 ${boardWidth} ${boardHeight}`}
|
||||
className={css({
|
||||
width: '100%',
|
||||
style={{
|
||||
// Use explicit dimensions when rotated, percentage sizing when normal
|
||||
width: svgDimensions ? svgDimensions.width : '100%',
|
||||
height: svgDimensions ? svgDimensions.height : 'auto',
|
||||
// Don't constrain with max dimensions when rotating - we've already calculated exact size
|
||||
maxWidth: svgDimensions ? 'none' : '100%',
|
||||
maxHeight: svgDimensions ? 'none' : '100%',
|
||||
// Don't use aspect-ratio when rotating - it overrides explicit dimensions
|
||||
aspectRatio: svgDimensions ? 'auto' : `${boardWidth} / ${boardHeight}`,
|
||||
cursor: isMyTurn ? 'pointer' : 'default',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
transform: shouldRotate ? 'rotate(-90deg)' : 'none',
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.3s ease',
|
||||
}}
|
||||
onClick={handleSvgClick}
|
||||
onMouseMove={handleSvgMouseMove}
|
||||
onMouseLeave={handleSvgMouseLeave}
|
||||
@@ -603,19 +809,21 @@ export function BoardDisplay() {
|
||||
const y = boardHeight - 10
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`col-${colLabel}`}
|
||||
x={x}
|
||||
y={y}
|
||||
fontSize="20"
|
||||
fontWeight="bold"
|
||||
fill="#374151"
|
||||
fontFamily="sans-serif"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{colLabel}
|
||||
</text>
|
||||
<g key={`col-${colLabel}`} transform={`translate(${x}, ${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize="20"
|
||||
fontWeight="bold"
|
||||
fill="#374151"
|
||||
fontFamily="sans-serif"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
transform={shouldRotate ? 'rotate(90)' : undefined}
|
||||
>
|
||||
{colLabel}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -626,19 +834,21 @@ export function BoardDisplay() {
|
||||
const y = padding + row * (cellSize + gap) + cellSize / 2
|
||||
|
||||
return (
|
||||
<text
|
||||
key={`row-${actualRank}`}
|
||||
x={x}
|
||||
y={y}
|
||||
fontSize="20"
|
||||
fontWeight="bold"
|
||||
fill="#374151"
|
||||
fontFamily="sans-serif"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{actualRank}
|
||||
</text>
|
||||
<g key={`row-${actualRank}`} transform={`translate(${x}, ${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fontSize="20"
|
||||
fontWeight="bold"
|
||||
fill="#374151"
|
||||
fontFamily="sans-serif"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
transform={shouldRotate ? 'rotate(90)' : undefined}
|
||||
>
|
||||
{actualRank}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -659,6 +869,7 @@ export function BoardDisplay() {
|
||||
opacity={isInNumberBond ? 0.2 : 1}
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
selected={isSelected}
|
||||
shouldRotate={shouldRotate}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -703,6 +914,7 @@ export function BoardDisplay() {
|
||||
selectedRelation,
|
||||
closing: closingDialog,
|
||||
allPieces: activePieces,
|
||||
pyramidFaceValues,
|
||||
findValidHelpers,
|
||||
selectRelation: handleCaptureWithRelation,
|
||||
selectHelper: handleHelperSelection,
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface SvgPieceProps {
|
||||
opacity?: number
|
||||
useNativeAbacusNumbers?: boolean
|
||||
selected?: boolean
|
||||
shouldRotate?: boolean
|
||||
}
|
||||
|
||||
export function SvgPiece({
|
||||
@@ -22,6 +23,7 @@ export function SvgPiece({
|
||||
opacity = 1,
|
||||
useNativeAbacusNumbers = false,
|
||||
selected = false,
|
||||
shouldRotate = false,
|
||||
}: SvgPieceProps) {
|
||||
const file = piece.square.charCodeAt(0) - 65 // A=0
|
||||
const rank = Number.parseInt(piece.square.slice(1), 10) // 1-8
|
||||
@@ -59,6 +61,7 @@ export function SvgPiece({
|
||||
useNativeAbacusNumbers={useNativeAbacusNumbers}
|
||||
selected={selected}
|
||||
pyramidFaces={piece.type === 'P' ? piece.pyramidFaces : undefined}
|
||||
shouldRotate={shouldRotate}
|
||||
/>
|
||||
</div>
|
||||
</foreignObject>
|
||||
|
||||
@@ -17,13 +17,28 @@ interface CaptureRelationOptionsProps {
|
||||
* Animated floating capture relation options with number bond preview on hover
|
||||
*/
|
||||
export function CaptureRelationOptions({ availableRelations }: CaptureRelationOptionsProps) {
|
||||
const { layout, pieces, closing, allPieces, findValidHelpers, selectRelation } =
|
||||
useCaptureContext()
|
||||
const {
|
||||
layout,
|
||||
pieces,
|
||||
closing,
|
||||
allPieces,
|
||||
pyramidFaceValues,
|
||||
findValidHelpers,
|
||||
selectRelation,
|
||||
} = useCaptureContext()
|
||||
const { targetPos, cellSize, gap, padding } = layout
|
||||
const { mover: moverPiece, target: targetPiece } = pieces
|
||||
const [hoveredRelation, setHoveredRelation] = useState<RelationKind | null>(null)
|
||||
const [currentHelperIndex, setCurrentHelperIndex] = useState(0)
|
||||
|
||||
// Get mover value - either from pyramidFaceValues map (for pyramids) or from piece directly
|
||||
const getMoverValue = (relation: RelationKind): number | null => {
|
||||
if (pyramidFaceValues && pyramidFaceValues.has(relation)) {
|
||||
return pyramidFaceValues.get(relation) || null
|
||||
}
|
||||
return getEffectiveValue(moverPiece)
|
||||
}
|
||||
|
||||
// Cycle through valid helpers every 1.5 seconds when hovering
|
||||
useEffect(() => {
|
||||
if (!hoveredRelation) {
|
||||
@@ -31,7 +46,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
|
||||
return
|
||||
}
|
||||
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
const moverValue = getMoverValue(hoveredRelation)
|
||||
const targetValue = getEffectiveValue(targetPiece)
|
||||
|
||||
if (
|
||||
@@ -56,7 +71,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
|
||||
}, 1500)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [hoveredRelation, moverPiece, targetPiece, findValidHelpers])
|
||||
}, [hoveredRelation, pyramidFaceValues, targetPiece, findValidHelpers])
|
||||
|
||||
// Generate tooltip text with actual numbers for the currently displayed helper
|
||||
const getTooltipText = (relation: RelationKind): string => {
|
||||
@@ -74,7 +89,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
|
||||
return genericMap[relation] || relation
|
||||
}
|
||||
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
const moverValue = getMoverValue(relation)
|
||||
const targetValue = getEffectiveValue(targetPiece)
|
||||
|
||||
if (
|
||||
@@ -287,7 +302,7 @@ export function CaptureRelationOptions({ availableRelations }: CaptureRelationOp
|
||||
{/* Number bond preview when hovering over a relation - cycle through valid helpers */}
|
||||
{hoveredRelation &&
|
||||
(() => {
|
||||
const moverValue = getEffectiveValue(moverPiece)
|
||||
const moverValue = getMoverValue(hoveredRelation)
|
||||
const targetValue = getEffectiveValue(targetPiece)
|
||||
|
||||
if (
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface PlayingPhaseProps {
|
||||
}
|
||||
|
||||
export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
|
||||
const { state, isMyTurn, lastError, clearError, rosterStatus } = useRithmomachia()
|
||||
const { state, lastError, clearError } = useRithmomachia()
|
||||
|
||||
// Get abacus settings for native abacus numbers
|
||||
const { data: abacusSettings } = useAbacusSettings()
|
||||
@@ -22,14 +22,106 @@ export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4',
|
||||
p: '4',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
})}
|
||||
>
|
||||
{/* Compact Header Bar */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
px: '4',
|
||||
py: '3',
|
||||
bg: 'rgba(255, 255, 255, 0.95)',
|
||||
borderBottom: '2px solid',
|
||||
borderColor: 'rgba(251, 191, 36, 0.3)',
|
||||
flexShrink: 0,
|
||||
gap: '4',
|
||||
})}
|
||||
>
|
||||
{/* Captured pieces info */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: { base: '4', md: '6' },
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'semibold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span>⚪</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>White:</span>
|
||||
<span className={css({ color: 'gray.900' })}>{state.capturedPieces.W.length}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'semibold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<span>⚫</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>Black:</span>
|
||||
<span className={css({ color: 'gray.900' })}>{state.capturedPieces.B.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Guide Button */}
|
||||
{!isGuideOpen && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-guide-playing"
|
||||
onClick={onOpenGuide}
|
||||
className={css({
|
||||
px: { base: '3', md: '4' },
|
||||
py: { base: '1.5', md: '2' },
|
||||
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
|
||||
color: 'white',
|
||||
border: '2px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: 'lg',
|
||||
fontSize: { base: 'sm', md: 'md' },
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'all 0.2s',
|
||||
flexShrink: 0,
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span className={css({ display: { base: 'none', sm: 'inline' } })}>Guide</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{lastError && (
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
mx: '4',
|
||||
mt: '4',
|
||||
p: '4',
|
||||
bg: 'red.100',
|
||||
borderColor: 'red.400',
|
||||
@@ -38,6 +130,7 @@ export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<span className={css({ color: 'red.800', fontWeight: 'semibold' })}>⚠️ {lastError}</span>
|
||||
@@ -60,103 +153,19 @@ export function PlayingPhase({ onOpenGuide, isGuideOpen }: PlayingPhaseProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Board Area - takes remaining space */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: '4',
|
||||
bg: 'gray.100',
|
||||
borderRadius: 'md',
|
||||
gap: '3',
|
||||
overflow: 'auto',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<span className={css({ fontWeight: 'bold' })}>Turn: </span>
|
||||
<span className={css({ color: state.turn === 'W' ? 'gray.800' : 'gray.600' })}>
|
||||
{state.turn === 'W' ? 'White' : 'Black'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', gap: '2', alignItems: 'center' })}>
|
||||
{!isGuideOpen && (
|
||||
<button
|
||||
type="button"
|
||||
data-action="open-guide-playing"
|
||||
onClick={onOpenGuide}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'linear-gradient(135deg, #7c2d12, #92400e)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(251, 191, 36, 0.6)',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, #92400e, #7c2d12)',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>📖</span>
|
||||
<span>Guide</span>
|
||||
</button>
|
||||
)}
|
||||
{isMyTurn && (
|
||||
<div
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'green.100',
|
||||
color: 'green.800',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
Your Turn
|
||||
</div>
|
||||
)}
|
||||
{!isMyTurn && rosterStatus.status === 'ok' && (
|
||||
<div
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'gray.200',
|
||||
color: 'gray.700',
|
||||
borderRadius: 'md',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
Waiting for {state.turn === 'W' ? 'White' : 'Black'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BoardDisplay />
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '4',
|
||||
})}
|
||||
>
|
||||
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
|
||||
<h3 className={css({ fontWeight: 'bold', mb: '2' })}>White Captured</h3>
|
||||
<div className={css({ fontSize: 'sm' })}>{state.capturedPieces.W.length} pieces</div>
|
||||
</div>
|
||||
<div className={css({ p: '4', bg: 'gray.100', borderRadius: 'md' })}>
|
||||
<h3 className={css({ fontWeight: 'bold', mb: '2' })}>Black Captured</h3>
|
||||
<div className={css({ fontSize: 'sm' })}>{state.capturedPieces.B.length} pieces</div>
|
||||
</div>
|
||||
<BoardDisplay />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -39,6 +39,9 @@ export interface CaptureContextValue {
|
||||
// All pieces on the board (for validation)
|
||||
allPieces: Piece[]
|
||||
|
||||
// For pyramid pieces, maps relation to which face value to use
|
||||
pyramidFaceValues: Map<RelationKind, number> | null
|
||||
|
||||
// Helper functions
|
||||
findValidHelpers: (moverValue: number, targetValue: number, relation: RelationKind) => Piece[]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user