255
DOCUMENTATION.md
Archivo normal
255
DOCUMENTATION.md
Archivo normal
@@ -0,0 +1,255 @@
|
|||||||
|
# Dominoes Game - Technical Documentation
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
This is a full-stack, real-time multiplayer dominoes game built with modern web technologies.
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 16 (App Router), React 19, TypeScript
|
||||||
|
- **State Management**: Zustand
|
||||||
|
- **Real-time Communication**: Socket.IO
|
||||||
|
- **Styling**: Tailwind CSS v4
|
||||||
|
- **Animations**: Framer Motion
|
||||||
|
- **Graphics**: HTML5 Canvas API
|
||||||
|
|
||||||
|
## Game Features
|
||||||
|
|
||||||
|
### 1. Multiplayer Mode
|
||||||
|
- Real-time synchronization using WebSockets
|
||||||
|
- Support for 2-4 players per room
|
||||||
|
- Room-based game sessions with unique 6-character codes
|
||||||
|
- Automatic reconnection handling
|
||||||
|
- Player ready system before game starts
|
||||||
|
|
||||||
|
### 2. AI Mode
|
||||||
|
- Offline single-player against AI
|
||||||
|
- Three difficulty levels implemented (easy, medium, hard)
|
||||||
|
- Strategic tile selection algorithm
|
||||||
|
- Simulated thinking delays for realistic gameplay
|
||||||
|
|
||||||
|
### 3. Game Mechanics
|
||||||
|
- Traditional domino rules (0-6 tiles, 28 total)
|
||||||
|
- 7 tiles per player at start
|
||||||
|
- Boneyard for drawing additional tiles
|
||||||
|
- Turn-based gameplay with validation
|
||||||
|
- Win conditions: first to empty hand or lowest score when blocked
|
||||||
|
- Pass turn functionality when no valid moves
|
||||||
|
|
||||||
|
### 4. Visual Design
|
||||||
|
- Canvas-based domino tile rendering
|
||||||
|
- Draggable board for navigation
|
||||||
|
- Animated tile placement and removal
|
||||||
|
- Gradient backgrounds and modern UI
|
||||||
|
- Responsive layout for all screen sizes
|
||||||
|
- Visual feedback for playable tiles
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
domino/
|
||||||
|
├── app/
|
||||||
|
│ ├── api/socket/
|
||||||
|
│ │ └── route.ts # Socket.IO API endpoint (legacy)
|
||||||
|
│ ├── globals.css # Global styles
|
||||||
|
│ ├── layout.tsx # Root layout with metadata
|
||||||
|
│ └── page.tsx # Main game page (client component)
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── DominoCanvas.tsx # Individual tile rendering with Canvas
|
||||||
|
│ ├── GameBoard.tsx # Game board with pan/zoom
|
||||||
|
│ ├── PlayerHand.tsx # Player's tile hand display
|
||||||
|
│ ├── Lobby.tsx # Game lobby/menu screen
|
||||||
|
│ ├── WaitingRoom.tsx # Pre-game waiting area
|
||||||
|
│ └── GameOver.tsx # End game modal
|
||||||
|
│
|
||||||
|
├── lib/
|
||||||
|
│ ├── types.ts # TypeScript type definitions
|
||||||
|
│ ├── gameLogic.ts # Core game rules and mechanics
|
||||||
|
│ ├── aiLogic.ts # AI opponent implementation
|
||||||
|
│ ├── store.ts # Zustand state management
|
||||||
|
│ └── socket-server.ts # Socket.IO server logic (legacy)
|
||||||
|
│
|
||||||
|
├── server.mjs # Custom Next.js server with Socket.IO
|
||||||
|
└── public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Zustand Store (`lib/store.ts`)
|
||||||
|
|
||||||
|
The application uses a single Zustand store that manages:
|
||||||
|
|
||||||
|
- **Socket connection**: WebSocket client instance
|
||||||
|
- **Game state**: Current game data (players, board, tiles, etc.)
|
||||||
|
- **Player data**: Current player ID, selected tile
|
||||||
|
- **Room information**: Room ID, connection status
|
||||||
|
- **Error handling**: Error messages and notifications
|
||||||
|
|
||||||
|
### Key Actions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Connection
|
||||||
|
initSocket() // Initialize Socket.IO connection
|
||||||
|
createRoom() // Create new game room
|
||||||
|
joinRoom(id, name) // Join existing room
|
||||||
|
leaveRoom() // Leave current room
|
||||||
|
|
||||||
|
// Gameplay
|
||||||
|
makeMove(move) // Place a tile
|
||||||
|
drawTile() // Draw from boneyard
|
||||||
|
selectTile(tile) // Select tile from hand
|
||||||
|
setPlayerReady() // Mark player as ready
|
||||||
|
|
||||||
|
// AI
|
||||||
|
startAIGame(name) // Start offline AI game
|
||||||
|
executeAITurn() // Process AI move
|
||||||
|
```
|
||||||
|
|
||||||
|
## Socket.IO Events
|
||||||
|
|
||||||
|
### Client → Server
|
||||||
|
|
||||||
|
- `create-room`: Request new room creation
|
||||||
|
- `join-room`: Join room with ID and player name
|
||||||
|
- `player-ready`: Mark player as ready to start
|
||||||
|
- `make-move`: Submit a game move
|
||||||
|
- `draw-tile`: Request tile from boneyard
|
||||||
|
- `leave-room`: Leave current room
|
||||||
|
|
||||||
|
### Server → Client
|
||||||
|
|
||||||
|
- `room-created`: Room successfully created (returns room ID)
|
||||||
|
- `room-joined`: Successfully joined room (returns game state)
|
||||||
|
- `game-started`: Game has begun
|
||||||
|
- `game-state-updated`: Game state changed
|
||||||
|
- `player-joined`: New player joined
|
||||||
|
- `player-left`: Player disconnected
|
||||||
|
- `invalid-move`: Move was rejected
|
||||||
|
- `error`: General error message
|
||||||
|
|
||||||
|
## Game Logic
|
||||||
|
|
||||||
|
### Tile Generation
|
||||||
|
- Full domino set: 28 tiles (0-0 through 6-6)
|
||||||
|
- Each tile has left and right values
|
||||||
|
- Doubles marked for special rendering
|
||||||
|
|
||||||
|
### Move Validation
|
||||||
|
1. Check if tile matches board end values
|
||||||
|
2. Verify it's the player's turn
|
||||||
|
3. Ensure player owns the tile
|
||||||
|
4. Validate placement side (left/right)
|
||||||
|
|
||||||
|
### Win Conditions
|
||||||
|
1. **Primary**: Player uses all tiles
|
||||||
|
2. **Blocked**: Game ends when no one can move
|
||||||
|
- Winner is player with lowest total pip count
|
||||||
|
|
||||||
|
### AI Strategy (Hard Mode)
|
||||||
|
- Evaluates tile values (higher = better early game)
|
||||||
|
- Considers board end options
|
||||||
|
- Blocks opponents by playing uncommon values
|
||||||
|
- Prioritizes doubles near end game
|
||||||
|
- Defensive play: discards high-value tiles first
|
||||||
|
|
||||||
|
## Canvas Rendering
|
||||||
|
|
||||||
|
### DominoCanvas Component
|
||||||
|
- Draws individual tiles with rounded corners
|
||||||
|
- Renders dots in traditional domino patterns
|
||||||
|
- Supports selection highlighting
|
||||||
|
- Hover effects for playable tiles
|
||||||
|
- Responsive sizing
|
||||||
|
|
||||||
|
### GameBoard Component
|
||||||
|
- Renders all placed tiles
|
||||||
|
- Pan/drag functionality for navigation
|
||||||
|
- Grid background for visual reference
|
||||||
|
- Automatic centering on first tile
|
||||||
|
- Dynamic positioning calculation
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
1. **Canvas Memoization**: Tiles re-render only when state changes
|
||||||
|
2. **Socket Batching**: State updates batched to reduce network traffic
|
||||||
|
3. **Lazy Loading**: Components loaded only when needed
|
||||||
|
4. **Optimized Re-renders**: React.memo and useCallback where appropriate
|
||||||
|
5. **Turbopack**: Fast bundling and HMR in development
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Development
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
Opens at http://localhost:3000 with Socket.IO server integrated
|
||||||
|
|
||||||
|
**Note:** The project uses a custom Next.js server (`server.mjs`) that integrates Socket.IO directly. This is required for WebSocket functionality.
|
||||||
|
|
||||||
|
### Production Build
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
Create `.env.local`:
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_SOCKET_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, update URLs to your domain.
|
||||||
|
|
||||||
|
### Vercel Deployment
|
||||||
|
1. Push to GitHub
|
||||||
|
2. Import in Vercel
|
||||||
|
3. Add environment variables
|
||||||
|
4. Deploy
|
||||||
|
|
||||||
|
Socket.IO works automatically with Vercel's serverless functions.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential features to add:
|
||||||
|
|
||||||
|
- [ ] Tournament mode with multiple rounds
|
||||||
|
- [ ] Player statistics and leaderboards
|
||||||
|
- [ ] Chat functionality
|
||||||
|
- [ ] Custom tile themes/skins
|
||||||
|
- [ ] Sound effects and music
|
||||||
|
- [ ] Spectator mode
|
||||||
|
- [ ] Replay game history
|
||||||
|
- [ ] Mobile app version
|
||||||
|
- [ ] Game speed settings
|
||||||
|
- [ ] Multiple domino variants (Cuban, Mexican, etc.)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Socket Connection Issues
|
||||||
|
- Ensure port 3000 is not blocked
|
||||||
|
- Check CORS settings for production
|
||||||
|
- Verify WebSocket support on hosting platform
|
||||||
|
|
||||||
|
### Build Errors
|
||||||
|
- Clear `.next` folder: `rm -rf .next`
|
||||||
|
- Reinstall dependencies: `rm -rf node_modules && npm install`
|
||||||
|
- Check Node.js version (18+ required)
|
||||||
|
|
||||||
|
### TypeScript Errors
|
||||||
|
- Run type check: `npx tsc --noEmit`
|
||||||
|
- Ensure all dependencies have type definitions
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - Free for personal and commercial use.
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Created with ❤️ using:
|
||||||
|
- Next.js by Vercel
|
||||||
|
- Socket.IO
|
||||||
|
- Zustand by Poimandres
|
||||||
|
- Framer Motion
|
||||||
|
- Tailwind CSS
|
||||||
109
QUICKSTART.md
Archivo normal
109
QUICKSTART.md
Archivo normal
@@ -0,0 +1,109 @@
|
|||||||
|
# Quick Start Guide - Dominoes Online
|
||||||
|
|
||||||
|
## 🚀 Get Started in 3 Steps
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Development Server
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Open in Browser
|
||||||
|
Visit **http://localhost:3000**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 How to Play
|
||||||
|
|
||||||
|
### Create a Game
|
||||||
|
1. Enter your name
|
||||||
|
2. Click "Create Room"
|
||||||
|
3. Share the 6-digit room code with friends
|
||||||
|
4. Click "Ready to Play" when everyone joins
|
||||||
|
5. Game starts automatically!
|
||||||
|
|
||||||
|
### Join a Game
|
||||||
|
1. Enter your name
|
||||||
|
2. Click "Join Room"
|
||||||
|
3. Enter the room code from your friend
|
||||||
|
4. Click "Ready to Play"
|
||||||
|
5. Wait for game to start!
|
||||||
|
|
||||||
|
### Play vs AI
|
||||||
|
1. Enter your name
|
||||||
|
2. Click "Play vs AI"
|
||||||
|
3. Game starts immediately!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Game Controls
|
||||||
|
|
||||||
|
### Your Turn
|
||||||
|
1. **Click a tile** in your hand to select it
|
||||||
|
2. **Click "Place Left"** or **"Place Right"** to place it
|
||||||
|
3. Tiles must match the numbers on board ends
|
||||||
|
|
||||||
|
### Can't Play?
|
||||||
|
- **Click "Draw Tile"** to get a new tile from the boneyard
|
||||||
|
- **Click "Pass Turn"** if no tiles left to draw
|
||||||
|
|
||||||
|
### Board Navigation
|
||||||
|
- **Click and drag** the board to pan around
|
||||||
|
- View all placed tiles easily
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏆 Winning
|
||||||
|
|
||||||
|
- First player to use all tiles wins!
|
||||||
|
- If game is blocked (no one can move), lowest score wins
|
||||||
|
- Score = sum of all remaining tile values
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Features
|
||||||
|
|
||||||
|
✅ Real-time multiplayer (2-4 players)
|
||||||
|
✅ AI opponent for solo play
|
||||||
|
✅ Beautiful Canvas graphics
|
||||||
|
✅ Smooth animations
|
||||||
|
✅ Mobile responsive
|
||||||
|
✅ Room-based games
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Game Rules
|
||||||
|
|
||||||
|
- 7 tiles per player at start
|
||||||
|
- Match tile numbers to board ends
|
||||||
|
- Draw if you can't play
|
||||||
|
- Pass if boneyard is empty
|
||||||
|
- Doubles can be placed either way
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Development server
|
||||||
|
npm run build # Build for production
|
||||||
|
npm start # Run production server
|
||||||
|
npm run lint # Check code quality
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🆘 Need Help?
|
||||||
|
|
||||||
|
Check out:
|
||||||
|
- **README.md** - Full project overview
|
||||||
|
- **DOCUMENTATION.md** - Technical details
|
||||||
|
- **GitHub Issues** - Report bugs or request features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Enjoy playing dominoes! 🎲**
|
||||||
110
README.md
110
README.md
@@ -1,36 +1,106 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Dominoes Online
|
||||||
|
|
||||||
|
A modern, real-time multiplayer dominoes game built with Next.js, TypeScript, Canvas, and Socket.IO.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎮 **Real-time Multiplayer** - Play with friends online using WebSocket connections
|
||||||
|
- 🤖 **AI Opponent** - Play against an intelligent AI when no other players are available
|
||||||
|
- 🎨 **Beautiful UI** - Modern, responsive design with smooth animations
|
||||||
|
- 🎯 **Canvas Rendering** - Domino tiles rendered using HTML5 Canvas
|
||||||
|
- 📱 **Responsive** - Works great on desktop and mobile devices
|
||||||
|
- ⚡ **Fast & Real-time** - Instant updates for all players
|
||||||
|
|
||||||
|
## How to Play
|
||||||
|
|
||||||
|
1. **Create a Room** - Start a new game and share the room code with friends
|
||||||
|
2. **Join a Room** - Enter a room code to join an existing game
|
||||||
|
3. **Play vs AI** - Practice against an AI opponent
|
||||||
|
4. **Match Tiles** - Place tiles that match the numbers on the board ends
|
||||||
|
5. **Win** - First player to use all tiles wins!
|
||||||
|
|
||||||
|
## Game Rules
|
||||||
|
|
||||||
|
- Each player starts with 7 domino tiles
|
||||||
|
- Players take turns placing tiles that match the numbers on either end of the board
|
||||||
|
- If you can't play, draw a tile from the boneyard
|
||||||
|
- The game ends when a player uses all their tiles or when no one can move
|
||||||
|
- Lowest total value wins if the game is blocked
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 16** - React framework for production
|
||||||
|
- **TypeScript** - Type-safe JavaScript
|
||||||
|
- **Socket.IO** - Real-time bidirectional communication
|
||||||
|
- **Zustand** - State management
|
||||||
|
- **Framer Motion** - Smooth animations
|
||||||
|
- **Tailwind CSS** - Utility-first CSS framework
|
||||||
|
- **HTML5 Canvas** - Tile rendering
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
First, run the development server:
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+ installed
|
||||||
|
- npm or yarn package manager
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm install
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
2. Run the custom server with Socket.IO:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
3. Open [http://localhost:3000](http://localhost:3000) in your browser
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
**Note:** This project uses a custom Next.js server (`server.mjs`) to integrate Socket.IO for real-time multiplayer functionality.
|
||||||
|
|
||||||
## Learn More
|
## Development
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
### Project Structure
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
```
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
domino/
|
||||||
|
├── app/ # Next.js app directory
|
||||||
|
│ ├── api/socket/ # Socket.IO API route
|
||||||
|
│ ├── page.tsx # Main game page
|
||||||
|
│ └── layout.tsx # Root layout
|
||||||
|
├── components/ # React components
|
||||||
|
│ ├── DominoCanvas.tsx
|
||||||
|
│ ├── GameBoard.tsx
|
||||||
|
│ ├── PlayerHand.tsx
|
||||||
|
│ ├── Lobby.tsx
|
||||||
|
│ ├── WaitingRoom.tsx
|
||||||
|
│ └── GameOver.tsx
|
||||||
|
├── lib/ # Core game logic
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ ├── gameLogic.ts # Game mechanics
|
||||||
|
│ ├── aiLogic.ts # AI opponent
|
||||||
|
│ ├── store.ts # Zustand store
|
||||||
|
│ └── socket-server.ts # Socket.IO server
|
||||||
|
└── public/ # Static assets
|
||||||
|
```
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
### Available Scripts
|
||||||
|
|
||||||
## Deploy on Vercel
|
- `npm run dev` - Start custom development server with Socket.IO
|
||||||
|
- `npm run build` - Build for production
|
||||||
|
- `npm start` - Start production server with Socket.IO
|
||||||
|
- `npm run lint` - Run ESLint
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
## Contributing
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - feel free to use this project for learning or personal use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Enjoy playing dominoes online! 🎲
|
||||||
|
|||||||
161
WEBSOCKET.md
Archivo normal
161
WEBSOCKET.md
Archivo normal
@@ -0,0 +1,161 @@
|
|||||||
|
# WebSocket Server - Configuración
|
||||||
|
|
||||||
|
## ✅ Problema Resuelto
|
||||||
|
|
||||||
|
El servidor WebSocket ahora funciona correctamente utilizando un servidor custom de Next.js.
|
||||||
|
|
||||||
|
## Arquitectura
|
||||||
|
|
||||||
|
Este proyecto utiliza un **servidor custom de Next.js** (`server.mjs`) que integra:
|
||||||
|
- Next.js para el renderizado de páginas
|
||||||
|
- Socket.IO para comunicación en tiempo real
|
||||||
|
- HTTP server compartido entre ambos
|
||||||
|
|
||||||
|
### ¿Por qué un servidor custom?
|
||||||
|
|
||||||
|
Next.js 16 con App Router no soporta nativamente Socket.IO en rutas de API. La solución es crear un servidor HTTP personalizado que:
|
||||||
|
|
||||||
|
1. Maneja las peticiones HTTP de Next.js
|
||||||
|
2. Maneja las conexiones WebSocket de Socket.IO
|
||||||
|
3. Comparte el mismo puerto (3000)
|
||||||
|
|
||||||
|
## Configuración Actual
|
||||||
|
|
||||||
|
### Archivo: `server.mjs`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
- Crea servidor HTTP
|
||||||
|
- Inicializa Next.js app
|
||||||
|
- Configura Socket.IO en el mismo servidor
|
||||||
|
- Maneja eventos de juego en tiempo real
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cliente: `lib/store.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const socket = io('http://localhost:3000', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** Ya NO usa `path: '/api/socket'` porque Socket.IO está en la raíz del servidor.
|
||||||
|
|
||||||
|
## Eventos Socket.IO
|
||||||
|
|
||||||
|
### Cliente → Servidor
|
||||||
|
- `create-room` - Crear nueva sala
|
||||||
|
- `join-room` - Unirse a sala
|
||||||
|
- `player-ready` - Jugador listo
|
||||||
|
- `make-move` - Hacer jugada
|
||||||
|
- `draw-tile` - Robar ficha
|
||||||
|
- `disconnect` - Desconectar
|
||||||
|
|
||||||
|
### Servidor → Cliente
|
||||||
|
- `room-created` - Sala creada (roomId)
|
||||||
|
- `room-joined` - Unido a sala (gameState, playerId)
|
||||||
|
- `game-started` - Juego iniciado
|
||||||
|
- `game-state-updated` - Estado actualizado
|
||||||
|
- `player-joined` - Nuevo jugador
|
||||||
|
- `player-left` - Jugador salió
|
||||||
|
- `invalid-move` - Jugada inválida
|
||||||
|
- `error` - Error general
|
||||||
|
|
||||||
|
## Comandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desarrollo (con hot reload)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Producción
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificar Conexión
|
||||||
|
|
||||||
|
1. Abre la consola del navegador (F12)
|
||||||
|
2. Busca mensajes como:
|
||||||
|
- "Connected to socket server"
|
||||||
|
- "Client connected: [socket-id]"
|
||||||
|
|
||||||
|
3. En la terminal del servidor verás:
|
||||||
|
- "> Ready on http://localhost:3000"
|
||||||
|
- "> Socket.IO server running"
|
||||||
|
- "Client connected: [socket-id]"
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### El cliente no conecta
|
||||||
|
|
||||||
|
**Síntomas:** No ves "Connected to socket server" en la consola
|
||||||
|
|
||||||
|
**Solución:**
|
||||||
|
1. Verifica que el servidor esté corriendo: `npm run dev`
|
||||||
|
2. Revisa la consola del servidor para errores
|
||||||
|
3. Asegúrate que el puerto 3000 no esté ocupado
|
||||||
|
4. Limpia caché del navegador y recarga
|
||||||
|
|
||||||
|
### Error: "ERR_CONNECTION_REFUSED"
|
||||||
|
|
||||||
|
**Causa:** El servidor no está corriendo o puerto bloqueado
|
||||||
|
|
||||||
|
**Solución:**
|
||||||
|
```bash
|
||||||
|
# Matar proceso en puerto 3000
|
||||||
|
lsof -ti:3000 | xargs kill -9
|
||||||
|
|
||||||
|
# Reiniciar servidor
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Socket.IO no envía eventos
|
||||||
|
|
||||||
|
**Causa:** El evento no está registrado correctamente
|
||||||
|
|
||||||
|
**Solución:**
|
||||||
|
1. Verifica que el evento exista en `server.mjs`
|
||||||
|
2. Revisa que el nombre del evento coincida exactamente
|
||||||
|
3. Checa la consola del servidor para errores
|
||||||
|
|
||||||
|
## Estado Actual
|
||||||
|
|
||||||
|
✅ **Funcionando correctamente**
|
||||||
|
|
||||||
|
El servidor WebSocket está:
|
||||||
|
- ✅ Corriendo en http://localhost:3000
|
||||||
|
- ✅ Aceptando conexiones Socket.IO
|
||||||
|
- ✅ Manejando eventos de juego
|
||||||
|
- ✅ Sincronizando estado entre jugadores
|
||||||
|
- ✅ Soportando múltiples salas
|
||||||
|
|
||||||
|
## Logs del Servidor
|
||||||
|
|
||||||
|
Cuando todo funciona correctamente verás:
|
||||||
|
|
||||||
|
```
|
||||||
|
> domino@0.1.0 dev
|
||||||
|
> node server.mjs
|
||||||
|
|
||||||
|
> Ready on http://localhost:3000
|
||||||
|
> Socket.IO server running
|
||||||
|
Client connected: abc123xyz
|
||||||
|
Room created: XYZ789
|
||||||
|
Player Player1 joined room XYZ789
|
||||||
|
Game started in room: XYZ789
|
||||||
|
```
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
Para producción, considera:
|
||||||
|
|
||||||
|
1. **Variables de entorno** para la URL del servidor
|
||||||
|
2. **SSL/TLS** para conexiones seguras (wss://)
|
||||||
|
3. **Autenticación** de jugadores
|
||||||
|
4. **Rate limiting** para prevenir spam
|
||||||
|
5. **Monitoreo** de conexiones activas
|
||||||
|
6. **Backup** de salas activas
|
||||||
|
7. **Scaling** con Redis para múltiples instancias
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**El servidor WebSocket está completamente funcional** 🎉
|
||||||
227
app/api/socket/route.ts
Archivo normal
227
app/api/socket/route.ts
Archivo normal
@@ -0,0 +1,227 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
|
import { Server as HTTPServer } from 'http';
|
||||||
|
import { GameState, Player, GameMove } from '@/lib/types';
|
||||||
|
import { dealTiles, findStartingPlayer, executeMove, canPlayerMove, isGameBlocked, determineBlockedWinner } from '@/lib/gameLogic';
|
||||||
|
|
||||||
|
// Store active game rooms
|
||||||
|
const gameRooms = new Map<string, GameState>();
|
||||||
|
const playerRooms = new Map<string, string>();
|
||||||
|
|
||||||
|
// Generate unique room ID
|
||||||
|
function generateRoomId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new game state
|
||||||
|
function createGameState(roomId: string): GameState {
|
||||||
|
return {
|
||||||
|
id: roomId,
|
||||||
|
players: [],
|
||||||
|
currentPlayerIndex: 0,
|
||||||
|
board: [],
|
||||||
|
boneyard: [],
|
||||||
|
boardEnds: [],
|
||||||
|
winner: null,
|
||||||
|
isGameOver: false,
|
||||||
|
turnsPassed: 0,
|
||||||
|
gameMode: 'waiting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize game when all players are ready
|
||||||
|
function startGame(roomId: string): GameState {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) throw new Error('Game not found');
|
||||||
|
|
||||||
|
const { playerTiles, boneyard } = dealTiles(gameState.players.length);
|
||||||
|
|
||||||
|
const updatedPlayers = gameState.players.map((player, index) => ({
|
||||||
|
...player,
|
||||||
|
tiles: playerTiles[index],
|
||||||
|
score: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startingPlayerIndex = findStartingPlayer(updatedPlayers);
|
||||||
|
|
||||||
|
const newGameState: GameState = {
|
||||||
|
...gameState,
|
||||||
|
players: updatedPlayers,
|
||||||
|
currentPlayerIndex: startingPlayerIndex,
|
||||||
|
boneyard,
|
||||||
|
gameMode: 'playing',
|
||||||
|
};
|
||||||
|
|
||||||
|
gameRooms.set(roomId, newGameState);
|
||||||
|
return newGameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = req.res || req.nextUrl;
|
||||||
|
|
||||||
|
if (!(res as any).socket?.server?.io) {
|
||||||
|
console.log('Initializing Socket.IO server...');
|
||||||
|
|
||||||
|
const httpServer: HTTPServer = (res as any).socket.server;
|
||||||
|
const io = new SocketIOServer(httpServer, {
|
||||||
|
path: '/api/socket',
|
||||||
|
addTrailingSlash: false,
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('Client connected:', socket.id);
|
||||||
|
|
||||||
|
socket.on('create-room', () => {
|
||||||
|
const roomId = generateRoomId();
|
||||||
|
const gameState = createGameState(roomId);
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.emit('room-created', roomId);
|
||||||
|
console.log('Room created:', roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('join-room', (roomId: string, playerName: string) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
|
||||||
|
if (!gameState) {
|
||||||
|
socket.emit('error', 'Room not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.players.length >= 4) {
|
||||||
|
socket.emit('error', 'Room is full');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.gameMode !== 'waiting') {
|
||||||
|
socket.emit('error', 'Game already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player: Player = {
|
||||||
|
id: socket.id,
|
||||||
|
name: playerName,
|
||||||
|
tiles: [],
|
||||||
|
score: 0,
|
||||||
|
isAI: false,
|
||||||
|
isReady: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
gameState.players.push(player);
|
||||||
|
playerRooms.set(socket.id, roomId);
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.emit('room-joined', gameState, socket.id);
|
||||||
|
socket.to(roomId).emit('player-joined', player);
|
||||||
|
|
||||||
|
console.log(`Player ${playerName} joined room ${roomId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('player-ready', (roomId: string) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const player = gameState.players.find(p => p.id === socket.id);
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
player.isReady = true;
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
|
||||||
|
const allReady = gameState.players.length >= 2 && gameState.players.every(p => p.isReady);
|
||||||
|
|
||||||
|
if (allReady) {
|
||||||
|
const startedGame = startGame(roomId);
|
||||||
|
io.to(roomId).emit('game-started', startedGame);
|
||||||
|
console.log('Game started in room:', roomId);
|
||||||
|
} else {
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('make-move', (roomId: string, move: GameMove) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
|
||||||
|
if (currentPlayer.id !== socket.id) {
|
||||||
|
socket.emit('invalid-move', 'Not your turn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newGameState = executeMove(gameState, move);
|
||||||
|
|
||||||
|
if (isGameBlocked(newGameState)) {
|
||||||
|
const winnerId = determineBlockedWinner(newGameState);
|
||||||
|
newGameState.winner = winnerId;
|
||||||
|
newGameState.isGameOver = true;
|
||||||
|
newGameState.gameMode = 'finished';
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRooms.set(roomId, newGameState);
|
||||||
|
io.to(roomId).emit('game-state-updated', newGameState);
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit('invalid-move', 'Invalid move');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('draw-tile', (roomId: string) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
|
||||||
|
if (currentPlayer.id !== socket.id) {
|
||||||
|
socket.emit('invalid-move', 'Not your turn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.boneyard.length === 0) {
|
||||||
|
socket.emit('invalid-move', 'No tiles left to draw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawnTile = gameState.boneyard.pop()!;
|
||||||
|
currentPlayer.tiles.push(drawnTile);
|
||||||
|
|
||||||
|
if (!canPlayerMove(currentPlayer, gameState.boardEnds)) {
|
||||||
|
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const roomId = playerRooms.get(socket.id);
|
||||||
|
if (roomId) {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (gameState) {
|
||||||
|
gameState.players = gameState.players.filter(p => p.id !== socket.id);
|
||||||
|
playerRooms.delete(socket.id);
|
||||||
|
|
||||||
|
if (gameState.players.length === 0) {
|
||||||
|
gameRooms.delete(roomId);
|
||||||
|
} else {
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('player-left', socket.id);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Client disconnected:', socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(res as any).socket.server.io = io;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Socket.IO server initialized', { status: 200 });
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Dominoes Online - Multiplayer Game",
|
||||||
description: "Generated by create next app",
|
description: "Play dominoes online with friends or against AI. Modern, real-time multiplayer domino game built with Next.js.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
408
app/page.tsx
408
app/page.tsx
@@ -1,65 +1,367 @@
|
|||||||
import Image from "next/image";
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useGameStore } from '@/lib/store';
|
||||||
|
import { Lobby } from '@/components/Lobby';
|
||||||
|
import { WaitingRoom } from '@/components/WaitingRoom';
|
||||||
|
import { GameBoard } from '@/components/GameBoard';
|
||||||
|
import { PlayerHand } from '@/components/PlayerHand';
|
||||||
|
import { GameOver } from '@/components/GameOver';
|
||||||
|
import { getValidMoves } from '@/lib/gameLogic';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const {
|
||||||
|
gameState,
|
||||||
|
currentPlayerId,
|
||||||
|
roomId,
|
||||||
|
error,
|
||||||
|
selectedTile,
|
||||||
|
initSocket,
|
||||||
|
createRoom,
|
||||||
|
joinRoom,
|
||||||
|
setPlayerReady,
|
||||||
|
makeMove,
|
||||||
|
drawTile,
|
||||||
|
selectTile,
|
||||||
|
leaveRoom,
|
||||||
|
startAIGame,
|
||||||
|
setError,
|
||||||
|
} = useGameStore();
|
||||||
|
|
||||||
|
const [showRules, setShowRules] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initSocket();
|
||||||
|
}, [initSocket]);
|
||||||
|
|
||||||
|
const handleCreateRoom = (playerName: string) => {
|
||||||
|
createRoom(playerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoinRoom = (roomId: string, playerName: string) => {
|
||||||
|
joinRoom(roomId, playerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartAI = (playerName: string) => {
|
||||||
|
startAIGame(playerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTileClick = (tileId: string) => {
|
||||||
|
const tile = gameState?.players
|
||||||
|
.find(p => p.id === currentPlayerId)
|
||||||
|
?.tiles.find(t => t.id === tileId);
|
||||||
|
|
||||||
|
if (tile) {
|
||||||
|
selectTile(selectedTile?.id === tileId ? null : tile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaceTile = (side: 'left' | 'right') => {
|
||||||
|
if (!selectedTile || !currentPlayerId || !gameState) return;
|
||||||
|
|
||||||
|
console.log('handlePlaceTile called', {
|
||||||
|
selectedTile,
|
||||||
|
side,
|
||||||
|
currentPlayerId,
|
||||||
|
boardEnds: gameState.boardEnds,
|
||||||
|
validMoves
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verificar si el movimiento es válido
|
||||||
|
const isValid = validMoves.some(m =>
|
||||||
|
m.tile.id === selectedTile.id &&
|
||||||
|
(gameState.boardEnds.length === 0 || m.side === side || validMoves.filter(vm => vm.tile.id === selectedTile.id).length > 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid && gameState.boardEnds.length > 0) {
|
||||||
|
console.error('Invalid move: tile cannot be placed on this side', { selectedTile, side, validMoves });
|
||||||
|
setError(`Cannot place tile ${selectedTile.left}-${selectedTile.right} on the ${side} side. It doesn't match the board end.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Making move:', { playerId: currentPlayerId, tile: selectedTile, side });
|
||||||
|
makeMove({
|
||||||
|
playerId: currentPlayerId,
|
||||||
|
tile: selectedTile,
|
||||||
|
side,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePass = () => {
|
||||||
|
if (!currentPlayerId || !gameState) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
|
||||||
|
if (!currentPlayer) return;
|
||||||
|
|
||||||
|
makeMove({
|
||||||
|
playerId: currentPlayerId,
|
||||||
|
tile: currentPlayer.tiles[0],
|
||||||
|
side: 'left',
|
||||||
|
pass: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayAgain = () => {
|
||||||
|
leaveRoom();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show lobby if no game state or game mode is waiting
|
||||||
|
if (!gameState || gameState.gameMode === 'waiting') {
|
||||||
|
if (roomId && gameState) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<WaitingRoom
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
roomId={roomId}
|
||||||
<Image
|
players={gameState.players}
|
||||||
className="dark:invert"
|
currentPlayerId={currentPlayerId}
|
||||||
src="/next.svg"
|
onReady={setPlayerReady}
|
||||||
alt="Next.js logo"
|
onLeave={leaveRoom}
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
);
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
}
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
|
return (
|
||||||
|
<Lobby
|
||||||
|
onCreateRoom={handleCreateRoom}
|
||||||
|
onJoinRoom={handleJoinRoom}
|
||||||
|
onStartAI={handleStartAI}
|
||||||
|
roomId={roomId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players.find(p => p.id === currentPlayerId);
|
||||||
|
const isMyTurn = gameState.players[gameState.currentPlayerIndex]?.id === currentPlayerId;
|
||||||
|
const validMoves = currentPlayer ? getValidMoves(currentPlayer, gameState.boardEnds) : [];
|
||||||
|
const validTileIds = currentPlayer && gameState.boardEnds.length === 0
|
||||||
|
? currentPlayer.tiles.map(t => t.id)
|
||||||
|
: Array.from(new Set(validMoves.map(m => m.tile.id)));
|
||||||
|
const canDraw = gameState.boneyard.length > 0 && validMoves.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-100 to-slate-200">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white shadow-md">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
|
||||||
|
Dominoes
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
{roomId && (
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
<div className="hidden sm:block bg-gradient-to-r from-blue-500 to-purple-500 text-white px-4 py-1 rounded-full text-sm font-mono">
|
||||||
<a
|
{roomId}
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
)}
|
||||||
<a
|
</div>
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="flex items-center gap-3">
|
||||||
target="_blank"
|
<button
|
||||||
rel="noopener noreferrer"
|
onClick={() => setShowRules(!showRules)}
|
||||||
|
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
|
||||||
>
|
>
|
||||||
<Image
|
Rules
|
||||||
className="dark:invert"
|
</button>
|
||||||
src="/vercel.svg"
|
<button
|
||||||
alt="Vercel logomark"
|
onClick={leaveRoom}
|
||||||
width={16}
|
className="bg-red-500 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-red-600 transition-colors"
|
||||||
height={16}
|
>
|
||||||
|
Leave Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Error notification */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -100, opacity: 0 }}
|
||||||
|
className="fixed top-20 left-1/2 transform -translate-x-1/2 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Rules modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showRules && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||||
|
onClick={() => setShowRules(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
exit={{ scale: 0.9 }}
|
||||||
|
className="bg-white rounded-lg p-6 max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-bold mb-4">How to Play</h3>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-700">
|
||||||
|
<li>• Click on a tile to select it</li>
|
||||||
|
<li>• Click "Place Left" or "Place Right" to place it</li>
|
||||||
|
<li>• Tiles must match the numbers on the board ends</li>
|
||||||
|
<li>• Draw a tile if you can't play</li>
|
||||||
|
<li>• First player to use all tiles wins!</li>
|
||||||
|
<li>• Game ends when blocked (no one can move)</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRules(false)}
|
||||||
|
className="mt-4 w-full bg-blue-500 text-white py-2 rounded-lg"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Main game area */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<div className="grid lg:grid-cols-[1fr_300px] gap-6">
|
||||||
|
{/* Left side - Game board and controls */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Game info */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Current Turn</div>
|
||||||
|
<div className="text-xl font-bold text-gray-800">
|
||||||
|
{gameState.players[gameState.currentPlayerIndex]?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-gray-600">Boneyard</div>
|
||||||
|
<div className="text-xl font-bold text-gray-800">
|
||||||
|
{gameState.boneyard.length} tiles
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game board */}
|
||||||
|
<GameBoard placedTiles={gameState.board} />
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
{isMyTurn && currentPlayer && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
className="bg-white rounded-lg shadow-md p-4"
|
||||||
|
>
|
||||||
|
{selectedTile && (
|
||||||
|
<div className="mb-3 text-center text-sm text-gray-600">
|
||||||
|
Selected: <span className="font-bold">{selectedTile.left}-{selectedTile.right}</span>
|
||||||
|
{gameState.boardEnds.length > 0 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
Board ends: <span className="font-bold">{gameState.boardEnds[0]?.value}</span> (left) | <span className="font-bold">{gameState.boardEnds[1]?.value}</span> (right)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePlaceTile('left')}
|
||||||
|
disabled={!selectedTile}
|
||||||
|
className="flex-1 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
Place Left
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePlaceTile('right')}
|
||||||
|
disabled={!selectedTile}
|
||||||
|
className="flex-1 bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
Place Right
|
||||||
|
</button>
|
||||||
|
{canDraw && (
|
||||||
|
<button
|
||||||
|
onClick={drawTile}
|
||||||
|
className="flex-1 bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
Draw Tile
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{validMoves.length === 0 && !canDraw && (
|
||||||
|
<button
|
||||||
|
onClick={handlePass}
|
||||||
|
className="flex-1 bg-gradient-to-r from-yellow-500 to-yellow-600 text-white py-3 rounded-lg font-semibold hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
Pass Turn
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current player's hand */}
|
||||||
|
{currentPlayer && (
|
||||||
|
<PlayerHand
|
||||||
|
player={currentPlayer}
|
||||||
|
isCurrentPlayer={isMyTurn}
|
||||||
|
selectedTileId={selectedTile?.id || null}
|
||||||
|
onTileClick={handleTileClick}
|
||||||
|
validTileIds={validTileIds}
|
||||||
/>
|
/>
|
||||||
Deploy Now
|
)}
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
|
{/* Right side - Other players */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700">Players</h3>
|
||||||
|
{gameState.players
|
||||||
|
.filter(p => p.id !== currentPlayerId)
|
||||||
|
.map(player => (
|
||||||
|
<div key={player.id} className="bg-white rounded-lg shadow-md p-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${
|
||||||
|
player.isAI ? 'bg-purple-500' : 'bg-blue-500'
|
||||||
|
}`}>
|
||||||
|
{player.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{player.tiles.length} tiles
|
||||||
|
{player.isAI && ' (AI)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gameState.players[gameState.currentPlayerIndex]?.id === player.id && (
|
||||||
|
<div className="bg-green-500 text-white px-2 py-1 rounded text-xs font-semibold">
|
||||||
|
Turn
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Show tile backs */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{player.tiles.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-12 h-6 bg-gradient-to-br from-gray-700 to-gray-900 rounded border border-gray-600"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game over modal */}
|
||||||
|
{gameState.isGameOver && (
|
||||||
|
<GameOver
|
||||||
|
winner={gameState.players.find(p => p.id === gameState.winner) || null}
|
||||||
|
players={gameState.players}
|
||||||
|
onPlayAgain={handlePlayAgain}
|
||||||
|
onLeave={leaveRoom}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
143
components/DominoCanvas.tsx
Archivo normal
143
components/DominoCanvas.tsx
Archivo normal
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { DominoTile } from '@/lib/types';
|
||||||
|
|
||||||
|
interface DominoCanvasProps {
|
||||||
|
tile: DominoTile;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
isSelected?: boolean;
|
||||||
|
isPlayable?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DominoCanvas({
|
||||||
|
tile,
|
||||||
|
width = 60,
|
||||||
|
height = 30,
|
||||||
|
isSelected = false,
|
||||||
|
isPlayable = true,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
}: DominoCanvasProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw tile background
|
||||||
|
ctx.fillStyle = isSelected ? '#3b82f6' : '#ffffff';
|
||||||
|
ctx.strokeStyle = isPlayable ? '#1f2937' : '#9ca3af';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
const radius = 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(radius, 0);
|
||||||
|
ctx.lineTo(width - radius, 0);
|
||||||
|
ctx.quadraticCurveTo(width, 0, width, radius);
|
||||||
|
ctx.lineTo(width, height - radius);
|
||||||
|
ctx.quadraticCurveTo(width, height, width - radius, height);
|
||||||
|
ctx.lineTo(radius, height);
|
||||||
|
ctx.quadraticCurveTo(0, height, 0, height - radius);
|
||||||
|
ctx.lineTo(0, radius);
|
||||||
|
ctx.quadraticCurveTo(0, 0, radius, 0);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw center divider
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(width / 2, 0);
|
||||||
|
ctx.lineTo(width / 2, height);
|
||||||
|
ctx.strokeStyle = '#6b7280';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw dots
|
||||||
|
const dotRadius = 2.5;
|
||||||
|
const drawDots = (value: number, x: number, y: number, size: number) => {
|
||||||
|
ctx.fillStyle = isSelected ? '#ffffff' : '#1f2937';
|
||||||
|
|
||||||
|
const positions = getDotPositions(value, size);
|
||||||
|
positions.forEach(pos => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + pos.x, y + pos.y, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftX = width / 4;
|
||||||
|
const rightX = (width * 3) / 4;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const dotAreaSize = width / 2 - 6;
|
||||||
|
|
||||||
|
drawDots(tile.left, leftX, centerY, dotAreaSize);
|
||||||
|
drawDots(tile.right, rightX, centerY, dotAreaSize);
|
||||||
|
|
||||||
|
}, [tile, width, height, isSelected, isPlayable]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={`${className} ${onClick && isPlayable ? 'cursor-pointer hover:scale-105 transition-transform' : ''} ${!isPlayable ? 'opacity-50' : ''}`}
|
||||||
|
onClick={isPlayable ? onClick : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dot positions for a domino value (0-6)
|
||||||
|
function getDotPositions(value: number, size: number): { x: number; y: number }[] {
|
||||||
|
const margin = size * 0.2;
|
||||||
|
const positions: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
switch (value) {
|
||||||
|
case 0:
|
||||||
|
return [];
|
||||||
|
case 1:
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: 0, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
230
components/GameBoard.tsx
Archivo normal
230
components/GameBoard.tsx
Archivo normal
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
|
import { PlacedTile, Position } from '@/lib/types';
|
||||||
|
|
||||||
|
interface GameBoardProps {
|
||||||
|
placedTiles: PlacedTile[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameBoard({ placedTiles, width = 1200, height = 700, className = '' }: GameBoardProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [offset, setOffset] = useState<Position>({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState<Position>({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
// Auto-center the board on first tile
|
||||||
|
useEffect(() => {
|
||||||
|
if (placedTiles.length === 1 && offset.x === 0 && offset.y === 0) {
|
||||||
|
setOffset({ x: width / 2 - 400, y: height / 2 - 300 });
|
||||||
|
}
|
||||||
|
}, [placedTiles.length, width, height, offset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw background pattern
|
||||||
|
ctx.fillStyle = '#f3f4f6';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Draw grid pattern
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let x = 0; x < width; x += 40) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let y = 0; y < height; y += 40) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, y);
|
||||||
|
ctx.lineTo(width, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw placed tiles
|
||||||
|
placedTiles.forEach((placedTile, index) => {
|
||||||
|
const { tile, position, orientation } = placedTile;
|
||||||
|
const tileWidth = orientation === 'horizontal' ? 60 : 30;
|
||||||
|
const tileHeight = orientation === 'horizontal' ? 30 : 60;
|
||||||
|
|
||||||
|
const x = position.x + offset.x;
|
||||||
|
const y = position.y + offset.y;
|
||||||
|
|
||||||
|
// Draw tile background with shadow
|
||||||
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
|
||||||
|
ctx.shadowBlur = 5;
|
||||||
|
ctx.shadowOffsetX = 2;
|
||||||
|
ctx.shadowOffsetY = 2;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.strokeStyle = '#1f2937';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
|
||||||
|
const radius = 4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + tileWidth - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + tileWidth, y, x + tileWidth, y + radius);
|
||||||
|
ctx.lineTo(x + tileWidth, y + tileHeight - radius);
|
||||||
|
ctx.quadraticCurveTo(x + tileWidth, y + tileHeight, x + tileWidth - radius, y + tileHeight);
|
||||||
|
ctx.lineTo(x + radius, y + tileHeight);
|
||||||
|
ctx.quadraticCurveTo(x, y + tileHeight, x, y + tileHeight - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Reset shadow
|
||||||
|
ctx.shadowColor = 'transparent';
|
||||||
|
|
||||||
|
// Draw center divider
|
||||||
|
ctx.strokeStyle = '#6b7280';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
ctx.moveTo(x + tileWidth / 2, y);
|
||||||
|
ctx.lineTo(x + tileWidth / 2, y + tileHeight);
|
||||||
|
} else {
|
||||||
|
ctx.moveTo(x, y + tileHeight / 2);
|
||||||
|
ctx.lineTo(x + tileWidth, y + tileHeight / 2);
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw dots
|
||||||
|
const dotRadius = 2.5;
|
||||||
|
ctx.fillStyle = '#1f2937';
|
||||||
|
|
||||||
|
const drawDots = (value: number, dotX: number, dotY: number, size: number) => {
|
||||||
|
const positions = getDotPositions(value, size);
|
||||||
|
positions.forEach(pos => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(dotX + pos.x, dotY + pos.y, dotRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
|
const leftX = x + tileWidth / 4;
|
||||||
|
const rightX = x + (tileWidth * 3) / 4;
|
||||||
|
const centerY = y + tileHeight / 2;
|
||||||
|
const dotAreaSize = tileWidth / 2 - 6;
|
||||||
|
|
||||||
|
drawDots(tile.left, leftX, centerY, dotAreaSize);
|
||||||
|
drawDots(tile.right, rightX, centerY, dotAreaSize);
|
||||||
|
} else {
|
||||||
|
const topY = y + tileHeight / 4;
|
||||||
|
const bottomY = y + (tileHeight * 3) / 4;
|
||||||
|
const centerX = x + tileWidth / 2;
|
||||||
|
const dotAreaSize = tileHeight / 2 - 6;
|
||||||
|
|
||||||
|
drawDots(tile.left, centerX, topY, dotAreaSize);
|
||||||
|
drawDots(tile.right, centerX, bottomY, dotAreaSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw tile number for debugging (optional)
|
||||||
|
if (placedTiles.length < 20) {
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`#${index + 1}`, x + tileWidth / 2, y - 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
}, [placedTiles, offset, width, height]);
|
||||||
|
|
||||||
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
setOffset({
|
||||||
|
x: e.clientX - dragStart.x,
|
||||||
|
y: e.clientY - dragStart.y,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={`border border-gray-300 rounded-lg ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
/>
|
||||||
|
{placedTiles.length > 0 && (
|
||||||
|
<div className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Drag to pan • {placedTiles.length} tiles placed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get dot positions for a domino value (0-6)
|
||||||
|
function getDotPositions(value: number, size: number): { x: number; y: number }[] {
|
||||||
|
const margin = size * 0.2;
|
||||||
|
const positions: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
switch (value) {
|
||||||
|
case 0:
|
||||||
|
return [];
|
||||||
|
case 1:
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: 0, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
110
components/GameOver.tsx
Archivo normal
110
components/GameOver.tsx
Archivo normal
@@ -0,0 +1,110 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Player } from '@/lib/types';
|
||||||
|
|
||||||
|
interface GameOverProps {
|
||||||
|
winner: Player | null;
|
||||||
|
players: Player[];
|
||||||
|
onPlayAgain: () => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameOver({ winner, players, onPlayAgain, onLeave }: GameOverProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, y: 50 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
|
||||||
|
className="text-6xl mb-4"
|
||||||
|
>
|
||||||
|
{winner ? '🏆' : '🤝'}
|
||||||
|
</motion.div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
{winner ? 'Game Over!' : 'Game Blocked!'}
|
||||||
|
</h2>
|
||||||
|
{winner && (
|
||||||
|
<p className="text-xl text-gray-600">
|
||||||
|
<span className="font-semibold text-blue-600">{winner.name}</span> wins!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-700 mb-3">Final Scores</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{players
|
||||||
|
.sort((a, b) => {
|
||||||
|
const scoreA = a.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
|
||||||
|
const scoreB = b.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
|
||||||
|
return scoreA - scoreB;
|
||||||
|
})
|
||||||
|
.map((player, index) => {
|
||||||
|
const score = player.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
|
||||||
|
const isWinner = player.id === winner?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={player.id}
|
||||||
|
initial={{ x: -50, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-lg ${
|
||||||
|
isWinner ? 'bg-yellow-100 border-2 border-yellow-400' : 'bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isWinner && <span className="text-2xl">👑</span>}
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{player.tiles.length} tiles remaining
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`text-2xl font-bold ${
|
||||||
|
isWinner ? 'text-yellow-600' : 'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{score}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onPlayAgain}
|
||||||
|
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow"
|
||||||
|
>
|
||||||
|
Play Again
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onLeave}
|
||||||
|
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
Back to Menu
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
components/Lobby.tsx
Archivo normal
148
components/Lobby.tsx
Archivo normal
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface LobbyProps {
|
||||||
|
onCreateRoom: (playerName: string) => void;
|
||||||
|
onJoinRoom: (roomId: string, playerName: string) => void;
|
||||||
|
onStartAI: (playerName: string) => void;
|
||||||
|
roomId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Lobby({ onCreateRoom, onJoinRoom, onStartAI, roomId }: LobbyProps) {
|
||||||
|
const [playerName, setPlayerName] = useState('');
|
||||||
|
const [joinRoomId, setJoinRoomId] = useState('');
|
||||||
|
const [mode, setMode] = useState<'menu' | 'create' | 'join' | 'ai'>('menu');
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!playerName.trim()) return;
|
||||||
|
onCreateRoom(playerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = () => {
|
||||||
|
if (!playerName.trim() || !joinRoomId.trim()) return;
|
||||||
|
onJoinRoom(joinRoomId.toUpperCase(), playerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAI = () => {
|
||||||
|
if (!playerName.trim()) return;
|
||||||
|
onStartAI(playerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'menu') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
|
||||||
|
>
|
||||||
|
<motion.h1
|
||||||
|
initial={{ y: -20 }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
className="text-4xl font-bold text-center mb-2 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
Dominoes
|
||||||
|
</motion.h1>
|
||||||
|
<p className="text-center text-gray-600 mb-8">Online Multiplayer Game</p>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your name"
|
||||||
|
value={playerName}
|
||||||
|
onChange={(e) => setPlayerName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none transition-colors"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!playerName.trim()}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Create Room
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => setMode('join')}
|
||||||
|
disabled={!playerName.trim()}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Join Room
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={handleAI}
|
||||||
|
disabled={!playerName.trim()}
|
||||||
|
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Play vs AI
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center text-sm text-gray-500">
|
||||||
|
<p>Built with Next.js, Canvas & Socket.IO</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'join') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
|
||||||
|
>
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-6 text-gray-800">Join Room</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter Room ID"
|
||||||
|
value={joinRoomId}
|
||||||
|
onChange={(e) => setJoinRoomId(e.target.value.toUpperCase())}
|
||||||
|
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-purple-500 focus:outline-none transition-colors uppercase"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={!joinRoomId.trim()}
|
||||||
|
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Join Game
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => setMode('menu')}
|
||||||
|
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
187
components/PlayerHand.tsx
Archivo normal
187
components/PlayerHand.tsx
Archivo normal
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Player } from '@/lib/types';
|
||||||
|
|
||||||
|
interface PlayerHandProps {
|
||||||
|
player: Player;
|
||||||
|
isCurrentPlayer: boolean;
|
||||||
|
selectedTileId: string | null;
|
||||||
|
onTileClick: (tileId: string) => void;
|
||||||
|
validTileIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayerHand({
|
||||||
|
player,
|
||||||
|
isCurrentPlayer,
|
||||||
|
selectedTileId,
|
||||||
|
onTileClick,
|
||||||
|
validTileIds,
|
||||||
|
}: PlayerHandProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${
|
||||||
|
player.isAI ? 'bg-purple-500' : 'bg-blue-500'
|
||||||
|
}`}>
|
||||||
|
{player.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{player.tiles.length} tiles</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isCurrentPlayer && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
className="bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Your Turn
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!player.isAI && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<AnimatePresence>
|
||||||
|
{player.tiles.map((tile, index) => {
|
||||||
|
const isSelected = tile.id === selectedTileId;
|
||||||
|
const isPlayable = validTileIds.includes(tile.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={tile.id}
|
||||||
|
initial={{ scale: 0, rotate: -180 }}
|
||||||
|
animate={{ scale: 1, rotate: 0 }}
|
||||||
|
exit={{ scale: 0, rotate: 180 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
whileHover={isPlayable ? { y: -5 } : {}}
|
||||||
|
onClick={() => isPlayable && isCurrentPlayer && onTileClick(tile.id)}
|
||||||
|
className={`relative ${
|
||||||
|
isPlayable && isCurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<DominoTileSVG
|
||||||
|
tile={tile}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isPlayable={isPlayable && isCurrentPlayer}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DominoTileSVGProps {
|
||||||
|
tile: { left: number; right: number; id: string };
|
||||||
|
isSelected: boolean;
|
||||||
|
isPlayable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DominoTileSVG({ tile, isSelected, isPlayable }: DominoTileSVGProps) {
|
||||||
|
const width = 60;
|
||||||
|
const height = 30;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={`transition-all ${
|
||||||
|
isSelected ? 'ring-4 ring-blue-500 rounded' : ''
|
||||||
|
} ${!isPlayable ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
<rect
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
rx={4}
|
||||||
|
fill={isSelected ? '#3b82f6' : '#ffffff'}
|
||||||
|
stroke={isPlayable ? '#1f2937' : '#9ca3af'}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center divider */}
|
||||||
|
<line
|
||||||
|
x1={width / 2}
|
||||||
|
y1={0}
|
||||||
|
x2={width / 2}
|
||||||
|
y2={height}
|
||||||
|
stroke="#6b7280"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left dots */}
|
||||||
|
<g transform={`translate(${width / 4}, ${height / 2})`}>
|
||||||
|
{renderDots(tile.left, isSelected)}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Right dots */}
|
||||||
|
<g transform={`translate(${(width * 3) / 4}, ${height / 2})`}>
|
||||||
|
{renderDots(tile.right, isSelected)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDots(value: number, isSelected: boolean) {
|
||||||
|
const dotRadius = 2.5;
|
||||||
|
const margin = 6;
|
||||||
|
const fill = isSelected ? '#ffffff' : '#1f2937';
|
||||||
|
|
||||||
|
const positions = getDotPositions(value, margin);
|
||||||
|
|
||||||
|
return positions.map((pos, i) => (
|
||||||
|
<circle key={i} cx={pos.x} cy={pos.y} r={dotRadius} fill={fill} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotPositions(value: number, margin: number): { x: number; y: number }[] {
|
||||||
|
const positions: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
switch (value) {
|
||||||
|
case 0:
|
||||||
|
return [];
|
||||||
|
case 1:
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: 0 });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
case 6:
|
||||||
|
positions.push({ x: -margin, y: -margin });
|
||||||
|
positions.push({ x: 0, y: -margin });
|
||||||
|
positions.push({ x: margin, y: -margin });
|
||||||
|
positions.push({ x: -margin, y: margin });
|
||||||
|
positions.push({ x: 0, y: margin });
|
||||||
|
positions.push({ x: margin, y: margin });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
137
components/WaitingRoom.tsx
Archivo normal
137
components/WaitingRoom.tsx
Archivo normal
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Player } from '@/lib/types';
|
||||||
|
|
||||||
|
interface WaitingRoomProps {
|
||||||
|
roomId: string;
|
||||||
|
players: Player[];
|
||||||
|
currentPlayerId: string | null;
|
||||||
|
onReady: () => void;
|
||||||
|
onLeave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WaitingRoom({ roomId, players, currentPlayerId, onReady, onLeave }: WaitingRoomProps) {
|
||||||
|
const currentPlayer = players.find(p => p.id === currentPlayerId);
|
||||||
|
const isReady = currentPlayer?.isReady || false;
|
||||||
|
const canStart = players.length >= 2 && players.every(p => p.isReady);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-8 max-w-2xl w-full"
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-800 mb-2">Waiting Room</h2>
|
||||||
|
<div className="inline-block bg-gradient-to-r from-blue-500 to-purple-500 text-white px-6 py-2 rounded-full font-mono text-xl">
|
||||||
|
{roomId}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mt-2 text-sm">Share this code with your friends</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-700 mb-4">
|
||||||
|
Players ({players.length}/4)
|
||||||
|
</h3>
|
||||||
|
{players.length < 2 && (
|
||||||
|
<div className="mb-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm text-center">
|
||||||
|
⚠️ Minimum 2 players required to start
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{players.map((player) => (
|
||||||
|
<motion.div
|
||||||
|
key={player.id}
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className={`p-4 rounded-lg border-2 ${
|
||||||
|
player.isReady
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: 'border-gray-300 bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center font-bold text-white ${
|
||||||
|
player.isReady ? 'bg-green-500' : 'bg-gray-400'
|
||||||
|
}`}>
|
||||||
|
{player.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||||
|
{player.id === currentPlayerId && (
|
||||||
|
<div className="text-xs text-blue-600 font-medium">You</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{player.isReady && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="text-green-500 text-2xl"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty slots */}
|
||||||
|
{Array.from({ length: 4 - players.length }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="p-4 rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="text-gray-400 text-center">
|
||||||
|
<div className="text-3xl mb-1">+</div>
|
||||||
|
<div className="text-sm">Optional player...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{!isReady ? (
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onReady}
|
||||||
|
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow"
|
||||||
|
>
|
||||||
|
Ready to Play
|
||||||
|
</motion.button>
|
||||||
|
) : (
|
||||||
|
<div className="w-full bg-green-100 border-2 border-green-500 text-green-700 py-3 rounded-lg font-semibold text-center">
|
||||||
|
{canStart ? 'Starting game...' : players.length < 2 ? 'Waiting for at least 1 more player...' : 'Waiting for other players...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onLeave}
|
||||||
|
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
Leave Room
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="font-semibold text-blue-900 mb-2">Game Rules</h4>
|
||||||
|
<ul className="text-sm text-blue-800 space-y-1">
|
||||||
|
<li>• 2-4 players can play (minimum 2 required)</li>
|
||||||
|
<li>• Match numbers on tiles to place them on the board</li>
|
||||||
|
<li>• Draw from the boneyard if you can't play</li>
|
||||||
|
<li>• First player to use all tiles wins!</li>
|
||||||
|
<li>• Game ends when someone runs out or no one can move</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
lib/aiLogic.ts
Archivo normal
153
lib/aiLogic.ts
Archivo normal
@@ -0,0 +1,153 @@
|
|||||||
|
import { Player, GameState, GameMove, DominoTile, BoardEnd } from './types';
|
||||||
|
import { getValidMoves, canPlaceTile } from './gameLogic';
|
||||||
|
|
||||||
|
// AI difficulty levels
|
||||||
|
export type AIDifficulty = 'easy' | 'medium' | 'hard';
|
||||||
|
|
||||||
|
// Evaluate tile value for strategic play
|
||||||
|
function evaluateTileValue(tile: DominoTile, boardEnds: BoardEnd[]): number {
|
||||||
|
let value = 0;
|
||||||
|
|
||||||
|
// Prefer higher value tiles early in the game
|
||||||
|
value += tile.left + tile.right;
|
||||||
|
|
||||||
|
// Doubles are slightly more valuable
|
||||||
|
if (tile.isDouble) {
|
||||||
|
value += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiles that match both ends are very valuable
|
||||||
|
const matchesLeft = boardEnds.some(end => end.side === 'left' && canPlaceTile(tile, end.value));
|
||||||
|
const matchesRight = boardEnds.some(end => end.side === 'right' && canPlaceTile(tile, end.value));
|
||||||
|
|
||||||
|
if (matchesLeft && matchesRight) {
|
||||||
|
value += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count remaining tiles with specific values
|
||||||
|
function countRemainingTiles(value: number, allPlayerTiles: DominoTile[][]): number {
|
||||||
|
let count = 0;
|
||||||
|
allPlayerTiles.forEach(tiles => {
|
||||||
|
tiles.forEach(tile => {
|
||||||
|
if (tile.left === value || tile.right === value) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the best move for AI based on difficulty
|
||||||
|
export function chooseAIMove(
|
||||||
|
gameState: GameState,
|
||||||
|
aiPlayer: Player,
|
||||||
|
difficulty: AIDifficulty = 'medium'
|
||||||
|
): GameMove | null {
|
||||||
|
const validMoves = getValidMoves(aiPlayer, gameState.boardEnds);
|
||||||
|
|
||||||
|
if (validMoves.length === 0) {
|
||||||
|
// Try to draw from boneyard if possible
|
||||||
|
if (gameState.boneyard.length > 0) {
|
||||||
|
return null; // Will trigger draw action
|
||||||
|
}
|
||||||
|
// Pass if can't move and no tiles to draw
|
||||||
|
return {
|
||||||
|
playerId: aiPlayer.id,
|
||||||
|
tile: aiPlayer.tiles[0], // Dummy tile
|
||||||
|
side: 'left',
|
||||||
|
pass: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Easy: Random move
|
||||||
|
if (difficulty === 'easy') {
|
||||||
|
const randomMove = validMoves[Math.floor(Math.random() * validMoves.length)];
|
||||||
|
return {
|
||||||
|
playerId: aiPlayer.id,
|
||||||
|
tile: randomMove.tile,
|
||||||
|
side: randomMove.side,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium: Prefer higher value tiles
|
||||||
|
if (difficulty === 'medium') {
|
||||||
|
let bestMove = validMoves[0];
|
||||||
|
let bestValue = evaluateTileValue(bestMove.tile, gameState.boardEnds);
|
||||||
|
|
||||||
|
validMoves.forEach(move => {
|
||||||
|
const value = evaluateTileValue(move.tile, gameState.boardEnds);
|
||||||
|
if (value > bestValue) {
|
||||||
|
bestValue = value;
|
||||||
|
bestMove = move;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerId: aiPlayer.id,
|
||||||
|
tile: bestMove.tile,
|
||||||
|
side: bestMove.side,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard: Strategic play
|
||||||
|
// Consider opponent's possible tiles and blocking strategies
|
||||||
|
let bestMove = validMoves[0];
|
||||||
|
let bestScore = -Infinity;
|
||||||
|
|
||||||
|
validMoves.forEach(move => {
|
||||||
|
let score = evaluateTileValue(move.tile, gameState.boardEnds);
|
||||||
|
|
||||||
|
// Prefer moves that limit opponent's options
|
||||||
|
const resultingValue = move.tile.left === gameState.boardEnds.find(e => e.side === move.side)?.value
|
||||||
|
? move.tile.right
|
||||||
|
: move.tile.left;
|
||||||
|
|
||||||
|
// Check how common this value is among remaining tiles
|
||||||
|
const allTiles = gameState.players.map(p => p.tiles);
|
||||||
|
const commonality = countRemainingTiles(resultingValue, allTiles);
|
||||||
|
|
||||||
|
// Prefer less common values to block opponents
|
||||||
|
score -= commonality * 3;
|
||||||
|
|
||||||
|
// Try to get rid of high-value tiles first (defensive play)
|
||||||
|
const tileValue = move.tile.left + move.tile.right;
|
||||||
|
score += tileValue * 0.5;
|
||||||
|
|
||||||
|
// Prefer doubles near the end of the game
|
||||||
|
if (move.tile.isDouble && aiPlayer.tiles.length <= 3) {
|
||||||
|
score += 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestMove = move;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerId: aiPlayer.id,
|
||||||
|
tile: bestMove.tile,
|
||||||
|
side: bestMove.side,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate AI thinking delay
|
||||||
|
export async function aiThinkingDelay(difficulty: AIDifficulty): Promise<void> {
|
||||||
|
const delays = {
|
||||||
|
easy: 500,
|
||||||
|
medium: 1000,
|
||||||
|
hard: 1500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const delay = delays[difficulty] + Math.random() * 500;
|
||||||
|
return new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if AI should draw a tile
|
||||||
|
export function shouldAIDraw(gameState: GameState, aiPlayer: Player): boolean {
|
||||||
|
const validMoves = getValidMoves(aiPlayer, gameState.boardEnds);
|
||||||
|
return validMoves.length === 0 && gameState.boneyard.length > 0;
|
||||||
|
}
|
||||||
364
lib/gameLogic.ts
Archivo normal
364
lib/gameLogic.ts
Archivo normal
@@ -0,0 +1,364 @@
|
|||||||
|
import { DominoTile, GameState, Player, PlacedTile, BoardEnd, Position, GameMove } from './types';
|
||||||
|
|
||||||
|
// Generate a complete domino set (0-0 to 6-6)
|
||||||
|
export function generateDominoSet(): DominoTile[] {
|
||||||
|
const tiles: DominoTile[] = [];
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
for (let j = i; j <= 6; j++) {
|
||||||
|
tiles.push({
|
||||||
|
id: `${i}-${j}`,
|
||||||
|
left: i,
|
||||||
|
right: j,
|
||||||
|
isDouble: i === j,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle array using Fisher-Yates algorithm
|
||||||
|
export function shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal tiles to players
|
||||||
|
export function dealTiles(numPlayers: number): { playerTiles: DominoTile[][], boneyard: DominoTile[] } {
|
||||||
|
const allTiles = shuffleArray(generateDominoSet());
|
||||||
|
const tilesPerPlayer = 7;
|
||||||
|
const playerTiles: DominoTile[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numPlayers; i++) {
|
||||||
|
playerTiles.push(allTiles.splice(0, tilesPerPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerTiles,
|
||||||
|
boneyard: allTiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the player with the highest double
|
||||||
|
export function findStartingPlayer(players: Player[]): number {
|
||||||
|
let highestDouble = -1;
|
||||||
|
let startingPlayerIndex = 0;
|
||||||
|
|
||||||
|
players.forEach((player, index) => {
|
||||||
|
player.tiles.forEach(tile => {
|
||||||
|
if (tile.isDouble && tile.left > highestDouble) {
|
||||||
|
highestDouble = tile.left;
|
||||||
|
startingPlayerIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return startingPlayerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a tile can be placed at a specific board end
|
||||||
|
export function canPlaceTile(tile: DominoTile, endValue: number): boolean {
|
||||||
|
return tile.left === endValue || tile.right === endValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a player can make any move
|
||||||
|
export function canPlayerMove(player: Player, boardEnds: BoardEnd[]): boolean {
|
||||||
|
if (boardEnds.length === 0) return true;
|
||||||
|
|
||||||
|
return player.tiles.some(tile =>
|
||||||
|
boardEnds.some(end => canPlaceTile(tile, end.value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get valid moves for a player
|
||||||
|
export function getValidMoves(player: Player, boardEnds: BoardEnd[]): { tile: DominoTile; side: 'left' | 'right' }[] {
|
||||||
|
if (boardEnds.length === 0) {
|
||||||
|
return player.tiles.map(tile => ({ tile, side: 'left' as const }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const validMoves: { tile: DominoTile; side: 'left' | 'right' }[] = [];
|
||||||
|
const addedTiles = new Set<string>();
|
||||||
|
|
||||||
|
player.tiles.forEach(tile => {
|
||||||
|
boardEnds.forEach(end => {
|
||||||
|
if (canPlaceTile(tile, end.value)) {
|
||||||
|
const moveKey = `${tile.id}-${end.side}`;
|
||||||
|
if (!addedTiles.has(moveKey)) {
|
||||||
|
validMoves.push({ tile, side: end.side === 'left' ? 'left' : 'right' });
|
||||||
|
addedTiles.add(moveKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return validMoves;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get playable tiles for a player (returns just tile IDs)
|
||||||
|
export function getPlayableTiles(player: Player, boardEnds: BoardEnd[]): string[] {
|
||||||
|
if (boardEnds.length === 0) {
|
||||||
|
return player.tiles.map(t => t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playableTileIds = new Set<string>();
|
||||||
|
|
||||||
|
player.tiles.forEach(tile => {
|
||||||
|
boardEnds.forEach(end => {
|
||||||
|
if (canPlaceTile(tile, end.value)) {
|
||||||
|
playableTileIds.add(tile.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(playableTileIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a specific move is valid
|
||||||
|
export function isValidMove(tile: DominoTile, side: 'left' | 'right', boardEnds: BoardEnd[]): boolean {
|
||||||
|
if (boardEnds.length === 0) return true;
|
||||||
|
|
||||||
|
const targetEnd = boardEnds.find(end =>
|
||||||
|
(side === 'left' && end.side === 'left') ||
|
||||||
|
(side === 'right' && end.side === 'right')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetEnd) return false;
|
||||||
|
|
||||||
|
return canPlaceTile(tile, targetEnd.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate tile position on board
|
||||||
|
export function calculateTilePosition(
|
||||||
|
board: PlacedTile[],
|
||||||
|
side: 'left' | 'right',
|
||||||
|
tileWidth: number,
|
||||||
|
tileHeight: number,
|
||||||
|
isDouble: boolean
|
||||||
|
): { position: Position; orientation: 'horizontal' | 'vertical'; rotation: number } {
|
||||||
|
const spacing = 5;
|
||||||
|
|
||||||
|
if (board.length === 0) {
|
||||||
|
return {
|
||||||
|
position: { x: 400, y: 300 },
|
||||||
|
orientation: 'horizontal',
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTile = side === 'right' ? board[board.length - 1] : board[0];
|
||||||
|
let position: Position;
|
||||||
|
let orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||||
|
let rotation = 0;
|
||||||
|
|
||||||
|
if (side === 'right') {
|
||||||
|
const offset = lastTile.orientation === 'horizontal' ? tileWidth : tileHeight;
|
||||||
|
position = {
|
||||||
|
x: lastTile.position.x + offset + spacing,
|
||||||
|
y: lastTile.position.y,
|
||||||
|
};
|
||||||
|
orientation = isDouble ? 'vertical' : 'horizontal';
|
||||||
|
} else {
|
||||||
|
const offset = lastTile.orientation === 'horizontal' ? tileWidth : tileHeight;
|
||||||
|
position = {
|
||||||
|
x: lastTile.position.x - offset - spacing,
|
||||||
|
y: lastTile.position.y,
|
||||||
|
};
|
||||||
|
orientation = isDouble ? 'vertical' : 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { position, orientation, rotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update board ends after placing a tile
|
||||||
|
export function updateBoardEnds(
|
||||||
|
board: PlacedTile[],
|
||||||
|
newTile: PlacedTile,
|
||||||
|
side: 'left' | 'right',
|
||||||
|
matchedValue: number
|
||||||
|
): BoardEnd[] {
|
||||||
|
if (board.length === 0) {
|
||||||
|
return [
|
||||||
|
{ value: newTile.tile.left, position: newTile.position, side: 'left' },
|
||||||
|
{ value: newTile.tile.right, position: newTile.position, side: 'right' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEnds: BoardEnd[] = [];
|
||||||
|
|
||||||
|
if (side === 'left') {
|
||||||
|
const newValue = newTile.tile.left === matchedValue ? newTile.tile.right : newTile.tile.left;
|
||||||
|
newEnds.push({ value: newValue, position: newTile.position, side: 'left' });
|
||||||
|
|
||||||
|
// Keep the right end
|
||||||
|
const rightTile = board[board.length - 1];
|
||||||
|
const rightValue = board.length === 1
|
||||||
|
? (rightTile.tile.left === matchedValue ? rightTile.tile.right : rightTile.tile.left)
|
||||||
|
: board[board.length - 1].tile.right;
|
||||||
|
newEnds.push({ value: rightValue, position: rightTile.position, side: 'right' });
|
||||||
|
} else {
|
||||||
|
// Keep the left end
|
||||||
|
const leftTile = board[0];
|
||||||
|
const leftValue = board.length === 1
|
||||||
|
? (leftTile.tile.left === matchedValue ? leftTile.tile.right : leftTile.tile.left)
|
||||||
|
: board[0].tile.left;
|
||||||
|
newEnds.push({ value: leftValue, position: leftTile.position, side: 'left' });
|
||||||
|
|
||||||
|
const newValue = newTile.tile.left === matchedValue ? newTile.tile.right : newTile.tile.left;
|
||||||
|
newEnds.push({ value: newValue, position: newTile.position, side: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return newEnds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute a move
|
||||||
|
export function executeMove(
|
||||||
|
gameState: GameState,
|
||||||
|
move: GameMove,
|
||||||
|
tileWidth: number = 60,
|
||||||
|
tileHeight: number = 30
|
||||||
|
): GameState {
|
||||||
|
const player = gameState.players.find(p => p.id === move.playerId);
|
||||||
|
if (!player) return gameState;
|
||||||
|
|
||||||
|
if (move.pass) {
|
||||||
|
return {
|
||||||
|
...gameState,
|
||||||
|
currentPlayerIndex: (gameState.currentPlayerIndex + 1) % gameState.players.length,
|
||||||
|
turnsPassed: gameState.turnsPassed + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tileIndex = player.tiles.findIndex(t => t.id === move.tile.id);
|
||||||
|
if (tileIndex === -1) return gameState;
|
||||||
|
|
||||||
|
// Remove tile from player's hand
|
||||||
|
const newTiles = [...player.tiles];
|
||||||
|
newTiles.splice(tileIndex, 1);
|
||||||
|
|
||||||
|
// Determine if we need to flip the tile
|
||||||
|
let tileToPlace = { ...move.tile };
|
||||||
|
|
||||||
|
if (gameState.board.length > 0) {
|
||||||
|
const targetEnd = gameState.boardEnds.find(end => end.side === move.side);
|
||||||
|
const matchValue = targetEnd?.value || 0;
|
||||||
|
|
||||||
|
// When placing on the RIGHT side:
|
||||||
|
// - The LEFT value of the new tile should match the board end
|
||||||
|
// - If tile.right matches, we need to flip it
|
||||||
|
// When placing on the LEFT side:
|
||||||
|
// - The RIGHT value of the new tile should match the board end
|
||||||
|
// - If tile.left matches, we need to flip it
|
||||||
|
|
||||||
|
let needsFlip = false;
|
||||||
|
if (move.side === 'right') {
|
||||||
|
// On right side, left value of tile should match
|
||||||
|
if (move.tile.right === matchValue && move.tile.left !== matchValue) {
|
||||||
|
needsFlip = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// On left side, right value of tile should match
|
||||||
|
if (move.tile.left === matchValue && move.tile.right !== matchValue) {
|
||||||
|
needsFlip = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip the tile if needed
|
||||||
|
if (needsFlip) {
|
||||||
|
tileToPlace = {
|
||||||
|
...move.tile,
|
||||||
|
left: move.tile.right,
|
||||||
|
right: move.tile.left,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position and place tile
|
||||||
|
const { position, orientation, rotation } = calculateTilePosition(
|
||||||
|
gameState.board,
|
||||||
|
move.side,
|
||||||
|
tileWidth,
|
||||||
|
tileHeight,
|
||||||
|
tileToPlace.isDouble
|
||||||
|
);
|
||||||
|
|
||||||
|
const placedTile: PlacedTile = {
|
||||||
|
tile: tileToPlace,
|
||||||
|
position,
|
||||||
|
orientation,
|
||||||
|
rotation,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newBoard = move.side === 'right'
|
||||||
|
? [...gameState.board, placedTile]
|
||||||
|
: [placedTile, ...gameState.board];
|
||||||
|
|
||||||
|
// Update board ends - after placing, it's straightforward:
|
||||||
|
// Left end is the left value of the leftmost tile
|
||||||
|
// Right end is the right value of the rightmost tile
|
||||||
|
let newBoardEnds: BoardEnd[];
|
||||||
|
|
||||||
|
if (newBoard.length === 1) {
|
||||||
|
newBoardEnds = [
|
||||||
|
{ value: tileToPlace.left, position: placedTile.position, side: 'left' },
|
||||||
|
{ value: tileToPlace.right, position: placedTile.position, side: 'right' },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const leftTile = newBoard[0];
|
||||||
|
const rightTile = newBoard[newBoard.length - 1];
|
||||||
|
|
||||||
|
newBoardEnds = [
|
||||||
|
{ value: leftTile.tile.left, position: leftTile.position, side: 'left' },
|
||||||
|
{ value: rightTile.tile.right, position: rightTile.position, side: 'right' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPlayers = gameState.players.map(p =>
|
||||||
|
p.id === player.id ? { ...p, tiles: newTiles } : p
|
||||||
|
);
|
||||||
|
|
||||||
|
const isGameOver = newTiles.length === 0;
|
||||||
|
const winner = isGameOver ? player.id : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...gameState,
|
||||||
|
players: updatedPlayers,
|
||||||
|
board: newBoard,
|
||||||
|
boardEnds: newBoardEnds,
|
||||||
|
currentPlayerIndex: (gameState.currentPlayerIndex + 1) % gameState.players.length,
|
||||||
|
isGameOver,
|
||||||
|
winner,
|
||||||
|
turnsPassed: 0,
|
||||||
|
gameMode: isGameOver ? 'finished' : 'playing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate score for a player
|
||||||
|
export function calculateScore(tiles: DominoTile[]): number {
|
||||||
|
return tiles.reduce((sum, tile) => sum + tile.left + tile.right, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if game is blocked (no one can move)
|
||||||
|
export function isGameBlocked(gameState: GameState): boolean {
|
||||||
|
if (gameState.boneyard.length > 0) return false;
|
||||||
|
|
||||||
|
return gameState.players.every(player => !canPlayerMove(player, gameState.boardEnds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine winner when game is blocked
|
||||||
|
export function determineBlockedWinner(gameState: GameState): string {
|
||||||
|
let lowestScore = Infinity;
|
||||||
|
let winnerId = '';
|
||||||
|
|
||||||
|
gameState.players.forEach(player => {
|
||||||
|
const score = calculateScore(player.tiles);
|
||||||
|
if (score < lowestScore) {
|
||||||
|
lowestScore = score;
|
||||||
|
winnerId = player.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return winnerId;
|
||||||
|
}
|
||||||
238
lib/socket-server.ts
Archivo normal
238
lib/socket-server.ts
Archivo normal
@@ -0,0 +1,238 @@
|
|||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { GameState, Player, GameMove } from '@/lib/types';
|
||||||
|
import { dealTiles, findStartingPlayer, executeMove, canPlayerMove, isGameBlocked, determineBlockedWinner } from '@/lib/gameLogic';
|
||||||
|
|
||||||
|
const httpServer = createServer();
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store active game rooms
|
||||||
|
const gameRooms = new Map<string, GameState>();
|
||||||
|
const playerRooms = new Map<string, string>(); // playerId -> roomId
|
||||||
|
|
||||||
|
// Generate unique room ID
|
||||||
|
function generateRoomId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new game state
|
||||||
|
function createGameState(roomId: string): GameState {
|
||||||
|
return {
|
||||||
|
id: roomId,
|
||||||
|
players: [],
|
||||||
|
currentPlayerIndex: 0,
|
||||||
|
board: [],
|
||||||
|
boneyard: [],
|
||||||
|
boardEnds: [],
|
||||||
|
winner: null,
|
||||||
|
isGameOver: false,
|
||||||
|
turnsPassed: 0,
|
||||||
|
gameMode: 'waiting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize game when all players are ready
|
||||||
|
function startGame(roomId: string): GameState {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) throw new Error('Game not found');
|
||||||
|
|
||||||
|
const { playerTiles, boneyard } = dealTiles(gameState.players.length);
|
||||||
|
|
||||||
|
const updatedPlayers = gameState.players.map((player, index) => ({
|
||||||
|
...player,
|
||||||
|
tiles: playerTiles[index],
|
||||||
|
score: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startingPlayerIndex = findStartingPlayer(updatedPlayers);
|
||||||
|
|
||||||
|
const newGameState: GameState = {
|
||||||
|
...gameState,
|
||||||
|
players: updatedPlayers,
|
||||||
|
currentPlayerIndex: startingPlayerIndex,
|
||||||
|
boneyard,
|
||||||
|
gameMode: 'playing',
|
||||||
|
};
|
||||||
|
|
||||||
|
gameRooms.set(roomId, newGameState);
|
||||||
|
return newGameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('Client connected:', socket.id);
|
||||||
|
|
||||||
|
// Create room
|
||||||
|
socket.on('create-room', () => {
|
||||||
|
const roomId = generateRoomId();
|
||||||
|
const gameState = createGameState(roomId);
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.emit('room-created', roomId);
|
||||||
|
console.log('Room created:', roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Join room
|
||||||
|
socket.on('join-room', (roomId: string, playerName: string) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
|
||||||
|
if (!gameState) {
|
||||||
|
socket.emit('error', 'Room not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.players.length >= 4) {
|
||||||
|
socket.emit('error', 'Room is full');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.gameMode !== 'waiting') {
|
||||||
|
socket.emit('error', 'Game already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player: Player = {
|
||||||
|
id: socket.id,
|
||||||
|
name: playerName,
|
||||||
|
tiles: [],
|
||||||
|
score: 0,
|
||||||
|
isAI: false,
|
||||||
|
isReady: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
gameState.players.push(player);
|
||||||
|
playerRooms.set(socket.id, roomId);
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.emit('room-joined', gameState, socket.id);
|
||||||
|
socket.to(roomId).emit('player-joined', player);
|
||||||
|
|
||||||
|
console.log(`Player ${playerName} joined room ${roomId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player ready
|
||||||
|
socket.on('player-ready', (roomId: string) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const player = gameState.players.find(p => p.id === socket.id);
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
player.isReady = true;
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
|
||||||
|
// Start game if all players are ready and at least 2 players
|
||||||
|
const allReady = gameState.players.length >= 2 && gameState.players.every(p => p.isReady);
|
||||||
|
|
||||||
|
if (allReady) {
|
||||||
|
const startedGame = startGame(roomId);
|
||||||
|
io.to(roomId).emit('game-started', startedGame);
|
||||||
|
console.log('Game started in room:', roomId);
|
||||||
|
} else {
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make move
|
||||||
|
socket.on('make-move', (roomId: string, move: GameMove) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
|
||||||
|
if (currentPlayer.id !== socket.id) {
|
||||||
|
socket.emit('invalid-move', 'Not your turn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newGameState = executeMove(gameState, move);
|
||||||
|
|
||||||
|
// Check if game is blocked
|
||||||
|
if (isGameBlocked(newGameState)) {
|
||||||
|
const winnerId = determineBlockedWinner(newGameState);
|
||||||
|
newGameState.winner = winnerId;
|
||||||
|
newGameState.isGameOver = true;
|
||||||
|
newGameState.gameMode = 'finished';
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRooms.set(roomId, newGameState);
|
||||||
|
io.to(roomId).emit('game-state-updated', newGameState);
|
||||||
|
} catch (error) {
|
||||||
|
socket.emit('invalid-move', 'Invalid move');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw tile
|
||||||
|
socket.on('draw-tile', (roomId: string) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
|
||||||
|
if (currentPlayer.id !== socket.id) {
|
||||||
|
socket.emit('invalid-move', 'Not your turn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.boneyard.length === 0) {
|
||||||
|
socket.emit('invalid-move', 'No tiles left to draw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawnTile = gameState.boneyard.pop()!;
|
||||||
|
currentPlayer.tiles.push(drawnTile);
|
||||||
|
|
||||||
|
// Check if player can move now
|
||||||
|
if (!canPlayerMove(currentPlayer, gameState.boardEnds)) {
|
||||||
|
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave room
|
||||||
|
socket.on('leave-room', (roomId: string) => {
|
||||||
|
handlePlayerLeave(socket.id, roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disconnect
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const roomId = playerRooms.get(socket.id);
|
||||||
|
if (roomId) {
|
||||||
|
handlePlayerLeave(socket.id, roomId);
|
||||||
|
}
|
||||||
|
console.log('Client disconnected:', socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handlePlayerLeave(playerId: string, roomId: string) {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
gameState.players = gameState.players.filter(p => p.id !== playerId);
|
||||||
|
playerRooms.delete(playerId);
|
||||||
|
|
||||||
|
if (gameState.players.length === 0) {
|
||||||
|
gameRooms.delete(roomId);
|
||||||
|
console.log('Room deleted:', roomId);
|
||||||
|
} else {
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('player-left', playerId);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.SOCKET_PORT || 3001;
|
||||||
|
|
||||||
|
httpServer.listen(PORT, () => {
|
||||||
|
console.log(`Socket.IO server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { io };
|
||||||
357
lib/store.ts
Archivo normal
357
lib/store.ts
Archivo normal
@@ -0,0 +1,357 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { GameState, Player, GameMove, DominoTile } from '@/lib/types';
|
||||||
|
import { chooseAIMove, aiThinkingDelay, shouldAIDraw } from '@/lib/aiLogic';
|
||||||
|
import { dealTiles, findStartingPlayer, executeMove, canPlayerMove } from '@/lib/gameLogic';
|
||||||
|
|
||||||
|
interface GameStore {
|
||||||
|
socket: Socket | null;
|
||||||
|
gameState: GameState | null;
|
||||||
|
currentPlayerId: string | null;
|
||||||
|
roomId: string | null;
|
||||||
|
error: string | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
selectedTile: DominoTile | null;
|
||||||
|
pendingPlayerName: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initSocket: () => void;
|
||||||
|
createRoom: (playerName: string) => void;
|
||||||
|
joinRoom: (roomId: string, playerName: string) => void;
|
||||||
|
setPlayerReady: () => void;
|
||||||
|
makeMove: (move: GameMove) => void;
|
||||||
|
drawTile: () => void;
|
||||||
|
selectTile: (tile: DominoTile | null) => void;
|
||||||
|
leaveRoom: () => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
|
||||||
|
// AI actions
|
||||||
|
startAIGame: (playerName: string) => void;
|
||||||
|
executeAITurn: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGameStore = create<GameStore>((set, get) => ({
|
||||||
|
socket: null,
|
||||||
|
gameState: null,
|
||||||
|
currentPlayerId: null,
|
||||||
|
roomId: null,
|
||||||
|
error: null,
|
||||||
|
isConnected: false,
|
||||||
|
selectedTile: null,
|
||||||
|
pendingPlayerName: null,
|
||||||
|
|
||||||
|
initSocket: () => {
|
||||||
|
const socket = io('http://localhost:3000', {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
console.log('Connected to socket server');
|
||||||
|
set({ isConnected: true, socket });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Disconnected from socket server');
|
||||||
|
set({ isConnected: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room-created', (roomId: string) => {
|
||||||
|
const { pendingPlayerName, socket } = get();
|
||||||
|
set({ roomId, error: null });
|
||||||
|
|
||||||
|
// Automatically join the room we just created
|
||||||
|
if (pendingPlayerName && socket) {
|
||||||
|
socket.emit('join-room', roomId, pendingPlayerName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room-joined', (gameState: GameState, playerId: string) => {
|
||||||
|
set({ gameState, currentPlayerId: playerId, error: null, pendingPlayerName: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('game-state-updated', (gameState: GameState) => {
|
||||||
|
set({ gameState });
|
||||||
|
|
||||||
|
// Check if it's AI's turn
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
if (currentPlayer?.isAI && gameState.gameMode === 'playing' && !gameState.isGameOver) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().executeAITurn();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('game-started', (gameState: GameState) => {
|
||||||
|
set({ gameState });
|
||||||
|
|
||||||
|
// Check if AI starts
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
if (currentPlayer?.isAI) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().executeAITurn();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('player-joined', (player: Player) => {
|
||||||
|
const { gameState } = get();
|
||||||
|
if (gameState) {
|
||||||
|
set({
|
||||||
|
gameState: {
|
||||||
|
...gameState,
|
||||||
|
players: [...gameState.players, player],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('player-left', (playerId: string) => {
|
||||||
|
const { currentPlayerId } = get();
|
||||||
|
|
||||||
|
// If we are the player who left (kicked or left from another tab), clear our state
|
||||||
|
if (playerId === currentPlayerId) {
|
||||||
|
set({
|
||||||
|
gameState: null,
|
||||||
|
roomId: null,
|
||||||
|
currentPlayerId: null,
|
||||||
|
selectedTile: null,
|
||||||
|
pendingPlayerName: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Note: Don't update gameState here - the server will send game-state-updated
|
||||||
|
// with the updated state including potential winner if only 1 player remains
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('invalid-move', (message: string) => {
|
||||||
|
set({ error: message });
|
||||||
|
setTimeout(() => set({ error: null }), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (message: string) => {
|
||||||
|
set({ error: message });
|
||||||
|
setTimeout(() => set({ error: null }), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
set({ socket });
|
||||||
|
},
|
||||||
|
|
||||||
|
createRoom: (playerName: string) => {
|
||||||
|
const { socket } = get();
|
||||||
|
if (socket) {
|
||||||
|
set({ pendingPlayerName: playerName });
|
||||||
|
socket.emit('create-room');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
joinRoom: (roomId: string, playerName: string) => {
|
||||||
|
const { socket } = get();
|
||||||
|
if (socket) {
|
||||||
|
set({ roomId });
|
||||||
|
socket.emit('join-room', roomId, playerName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlayerReady: () => {
|
||||||
|
const { socket, roomId } = get();
|
||||||
|
if (socket && roomId) {
|
||||||
|
socket.emit('player-ready', roomId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
makeMove: (move: GameMove) => {
|
||||||
|
const { socket, roomId, gameState } = get();
|
||||||
|
|
||||||
|
// Modo AI (offline)
|
||||||
|
if (roomId?.startsWith('AI-') && gameState) {
|
||||||
|
console.log('AI mode: executing move', move);
|
||||||
|
const newGameState = executeMove(gameState, move);
|
||||||
|
set({ gameState: newGameState, selectedTile: null });
|
||||||
|
|
||||||
|
// Si es turno de la IA, ejecutar su movimiento
|
||||||
|
const currentPlayer = newGameState.players[newGameState.currentPlayerIndex];
|
||||||
|
if (currentPlayer?.isAI && !newGameState.isGameOver) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().executeAITurn();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo multijugador (online)
|
||||||
|
if (socket && roomId) {
|
||||||
|
console.log('Online mode: sending move to server', move);
|
||||||
|
socket.emit('make-move', roomId, move);
|
||||||
|
set({ selectedTile: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
drawTile: () => {
|
||||||
|
const { socket, roomId, gameState, currentPlayerId } = get();
|
||||||
|
|
||||||
|
// AI mode - execute locally
|
||||||
|
if (roomId?.startsWith('AI-')) {
|
||||||
|
if (!gameState || !currentPlayerId) return;
|
||||||
|
|
||||||
|
const player = gameState.players.find(p => p.id === currentPlayerId);
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
// Check if there are tiles in the boneyard
|
||||||
|
if (gameState.boneyard.length === 0) {
|
||||||
|
console.log('No tiles in boneyard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw a tile from the boneyard
|
||||||
|
const drawnTile = gameState.boneyard[0];
|
||||||
|
const newBoneyard = gameState.boneyard.slice(1);
|
||||||
|
|
||||||
|
// Add to player's hand
|
||||||
|
const updatedPlayers = gameState.players.map(p =>
|
||||||
|
p.id === currentPlayerId
|
||||||
|
? { ...p, tiles: [...p.tiles, drawnTile] }
|
||||||
|
: p
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update game state
|
||||||
|
set({
|
||||||
|
gameState: {
|
||||||
|
...gameState,
|
||||||
|
boneyard: newBoneyard,
|
||||||
|
players: updatedPlayers,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Drew tile in AI mode:', drawnTile);
|
||||||
|
}
|
||||||
|
// Online mode - send to server
|
||||||
|
else if (socket && roomId) {
|
||||||
|
socket.emit('draw-tile', roomId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectTile: (tile: DominoTile | null) => {
|
||||||
|
set({ selectedTile: tile });
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveRoom: () => {
|
||||||
|
const { socket, roomId } = get();
|
||||||
|
if (socket && roomId) {
|
||||||
|
socket.emit('leave-room', roomId);
|
||||||
|
}
|
||||||
|
// Clear state immediately on client side
|
||||||
|
set({
|
||||||
|
gameState: null,
|
||||||
|
roomId: null,
|
||||||
|
currentPlayerId: null,
|
||||||
|
selectedTile: null,
|
||||||
|
pendingPlayerName: null,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error: string | null) => {
|
||||||
|
set({ error });
|
||||||
|
if (error) {
|
||||||
|
// Auto-clear error after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
set({ error: null });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start AI game (offline mode)
|
||||||
|
startAIGame: (playerName: string) => {
|
||||||
|
const roomId = 'AI-' + Math.random().toString(36).substring(2, 8);
|
||||||
|
|
||||||
|
const humanPlayer: Player = {
|
||||||
|
id: 'human',
|
||||||
|
name: playerName,
|
||||||
|
tiles: [],
|
||||||
|
score: 0,
|
||||||
|
isAI: false,
|
||||||
|
isReady: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const aiPlayer: Player = {
|
||||||
|
id: 'ai',
|
||||||
|
name: 'AI Opponent',
|
||||||
|
tiles: [],
|
||||||
|
score: 0,
|
||||||
|
isAI: true,
|
||||||
|
isReady: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { playerTiles, boneyard } = dealTiles(2);
|
||||||
|
|
||||||
|
humanPlayer.tiles = playerTiles[0];
|
||||||
|
aiPlayer.tiles = playerTiles[1];
|
||||||
|
|
||||||
|
const players = [humanPlayer, aiPlayer];
|
||||||
|
const startingPlayerIndex = findStartingPlayer(players);
|
||||||
|
|
||||||
|
const gameState: GameState = {
|
||||||
|
id: roomId,
|
||||||
|
players,
|
||||||
|
currentPlayerIndex: startingPlayerIndex,
|
||||||
|
board: [],
|
||||||
|
boneyard,
|
||||||
|
boardEnds: [],
|
||||||
|
winner: null,
|
||||||
|
isGameOver: false,
|
||||||
|
turnsPassed: 0,
|
||||||
|
gameMode: 'playing',
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ gameState, currentPlayerId: 'human', roomId });
|
||||||
|
|
||||||
|
// If AI starts, make first move
|
||||||
|
if (startingPlayerIndex === 1) {
|
||||||
|
setTimeout(() => {
|
||||||
|
get().executeAITurn();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Execute AI turn
|
||||||
|
executeAITurn: async () => {
|
||||||
|
const { gameState } = get();
|
||||||
|
if (!gameState || gameState.isGameOver) return;
|
||||||
|
|
||||||
|
const aiPlayer = gameState.players.find(p => p.isAI);
|
||||||
|
if (!aiPlayer) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
if (currentPlayer.id !== aiPlayer.id) return;
|
||||||
|
|
||||||
|
await aiThinkingDelay('medium');
|
||||||
|
|
||||||
|
// Check if AI should draw
|
||||||
|
if (shouldAIDraw(gameState, aiPlayer)) {
|
||||||
|
if (gameState.boneyard.length > 0) {
|
||||||
|
const drawnTile = gameState.boneyard.pop()!;
|
||||||
|
aiPlayer.tiles.push(drawnTile);
|
||||||
|
|
||||||
|
if (!canPlayerMove(aiPlayer, gameState.boardEnds)) {
|
||||||
|
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
|
||||||
|
set({ gameState: { ...gameState } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pass turn
|
||||||
|
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
|
||||||
|
gameState.turnsPassed++;
|
||||||
|
set({ gameState: { ...gameState } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = chooseAIMove(gameState, aiPlayer, 'medium');
|
||||||
|
if (move) {
|
||||||
|
const newGameState = executeMove(gameState, move);
|
||||||
|
set({ gameState: newGameState });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
77
lib/types.ts
Archivo normal
77
lib/types.ts
Archivo normal
@@ -0,0 +1,77 @@
|
|||||||
|
// Domino game types and interfaces
|
||||||
|
|
||||||
|
export type DominoTile = {
|
||||||
|
id: string;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
isDouble: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Orientation = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
|
export type PlacedTile = {
|
||||||
|
tile: DominoTile;
|
||||||
|
position: Position;
|
||||||
|
orientation: Orientation;
|
||||||
|
rotation: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Player = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tiles: DominoTile[];
|
||||||
|
score: number;
|
||||||
|
isAI: boolean;
|
||||||
|
isReady: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BoardEnd = {
|
||||||
|
value: number;
|
||||||
|
position: Position;
|
||||||
|
side: 'left' | 'right' | 'top' | 'bottom';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameState = {
|
||||||
|
id: string;
|
||||||
|
players: Player[];
|
||||||
|
currentPlayerIndex: number;
|
||||||
|
board: PlacedTile[];
|
||||||
|
boneyard: DominoTile[];
|
||||||
|
boardEnds: BoardEnd[];
|
||||||
|
winner: string | null;
|
||||||
|
isGameOver: boolean;
|
||||||
|
turnsPassed: number;
|
||||||
|
gameMode: 'waiting' | 'playing' | 'finished';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GameMove = {
|
||||||
|
playerId: string;
|
||||||
|
tile: DominoTile;
|
||||||
|
side: 'left' | 'right';
|
||||||
|
pass?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SocketEvents = {
|
||||||
|
// Client to Server
|
||||||
|
'create-room': () => void;
|
||||||
|
'join-room': (roomId: string, playerName: string) => void;
|
||||||
|
'player-ready': (roomId: string) => void;
|
||||||
|
'make-move': (roomId: string, move: GameMove) => void;
|
||||||
|
'draw-tile': (roomId: string) => void;
|
||||||
|
'leave-room': (roomId: string) => void;
|
||||||
|
|
||||||
|
// Server to Client
|
||||||
|
'room-created': (roomId: string) => void;
|
||||||
|
'room-joined': (gameState: GameState, playerId: string) => void;
|
||||||
|
'game-state-updated': (gameState: GameState) => void;
|
||||||
|
'game-started': (gameState: GameState) => void;
|
||||||
|
'invalid-move': (message: string) => void;
|
||||||
|
'player-joined': (player: Player) => void;
|
||||||
|
'player-left': (playerId: string) => void;
|
||||||
|
'error': (message: string) => void;
|
||||||
|
};
|
||||||
6
next.config.js
Archivo normal
6
next.config.js
Archivo normal
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
turbopack: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6577
package-lock.json
generado
6577
package-lock.json
generado
La diferencia del archivo ha sido suprimido porque es demasiado grande
Cargar Diff
19
package.json
19
package.json
@@ -2,25 +2,30 @@
|
|||||||
"name": "domino",
|
"name": "domino",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "node server.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "NODE_ENV=production node server.mjs",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
|
"next": "16.0.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"next": "16.0.1"
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.1"
|
"eslint-config-next": "16.0.1",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
494
server.mjs
Archivo normal
494
server.mjs
Archivo normal
@@ -0,0 +1,494 @@
|
|||||||
|
import { createServer } from 'http';
|
||||||
|
import { parse } from 'url';
|
||||||
|
import next from 'next';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
|
||||||
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
|
const hostname = 'localhost';
|
||||||
|
const port = 3000;
|
||||||
|
|
||||||
|
const app = next({ dev, hostname, port });
|
||||||
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
|
// Store active game rooms
|
||||||
|
const gameRooms = new Map();
|
||||||
|
const playerRooms = new Map();
|
||||||
|
|
||||||
|
// Generate unique room ID
|
||||||
|
function generateRoomId() {
|
||||||
|
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new game state
|
||||||
|
function createGameState(roomId) {
|
||||||
|
return {
|
||||||
|
id: roomId,
|
||||||
|
players: [],
|
||||||
|
currentPlayerIndex: 0,
|
||||||
|
board: [],
|
||||||
|
boneyard: [],
|
||||||
|
boardEnds: [],
|
||||||
|
winner: null,
|
||||||
|
isGameOver: false,
|
||||||
|
turnsPassed: 0,
|
||||||
|
gameMode: 'waiting',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate domino set
|
||||||
|
function generateDominoSet() {
|
||||||
|
const tiles = [];
|
||||||
|
for (let i = 0; i <= 6; i++) {
|
||||||
|
for (let j = i; j <= 6; j++) {
|
||||||
|
tiles.push({
|
||||||
|
id: `${i}-${j}`,
|
||||||
|
left: i,
|
||||||
|
right: j,
|
||||||
|
isDouble: i === j,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle array
|
||||||
|
function shuffleArray(array) {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal tiles
|
||||||
|
function dealTiles(numPlayers) {
|
||||||
|
const allTiles = shuffleArray(generateDominoSet());
|
||||||
|
const tilesPerPlayer = 7;
|
||||||
|
const playerTiles = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numPlayers; i++) {
|
||||||
|
playerTiles.push(allTiles.splice(0, tilesPerPlayer));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerTiles,
|
||||||
|
boneyard: allTiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find starting player
|
||||||
|
function findStartingPlayer(players) {
|
||||||
|
let highestDouble = -1;
|
||||||
|
let startingPlayerIndex = 0;
|
||||||
|
|
||||||
|
players.forEach((player, index) => {
|
||||||
|
player.tiles.forEach(tile => {
|
||||||
|
if (tile.isDouble && tile.left > highestDouble) {
|
||||||
|
highestDouble = tile.left;
|
||||||
|
startingPlayerIndex = index;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return startingPlayerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start game
|
||||||
|
function startGame(roomId) {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) throw new Error('Game not found');
|
||||||
|
|
||||||
|
const { playerTiles, boneyard } = dealTiles(gameState.players.length);
|
||||||
|
|
||||||
|
const updatedPlayers = gameState.players.map((player, index) => ({
|
||||||
|
...player,
|
||||||
|
tiles: playerTiles[index],
|
||||||
|
score: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const startingPlayerIndex = findStartingPlayer(updatedPlayers);
|
||||||
|
|
||||||
|
const newGameState = {
|
||||||
|
...gameState,
|
||||||
|
players: updatedPlayers,
|
||||||
|
currentPlayerIndex: startingPlayerIndex,
|
||||||
|
boneyard,
|
||||||
|
gameMode: 'playing',
|
||||||
|
};
|
||||||
|
|
||||||
|
gameRooms.set(roomId, newGameState);
|
||||||
|
return newGameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.prepare().then(() => {
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = parse(req.url, true);
|
||||||
|
await handle(req, res, parsedUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error occurred handling', req.url, err);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('internal server error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const io = new Server(server, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
console.log('Client connected:', socket.id);
|
||||||
|
|
||||||
|
socket.on('create-room', () => {
|
||||||
|
const roomId = generateRoomId();
|
||||||
|
const gameState = createGameState(roomId);
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.emit('room-created', roomId);
|
||||||
|
console.log('Room created:', roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('join-room', (roomId, playerName) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
|
||||||
|
if (!gameState) {
|
||||||
|
socket.emit('error', 'Room not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.players.length >= 4) {
|
||||||
|
socket.emit('error', 'Room is full');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.gameMode !== 'waiting') {
|
||||||
|
socket.emit('error', 'Game already started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = {
|
||||||
|
id: socket.id,
|
||||||
|
name: playerName,
|
||||||
|
tiles: [],
|
||||||
|
score: 0,
|
||||||
|
isAI: false,
|
||||||
|
isReady: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
gameState.players.push(player);
|
||||||
|
playerRooms.set(socket.id, roomId);
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
socket.emit('room-joined', gameState, socket.id);
|
||||||
|
socket.to(roomId).emit('player-joined', player);
|
||||||
|
|
||||||
|
console.log(`Player ${playerName} joined room ${roomId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('player-ready', (roomId) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const player = gameState.players.find(p => p.id === socket.id);
|
||||||
|
if (!player) return;
|
||||||
|
|
||||||
|
player.isReady = true;
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
|
||||||
|
const allReady = gameState.players.length >= 2 && gameState.players.every(p => p.isReady);
|
||||||
|
|
||||||
|
if (allReady) {
|
||||||
|
const startedGame = startGame(roomId);
|
||||||
|
io.to(roomId).emit('game-started', startedGame);
|
||||||
|
console.log('Game started in room:', roomId);
|
||||||
|
} else {
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('make-move', (roomId, move) => {
|
||||||
|
console.log('Received make-move:', { roomId, move, socketId: socket.id });
|
||||||
|
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) {
|
||||||
|
console.log('Game state not found for room:', roomId);
|
||||||
|
socket.emit('error', 'Game not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
|
||||||
|
if (currentPlayer.id !== socket.id) {
|
||||||
|
console.log('Not player turn:', { currentPlayerId: currentPlayer.id, socketId: socket.id });
|
||||||
|
socket.emit('invalid-move', 'Not your turn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = gameState.players.find(p => p.id === move.playerId);
|
||||||
|
if (!player) {
|
||||||
|
console.log('Player not found:', move.playerId);
|
||||||
|
socket.emit('error', 'Player not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (move.pass) {
|
||||||
|
console.log('Player passing turn');
|
||||||
|
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
|
||||||
|
gameState.turnsPassed++;
|
||||||
|
} else {
|
||||||
|
const tileIndex = player.tiles.findIndex(t => t.id === move.tile.id);
|
||||||
|
if (tileIndex === -1) {
|
||||||
|
console.log('Tile not found in player hand:', move.tile.id);
|
||||||
|
socket.emit('invalid-move', 'Tile not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar el movimiento
|
||||||
|
if (gameState.boardEnds.length > 0) {
|
||||||
|
const targetEnd = gameState.boardEnds.find(end =>
|
||||||
|
(move.side === 'left' && end.side === 'left') ||
|
||||||
|
(move.side === 'right' && end.side === 'right')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetEnd) {
|
||||||
|
console.log('Invalid side:', move.side);
|
||||||
|
socket.emit('invalid-move', 'Invalid side');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPlace = move.tile.left === targetEnd.value || move.tile.right === targetEnd.value;
|
||||||
|
if (!canPlace) {
|
||||||
|
console.log('Tile does not match:', { tile: move.tile, targetEnd: targetEnd.value });
|
||||||
|
socket.emit('invalid-move', 'Tile does not match board end');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Placing tile:', { tile: move.tile, side: move.side });
|
||||||
|
|
||||||
|
// Remover la ficha de la mano del jugador
|
||||||
|
player.tiles.splice(tileIndex, 1);
|
||||||
|
|
||||||
|
// Calcular posición según el lado del tablero
|
||||||
|
let position;
|
||||||
|
let tileToPlace = { ...move.tile };
|
||||||
|
|
||||||
|
if (gameState.board.length === 0) {
|
||||||
|
position = { x: 400, y: 300 };
|
||||||
|
} else {
|
||||||
|
// Get the current board end value
|
||||||
|
const targetEnd = gameState.boardEnds.find(end => end.side === move.side);
|
||||||
|
const matchValue = targetEnd?.value || 0;
|
||||||
|
|
||||||
|
// Determine if we need to flip the tile
|
||||||
|
// When placing on the RIGHT side:
|
||||||
|
// - The LEFT value of the new tile should match the board end
|
||||||
|
// - If tile.right matches, we need to flip it
|
||||||
|
// When placing on the LEFT side:
|
||||||
|
// - The RIGHT value of the new tile should match the board end
|
||||||
|
// - If tile.left matches, we need to flip it
|
||||||
|
|
||||||
|
let needsFlip = false;
|
||||||
|
if (move.side === 'right') {
|
||||||
|
// On right side, left value of tile should match
|
||||||
|
if (move.tile.right === matchValue && move.tile.left !== matchValue) {
|
||||||
|
needsFlip = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// On left side, right value of tile should match
|
||||||
|
if (move.tile.left === matchValue && move.tile.right !== matchValue) {
|
||||||
|
needsFlip = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip the tile if needed
|
||||||
|
if (needsFlip) {
|
||||||
|
tileToPlace = {
|
||||||
|
...move.tile,
|
||||||
|
left: move.tile.right,
|
||||||
|
right: move.tile.left,
|
||||||
|
};
|
||||||
|
console.log('Flipping tile from', move.tile, 'to', tileToPlace);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (move.side === 'right') {
|
||||||
|
const lastTile = gameState.board[gameState.board.length - 1];
|
||||||
|
position = { x: lastTile.position.x + 65, y: lastTile.position.y };
|
||||||
|
} else {
|
||||||
|
const firstTile = gameState.board[0];
|
||||||
|
position = { x: firstTile.position.x - 65, y: firstTile.position.y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placedTile = {
|
||||||
|
tile: tileToPlace,
|
||||||
|
position,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agregar al tablero
|
||||||
|
if (move.side === 'right') {
|
||||||
|
gameState.board.push(placedTile);
|
||||||
|
} else {
|
||||||
|
gameState.board.unshift(placedTile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar los extremos del tablero
|
||||||
|
if (gameState.board.length === 1) {
|
||||||
|
gameState.boardEnds = [
|
||||||
|
{ value: tileToPlace.left, position: placedTile.position, side: 'left' },
|
||||||
|
{ value: tileToPlace.right, position: placedTile.position, side: 'right' },
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// After placing, the new ends are straightforward:
|
||||||
|
// Left end is the left value of the leftmost tile
|
||||||
|
// Right end is the right value of the rightmost tile
|
||||||
|
const leftTile = gameState.board[0];
|
||||||
|
const rightTile = gameState.board[gameState.board.length - 1];
|
||||||
|
|
||||||
|
gameState.boardEnds = [
|
||||||
|
{ value: leftTile.tile.left, position: leftTile.position, side: 'left' },
|
||||||
|
{ value: rightTile.tile.right, position: rightTile.position, side: 'right' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
gameState.currentPlayerIndex = (gameState.currentPlayerIndex + 1) % gameState.players.length;
|
||||||
|
gameState.turnsPassed = 0;
|
||||||
|
|
||||||
|
if (player.tiles.length === 0) {
|
||||||
|
gameState.winner = player.id;
|
||||||
|
gameState.isGameOver = true;
|
||||||
|
gameState.gameMode = 'finished';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Updating game state and emitting to room:', roomId);
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
console.log('Game state updated successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('draw-tile', (roomId) => {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
|
||||||
|
|
||||||
|
if (currentPlayer.id !== socket.id) {
|
||||||
|
socket.emit('invalid-move', 'Not your turn');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.boneyard.length === 0) {
|
||||||
|
socket.emit('invalid-move', 'No tiles left to draw');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawnTile = gameState.boneyard.pop();
|
||||||
|
currentPlayer.tiles.push(drawnTile);
|
||||||
|
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('leave-room', (roomId) => {
|
||||||
|
console.log('Player leaving room:', { socketId: socket.id, roomId });
|
||||||
|
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
// Filter out AI players to count only human players
|
||||||
|
const humanPlayers = gameState.players.filter(p => !p.isAI);
|
||||||
|
const leavingPlayer = gameState.players.find(p => p.id === socket.id);
|
||||||
|
|
||||||
|
if (!leavingPlayer) return;
|
||||||
|
|
||||||
|
// Remove player from game
|
||||||
|
gameState.players = gameState.players.filter(p => p.id !== socket.id);
|
||||||
|
playerRooms.delete(socket.id);
|
||||||
|
socket.leave(roomId);
|
||||||
|
|
||||||
|
if (gameState.players.length === 0) {
|
||||||
|
// No players left, delete room
|
||||||
|
gameRooms.delete(roomId);
|
||||||
|
console.log('Room deleted - no players remaining:', roomId);
|
||||||
|
} else {
|
||||||
|
// Check if only one human player remains
|
||||||
|
const remainingHumanPlayers = gameState.players.filter(p => !p.isAI);
|
||||||
|
|
||||||
|
if (gameState.gameMode === 'playing' && remainingHumanPlayers.length === 1 && humanPlayers.length > 1) {
|
||||||
|
// Only one human player left, they win by default
|
||||||
|
const winner = remainingHumanPlayers[0];
|
||||||
|
gameState.isGameOver = true;
|
||||||
|
gameState.winner = winner.id;
|
||||||
|
gameState.gameMode = 'finished';
|
||||||
|
console.log(`Player ${winner.name} wins - only player remaining in room ${roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust currentPlayerIndex if needed
|
||||||
|
if (gameState.currentPlayerIndex >= gameState.players.length) {
|
||||||
|
gameState.currentPlayerIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('player-left', socket.id);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
console.log(`Player ${leavingPlayer.name} left room ${roomId}. ${gameState.players.length} players remaining.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
const roomId = playerRooms.get(socket.id);
|
||||||
|
if (roomId) {
|
||||||
|
const gameState = gameRooms.get(roomId);
|
||||||
|
if (gameState) {
|
||||||
|
// Filter out AI players to count only human players
|
||||||
|
const humanPlayers = gameState.players.filter(p => !p.isAI);
|
||||||
|
const disconnectedPlayer = gameState.players.find(p => p.id === socket.id);
|
||||||
|
|
||||||
|
gameState.players = gameState.players.filter(p => p.id !== socket.id);
|
||||||
|
playerRooms.delete(socket.id);
|
||||||
|
|
||||||
|
if (gameState.players.length === 0) {
|
||||||
|
gameRooms.delete(roomId);
|
||||||
|
console.log('Room deleted:', roomId);
|
||||||
|
} else {
|
||||||
|
// Check if only one human player remains after disconnect
|
||||||
|
const remainingHumanPlayers = gameState.players.filter(p => !p.isAI);
|
||||||
|
|
||||||
|
if (gameState.gameMode === 'playing' && remainingHumanPlayers.length === 1 && humanPlayers.length > 1) {
|
||||||
|
// Only one human player left, they win by default
|
||||||
|
const winner = remainingHumanPlayers[0];
|
||||||
|
gameState.isGameOver = true;
|
||||||
|
gameState.winner = winner.id;
|
||||||
|
gameState.gameMode = 'finished';
|
||||||
|
console.log(`Player ${winner.name} wins - only player remaining in room ${roomId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust currentPlayerIndex if needed
|
||||||
|
if (gameState.currentPlayerIndex >= gameState.players.length) {
|
||||||
|
gameState.currentPlayerIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
gameRooms.set(roomId, gameState);
|
||||||
|
io.to(roomId).emit('player-left', socket.id);
|
||||||
|
io.to(roomId).emit('game-state-updated', gameState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Client disconnected:', socket.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, (err) => {
|
||||||
|
if (err) throw err;
|
||||||
|
console.log(`> Ready on http://${hostname}:${port}`);
|
||||||
|
console.log('> Socket.IO server running');
|
||||||
|
});
|
||||||
|
});
|
||||||
Referencia en una nueva incidencia
Block a user