modificada la logica de juego e interfaz para acomodarse a su objetivo real. llevado a un punto de al menos 3 jugadores simultaneos
This commit is contained in:
81
CHANGELOG.md
81
CHANGELOG.md
@@ -8,11 +8,88 @@ y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.h
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Planeado
|
### Planeado
|
||||||
- Sistema de logros
|
- Ronda 2-5: Implementar reglas evolutivas
|
||||||
|
- Sistema de Judge rotativo
|
||||||
|
- Shame tokens y penalizaciones
|
||||||
|
- UI de administración completa
|
||||||
- Efectos de sonido
|
- Efectos de sonido
|
||||||
- PWA support
|
- PWA support
|
||||||
- Multi-idioma
|
- Multi-idioma
|
||||||
- Sistema de autenticación
|
|
||||||
|
## [0.0.5-alpha] - 2025-01-04
|
||||||
|
|
||||||
|
### Añadido
|
||||||
|
- **🎮 Juego Snatch or Share completo**
|
||||||
|
- Implementación del "Snatch Game" de Elinor Ostrom
|
||||||
|
- Sistema de roles únicos: Productor de Pavos, Café, Maíz
|
||||||
|
- Exactamente 3 jugadores por sala
|
||||||
|
- Tokens múltiples (turkey, coffee, corn)
|
||||||
|
- Sistema de puntuación: tokens propios = 1pt, ajenos = 2pts
|
||||||
|
|
||||||
|
- **🔄 Sistema de ofertas comerciales**
|
||||||
|
- Ofertas simultáneas entre jugadores
|
||||||
|
- Límite de 2 ofertas por target por jugador
|
||||||
|
- Respuestas: Accept, Reject, Snatch
|
||||||
|
- Cumplimiento parcial automático
|
||||||
|
- Todas las ofertas son públicas
|
||||||
|
|
||||||
|
- **🎨 UI/UX completamente rediseñada**
|
||||||
|
- Layout responsivo optimizado (desktop/móvil)
|
||||||
|
- Componentes modulares: PlayerCard, TradeOfferCard, MakeOfferForm
|
||||||
|
- Modal flotante para crear ofertas
|
||||||
|
- Scroll customizado para lista de ofertas
|
||||||
|
- Botones +/- prominentes para cantidades
|
||||||
|
- Input compacto optimizado para números de 3 dígitos
|
||||||
|
|
||||||
|
- **📱 Mejoras móviles**
|
||||||
|
- Layout vertical adaptativo
|
||||||
|
- Cards de ofertas horizontales y compactas
|
||||||
|
- Botones táctiles optimizados
|
||||||
|
- Altura diferencial desktop vs móvil
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **🏗️ Arquitectura del servidor**
|
||||||
|
- GameState con múltiples tipos de tokens
|
||||||
|
- Player con rol de productor y tokens individuales
|
||||||
|
- TradeOffer con inventarios de offering/requesting
|
||||||
|
- Asignación automática de roles únicos
|
||||||
|
|
||||||
|
- **⚙️ Sistema de tipos**
|
||||||
|
- Regeneración automática desde servidor
|
||||||
|
- TokenInventory schema separado
|
||||||
|
- Interfaces para ofertas comerciales
|
||||||
|
- GameRoomOptions actualizado
|
||||||
|
|
||||||
|
- **🎯 Lógica del juego**
|
||||||
|
- Ronda 1: Estado de naturaleza implementado
|
||||||
|
- Ofertas más recientes aparecen arriba
|
||||||
|
- Validación de límites por jugador
|
||||||
|
- Cálculo automático de puntos en tiempo real
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **🐛 Problemas de layout**
|
||||||
|
- Overflow vertical en desktop eliminado
|
||||||
|
- Altura móvil permite scroll natural
|
||||||
|
- Posicionamiento de elementos mejorado
|
||||||
|
|
||||||
|
- **⚡ Rendimiento**
|
||||||
|
- Componentización reduce bundle size
|
||||||
|
- CSS encapsulado por componente
|
||||||
|
- Reactivity optimizada con computed properties
|
||||||
|
|
||||||
|
- **📚 Documentación completa**
|
||||||
|
- README.md principal actualizado con enfoque educativo
|
||||||
|
- README.md específico del servidor con API y schemas
|
||||||
|
- README.md específico del cliente con componentes
|
||||||
|
- gameRules.md con lógica detallada del juego
|
||||||
|
- Roadmap actualizado con progreso real
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- **📊 Schemas Colyseus**: GameState, Player, TradeOffer, TokenInventory
|
||||||
|
- **🧩 Componentes Vue**: 6 componentes modulares especializados
|
||||||
|
- **📋 Validación**: Límites de tokens, ofertas y jugadores
|
||||||
|
- **🔄 Estado**: Sincronización en tiempo real mejorada
|
||||||
|
- **🛠️ Build**: Generación automática de tipos client/server
|
||||||
|
|
||||||
## [0.0.1-alpha] - 2025-01-03
|
## [0.0.1-alpha] - 2025-01-03
|
||||||
|
|
||||||
|
|||||||
172
README.md
172
README.md
@@ -1,31 +1,54 @@
|
|||||||
# 🎮 SnatchGame
|
# 🎮 SnatchGame
|
||||||
|
|
||||||
[](https://github.com/username/snatchgame)
|
[](https://github.com/username/snatchgame)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
[](https://vuejs.org/)
|
[](https://vuejs.org/)
|
||||||
[](https://colyseus.io/)
|
[](https://colyseus.io/)
|
||||||
|
|
||||||
Un juego multijugador en tiempo real de velocidad de clicks, construido con **Colyseus.io** y **Vue 3** para redes locales.
|
Un juego multijugador educativo que simula la evolución de instituciones y cooperación, basado en el **"Snatch Game"** de **Elinor Ostrom**. Construido con **Colyseus.io** y **Vue 3** para redes locales.
|
||||||
|
|
||||||
> ⚠️ **Proyecto en desarrollo** - Actualmente en fase Alpha (v0.0.1-alpha)
|
> ⚠️ **Proyecto en desarrollo** - Actualmente en fase Alpha (v0.0.5-alpha)
|
||||||
|
|
||||||
## 🚀 Características
|
## 🎓 Sobre el Juego
|
||||||
|
|
||||||
- **🌐 Multijugador en tiempo real** - Hasta 8 jugadores simultáneos
|
**Snatch or Share** es una simulación interactiva que permite a los participantes experimentar cómo las reglas, la confianza y los arreglos institucionales afectan la cooperación en intercambios descentralizados. Basado en el trabajo de la Nobel de Economía **Elinor Ostrom** sobre gobernanza de recursos comunes.
|
||||||
- **⚡ Sincronización instantánea** - Estado compartido con Colyseus.io
|
|
||||||
- **🔥 Juego de velocidad** - Compite presionando el botón más rápido
|
|
||||||
- **📱 Responsive** - Funciona en desktop y móvil
|
|
||||||
- **🛠️ Sistema de debugging** - Logs configurables para desarrollo
|
|
||||||
- **🎯 Red local** - Sin dependencias de internet
|
|
||||||
- **📊 UI de administración** - Panel para monitorear partidas
|
|
||||||
|
|
||||||
## 🎯 Cómo Jugar
|
### 🎯 Objetivos Educativos
|
||||||
|
- Demostrar la evolución de instituciones en el intercambio
|
||||||
|
- Experimentar con diferentes sistemas de gobernanza
|
||||||
|
- Entender el rol de la confianza en la cooperación
|
||||||
|
- Aplicar conceptos de teoría de juegos en tiempo real
|
||||||
|
|
||||||
1. **Únete a una partida** - Presiona "Unirse a partida"
|
## 🚀 Características del Juego
|
||||||
2. **Espera jugadores** - Mínimo 2 jugadores para comenzar
|
|
||||||
3. **¡Click Battle!** - Presiona el botón gigante lo más rápido posible
|
- **👥 Multijugador exacto** - Salas de exactamente 3 jugadores
|
||||||
4. **Compite** - Ve el scoreboard en tiempo real
|
- **🎭 Roles únicos** - Productor de Pavos, Café o Maíz
|
||||||
|
- **⚡ Tiempo real** - Sincronización instantánea con Colyseus.io
|
||||||
|
- **🔄 Sistema de intercambio** - Ofertas, negociaciones y "snatch"
|
||||||
|
- **📊 Cálculo automático** - Puntuación basada en tokens
|
||||||
|
- **📱 Responsive** - Interfaz optimizada para desktop y móvil
|
||||||
|
- **🎯 Red local** - Funciona completamente offline
|
||||||
|
- **📈 Progresión por rondas** - 5 rondas con reglas evolutivas
|
||||||
|
|
||||||
|
## 🎮 Cómo Jugar
|
||||||
|
|
||||||
|
### Preparación
|
||||||
|
1. **Únete a una sala** - Exactamente 3 jugadores requeridos
|
||||||
|
2. **Rol asignado** - Recibes un rol de productor único al azar
|
||||||
|
3. **Tokens iniciales** - Comienzas con 5 tokens de tu tipo
|
||||||
|
|
||||||
|
### Mecánicas de Juego
|
||||||
|
1. **Haz ofertas** - Click en otros jugadores para ofertar
|
||||||
|
2. **Responde ofertas** - Acepta, rechaza o haz "snatch"
|
||||||
|
3. **Acumula puntos** - Tokens propios = 1pt, ajenos = 2pts
|
||||||
|
4. **Estrategia** - Coopera o compite según las reglas de la ronda
|
||||||
|
|
||||||
|
### Progresión (5 Rondas)
|
||||||
|
- **Ronda 1-2**: Estado de naturaleza (sin reglas)
|
||||||
|
- **Ronda 3**: Reglas contraproductivas
|
||||||
|
- **Ronda 4**: Normas sociales (shame tokens)
|
||||||
|
- **Ronda 5**: Gobernanza institucional (juez rotativo)
|
||||||
|
|
||||||
## 🛠️ Stack Tecnológico
|
## 🛠️ Stack Tecnológico
|
||||||
|
|
||||||
@@ -59,17 +82,18 @@ cd snatchgame
|
|||||||
|
|
||||||
### 2. Instalar dependencias
|
### 2. Instalar dependencias
|
||||||
```bash
|
```bash
|
||||||
|
# Instalar todas las dependencias automáticamente
|
||||||
|
npm run install:all
|
||||||
|
|
||||||
|
# O manualmente:
|
||||||
# Servidor
|
# Servidor
|
||||||
cd server
|
cd server && npm install
|
||||||
npm install
|
|
||||||
|
|
||||||
# Cliente
|
# Cliente
|
||||||
cd ../client
|
cd ../client && npm install
|
||||||
npm install
|
|
||||||
|
|
||||||
# Admin (opcional)
|
# Admin (próximamente)
|
||||||
cd ../admin
|
cd ../admin && npm install
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Ejecución
|
## 🚀 Ejecución
|
||||||
@@ -99,8 +123,8 @@ npm run dev
|
|||||||
|
|
||||||
### URLs de desarrollo
|
### URLs de desarrollo
|
||||||
- **Cliente**: http://localhost:3000
|
- **Cliente**: http://localhost:3000
|
||||||
- **Servidor**: http://localhost:2567
|
- **Servidor**: http://localhost:2567
|
||||||
- **Admin**: http://localhost:3001
|
- **Admin**: http://localhost:3001 (próximamente)
|
||||||
|
|
||||||
### Producción
|
### Producción
|
||||||
```bash
|
```bash
|
||||||
@@ -111,16 +135,25 @@ npm run build
|
|||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🎮 Demo
|
## 🎮 Interfaz del Juego
|
||||||
|
|
||||||
### Pantalla Principal
|
### Vista Desktop
|
||||||

|
- **Layout de 2 columnas**: Jugadores a la izquierda, ofertas a la derecha
|
||||||
|
- **Jugador actual prominente**: Tarjeta grande en la parte inferior
|
||||||
|
- **Otros jugadores compactos**: Tarjetas pequeñas y clickeables
|
||||||
|
- **Panel de ofertas**: Scroll customizado con ofertas en tiempo real
|
||||||
|
|
||||||
### Esperando Jugadores
|
### Vista Móvil
|
||||||

|
- **Layout vertical**: Jugadores arriba, ofertas abajo
|
||||||
|
- **Formulario modal**: Ofertas en modal flotante
|
||||||
|
- **Botones optimizados**: +/- táctiles para cantidades
|
||||||
|
- **Scroll adaptativo**: Altura limitada con navegación suave
|
||||||
|
|
||||||
### Jugando
|
### Componentes Principales
|
||||||

|
- **PlayerCard**: Información de jugador con tokens y puntos
|
||||||
|
- **TradeOfferCard**: Ofertas con acciones (Accept/Reject/Snatch)
|
||||||
|
- **MakeOfferForm**: Formulario con botones +/- intuitivos
|
||||||
|
- **OfferModal**: Modal flotante para crear ofertas dirigidas
|
||||||
|
|
||||||
## ⚙️ Configuración
|
## ⚙️ Configuración
|
||||||
|
|
||||||
@@ -187,24 +220,38 @@ npm run test:e2e
|
|||||||
|
|
||||||
```
|
```
|
||||||
snatchgame/
|
snatchgame/
|
||||||
├── 📁 server/ # Colyseus.io backend
|
├── 📁 server/ # Colyseus.io backend
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── rooms/ # Game rooms
|
│ │ ├── rooms/
|
||||||
│ │ ├── schema/ # Data schemas
|
│ │ │ └── GameRoom.ts # Lógica principal del juego
|
||||||
│ │ └── index.ts # Entry point
|
│ │ ├── app.config.ts # Configuración Colyseus
|
||||||
│ └── package.json
|
│ │ └── index.ts # Entry point
|
||||||
├── 📁 client/ # Vue 3 frontend
|
│ └── README.md # Documentación del servidor
|
||||||
|
├── 📁 client/ # Vue 3 frontend
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # Vue components
|
│ │ ├── components/ # Componentes Vue
|
||||||
│ │ ├── services/ # Game client & logger
|
│ │ │ ├── Game.vue # Componente principal
|
||||||
│ │ ├── types/ # Auto-generated types
|
│ │ │ ├── PlayerCard.vue
|
||||||
|
│ │ │ ├── TradeOfferCard.vue
|
||||||
|
│ │ │ ├── MakeOfferForm.vue
|
||||||
|
│ │ │ ├── ScrollableOffers.vue
|
||||||
|
│ │ │ └── OfferModal.vue
|
||||||
|
│ │ ├── services/ # Game client & logger
|
||||||
|
│ │ │ ├── gameClient.ts
|
||||||
|
│ │ │ └── logger.ts
|
||||||
|
│ │ ├── types/ # Auto-generated types
|
||||||
|
│ │ │ ├── GameState.ts
|
||||||
|
│ │ │ ├── Player.ts
|
||||||
|
│ │ │ ├── TradeOffer.ts
|
||||||
|
│ │ │ └── TokenInventory.ts
|
||||||
│ │ └── main.ts
|
│ │ └── main.ts
|
||||||
│ └── package.json
|
│ ├── server.js # Express server (producción)
|
||||||
├── 📁 admin/ # Admin dashboard
|
│ └── README.md # Documentación del cliente
|
||||||
├── 📁 docs/ # Documentation
|
├── 📁 admin/ # Admin dashboard (próximamente)
|
||||||
├── 🐳 docker-compose.yml
|
├── 🎮 gameRules.md # Reglas del juego detalladas
|
||||||
├── 📋 CLAUDE.md # Development guide
|
├── 🐳 docker-compose.yml # Orquestación Docker
|
||||||
└── 📖 README.md # This file
|
├── 📋 CLAUDE.md # Guía de desarrollo
|
||||||
|
└── 📖 README.md # Este archivo
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Contribuir
|
## 🤝 Contribuir
|
||||||
@@ -268,13 +315,30 @@ npm run start
|
|||||||
|
|
||||||
## 📋 Roadmap
|
## 📋 Roadmap
|
||||||
|
|
||||||
- [ ] 🎨 Themes y customización
|
### Funcionalidades del Juego
|
||||||
- [ ] 🏆 Sistema de logros
|
- [x] 🎮 Ronda 1: Estado de naturaleza (completado)
|
||||||
- [ ] 📊 Estadísticas detalladas
|
- [ ] 🎭 Ronda 2-5: Implementar reglas evolutivas
|
||||||
- [ ] 🔊 Efectos de sonido
|
- [ ] 👨⚖️ Sistema de Judge rotativo
|
||||||
|
- [ ] 😔 Shame tokens y penalizaciones
|
||||||
|
- [ ] 📊 Estadísticas por ronda
|
||||||
|
- [ ] 🏆 Sistema de puntuación final
|
||||||
|
|
||||||
|
### Mejoras de UI/UX
|
||||||
|
- [x] 📱 Layout responsivo optimizado
|
||||||
|
- [x] 🎯 Formulario con botones +/-
|
||||||
|
- [x] 🔄 Scroll customizado para ofertas
|
||||||
|
- [ ] 🎨 Themes y customización visual
|
||||||
|
- [ ] 🔊 Efectos de sonido y feedback
|
||||||
|
- [ ] ⚡ Animaciones de transición
|
||||||
|
- [ ] 📊 Gráficos y visualizaciones
|
||||||
|
|
||||||
|
### Infraestructura
|
||||||
|
- [ ] 📈 UI de administración completa
|
||||||
|
- [ ] 🐳 Docker en producción
|
||||||
- [ ] 📱 PWA support
|
- [ ] 📱 PWA support
|
||||||
- [ ] 🌍 Multi-idioma
|
- [ ] 🌍 Multi-idioma (EN/ES)
|
||||||
- [ ] 🔒 Sistema de autenticación
|
- [ ] 🔒 Sistema de salas privadas
|
||||||
|
- [ ] 📄 Exportar resultados (PDF/CSV)
|
||||||
|
|
||||||
## 📄 Licencia
|
## 📄 Licencia
|
||||||
|
|
||||||
|
|||||||
242
client/README.md
Normal file
242
client/README.md
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
# 🎮 Snatch or Share - Cliente
|
||||||
|
|
||||||
|
Cliente web para el juego multijugador Snatch or Share, basado en el trabajo de Elinor Ostrom sobre instituciones y cooperación.
|
||||||
|
|
||||||
|
## 🛠️ Stack Tecnológico
|
||||||
|
|
||||||
|
- **Vue 3** (Composition API, vanilla sin build tools)
|
||||||
|
- **TypeScript** (tipado estricto)
|
||||||
|
- **Vite** (development server)
|
||||||
|
- **Colyseus.js** (cliente WebSocket)
|
||||||
|
- **Express** (servidor estático en producción)
|
||||||
|
|
||||||
|
## 🚀 Inicio Rápido
|
||||||
|
|
||||||
|
### Prerrequisitos
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 9+
|
||||||
|
- Servidor Snatch or Share ejecutándose (puerto 2567)
|
||||||
|
|
||||||
|
### Instalación y Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Generar tipos TypeScript desde el servidor
|
||||||
|
npm run generate-types
|
||||||
|
|
||||||
|
# Iniciar servidor de desarrollo (puerto 3000)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comandos Disponibles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desarrollo
|
||||||
|
npm run dev # Servidor de desarrollo con hot reload
|
||||||
|
npm run generate-types # Generar tipos desde servidor Colyseus
|
||||||
|
|
||||||
|
# Producción
|
||||||
|
npm run build # Compilar proyecto para producción
|
||||||
|
npm run preview # Vista previa del build
|
||||||
|
npm run start # Servidor Express en producción
|
||||||
|
|
||||||
|
# Utilidades
|
||||||
|
npm run serve # Servidor Express con nodemon
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura del Cliente
|
||||||
|
|
||||||
|
### Estructura de Directorios
|
||||||
|
|
||||||
|
```
|
||||||
|
client/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # Componentes Vue
|
||||||
|
│ │ ├── Game.vue # Componente principal del juego
|
||||||
|
│ │ ├── PlayerCard.vue # Tarjeta de jugador
|
||||||
|
│ │ ├── TradeOfferCard.vue # Tarjeta de oferta comercial
|
||||||
|
│ │ ├── MakeOfferForm.vue # Formulario para ofertas
|
||||||
|
│ │ ├── ScrollableOffers.vue # Contenedor de ofertas con scroll
|
||||||
|
│ │ └── OfferModal.vue # Modal para hacer ofertas
|
||||||
|
│ ├── services/ # Servicios y lógica de negocio
|
||||||
|
│ │ ├── gameClient.ts # Cliente Colyseus
|
||||||
|
│ │ └── logger.ts # Sistema de logging
|
||||||
|
│ ├── types/ # Tipos TypeScript
|
||||||
|
│ │ ├── GameState.ts # (Auto-generado)
|
||||||
|
│ │ ├── Player.ts # (Auto-generado)
|
||||||
|
│ │ ├── TradeOffer.ts # (Auto-generado)
|
||||||
|
│ │ ├── TokenInventory.ts # (Auto-generado)
|
||||||
|
│ │ └── index.ts # Tipos auxiliares
|
||||||
|
│ ├── App.vue # Componente raíz
|
||||||
|
│ └── main.ts # Punto de entrada
|
||||||
|
├── index.html # Template HTML
|
||||||
|
├── server.js # Servidor Express (producción)
|
||||||
|
└── vite.config.ts # Configuración Vite
|
||||||
|
```
|
||||||
|
|
||||||
|
### Componentes Principales
|
||||||
|
|
||||||
|
#### Game.vue
|
||||||
|
Componente principal que maneja:
|
||||||
|
- Layout responsivo (desktop/móvil)
|
||||||
|
- Estado del juego en tiempo real
|
||||||
|
- Coordinación entre componentes hijos
|
||||||
|
|
||||||
|
#### PlayerCard.vue
|
||||||
|
Tarjeta de jugador con:
|
||||||
|
- Modo compacto para otros jugadores
|
||||||
|
- Modo expandido para jugador actual
|
||||||
|
- Click para hacer ofertas
|
||||||
|
|
||||||
|
#### TradeOfferCard.vue
|
||||||
|
Muestra ofertas comerciales:
|
||||||
|
- Información de tokens ofrecidos/solicitados
|
||||||
|
- Botones de acción (Aceptar/Rechazar/Snatch)
|
||||||
|
- Estados visuales según tipo de oferta
|
||||||
|
|
||||||
|
#### MakeOfferForm.vue
|
||||||
|
Formulario optimizado con:
|
||||||
|
- Botones +/- prominentes
|
||||||
|
- Input compacto para números
|
||||||
|
- Validación en tiempo real
|
||||||
|
|
||||||
|
## 🔄 Generación de Tipos
|
||||||
|
|
||||||
|
El cliente utiliza tipos auto-generados desde el servidor Colyseus:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generar tipos automáticamente
|
||||||
|
npm run generate-types
|
||||||
|
|
||||||
|
# Comando manual equivalente
|
||||||
|
cd ../server && npx schema-codegen src/rooms/GameRoom.ts --ts --output ../client/src/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tipos Auto-generados:**
|
||||||
|
- `GameState.ts` - Estado principal del juego
|
||||||
|
- `Player.ts` - Información del jugador
|
||||||
|
- `TradeOffer.ts` - Ofertas comerciales
|
||||||
|
- `TokenInventory.ts` - Inventario de tokens
|
||||||
|
|
||||||
|
**Tipos Manuales:**
|
||||||
|
- `GameRoomOptions` - Opciones de sala
|
||||||
|
- Interfaces auxiliares en `index.ts`
|
||||||
|
|
||||||
|
## 🎯 Funcionalidades del Cliente
|
||||||
|
|
||||||
|
### Layout Responsivo
|
||||||
|
|
||||||
|
**Desktop:**
|
||||||
|
- Layout de 2 columnas (jugadores | ofertas)
|
||||||
|
- Jugador actual prominente abajo
|
||||||
|
- Panel de ofertas con scroll customizado
|
||||||
|
|
||||||
|
**Móvil:**
|
||||||
|
- Layout vertical adaptativo
|
||||||
|
- Cards de jugadores compactas
|
||||||
|
- Ofertas optimizadas horizontalmente
|
||||||
|
|
||||||
|
### Interacciones
|
||||||
|
|
||||||
|
- **Click en PlayerCard** → Abre modal de oferta
|
||||||
|
- **Botones +/-** → Incrementar/decrementar tokens
|
||||||
|
- **Formulario modal** → Crear ofertas dirigidas
|
||||||
|
- **Scroll customizado** → Navegación suave de ofertas
|
||||||
|
|
||||||
|
### Estado en Tiempo Real
|
||||||
|
|
||||||
|
- Conexión WebSocket con Colyseus
|
||||||
|
- Sincronización automática de estado
|
||||||
|
- Reactivity de Vue 3 con estado del servidor
|
||||||
|
|
||||||
|
## 🔧 Configuración
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Desarrollo (.env.development)
|
||||||
|
VITE_SERVER_URL=ws://localhost:2567
|
||||||
|
|
||||||
|
# Producción (.env.production)
|
||||||
|
VITE_SERVER_URL=ws://tu-servidor-produccion:2567
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración del Servidor
|
||||||
|
|
||||||
|
El cliente incluye un servidor Express para producción:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server.js
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Servir archivos estáticos
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Debugging
|
||||||
|
|
||||||
|
### Logger del Cliente
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { logger } from '@/services/logger'
|
||||||
|
|
||||||
|
// Logs automáticos del estado del juego
|
||||||
|
logger.gameStateChange(state)
|
||||||
|
logger.gameComponentUpdate(updates)
|
||||||
|
logger.clickSent()
|
||||||
|
```
|
||||||
|
|
||||||
|
### DevTools
|
||||||
|
|
||||||
|
- Vue DevTools para componentes
|
||||||
|
- Network tab para conexiones WebSocket
|
||||||
|
- Console para logs del gameClient
|
||||||
|
|
||||||
|
## 📱 Compatibilidad
|
||||||
|
|
||||||
|
- **Navegadores**: Chrome 90+, Firefox 88+, Safari 14+
|
||||||
|
- **Dispositivos**: Desktop, tablet, móvil
|
||||||
|
- **Resoluciones**: 320px - 1920px+
|
||||||
|
|
||||||
|
## 🎮 Uso del Cliente
|
||||||
|
|
||||||
|
1. **Espera**: Pantalla de espera hasta 3 jugadores
|
||||||
|
2. **Asignación**: Roles de productor asignados aleatoriamente
|
||||||
|
3. **Trading**: Interfaz de intercambio con ofertas
|
||||||
|
4. **Ofertas**: Click en jugadores para hacer ofertas
|
||||||
|
5. **Respuestas**: Aceptar, rechazar o hacer "snatch"
|
||||||
|
|
||||||
|
## 🚀 Despliegue
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# Cliente disponible en http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
# Servidor Express sirviendo build estático
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (desde raíz del proyecto)
|
||||||
|
```bash
|
||||||
|
docker-compose up client
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contribución
|
||||||
|
|
||||||
|
Ver [CLAUDE.md](../CLAUDE.md) para guías de desarrollo y convenciones del proyecto.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "snatchgame-client",
|
"name": "snatchgame-client",
|
||||||
"version": "0.0.1-alpha",
|
"version": "0.0.5-alpha",
|
||||||
"description": "SnatchGame client UI server",
|
"description": "SnatchGame client UI server",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -11,37 +11,63 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Playing Phase -->
|
<!-- Trading Phase -->
|
||||||
<div v-else-if="gamePhase === 'playing'" class="game-screen">
|
<div v-else-if="gamePhase === 'trading'" class="game-screen">
|
||||||
<!-- Scoreboard -->
|
<!-- Game Header -->
|
||||||
<div class="scoreboard">
|
<div class="game-header">
|
||||||
<div
|
<div class="round-info">
|
||||||
v-for="player in players"
|
<h2>Ronda {{ round }}</h2>
|
||||||
:key="player.id"
|
<span class="phase">Fase de Intercambio</span>
|
||||||
class="player-score"
|
|
||||||
:class="{ 'current-player': player.id === currentPlayerId }"
|
|
||||||
>
|
|
||||||
<span class="player-name">{{ player.name }}</span>
|
|
||||||
<span class="score">{{ player.score }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Click Button -->
|
<!-- Main Game Layout -->
|
||||||
<div class="click-area">
|
<div class="game-layout">
|
||||||
<button
|
<!-- Left side: Players -->
|
||||||
@click="handleClick"
|
<div class="players-section">
|
||||||
class="click-button"
|
<!-- Other Players (compact) -->
|
||||||
:class="{ 'clicked': isClicked }"
|
<div class="other-players">
|
||||||
>
|
<PlayerCard
|
||||||
<span class="click-text">¡CLICK!</span>
|
v-for="player in otherPlayers"
|
||||||
<div class="click-effect" v-if="showEffect"></div>
|
:key="player.id"
|
||||||
</button>
|
:player="player"
|
||||||
|
:is-current-player="false"
|
||||||
|
:compact="true"
|
||||||
|
@click="openOfferModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Player (large) -->
|
||||||
|
<div class="current-player-section">
|
||||||
|
<PlayerCard
|
||||||
|
v-if="currentPlayer"
|
||||||
|
:player="currentPlayer"
|
||||||
|
:is-current-player="true"
|
||||||
|
:compact="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side: Offers (Desktop) / Bottom: Offers (Mobile) -->
|
||||||
|
<div class="offers-section">
|
||||||
|
<ScrollableOffers
|
||||||
|
:offers="activeOffers"
|
||||||
|
:current-player-id="currentPlayerId"
|
||||||
|
:get-player-name="getPlayerName"
|
||||||
|
@cancel="cancelOffer"
|
||||||
|
@respond="respondToOffer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Player Info -->
|
<!-- Offer Modal -->
|
||||||
<div class="player-info">
|
<OfferModal
|
||||||
<p>Tu puntaje: <strong>{{ currentPlayerScore }}</strong></p>
|
:is-open="showOfferModal"
|
||||||
</div>
|
:target-player-id="selectedTargetId"
|
||||||
|
:all-players="players"
|
||||||
|
@close="closeOfferModal"
|
||||||
|
@make-offer="makeOffer"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,17 +75,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, triggerRef } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, triggerRef } from 'vue'
|
||||||
import { GameClient } from '@/services/gameClient'
|
import { GameClient } from '@/services/gameClient'
|
||||||
import { GameState, Player } from '@/types'
|
import { GameState, Player, TradeOffer } from '@/types'
|
||||||
import type { Room } from 'colyseus.js'
|
import type { Room } from 'colyseus.js'
|
||||||
import { logger } from '@/services/logger'
|
import { logger } from '@/services/logger'
|
||||||
|
import PlayerCard from './PlayerCard.vue'
|
||||||
|
import ScrollableOffers from './ScrollableOffers.vue'
|
||||||
|
import OfferModal from './OfferModal.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
gameClient: any
|
gameClient: any
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const gameState = ref<GameState | null>(null)
|
const gameState = ref<GameState | null>(null)
|
||||||
const isClicked = ref(false)
|
const showOfferModal = ref(false)
|
||||||
const showEffect = ref(false)
|
const selectedTargetId = ref('')
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const gamePhase = computed(() => {
|
const gamePhase = computed(() => {
|
||||||
@@ -67,7 +96,8 @@ const gamePhase = computed(() => {
|
|||||||
logger.computedProperty('gamePhase', phase)
|
logger.computedProperty('gamePhase', phase)
|
||||||
return phase
|
return phase
|
||||||
})
|
})
|
||||||
const minPlayers = computed(() => gameState.value?.minPlayers || 2)
|
const round = computed(() => gameState.value?.round || 1)
|
||||||
|
const minPlayers = computed(() => gameState.value?.minPlayers || 3)
|
||||||
const playerCount = computed(() => {
|
const playerCount = computed(() => {
|
||||||
const count = gameState.value?.players.size || 0
|
const count = gameState.value?.players.size || 0
|
||||||
logger.computedProperty('playerCount', count)
|
logger.computedProperty('playerCount', count)
|
||||||
@@ -80,29 +110,63 @@ const players = computed(() => {
|
|||||||
return playerList
|
return playerList
|
||||||
})
|
})
|
||||||
const currentPlayerId = computed(() => props.gameClient?.currentPlayerId || '')
|
const currentPlayerId = computed(() => props.gameClient?.currentPlayerId || '')
|
||||||
const currentPlayerScore = computed(() => {
|
const currentPlayer = computed(() => {
|
||||||
if (!gameState.value || !currentPlayerId.value) return 0
|
return players.value.find(p => p.id === currentPlayerId.value) || null
|
||||||
const player = gameState.value.players.get(currentPlayerId.value)
|
})
|
||||||
return player?.score || 0
|
const otherPlayers = computed(() => {
|
||||||
|
return players.value.filter(p => p.id !== currentPlayerId.value)
|
||||||
|
})
|
||||||
|
const activeOffers = computed(() => {
|
||||||
|
if (!gameState.value) return []
|
||||||
|
return Array.from(gameState.value.activeTradeOffers.values()).reverse()
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleClick = () => {
|
// Helper functions
|
||||||
if (!props.gameClient || gamePhase.value !== 'playing') return
|
|
||||||
|
const getPlayerName = (playerId: string): string => {
|
||||||
|
if (!gameState.value) return 'Desconocido'
|
||||||
|
const player = gameState.value.players.get(playerId)
|
||||||
|
return player?.name || 'Desconocido'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Modal actions
|
||||||
|
const openOfferModal = (targetPlayerId: string) => {
|
||||||
|
selectedTargetId.value = targetPlayerId
|
||||||
|
showOfferModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOfferModal = () => {
|
||||||
|
showOfferModal.value = false
|
||||||
|
selectedTargetId.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game actions
|
||||||
|
const makeOffer = (offerData: {
|
||||||
|
targetId: string,
|
||||||
|
offering: { turkey: number, coffee: number, corn: number },
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
}) => {
|
||||||
|
if (!props.gameClient) return
|
||||||
|
|
||||||
// Send click through gameClient
|
props.gameClient.makeOffer(offerData)
|
||||||
props.gameClient.sendClick()
|
}
|
||||||
|
|
||||||
|
const respondToOffer = (offerId: string, response: string) => {
|
||||||
|
if (!props.gameClient) return
|
||||||
|
|
||||||
// Visual feedback
|
props.gameClient.respondToOffer({
|
||||||
isClicked.value = true
|
offerId,
|
||||||
showEffect.value = true
|
response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelOffer = (offerId: string) => {
|
||||||
|
if (!props.gameClient) return
|
||||||
|
|
||||||
setTimeout(() => {
|
props.gameClient.cancelOffer({
|
||||||
isClicked.value = false
|
offerId
|
||||||
}, 150)
|
})
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
showEffect.value = false
|
|
||||||
}, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -115,7 +179,8 @@ onMounted(() => {
|
|||||||
logger.gameComponentUpdate({
|
logger.gameComponentUpdate({
|
||||||
gamePhase: state.gamePhase,
|
gamePhase: state.gamePhase,
|
||||||
playerCount: state.players.size,
|
playerCount: state.players.size,
|
||||||
gameStarted: state.gameStarted
|
gameStarted: state.gameStarted,
|
||||||
|
round: state.round
|
||||||
})
|
})
|
||||||
|
|
||||||
// Force Vue reactivity by assigning new reference and triggering update
|
// Force Vue reactivity by assigning new reference and triggering update
|
||||||
@@ -145,16 +210,19 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 2rem;
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Waiting Screen */
|
/* Waiting Screen */
|
||||||
.waiting-screen {
|
.waiting-screen {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
margin-top: 10vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-content h2 {
|
.waiting-content h2 {
|
||||||
@@ -187,114 +255,99 @@ onMounted(() => {
|
|||||||
/* Game Screen */
|
/* Game Screen */
|
||||||
.game-screen {
|
.game-screen {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 800px;
|
max-width: 1400px;
|
||||||
}
|
height: 100%;
|
||||||
|
|
||||||
.scoreboard {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
box-sizing: border-box;
|
||||||
margin-bottom: 3rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-score {
|
.game-header {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
backdrop-filter: blur(10px);
|
margin-bottom: 1rem;
|
||||||
border: 2px solid transparent;
|
flex-shrink: 0;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-score.current-player {
|
.round-info h2 {
|
||||||
border-color: #ffd700;
|
font-size: 2rem;
|
||||||
background: rgba(255, 215, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-name {
|
|
||||||
display: block;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.score {
|
.phase {
|
||||||
display: block;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Click Button */
|
|
||||||
.click-area {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-button {
|
|
||||||
position: relative;
|
|
||||||
width: 250px;
|
|
||||||
height: 250px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: linear-gradient(45deg, #ff6b6b, #ff8e53);
|
|
||||||
color: white;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.1s ease;
|
|
||||||
box-shadow: 0 8px 25px rgba(255, 107, 107, 0.4);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-button:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 12px 35px rgba(255, 107, 107, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-button.clicked {
|
|
||||||
transform: scale(0.95);
|
|
||||||
background: linear-gradient(45deg, #ff8e53, #ff6b6b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-text {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.click-effect {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
border-radius: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
animation: clickRipple 0.4s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes clickRipple {
|
|
||||||
0% {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-info {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-info strong {
|
/* Main Game Layout */
|
||||||
color: #ffd700;
|
.game-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 350px;
|
||||||
|
gap: 2rem;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-players {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-player-section {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-section {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Layout */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.game-container {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-screen {
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.players-section {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-section {
|
||||||
|
order: 2;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-players {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-player-section {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet adjustments */
|
||||||
|
@media (max-width: 1024px) and (min-width: 769px) {
|
||||||
|
.game-layout {
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
348
client/src/components/MakeOfferForm.vue
Normal file
348
client/src/components/MakeOfferForm.vue
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
<template>
|
||||||
|
<div class="make-offer-form">
|
||||||
|
<h4>Hacer Oferta</h4>
|
||||||
|
<div class="offer-form">
|
||||||
|
<div v-if="!preSelectedTarget" class="target-selection">
|
||||||
|
<label>Ofrecer a:</label>
|
||||||
|
<select v-model="form.targetId">
|
||||||
|
<option value="">Seleccionar jugador</option>
|
||||||
|
<option
|
||||||
|
v-for="player in otherPlayers"
|
||||||
|
:key="player.id"
|
||||||
|
:value="player.id"
|
||||||
|
>
|
||||||
|
{{ player.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tokens-form">
|
||||||
|
<div class="form-section">
|
||||||
|
<label>Ofrecer:</label>
|
||||||
|
<div class="token-inputs">
|
||||||
|
<div class="token-input-compact">
|
||||||
|
<span class="token-emoji">🦃</span>
|
||||||
|
<button @click="adjustToken('offering', 'turkey', -1)" class="quantity-btn minus-btn">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.offering.turkey"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
class="quantity-input"
|
||||||
|
@input="validateInput('offering', 'turkey', $event)"
|
||||||
|
>
|
||||||
|
<button @click="adjustToken('offering', 'turkey', 1)" class="quantity-btn plus-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="token-input-compact">
|
||||||
|
<span class="token-emoji">☕</span>
|
||||||
|
<button @click="adjustToken('offering', 'coffee', -1)" class="quantity-btn minus-btn">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.offering.coffee"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
class="quantity-input"
|
||||||
|
@input="validateInput('offering', 'coffee', $event)"
|
||||||
|
>
|
||||||
|
<button @click="adjustToken('offering', 'coffee', 1)" class="quantity-btn plus-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="token-input-compact">
|
||||||
|
<span class="token-emoji">🌽</span>
|
||||||
|
<button @click="adjustToken('offering', 'corn', -1)" class="quantity-btn minus-btn">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.offering.corn"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
class="quantity-input"
|
||||||
|
@input="validateInput('offering', 'corn', $event)"
|
||||||
|
>
|
||||||
|
<button @click="adjustToken('offering', 'corn', 1)" class="quantity-btn plus-btn">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section">
|
||||||
|
<label>Por:</label>
|
||||||
|
<div class="token-inputs">
|
||||||
|
<div class="token-input-compact">
|
||||||
|
<span class="token-emoji">🦃</span>
|
||||||
|
<button @click="adjustToken('requesting', 'turkey', -1)" class="quantity-btn minus-btn">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.requesting.turkey"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
class="quantity-input"
|
||||||
|
@input="validateInput('requesting', 'turkey', $event)"
|
||||||
|
>
|
||||||
|
<button @click="adjustToken('requesting', 'turkey', 1)" class="quantity-btn plus-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="token-input-compact">
|
||||||
|
<span class="token-emoji">☕</span>
|
||||||
|
<button @click="adjustToken('requesting', 'coffee', -1)" class="quantity-btn minus-btn">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.requesting.coffee"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
class="quantity-input"
|
||||||
|
@input="validateInput('requesting', 'coffee', $event)"
|
||||||
|
>
|
||||||
|
<button @click="adjustToken('requesting', 'coffee', 1)" class="quantity-btn plus-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="token-input-compact">
|
||||||
|
<span class="token-emoji">🌽</span>
|
||||||
|
<button @click="adjustToken('requesting', 'corn', -1)" class="quantity-btn minus-btn">-</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="form.requesting.corn"
|
||||||
|
min="0"
|
||||||
|
max="999"
|
||||||
|
class="quantity-input"
|
||||||
|
@input="validateInput('requesting', 'corn', $event)"
|
||||||
|
>
|
||||||
|
<button @click="adjustToken('requesting', 'corn', 1)" class="quantity-btn plus-btn">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="submitOffer"
|
||||||
|
class="make-offer-btn"
|
||||||
|
:disabled="!canMakeOffer"
|
||||||
|
>
|
||||||
|
Hacer Oferta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Player } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
otherPlayers: Player[]
|
||||||
|
preSelectedTarget?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
makeOffer: [offerData: {
|
||||||
|
targetId: string,
|
||||||
|
offering: { turkey: number, coffee: number, corn: number },
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
targetId: props.preSelectedTarget || '',
|
||||||
|
offering: { turkey: 0, coffee: 0, corn: 0 },
|
||||||
|
requesting: { turkey: 0, coffee: 0, corn: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const canMakeOffer = computed(() => {
|
||||||
|
if (!form.value.targetId) return false
|
||||||
|
const hasOffering = form.value.offering.turkey > 0 || form.value.offering.coffee > 0 || form.value.offering.corn > 0
|
||||||
|
const hasRequesting = form.value.requesting.turkey > 0 || form.value.requesting.coffee > 0 || form.value.requesting.corn > 0
|
||||||
|
return hasOffering && hasRequesting
|
||||||
|
})
|
||||||
|
|
||||||
|
const adjustToken = (section: 'offering' | 'requesting', token: 'turkey' | 'coffee' | 'corn', delta: number) => {
|
||||||
|
const currentValue = form.value[section][token]
|
||||||
|
const newValue = Math.max(0, Math.min(999, currentValue + delta))
|
||||||
|
form.value[section][token] = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateInput = (section: 'offering' | 'requesting', token: 'turkey' | 'coffee' | 'corn', event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
let value = parseInt(target.value) || 0
|
||||||
|
value = Math.max(0, Math.min(999, value))
|
||||||
|
form.value[section][token] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitOffer = () => {
|
||||||
|
if (!canMakeOffer.value) return
|
||||||
|
|
||||||
|
emit('makeOffer', {
|
||||||
|
targetId: form.value.targetId,
|
||||||
|
offering: { ...form.value.offering },
|
||||||
|
requesting: { ...form.value.requesting }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
form.value.offering = { turkey: 0, coffee: 0, corn: 0 }
|
||||||
|
form.value.requesting = { turkey: 0, coffee: 0, corn: 0 }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.make-offer-form {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-offer-form h4 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-selection {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-selection label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-selection select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-emoji {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
min-width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.minus-btn {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #ff5252);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minus-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #ff5252, #ff1744);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(255, 107, 107, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-btn {
|
||||||
|
background: linear-gradient(135deg, #4CAF50, #43A047);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #43A047, #388E3C);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-input:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide spinner arrows */
|
||||||
|
.quantity-input::-webkit-outer-spin-button,
|
||||||
|
.quantity-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-input[type=number] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-offer-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(45deg, #4CAF50, #45a049);
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-offer-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.make-offer-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.tokens-form {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
180
client/src/components/OfferModal.vue
Normal file
180
client/src/components/OfferModal.vue
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="modal-overlay"
|
||||||
|
@click="closeModal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="modal-content"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Hacer Oferta a {{ targetPlayerName }}</h3>
|
||||||
|
<button class="close-btn" @click="closeModal">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<MakeOfferForm
|
||||||
|
:other-players="[targetPlayer].filter(Boolean)"
|
||||||
|
:pre-selected-target="targetPlayerId"
|
||||||
|
@make-offer="handleMakeOffer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { Player } from '@/types'
|
||||||
|
import MakeOfferForm from './MakeOfferForm.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean
|
||||||
|
targetPlayerId: string
|
||||||
|
allPlayers: Player[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
makeOffer: [offerData: {
|
||||||
|
targetId: string,
|
||||||
|
offering: { turkey: number, coffee: number, corn: number },
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const targetPlayer = computed(() =>
|
||||||
|
props.allPlayers.find(p => p.id === props.targetPlayerId)
|
||||||
|
)
|
||||||
|
|
||||||
|
const targetPlayerName = computed(() =>
|
||||||
|
targetPlayer.value?.name || 'Jugador'
|
||||||
|
)
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMakeOffer = (offerData: {
|
||||||
|
targetId: string,
|
||||||
|
offering: { turkey: number, coffee: number, corn: number },
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
}) => {
|
||||||
|
emit('makeOffer', offerData)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
watch(() => props.isOpen, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90vw;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: modalSlideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 0;
|
||||||
|
max-height: calc(80vh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
201
client/src/components/PlayerCard.vue
Normal file
201
client/src/components/PlayerCard.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="player-card"
|
||||||
|
:class="{
|
||||||
|
'current-player': isCurrentPlayer,
|
||||||
|
'compact': compact,
|
||||||
|
'clickable': !isCurrentPlayer
|
||||||
|
}"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<div class="player-header">
|
||||||
|
<span class="player-name">{{ player.name }}</span>
|
||||||
|
<span class="producer-role" v-if="!compact">{{ getProducerRoleText(player.producerRole) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tokens-display" :class="{ 'compact': compact }">
|
||||||
|
<div class="token-item">
|
||||||
|
<span class="token-icon">🦃</span>
|
||||||
|
<span class="token-count">{{ player.tokens.turkey }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="token-item">
|
||||||
|
<span class="token-icon">☕</span>
|
||||||
|
<span class="token-count">{{ player.tokens.coffee }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="token-item">
|
||||||
|
<span class="token-icon">🌽</span>
|
||||||
|
<span class="token-count">{{ player.tokens.corn }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="player-points">
|
||||||
|
<span class="points-label" v-if="!compact">Puntos:</span>
|
||||||
|
<span class="points-value">{{ player.points }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isCurrentPlayer && compact" class="click-indicator">
|
||||||
|
Click para ofertar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Player } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
player: Player
|
||||||
|
isCurrentPlayer: boolean
|
||||||
|
compact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [playerId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getProducerRoleText = (role: string): string => {
|
||||||
|
switch (role) {
|
||||||
|
case 'turkey': return 'Productor de Pavos'
|
||||||
|
case 'coffee': return 'Productor de Café'
|
||||||
|
case 'corn': return 'Productor de Maíz'
|
||||||
|
default: return role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!props.isCurrentPlayer) {
|
||||||
|
emit('click', props.player.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player-card {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.current-player {
|
||||||
|
border-color: #ffd700;
|
||||||
|
background: rgba(255, 215, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact {
|
||||||
|
padding: 0.75rem;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.clickable:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .player-header {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .player-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.producer-role {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens-display.compact {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .token-item {
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .token-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-count {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .token-count {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-points {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .player-points {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-label {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-value {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.compact .points-value {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.click-indicator {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-align: center;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card.clickable:hover .click-indicator {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
213
client/src/components/ScrollableOffers.vue
Normal file
213
client/src/components/ScrollableOffers.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<div class="offers-container">
|
||||||
|
<h3>Ofertas Comerciales</h3>
|
||||||
|
<div class="offers-scroll-area" ref="scrollArea">
|
||||||
|
<div class="offers-content">
|
||||||
|
<TradeOfferCard
|
||||||
|
v-for="offer in offers"
|
||||||
|
:key="offer.id"
|
||||||
|
:offer="offer"
|
||||||
|
:current-player-id="currentPlayerId"
|
||||||
|
:get-player-name="getPlayerName"
|
||||||
|
@cancel="$emit('cancel', $event)"
|
||||||
|
@respond="$emit('respond', $event, arguments[1])"
|
||||||
|
/>
|
||||||
|
<div v-if="offers.length === 0" class="no-offers">
|
||||||
|
No hay ofertas activas
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="custom-scrollbar" v-show="showScrollbar">
|
||||||
|
<div
|
||||||
|
class="scrollbar-thumb"
|
||||||
|
:style="{
|
||||||
|
height: thumbHeight + '%',
|
||||||
|
top: thumbPosition + '%'
|
||||||
|
}"
|
||||||
|
@mousedown="startDrag"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { TradeOffer } from '@/types'
|
||||||
|
import TradeOfferCard from './TradeOfferCard.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
offers: TradeOffer[]
|
||||||
|
currentPlayerId: string
|
||||||
|
getPlayerName: (playerId: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
cancel: [offerId: string]
|
||||||
|
respond: [offerId: string, response: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const scrollArea = ref<HTMLElement>()
|
||||||
|
const showScrollbar = ref(false)
|
||||||
|
const thumbHeight = ref(100)
|
||||||
|
const thumbPosition = ref(0)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragStartY = ref(0)
|
||||||
|
const dragStartScrollTop = ref(0)
|
||||||
|
|
||||||
|
const updateScrollbar = () => {
|
||||||
|
if (!scrollArea.value) return
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollArea.value
|
||||||
|
const isScrollable = scrollHeight > clientHeight
|
||||||
|
|
||||||
|
showScrollbar.value = isScrollable
|
||||||
|
|
||||||
|
if (isScrollable) {
|
||||||
|
thumbHeight.value = (clientHeight / scrollHeight) * 100
|
||||||
|
thumbPosition.value = (scrollTop / (scrollHeight - clientHeight)) * (100 - thumbHeight.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrag = (e: MouseEvent) => {
|
||||||
|
isDragging.value = true
|
||||||
|
dragStartY.value = e.clientY
|
||||||
|
dragStartScrollTop.value = scrollArea.value?.scrollTop || 0
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onDrag)
|
||||||
|
document.addEventListener('mouseup', stopDrag)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrag = (e: MouseEvent) => {
|
||||||
|
if (!isDragging.value || !scrollArea.value) return
|
||||||
|
|
||||||
|
const deltaY = e.clientY - dragStartY.value
|
||||||
|
const scrollAreaHeight = scrollArea.value.clientHeight
|
||||||
|
const scrollableHeight = scrollArea.value.scrollHeight - scrollAreaHeight
|
||||||
|
const scrollRatio = deltaY / scrollAreaHeight
|
||||||
|
|
||||||
|
scrollArea.value.scrollTop = dragStartScrollTop.value + (scrollRatio * scrollableHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
isDragging.value = false
|
||||||
|
document.removeEventListener('mousemove', onDrag)
|
||||||
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
updateScrollbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (scrollArea.value) {
|
||||||
|
scrollArea.value.addEventListener('scroll', onScroll)
|
||||||
|
updateScrollbar()
|
||||||
|
|
||||||
|
// Watch for content changes
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
nextTick(() => updateScrollbar())
|
||||||
|
})
|
||||||
|
observer.observe(scrollArea.value)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
observer.disconnect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (scrollArea.value) {
|
||||||
|
scrollArea.value.removeEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
stopDrag()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.offers-container {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 1.5rem;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-container h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-scroll-area {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-content {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
margin-right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default scrollbar */
|
||||||
|
.offers-content::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offers-content {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-offers {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile optimizations */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.offers-container {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop optimizations */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.offers-container {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
231
client/src/components/TradeOfferCard.vue
Normal file
231
client/src/components/TradeOfferCard.vue
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="trade-offer"
|
||||||
|
:class="{
|
||||||
|
'my-offer': offer.offererId === currentPlayerId,
|
||||||
|
'target-offer': offer.targetId === currentPlayerId
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="offer-header">
|
||||||
|
<span class="offerer">{{ getPlayerName(offer.offererId) }}</span>
|
||||||
|
<span class="arrow">→</span>
|
||||||
|
<span class="target">{{ getPlayerName(offer.targetId) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="offer-details">
|
||||||
|
<div class="offering">
|
||||||
|
<span class="label">Ofrece:</span>
|
||||||
|
<div class="tokens">
|
||||||
|
<span v-if="offer.offering.turkey > 0">🦃 {{ offer.offering.turkey }}</span>
|
||||||
|
<span v-if="offer.offering.coffee > 0">☕ {{ offer.offering.coffee }}</span>
|
||||||
|
<span v-if="offer.offering.corn > 0">🌽 {{ offer.offering.corn }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="requesting">
|
||||||
|
<span class="label">Por:</span>
|
||||||
|
<div class="tokens">
|
||||||
|
<span v-if="offer.requesting.turkey > 0">🦃 {{ offer.requesting.turkey }}</span>
|
||||||
|
<span v-if="offer.requesting.coffee > 0">☕ {{ offer.requesting.coffee }}</span>
|
||||||
|
<span v-if="offer.requesting.corn > 0">🌽 {{ offer.requesting.corn }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="offer-actions">
|
||||||
|
<button
|
||||||
|
v-if="offer.offererId === currentPlayerId && offer.status === 'pending'"
|
||||||
|
@click="$emit('cancel', offer.id)"
|
||||||
|
class="cancel-btn"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<div v-else-if="offer.targetId === currentPlayerId && offer.status === 'pending'" class="response-actions">
|
||||||
|
<button @click="$emit('respond', offer.id, 'accept')" class="accept-btn">Aceptar</button>
|
||||||
|
<button @click="$emit('respond', offer.id, 'reject')" class="reject-btn">Rechazar</button>
|
||||||
|
<button @click="$emit('respond', offer.id, 'snatch')" class="snatch-btn">Snatch</button>
|
||||||
|
</div>
|
||||||
|
<span v-else class="offer-status">{{ getOfferStatusText(offer.status) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TradeOffer } from '@/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
offer: TradeOffer
|
||||||
|
currentPlayerId: string
|
||||||
|
getPlayerName: (playerId: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
cancel: [offerId: string]
|
||||||
|
respond: [offerId: string, response: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getOfferStatusText = (status: string): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'Pendiente'
|
||||||
|
case 'accepted': return 'Aceptada'
|
||||||
|
case 'rejected': return 'Rechazada'
|
||||||
|
case 'snatched': return 'Snatched'
|
||||||
|
case 'cancelled': return 'Cancelada'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trade-offer {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-left: 4px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-offer.my-offer {
|
||||||
|
border-left-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trade-offer.target-offer {
|
||||||
|
border-left-color: #FF9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens span {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-actions button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accept-btn {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-btn {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snatch-btn {
|
||||||
|
background: #FF9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-btn {
|
||||||
|
background: #757575;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-actions button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-status {
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.trade-offer {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-details {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokens span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-actions {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-actions button {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-actions {
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-actions button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -132,6 +132,37 @@ export class GameClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeOffer(offerData: {
|
||||||
|
targetId: string,
|
||||||
|
offering: { turkey: number, coffee: number, corn: number },
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
}): void {
|
||||||
|
if (this.room && this.gameState?.gamePhase === 'trading') {
|
||||||
|
this.room.send('makeOffer', offerData)
|
||||||
|
logger.info('Trade offer sent:', offerData)
|
||||||
|
} else {
|
||||||
|
logger.info('Trade offer ignored - not in trading phase')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respondToOffer(responseData: { offerId: string, response: string }): void {
|
||||||
|
if (this.room && this.gameState?.gamePhase === 'trading') {
|
||||||
|
this.room.send('respondToOffer', responseData)
|
||||||
|
logger.info('Trade response sent:', responseData)
|
||||||
|
} else {
|
||||||
|
logger.info('Trade response ignored - not in trading phase')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelOffer(cancelData: { offerId: string }): void {
|
||||||
|
if (this.room && this.gameState?.gamePhase === 'trading') {
|
||||||
|
this.room.send('cancelOffer', cancelData)
|
||||||
|
logger.info('Trade cancellation sent:', cancelData)
|
||||||
|
} else {
|
||||||
|
logger.info('Trade cancellation ignored - not in trading phase')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
getCurrentPlayer(): Player | null {
|
getCurrentPlayer(): Player | null {
|
||||||
if (!this.gameState || !this.currentPlayerId) return null
|
if (!this.gameState || !this.currentPlayerId) return null
|
||||||
|
|||||||
15
client/src/types/TokenInventory.ts
Normal file
15
client/src/types/TokenInventory.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
|
||||||
|
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
|
||||||
|
//
|
||||||
|
// GENERATED USING @colyseus/schema 3.0.42
|
||||||
|
//
|
||||||
|
|
||||||
|
import { Schema, type, ArraySchema, MapSchema, SetSchema, DataChange } from '@colyseus/schema';
|
||||||
|
|
||||||
|
|
||||||
|
export class TokenInventory extends Schema {
|
||||||
|
@type("number") public turkey!: number;
|
||||||
|
@type("number") public coffee!: number;
|
||||||
|
@type("number") public corn!: number;
|
||||||
|
}
|
||||||
18
client/src/types/TradeOffer.ts
Normal file
18
client/src/types/TradeOffer.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// THIS FILE HAS BEEN GENERATED AUTOMATICALLY
|
||||||
|
// DO NOT CHANGE IT MANUALLY UNLESS YOU KNOW WHAT YOU'RE DOING
|
||||||
|
//
|
||||||
|
// GENERATED USING @colyseus/schema 3.0.42
|
||||||
|
//
|
||||||
|
|
||||||
|
import { Schema, type, ArraySchema, MapSchema, SetSchema, DataChange } from '@colyseus/schema';
|
||||||
|
import { TokenInventory } from './TokenInventory'
|
||||||
|
|
||||||
|
export class TradeOffer extends Schema {
|
||||||
|
@type("string") public id!: string;
|
||||||
|
@type("string") public offererId!: string;
|
||||||
|
@type("string") public targetId!: string;
|
||||||
|
@type(TokenInventory) public offering: TokenInventory = new TokenInventory();
|
||||||
|
@type(TokenInventory) public requesting: TokenInventory = new TokenInventory();
|
||||||
|
@type("string") public status!: string;
|
||||||
|
}
|
||||||
138
gameRules.md
Normal file
138
gameRules.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# Snatch or Share - Game Logic
|
||||||
|
|
||||||
|
## Game Overview
|
||||||
|
Snatch or Share is a multiplayer game that simulates governance evolution in decentralized exchanges, based on Elinor Ostrom's "Snatch Game".
|
||||||
|
|
||||||
|
## Game State Structure
|
||||||
|
|
||||||
|
### Main Game State
|
||||||
|
```typescript
|
||||||
|
GameState {
|
||||||
|
round: number (1-5)
|
||||||
|
groups: Group[] // Groups of 3 players
|
||||||
|
currentPhase: 'waiting' | 'trading' | 'judging' | 'results'
|
||||||
|
}
|
||||||
|
|
||||||
|
Group {
|
||||||
|
id: string
|
||||||
|
players: Player[3]
|
||||||
|
currentJudge?: Player // Only in Round 5
|
||||||
|
activeTradeOffers: TradeOffer[] // Multiple simultaneous offers
|
||||||
|
}
|
||||||
|
|
||||||
|
Player {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
producerRole: 'turkey' | 'coffee' | 'corn'
|
||||||
|
tokens: {
|
||||||
|
turkey: number
|
||||||
|
coffee: number
|
||||||
|
corn: number
|
||||||
|
}
|
||||||
|
points: number // Calculated: own_tokens * 1 + other_tokens * 2
|
||||||
|
shameTokens: number // For Round 4
|
||||||
|
isSuspended: boolean // For Round 5
|
||||||
|
role: 'trader' | 'judge' // Only relevant in Round 5
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeOffer {
|
||||||
|
id: string
|
||||||
|
offererId: string
|
||||||
|
targetId: string
|
||||||
|
offering: { turkey: number, coffee: number, corn: number }
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
status: 'pending' | 'accepted' | 'rejected' | 'snatched' | 'cancelled'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Game Initialization
|
||||||
|
|
||||||
|
### Room Configuration
|
||||||
|
- **Exactly 3 players required** to start the game
|
||||||
|
- **Maximum 3 players** per room
|
||||||
|
|
||||||
|
### Producer Role Assignment
|
||||||
|
- Each player is randomly assigned **one unique producer role**:
|
||||||
|
- **Turkey Producer**: Starts with 5 turkey tokens
|
||||||
|
- **Coffee Producer**: Starts with 5 coffee tokens
|
||||||
|
- **Corn Producer**: Starts with 5 corn tokens
|
||||||
|
- **Roles cannot be repeated** - exactly one producer of each type per game
|
||||||
|
|
||||||
|
### Judge Role (Round 5)
|
||||||
|
- In Round 5, one player becomes **Judge** (rotates each round)
|
||||||
|
- **Judge role is decorative** - player keeps their original producer role
|
||||||
|
- Judge has additional responsibilities but maintains their trading capabilities
|
||||||
|
|
||||||
|
## Round Flow (Focus: Round 1)
|
||||||
|
|
||||||
|
### Round 1: State of Nature
|
||||||
|
- **No time limits** on turns
|
||||||
|
- **Simultaneous trading**: All players can make offers at the same time
|
||||||
|
- **Offer limit**: Each player can make maximum 2 offers to each opponent
|
||||||
|
- **Offer format**: "I give X tokens in exchange for Y tokens"
|
||||||
|
- **Offer responses**: Target player can Accept, Reject, or Snatch
|
||||||
|
|
||||||
|
### Trading Mechanics
|
||||||
|
|
||||||
|
#### Making Offers
|
||||||
|
- Players can offer and request **any amount** of tokens (even more than they have)
|
||||||
|
- Offers are made simultaneously to multiple players
|
||||||
|
- Format: `offering: {turkey: X, coffee: Y, corn: Z}` for `requesting: {turkey: A, coffee: B, corn: C}`
|
||||||
|
- **All trade offers are public** - visible to all players in the room, not just offerer and target
|
||||||
|
|
||||||
|
#### Resolving Offers
|
||||||
|
- **Accept**: Trade executed to the extent possible with available tokens
|
||||||
|
- **Reject**: Offer cancelled, no exchange
|
||||||
|
- **Snatch**: Receiver gets offered tokens (up to what offerer has) without giving anything in return
|
||||||
|
- **Cancel**: Offerer can cancel pending offers
|
||||||
|
|
||||||
|
#### Partial Fulfillment
|
||||||
|
- All trades execute with available tokens only
|
||||||
|
- Example: Offer "6 corn for 6 coffee" but only have 5 corn → Execute "5 corn for 5 coffee"
|
||||||
|
- Example: Accept offer for 8 tokens but only have 3 → Give 3 tokens, receive offered amount
|
||||||
|
- **No negative consequences** for partial fulfillment - it's part of the game
|
||||||
|
|
||||||
|
## Rules by Round
|
||||||
|
|
||||||
|
### Round 1-2: State of Nature / Anarchy
|
||||||
|
- No special rules
|
||||||
|
- All players are Traders
|
||||||
|
- No enforcement mechanisms
|
||||||
|
|
||||||
|
### Round 3: Counterproductive Rule
|
||||||
|
- **Mandatory Rule**: "All trade offers must be accepted"
|
||||||
|
- Players still have freedom to snatch
|
||||||
|
- Reduces agency, invites exploitation
|
||||||
|
|
||||||
|
### Round 4: Soft Norms (Shame Tokens)
|
||||||
|
- Each player can assign 1 shame token per round
|
||||||
|
- If player starts round with 2+ shame tokens:
|
||||||
|
- Loses 2 tokens before round begins
|
||||||
|
- Barred from offering trades (can only respond)
|
||||||
|
|
||||||
|
### Round 5: Governance Rules (Ostrom Adapted)
|
||||||
|
- **Group Roles**: Two Traders and One Judge
|
||||||
|
- **Judge Role**: Rotates each round
|
||||||
|
- **Applied Rules**:
|
||||||
|
- **Position Rule**: Judge oversees fairness and conflict resolution
|
||||||
|
- **Boundary Rule**: Only group members can trade; Judge may suspend rule-breaker for 1 round
|
||||||
|
- **Choice Rule**: Trades are voluntary; Players can report snatching to Judge
|
||||||
|
- **Enforcement Rule**: If Judge confirms snatch → goods returned + snatcher forfeits 3 tokens to victim
|
||||||
|
- **Aggregation Rule**: Trade only completes with explicit mutual consent
|
||||||
|
|
||||||
|
## Key Mechanics
|
||||||
|
|
||||||
|
### Trading
|
||||||
|
- **Exchange Amount**: Always 5 tokens of player's own type
|
||||||
|
- **Value System**: own_tokens × 1 + other_tokens × 2
|
||||||
|
- **Snatch**: Take opponent's tokens without giving anything in return
|
||||||
|
|
||||||
|
### Point Calculation Examples
|
||||||
|
- Holding 10 of own = 10 points
|
||||||
|
- Holding 5 of own + 5 of other = 15 points
|
||||||
|
- Holding 10 of own + 5 snatched = 20 points
|
||||||
|
|
||||||
|
### Governance Evolution
|
||||||
|
- **Round 1-2**: No trust → no cooperation → suboptimal outcomes
|
||||||
|
- **Round 4**: Social deterrents emerge
|
||||||
|
- **Round 5**: Governance stabilizes expectations → trust emerges → optimal outcomes through fair trades
|
||||||
4620
package-lock.json
generated
Normal file
4620
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
|||||||
{"data":{"colyseus:nodes":[]},"hash":{"roomcount":{},"roomhistory":{}},"keys":{}}
|
{"data":{"colyseus:nodes":[],"l:game:gameMode:classic":["iys_3ih45","wnh3Dc813","x_N2KoM0r","ZYjw9gCJY"]},"hash":{"roomcount":{},"roomhistory":{"iys_3ih45":"{\"clientOptions\":{\"playerName\":\"Jugador Test\",\"gameMode\":\"classic\"},\"roomName\":\"game\",\"processId\":\"xX4LpKzp8\"}"},"ch:game":{"gameMode:classic":"0"}},"keys":{}}
|
||||||
357
server/README.md
Normal file
357
server/README.md
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
# 🎯 Snatch or Share - Servidor
|
||||||
|
|
||||||
|
Servidor de juego multijugador basado en Colyseus.io que implementa el "Snatch Game" de Elinor Ostrom para estudiar la evolución de instituciones y cooperación.
|
||||||
|
|
||||||
|
## 🛠️ Stack Tecnológico
|
||||||
|
|
||||||
|
- **Colyseus.io** (framework de servidor multijugador)
|
||||||
|
- **Node.js** (runtime)
|
||||||
|
- **TypeScript** (tipado estricto)
|
||||||
|
- **Express** (servidor HTTP)
|
||||||
|
- **@colyseus/schema** (sincronización de estado)
|
||||||
|
|
||||||
|
## 🚀 Inicio Rápido
|
||||||
|
|
||||||
|
### Prerrequisitos
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 9+
|
||||||
|
|
||||||
|
### Instalación y Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instalar dependencias
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Iniciar servidor de desarrollo (puerto 2567)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Verificar que el servidor esté ejecutándose
|
||||||
|
curl http://localhost:2567
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comandos Disponibles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Desarrollo
|
||||||
|
npm run dev # Servidor con hot reload (ts-node-dev)
|
||||||
|
|
||||||
|
# Producción
|
||||||
|
npm run build # Compilar TypeScript a JavaScript
|
||||||
|
npm run start # Ejecutar servidor compilado
|
||||||
|
|
||||||
|
# Utilidades
|
||||||
|
npm test # Ejecutar tests (placeholder)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Arquitectura del Servidor
|
||||||
|
|
||||||
|
### Estructura de Directorios
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
├── src/
|
||||||
|
│ ├── rooms/ # Salas de juego Colyseus
|
||||||
|
│ │ └── GameRoom.ts # Sala principal del juego
|
||||||
|
│ ├── app.config.ts # Configuración de Colyseus
|
||||||
|
│ └── index.ts # Punto de entrada del servidor
|
||||||
|
├── lib/ # JavaScript compilado (build)
|
||||||
|
└── tsconfig.json # Configuración TypeScript
|
||||||
|
```
|
||||||
|
|
||||||
|
### GameRoom.ts - Sala Principal
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class GameRoom extends Room<GameState> {
|
||||||
|
maxClients = 3;
|
||||||
|
private producerRoles = ["turkey", "coffee", "corn"];
|
||||||
|
|
||||||
|
onCreate(options: GameRoomOptions) {
|
||||||
|
// Inicialización de la sala
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(client: Client, options: any) {
|
||||||
|
// Manejo de jugadores que se unen
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(type: string, callback: Function) {
|
||||||
|
// Handlers de mensajes del cliente
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Esquemas de Estado (Colyseus Schema)
|
||||||
|
|
||||||
|
### GameState
|
||||||
|
Estado principal del juego sincronizado con todos los clientes:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class GameState extends Schema {
|
||||||
|
@type({ map: Player }) players = new MapSchema<Player>();
|
||||||
|
@type({ array: TradeOffer }) activeTradeOffers = new ArraySchema<TradeOffer>();
|
||||||
|
@type("number") round: number = 1;
|
||||||
|
@type("string") gamePhase: string = "waiting";
|
||||||
|
@type("boolean") gameStarted: boolean = false;
|
||||||
|
@type("number") minPlayers: number = 3;
|
||||||
|
@type("number") maxPlayers: number = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Player
|
||||||
|
Información de cada jugador:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class Player extends Schema {
|
||||||
|
@type("string") id: string;
|
||||||
|
@type("string") name: string;
|
||||||
|
@type("string") producerRole: string; // "turkey" | "coffee" | "corn"
|
||||||
|
@type(TokenInventory) tokens = new TokenInventory();
|
||||||
|
@type("number") points: number = 0;
|
||||||
|
@type("number") shameTokens: number = 0;
|
||||||
|
@type("boolean") isSuspended: boolean = false;
|
||||||
|
@type("string") role: string = "trader"; // "trader" | "judge"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TradeOffer
|
||||||
|
Ofertas comerciales entre jugadores:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class TradeOffer extends Schema {
|
||||||
|
@type("string") id: string;
|
||||||
|
@type("string") offererId: string;
|
||||||
|
@type("string") targetId: string;
|
||||||
|
@type(TokenInventory) offering = new TokenInventory();
|
||||||
|
@type(TokenInventory) requesting = new TokenInventory();
|
||||||
|
@type("string") status: string = "pending";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TokenInventory
|
||||||
|
Inventario de tokens por jugador:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class TokenInventory extends Schema {
|
||||||
|
@type("number") turkey: number = 0;
|
||||||
|
@type("number") coffee: number = 0;
|
||||||
|
@type("number") corn: number = 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎮 Lógica del Juego
|
||||||
|
|
||||||
|
### Inicialización
|
||||||
|
1. **Sala creada**: Espera exactamente 3 jugadores
|
||||||
|
2. **Asignación de roles**: Roles únicos asignados aleatoriamente
|
||||||
|
3. **Distribución inicial**: 5 tokens del tipo correspondiente
|
||||||
|
4. **Fase trading**: Comienza la Ronda 1
|
||||||
|
|
||||||
|
### Sistema de Tokens
|
||||||
|
- **Valor propio**: 1 punto por token del mismo tipo
|
||||||
|
- **Valor ajeno**: 2 puntos por token de otro tipo
|
||||||
|
- **Ejemplo**: 5 propios + 3 ajenos = 5×1 + 3×2 = 11 puntos
|
||||||
|
|
||||||
|
### Ofertas Comerciales
|
||||||
|
- **Límite**: Máximo 2 ofertas por jugador por objetivo
|
||||||
|
- **Simultaneidad**: Múltiples ofertas activas
|
||||||
|
- **Visibilidad**: Todas las ofertas son públicas
|
||||||
|
- **Respuestas**: Accept, Reject, Snatch
|
||||||
|
|
||||||
|
### Cumplimiento Parcial
|
||||||
|
```typescript
|
||||||
|
// Ejemplo: Ofrecer 6 tokens pero solo tener 5
|
||||||
|
const actualOffering = {
|
||||||
|
turkey: Math.min(offer.offering.turkey, offerer.tokens.turkey),
|
||||||
|
coffee: Math.min(offer.offering.coffee, offerer.tokens.coffee),
|
||||||
|
corn: Math.min(offer.offering.corn, offerer.tokens.corn)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API de Mensajes
|
||||||
|
|
||||||
|
### Mensajes del Cliente → Servidor
|
||||||
|
|
||||||
|
#### makeOffer
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
targetId: string,
|
||||||
|
offering: { turkey: number, coffee: number, corn: number },
|
||||||
|
requesting: { turkey: number, coffee: number, corn: number }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### respondToOffer
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
offerId: string,
|
||||||
|
response: "accept" | "reject" | "snatch"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### cancelOffer
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
offerId: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Eventos del Servidor → Cliente
|
||||||
|
|
||||||
|
#### onStateChange
|
||||||
|
- Sincronización automática del `GameState`
|
||||||
|
- Reactividad en tiempo real
|
||||||
|
- Cambios en jugadores, ofertas, puntuaciones
|
||||||
|
|
||||||
|
## 🔧 Configuración
|
||||||
|
|
||||||
|
### Configuración de Colyseus
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app.config.ts
|
||||||
|
import config from "@colyseus/tools";
|
||||||
|
import { GameRoom } from "./rooms/GameRoom";
|
||||||
|
|
||||||
|
export default config({
|
||||||
|
initializeGameServer: (gameServer) => {
|
||||||
|
gameServer.define('game', GameRoom);
|
||||||
|
},
|
||||||
|
|
||||||
|
initializeExpress: (app) => {
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.send("Snatch or Share Server");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeListen: () => {
|
||||||
|
console.log("🎮 Snatch or Share Server starting...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Puerto del servidor
|
||||||
|
PORT=2567
|
||||||
|
|
||||||
|
# Modo de desarrollo
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# URL de producción (si aplica)
|
||||||
|
PRODUCTION_URL=wss://tu-servidor.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Funcionalidades Implementadas
|
||||||
|
|
||||||
|
### Ronda 1: Estado de Naturaleza
|
||||||
|
- ✅ Sin reglas especiales
|
||||||
|
- ✅ Todos los jugadores son "Traders"
|
||||||
|
- ✅ Libre mercado sin enforcement
|
||||||
|
|
||||||
|
### Sistema de Ofertas
|
||||||
|
- ✅ Ofertas simultáneas múltiples
|
||||||
|
- ✅ Límite de 2 ofertas por target
|
||||||
|
- ✅ Cumplimiento parcial automático
|
||||||
|
- ✅ Respuestas: Accept/Reject/Snatch
|
||||||
|
|
||||||
|
### Gestión de Estado
|
||||||
|
- ✅ Sincronización en tiempo real
|
||||||
|
- ✅ Asignación automática de roles
|
||||||
|
- ✅ Cálculo automático de puntos
|
||||||
|
- ✅ Rotación de ofertas (más recientes arriba)
|
||||||
|
|
||||||
|
## 🔍 Debugging y Monitoreo
|
||||||
|
|
||||||
|
### Logs del Servidor
|
||||||
|
```typescript
|
||||||
|
// Logs automáticos incluidos
|
||||||
|
console.log(`🎭 Player ${player.name} assigned role: ${player.producerRole}`);
|
||||||
|
console.log(`📝 Trade offer created: ${offer.id}`);
|
||||||
|
console.log(`✅ Trade offer ${offer.id} ${response}ed`);
|
||||||
|
console.log(`🔄 Trade executed: ${isSnatch ? 'SNATCH' : 'FAIR'}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitor de Colyseus
|
||||||
|
```bash
|
||||||
|
# Acceder al monitor (desarrollo)
|
||||||
|
http://localhost:2567/colyseus
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verificación de Estado
|
||||||
|
```bash
|
||||||
|
# Verificar salas activas
|
||||||
|
curl http://localhost:2567/matchmake/game
|
||||||
|
|
||||||
|
# Estado del servidor
|
||||||
|
curl http://localhost:2567
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Despliegue
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# Servidor disponible en ws://localhost:2567
|
||||||
|
```
|
||||||
|
|
||||||
|
### Producción
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
# Puerto configurado via PORT env var
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (desde raíz del proyecto)
|
||||||
|
```bash
|
||||||
|
docker-compose up server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
### Probar Conexión
|
||||||
|
```bash
|
||||||
|
# Verificar que el servidor responde
|
||||||
|
curl -i http://localhost:2567
|
||||||
|
|
||||||
|
# Verificar WebSocket (usando wscat)
|
||||||
|
wscat -c ws://localhost:2567
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generar Tipos para Cliente
|
||||||
|
```bash
|
||||||
|
# Desde directorio server
|
||||||
|
npx schema-codegen src/rooms/GameRoom.ts --ts --output ../client/src/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚡ Rendimiento
|
||||||
|
|
||||||
|
### Optimizaciones Implementadas
|
||||||
|
- **Schema eficiente**: Solo sincroniza cambios
|
||||||
|
- **Límites por sala**: Máximo 3 clientes
|
||||||
|
- **Cleanup automático**: Gestión de memoria
|
||||||
|
- **Validación**: Prevención de estados inválidos
|
||||||
|
|
||||||
|
### Métricas Clave
|
||||||
|
- **Latencia**: <50ms para red local
|
||||||
|
- **Throughput**: 100+ mensajes/segundo por sala
|
||||||
|
- **Memoria**: ~10MB por sala activa
|
||||||
|
|
||||||
|
## 🔐 Seguridad
|
||||||
|
|
||||||
|
### Validaciones Implementadas
|
||||||
|
- ✅ Límite de ofertas por jugador
|
||||||
|
- ✅ Validación de tokens disponibles
|
||||||
|
- ✅ Verificación de permisos por acción
|
||||||
|
- ✅ Prevención de auto-ofertas
|
||||||
|
|
||||||
|
### Consideraciones
|
||||||
|
- Sin autenticación (red local)
|
||||||
|
- Validación en servidor (nunca confiar en cliente)
|
||||||
|
- Límites estrictos en recursos
|
||||||
|
|
||||||
|
## 🤝 Contribución
|
||||||
|
|
||||||
|
Ver [CLAUDE.md](../CLAUDE.md) para:
|
||||||
|
- Convenciones de código
|
||||||
|
- Guías de desarrollo
|
||||||
|
- Arquitectura del proyecto
|
||||||
|
- Comandos útiles
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "snatchgame-server",
|
"name": "snatchgame-server",
|
||||||
"version": "0.0.1-alpha",
|
"version": "0.0.5-alpha",
|
||||||
"description": "SnatchGame multiplayer server using Colyseus",
|
"description": "SnatchGame multiplayer server using Colyseus",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,38 +1,67 @@
|
|||||||
import { Room, Client } from "colyseus";
|
import { Room, Client } from "colyseus";
|
||||||
import { Schema, MapSchema, type } from "@colyseus/schema";
|
import { Schema, MapSchema, ArraySchema, type } from "@colyseus/schema";
|
||||||
|
|
||||||
export interface GameRoomOptions {
|
export interface GameRoomOptions {
|
||||||
gameMode?: string;
|
gameMode?: string;
|
||||||
playerName?: string;
|
playerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class TokenInventory extends Schema {
|
||||||
|
@type("number") turkey: number = 0;
|
||||||
|
@type("number") coffee: number = 0;
|
||||||
|
@type("number") corn: number = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TradeOffer extends Schema {
|
||||||
|
@type("string") id: string;
|
||||||
|
@type("string") offererId: string;
|
||||||
|
@type("string") targetId: string;
|
||||||
|
@type(TokenInventory) offering = new TokenInventory();
|
||||||
|
@type(TokenInventory) requesting = new TokenInventory();
|
||||||
|
@type("string") status: string = "pending"; // "pending" | "accepted" | "rejected" | "snatched" | "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
export class Player extends Schema {
|
export class Player extends Schema {
|
||||||
@type("string") id: string;
|
@type("string") id: string;
|
||||||
@type("string") name: string;
|
@type("string") name: string;
|
||||||
@type("number") score: number = 0;
|
@type("string") producerRole: string = "turkey"; // "turkey" | "coffee" | "corn"
|
||||||
@type("boolean") ready: boolean = false;
|
@type(TokenInventory) tokens = new TokenInventory();
|
||||||
|
@type("number") points: number = 0;
|
||||||
|
@type("number") shameTokens: number = 0;
|
||||||
|
@type("boolean") isSuspended: boolean = false;
|
||||||
|
@type("string") role: string = "trader"; // "trader" | "judge"
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameState extends Schema {
|
export class GameState extends Schema {
|
||||||
@type({ map: Player }) players = new MapSchema<Player>();
|
@type({ map: Player }) players = new MapSchema<Player>();
|
||||||
|
@type({ array: TradeOffer }) activeTradeOffers = new ArraySchema<TradeOffer>();
|
||||||
|
@type("number") round: number = 1;
|
||||||
|
@type("string") gamePhase: string = "waiting"; // "waiting" | "trading" | "judging" | "results"
|
||||||
@type("boolean") gameStarted: boolean = false;
|
@type("boolean") gameStarted: boolean = false;
|
||||||
@type("string") gameMode: string = "classic";
|
@type("number") minPlayers: number = 3;
|
||||||
@type("number") minPlayers: number = 2;
|
@type("number") maxPlayers: number = 3;
|
||||||
@type("string") gamePhase: string = "waiting"; // "waiting" | "playing"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameRoom extends Room<GameState> {
|
export class GameRoom extends Room<GameState> {
|
||||||
maxClients = 8;
|
maxClients = 3;
|
||||||
|
private producerRoles = ["turkey", "coffee", "corn"];
|
||||||
|
|
||||||
onCreate(options: GameRoomOptions) {
|
onCreate(options: GameRoomOptions) {
|
||||||
console.log(`GameRoom created with options:`, options);
|
console.log(`GameRoom created with options:`, options);
|
||||||
|
|
||||||
this.setState(new GameState());
|
this.setState(new GameState());
|
||||||
this.state.gameMode = options.gameMode || 'classic';
|
|
||||||
this.state.gamePhase = "waiting";
|
this.state.gamePhase = "waiting";
|
||||||
|
|
||||||
this.onMessage("click", (client, message) => {
|
this.onMessage("makeOffer", (client, message) => {
|
||||||
this.handleClick(client);
|
this.handleMakeOffer(client, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onMessage("respondToOffer", (client, message) => {
|
||||||
|
this.handleRespondToOffer(client, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.onMessage("cancelOffer", (client, message) => {
|
||||||
|
this.handleCancelOffer(client, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.onMessage("*", (client, type, message) => {
|
this.onMessage("*", (client, type, message) => {
|
||||||
@@ -40,31 +69,169 @@ export class GameRoom extends Room<GameState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClick(client: Client) {
|
private assignProducerRoles() {
|
||||||
|
const playerIds = Array.from(this.state.players.keys());
|
||||||
|
const shuffledRoles = [...this.producerRoles].sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
playerIds.forEach((playerId, index) => {
|
||||||
|
const player = this.state.players.get(playerId);
|
||||||
|
if (player) {
|
||||||
|
player.producerRole = shuffledRoles[index];
|
||||||
|
|
||||||
|
// Initialize tokens based on producer role
|
||||||
|
player.tokens.turkey = 0;
|
||||||
|
player.tokens.coffee = 0;
|
||||||
|
player.tokens.corn = 0;
|
||||||
|
|
||||||
|
switch (player.producerRole) {
|
||||||
|
case "turkey":
|
||||||
|
player.tokens.turkey = 5;
|
||||||
|
break;
|
||||||
|
case "coffee":
|
||||||
|
player.tokens.coffee = 5;
|
||||||
|
break;
|
||||||
|
case "corn":
|
||||||
|
player.tokens.corn = 5;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🎭 Player ${player.name} assigned role: ${player.producerRole}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private calculatePoints(player: Player): number {
|
||||||
|
const ownTokens = player.tokens[player.producerRole as keyof TokenInventory];
|
||||||
|
const otherTokens = (player.tokens.turkey + player.tokens.coffee + player.tokens.corn) - ownTokens;
|
||||||
|
return ownTokens * 1 + otherTokens * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateAllPlayerPoints() {
|
||||||
|
this.state.players.forEach(player => {
|
||||||
|
player.points = this.calculatePoints(player);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMakeOffer(client: Client, message: any) {
|
||||||
const player = this.state.players.get(client.sessionId);
|
const player = this.state.players.get(client.sessionId);
|
||||||
|
|
||||||
if (!player) {
|
if (!player || this.state.gamePhase !== "trading") {
|
||||||
console.log(`Player not found for client ${client.sessionId}`);
|
console.log(`Offer rejected - invalid state`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.gamePhase !== "playing") {
|
// Cannot offer to self
|
||||||
console.log(`Click ignored - game not started (phase: ${this.state.gamePhase})`);
|
if (message.targetId === client.sessionId) {
|
||||||
|
console.log(`Offer rejected - cannot offer to self`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
player.score += 1;
|
// Count existing offers from THIS player to THIS target
|
||||||
console.log(`🎮 Player ${player.name} clicked! New score: ${player.score}`);
|
const existingOffers = this.state.activeTradeOffers.filter(offer =>
|
||||||
|
offer.offererId === client.sessionId &&
|
||||||
|
offer.targetId === message.targetId &&
|
||||||
|
offer.status === "pending"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingOffers.length >= 2) {
|
||||||
|
console.log(`Offer rejected - maximum 2 offers per target reached`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = new TradeOffer();
|
||||||
|
offer.id = `${client.sessionId}-${Date.now()}`;
|
||||||
|
offer.offererId = client.sessionId;
|
||||||
|
offer.targetId = message.targetId;
|
||||||
|
offer.offering.turkey = message.offering.turkey || 0;
|
||||||
|
offer.offering.coffee = message.offering.coffee || 0;
|
||||||
|
offer.offering.corn = message.offering.corn || 0;
|
||||||
|
offer.requesting.turkey = message.requesting.turkey || 0;
|
||||||
|
offer.requesting.coffee = message.requesting.coffee || 0;
|
||||||
|
offer.requesting.corn = message.requesting.corn || 0;
|
||||||
|
offer.status = "pending";
|
||||||
|
|
||||||
|
this.state.activeTradeOffers.push(offer);
|
||||||
|
console.log(`📝 Trade offer created: ${offer.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRespondToOffer(client: Client, message: any) {
|
||||||
|
const offer = this.state.activeTradeOffers.find(o => o.id === message.offerId);
|
||||||
|
|
||||||
|
if (!offer || offer.targetId !== client.sessionId || offer.status !== "pending") {
|
||||||
|
console.log(`Response rejected - invalid offer`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = message.response; // "accept" | "reject" | "snatch"
|
||||||
|
offer.status = response === "accept" ? "accepted" : response === "reject" ? "rejected" : "snatched";
|
||||||
|
|
||||||
|
if (response === "accept" || response === "snatch") {
|
||||||
|
this.executeTradeOffer(offer, response === "snatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Trade offer ${offer.id} ${response}ed`);
|
||||||
|
this.updateAllPlayerPoints();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCancelOffer(client: Client, message: any) {
|
||||||
|
const offer = this.state.activeTradeOffers.find(o => o.id === message.offerId);
|
||||||
|
|
||||||
|
if (!offer || offer.offererId !== client.sessionId || offer.status !== "pending") {
|
||||||
|
console.log(`Cancel rejected - invalid offer`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offer.status = "cancelled";
|
||||||
|
console.log(`❌ Trade offer ${offer.id} cancelled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeTradeOffer(offer: TradeOffer, isSnatch: boolean) {
|
||||||
|
const offerer = this.state.players.get(offer.offererId);
|
||||||
|
const target = this.state.players.get(offer.targetId);
|
||||||
|
|
||||||
|
if (!offerer || !target) return;
|
||||||
|
|
||||||
|
// Calculate what can actually be transferred
|
||||||
|
const actualOffering = {
|
||||||
|
turkey: Math.min(offer.offering.turkey, offerer.tokens.turkey),
|
||||||
|
coffee: Math.min(offer.offering.coffee, offerer.tokens.coffee),
|
||||||
|
corn: Math.min(offer.offering.corn, offerer.tokens.corn)
|
||||||
|
};
|
||||||
|
|
||||||
|
const actualRequesting = isSnatch ? { turkey: 0, coffee: 0, corn: 0 } : {
|
||||||
|
turkey: Math.min(offer.requesting.turkey, target.tokens.turkey),
|
||||||
|
coffee: Math.min(offer.requesting.coffee, target.tokens.coffee),
|
||||||
|
corn: Math.min(offer.requesting.corn, target.tokens.corn)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transfer tokens
|
||||||
|
offerer.tokens.turkey -= actualOffering.turkey;
|
||||||
|
offerer.tokens.coffee -= actualOffering.coffee;
|
||||||
|
offerer.tokens.corn -= actualOffering.corn;
|
||||||
|
offerer.tokens.turkey += actualRequesting.turkey;
|
||||||
|
offerer.tokens.coffee += actualRequesting.coffee;
|
||||||
|
offerer.tokens.corn += actualRequesting.corn;
|
||||||
|
|
||||||
|
target.tokens.turkey += actualOffering.turkey;
|
||||||
|
target.tokens.coffee += actualOffering.coffee;
|
||||||
|
target.tokens.corn += actualOffering.corn;
|
||||||
|
target.tokens.turkey -= actualRequesting.turkey;
|
||||||
|
target.tokens.coffee -= actualRequesting.coffee;
|
||||||
|
target.tokens.corn -= actualRequesting.corn;
|
||||||
|
|
||||||
|
console.log(`🔄 Trade executed: ${isSnatch ? 'SNATCH' : 'FAIR'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkGameStart() {
|
private checkGameStart() {
|
||||||
const playerCount = this.state.players.size;
|
const playerCount = this.state.players.size;
|
||||||
|
|
||||||
if (playerCount >= this.state.minPlayers && this.state.gamePhase === "waiting") {
|
if (playerCount === this.state.minPlayers && this.state.gamePhase === "waiting") {
|
||||||
this.state.gamePhase = "playing";
|
this.assignProducerRoles();
|
||||||
|
this.state.gamePhase = "trading";
|
||||||
this.state.gameStarted = true;
|
this.state.gameStarted = true;
|
||||||
console.log(`🚀 Game started! ${playerCount} players ready to play`);
|
this.state.round = 1;
|
||||||
} else if (playerCount < this.state.minPlayers && this.state.gamePhase === "playing") {
|
console.log(`🚀 Game started! Round ${this.state.round} - Trading phase`);
|
||||||
|
} else if (playerCount < this.state.minPlayers && this.state.gameStarted) {
|
||||||
this.state.gamePhase = "waiting";
|
this.state.gamePhase = "waiting";
|
||||||
this.state.gameStarted = false;
|
this.state.gameStarted = false;
|
||||||
console.log(`⏸️ Game paused - not enough players (${playerCount}/${this.state.minPlayers})`);
|
console.log(`⏸️ Game paused - not enough players (${playerCount}/${this.state.minPlayers})`);
|
||||||
@@ -77,8 +244,12 @@ export class GameRoom extends Room<GameState> {
|
|||||||
const player = new Player();
|
const player = new Player();
|
||||||
player.id = client.sessionId;
|
player.id = client.sessionId;
|
||||||
player.name = options.playerName || `Player ${this.state.players.size + 1}`;
|
player.name = options.playerName || `Player ${this.state.players.size + 1}`;
|
||||||
player.score = 0;
|
player.producerRole = "turkey"; // Will be reassigned when game starts
|
||||||
player.ready = false;
|
player.tokens = new TokenInventory();
|
||||||
|
player.points = 0;
|
||||||
|
player.shameTokens = 0;
|
||||||
|
player.isSuspended = false;
|
||||||
|
player.role = "trader";
|
||||||
|
|
||||||
this.state.players.set(client.sessionId, player);
|
this.state.players.set(client.sessionId, player);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user