first commit

This commit is contained in:
2025-07-03 00:06:32 -06:00
commit f739c6b3c7
33 changed files with 8197 additions and 0 deletions

260
.gitignore vendored Normal file
View File

@@ -0,0 +1,260 @@
# SnatchGame - GitIgnore
# Comprehensive ignore file for Node.js, Vue 3, TypeScript, Docker project
# ===== DEPENDENCIES =====
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# ===== BUILD OUTPUTS =====
# Client build
client/dist/
client/build/
# Server build
server/lib/
server/build/
server/dist/
# Admin build
admin/dist/
admin/build/
# ===== ENVIRONMENT & CONFIG =====
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Runtime data
pids/
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# ===== LOGS =====
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# ===== TYPESCRIPT =====
# TypeScript cache
*.tsbuildinfo
# TypeScript compiled output
*.js.map
*.d.ts
# ===== VUE SPECIFIC =====
# Vue build outputs
dist/
dist-ssr/
*.local
# Vue CLI generated files
.DS_Store
thumbs.db
# ===== VITE =====
# Vite cache
.vite/
# ===== IDE / EDITORS =====
# VSCode
.vscode/
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/settings.json
# WebStorm / IntelliJ
.idea/
*.swp
*.swo
# Sublime Text
*.sublime-workspace
*.sublime-project
# Vim
*.swp
*.swo
*~
# Emacs
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# ===== OPERATING SYSTEMS =====
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.lnk
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
.nfs*
# ===== DOCKER =====
# Docker
.dockerignore
docker-compose.override.yml
# ===== TESTING =====
# Test coverage
coverage/
.nyc_output/
# Jest
coverage/
*.lcov
.jest/
# Cypress
cypress/videos/
cypress/screenshots/
# ===== TEMPORARY FILES =====
# Temporary folders
tmp/
temp/
.tmp/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# ===== CACHE =====
# Cache directories
.cache/
.parcel-cache/
.eslintcache
# ===== COLYSEUS SPECIFIC =====
# Colyseus build artifacts
*.arena.env
# ===== PROJECT SPECIFIC =====
# Generated types (these are auto-generated)
client/src/types/Player.ts
client/src/types/GameState.ts
admin/src/types/Player.ts
admin/src/types/GameState.ts
# Development databases
*.db
*.sqlite
*.sqlite3
# Documentation that shouldn't be tracked
/docs/generated/
# Backup files
*.backup
*.bak
*.orig
# ===== SECURITY =====
# Sensitive files
*.pem
*.key
*.cert
*.crt
.env.*.local
secrets/
# ===== PACKAGE MANAGERS =====
# Yarn
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# PNPM
.pnpm-store/
# ===== MISCELLANEOUS =====
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
.env.production
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

84
CHANGELOG.md Normal file
View File

@@ -0,0 +1,84 @@
# Changelog
Todos los cambios notables de este proyecto serán documentados en este archivo.
El formato está basado en [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Planeado
- Sistema de logros
- Efectos de sonido
- PWA support
- Multi-idioma
- Sistema de autenticación
## [0.0.1-alpha] - 2025-01-03
### Añadido
- **Funcionalidad core del juego**
- Juego multijugador de click battle en tiempo real
- Soporte para hasta 8 jugadores simultáneos
- Scoreboard en tiempo real
- Auto-inicio cuando hay 2+ jugadores
- Auto-pausa cuando <2 jugadores
- **Arquitectura técnica**
- Servidor Colyseus.io con TypeScript
- Cliente Vue 3 con Composition API
- Sincronización automática de tipos con schema-codegen
- Sistema de logging configurable
- Variables de entorno por ambiente
- **UI/UX**
- Pantalla de home con branding
- Pantalla de espera con contador de jugadores
- Pantalla de juego con botón animado
- Modal de configuración
- Diseño responsive
- **Desarrollo**
- VSCode tasks para desarrollo
- Hot reload en desarrollo
- TypeScript estricto en todo el proyecto
- Sistema de tipos compartidos
- Docker setup para producción
- **Documentación**
- README.md completo
- CLAUDE.md para desarrollo interno
- .gitignore comprehensivo
- Estructura de proyecto documentada
### Características técnicas
- **Backend**: Colyseus.io 0.16+, Node.js 18+, TypeScript, Express
- **Frontend**: Vue 3, TypeScript, Vite, CSS vanilla
- **Desarrollo**: Schema-codegen, ESLint, Hot reload
- **Deploy**: Docker, docker-compose, Nginx proxy
### Estado actual
- Funcionalidad básica del juego completamente funcional
- Multijugador en tiempo real working
- UI responsive y animada
- Sistema de logging configurable
- Documentación completa
- En desarrollo activo - versión Alpha
---
## Formato de versiones
- **Major.Minor.Patch-PreRelease**
- **Alpha**: Desarrollo inicial, funcionalidad básica
- **Beta**: Características completas, testing intensivo
- **RC**: Release Candidate, listo para producción
- **Stable**: Versión estable para uso general
### Tipos de cambios
- `Added` para nuevas características
- `Changed` para cambios en funcionalidad existente
- `Deprecated` para características que serán removidas
- `Removed` para características removidas
- `Fixed` para bug fixes
- `Security` para fixes de vulnerabilidades

187
CLAUDE.md Normal file
View File

@@ -0,0 +1,187 @@
# Guía de Trabajo - SnatchGame
## Instrucciones del Proyecto
Crear un juego multijugador para red local con:
- Servidor central usando Colyseus.io para estado compartido
- Cliente con Vue 3 + CSS + HTML vanilla
- Cliente usa Colyseus.js SDK
- Servidor y clientes en la misma red local
## Estructura del Proyecto
```
/server/ # Servidor Colyseus.io
/client/ # Cliente Vue 3 vanilla
```
## Stack Tecnológico
**Servidor:**
- Colyseus.io (framework de servidor)
- Node.js
- TypeScript
**Cliente:**
- Vue 3 (vanilla, sin build tools)
- HTML vanilla
- CSS vanilla
- Colyseus.js (SDK cliente)
- TypeScript
**UI Administración:**
- Vue 3 (vanilla, sin build tools)
- HTML vanilla
- CSS vanilla
- Colyseus.js (SDK cliente)
- TypeScript
## Convenciones de Código
**Lenguaje:**
- Código: Inglés
- UI/Diálogos: Español
- Comentarios: Inglés
**Naming Conventions:**
- Variables/Funciones: `thisIsAFunction`
- Clases: `ThisIsAClass`
- Constantes globales/env: `THIS_IS_A_GLOBAL_CONSTANT`
**TypeScript:**
- Tipado estricto en todos los componentes
- Tipos auto-generados desde servidor usando schema-codegen
- Homogeneidad de tipos entre los 3 componentes
## Arquitectura - Microservicios
### Desarrollo
```
/server/ # Servidor Colyseus.io + TypeScript (Puerto 2567)
/client/ # Cliente Vue 3 + Express server (Puerto 3000)
/admin/ # UI Admin Vue 3 + Express server (Puerto 3001)
```
### Producción (Docker + Nginx Proxy Manager)
```
snatchgame-server # Contenedor servidor Colyseus
snatchgame-client # Contenedor cliente Express
snatchgame-admin # Contenedor admin Express
nginx-proxy-manager # Proxy reverso y balanceador
```
**Enrutamiento Producción:**
- `/` → Cliente UI
- `/admin` → Admin UI
- `/server` → API Servidor Colyseus
## UI de Administración
**Funcionalidades:**
- Estadísticas en tiempo real de partidas activas
- Leaderboard global
- Monitor de estado de jugadores
- Panel de debugging para IT profesional
- Transparencia total del estado del servidor
**Usuarios objetivo:**
- Admin no-técnico: Vista simple de estadísticas
- IT profesional: Vista detallada de debugging
## Comandos Importantes
### Desarrollo
- **Servidor**: `cd server && npm run dev` (Puerto 2567)
- **Cliente**: `cd client && npm run dev` (Puerto 3000)
- **Admin**: `cd admin && npm run dev` (Puerto 3001)
### Generación de Tipos TypeScript
- **Manual**: `cd server && npx schema-codegen src/rooms/GameRoom.ts --ts --output ../client/src/types/`
- **Automático (cliente)**: `cd client && npm run generate-types`
- **Automático (admin)**: `cd admin && npm run generate-types`
### Testing y Debugging
- **Verificar puertos libres**: `lsof -i :2567,3000,3001`
- **Cerrar procesos**: `pkill -f "ts-node-dev" && pkill -f "vite" && pkill -f "node.*server"`
- **Cerrar procesos específicos**: `kill <PID>` (usar PID del proceso)
**IMPORTANTE:**
- ⚠️ **Siempre cerrar servicios** después de testing/debugging
- ⚠️ **Si servicios no arrancan** probablemente el usuario los levantó manualmente
- ⚠️ **Verificar puertos** antes de iniciar servicios para evitar conflictos
### Producción
- **Build Server**: `cd server && npm run build`
- **Start Server**: `cd server && npm run start`
- **Start Client**: `cd client && npm run start`
- **Start Admin**: `cd admin && npm run start`
### Docker (Producción)
- **Build**: `docker-compose build`
- **Start**: `docker-compose up -d`
- **Stop**: `docker-compose down`
- **Logs**: `docker-compose logs -f [service]`
### Variables de Entorno
- Desarrollo: `.env.development`
- Producción: `.env.production`
### VSCode Tasks
- **Ctrl+Shift+P** → `Tasks: Run Task`
- **Start Server** - Ejecutar solo servidor Colyseus
- **Start Client** - Ejecutar solo cliente UI
- **Start Admin** - Ejecutar solo admin UI
- **Start All Services** - Ejecutar todos los servicios paralelo
- **Install All Dependencies** - Instalar deps de todos
- **Build Server** - Compilar servidor TypeScript
## Gestión de Tipos TypeScript
### Schema-Codegen (Colyseus)
**Funcionalidad:**
- Genera automáticamente tipos TypeScript desde Schema classes del servidor
- Solo funciona para clases que extienden `Schema` con decoradores `@type`
- Mantiene sincronización perfecta entre servidor y cliente
- Es la práctica oficial recomendada por Colyseus
**Comandos:**
```bash
# Desde directorio server
npx schema-codegen src/rooms/GameRoom.ts --ts --output ../client/src/types/
# Desde directorio client (npm script configurado)
npm run generate-types
```
**Archivos generados:**
```
client/src/types/
├── Player.ts # Auto-generado desde server/src/rooms/GameRoom.ts
├── GameState.ts # Auto-generado desde server/src/rooms/GameRoom.ts
└── index.ts # Re-exports + tipos auxiliares manuales
```
### Tipos No-Schema (Manuales)
**Importante:** Los tipos que NO son Schema classes deben copiarse manualmente:
**Ejemplos de tipos manuales:**
- `GameRoomOptions` (interfaces para opciones)
- `export interface` (interfaces auxiliares)
- `export type` (type aliases)
- Enums, constantes, utilidades
**Ubicación:**
- **Server**: `server/src/rooms/GameRoom.ts` (junto a Schema classes)
- **Cliente**: `client/src/types/index.ts` (copiados manualmente)
- **Admin**: `admin/src/types/index.ts` (copiados manualmente)
**Flujo de trabajo:**
1. **Schema classes**: Se auto-generan con schema-codegen
2. **Tipos auxiliares**: Se copian manualmente cuando se añaden/modifican
3. **Consistencia**: Usar nombres idénticos entre server y client
## Notas Específicas
- **Offline**: Sin dependencias externas de internet
- **Microservicios**: Arquitectura separada por responsabilidades
- **Red Local**: Funciona completamente en LAN
- **Monorepo**: Un repositorio para todos los servicios
- **Docker**: Orquestación con docker-compose en producción
- **Nginx Proxy Manager**: Enrutamiento y balanceo de carga
- **Variables de Entorno**: Configuración por ambiente (.env)
- **Logging**: Detallado para debugging profesional
- **Tipos TypeScript**: Auto-generación con schema-codegen + copiar tipos auxiliares manualmente

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 NucleoServices
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

303
README.md Normal file
View File

@@ -0,0 +1,303 @@
# 🎮 SnatchGame
[![Version](https://img.shields.io/badge/version-0.0.1--alpha-orange.svg)](https://github.com/username/snatchgame)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18.0.0-green.svg)](https://nodejs.org/)
[![Vue.js](https://img.shields.io/badge/vue-3.0+-brightgreen.svg)](https://vuejs.org/)
[![Colyseus](https://img.shields.io/badge/colyseus-0.16+-purple.svg)](https://colyseus.io/)
Un juego multijugador en tiempo real de velocidad de clicks, construido con **Colyseus.io** y **Vue 3** para redes locales.
> ⚠️ **Proyecto en desarrollo** - Actualmente en fase Alpha (v0.0.1-alpha)
## 🚀 Características
- **🌐 Multijugador en tiempo real** - Hasta 8 jugadores simultáneos
- **⚡ 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
1. **Únete a una partida** - Presiona "Unirse a partida"
2. **Espera jugadores** - Mínimo 2 jugadores para comenzar
3. **¡Click Battle!** - Presiona el botón gigante lo más rápido posible
4. **Compite** - Ve el scoreboard en tiempo real
## 🛠️ Stack Tecnológico
### Backend
- **[Colyseus.io](https://colyseus.io/)** - Framework multijugador en tiempo real
- **Node.js** + **TypeScript**
- **Express.js**
### Frontend
- **[Vue 3](https://vuejs.org/)** - Composition API
- **TypeScript** - Tipado estricto
- **Vite** - Build tool ultra-rápido
- **CSS vanilla** - Estilos custom
### Desarrollo
- **Schema-codegen** - Sincronización automática de tipos
- **Hot reload** - Desarrollo en vivo
- **ESLint + Prettier** - Calidad de código
## 📦 Instalación
### Prerrequisitos
- **Node.js** >= 18.0.0
- **npm** >= 8.0.0
### 1. Clonar el repositorio
```bash
git clone https://github.com/username/snatchgame.git
cd snatchgame
```
### 2. Instalar dependencias
```bash
# Servidor
cd server
npm install
# Cliente
cd ../client
npm install
# Admin (opcional)
cd ../admin
npm install
```
## 🚀 Ejecución
### Desarrollo
#### Opción 1: VSCode Tasks (Recomendado)
```bash
# Presiona Ctrl+Shift+P → "Tasks: Run Task"
# Selecciona "Start All Services"
```
#### Opción 2: Manual
```bash
# Terminal 1 - Servidor
cd server
npm run dev
# Terminal 2 - Cliente
cd client
npm run dev
# Terminal 3 - Admin (opcional)
cd admin
npm run dev
```
### URLs de desarrollo
- **Cliente**: http://localhost:3000
- **Servidor**: http://localhost:2567
- **Admin**: http://localhost:3001
### Producción
```bash
# Build
npm run build
# Deploy con Docker
docker-compose up -d
```
## 🎮 Demo
### Pantalla Principal
![Home Screen](docs/images/home-screen.png)
### Esperando Jugadores
![Waiting Screen](docs/images/waiting-screen.png)
### Jugando
![Game Screen](docs/images/game-screen.png)
## ⚙️ Configuración
### Variables de Entorno
**Desarrollo** (`.env.development`):
```env
VITE_SERVER_URL=ws://localhost:2567
NODE_ENV=development
PORT=2567
```
**Producción** (`.env.production`):
```env
VITE_SERVER_URL=wss://your-domain.com
NODE_ENV=production
PORT=2567
```
### Configuración de Logs
- **Desarrollo**: Logs habilitados por defecto
- **Producción**: Logs deshabilitados por defecto
- **Usuario**: Configurable en "Configuración" → "Logs de depuración"
## 🏗️ Arquitectura
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Client UI │ │ Colyseus │ │ Admin UI │
│ (Vue 3) │◄──►│ Server │◄──►│ (Vue 3) │
│ Port 3000 │ │ Port 2567 │ │ Port 3001 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌─────────────────┐
│ Docker + │
│ Nginx Proxy │
└─────────────────┘
```
### Sincronización de Tipos
```bash
# Los tipos se generan automáticamente del servidor al cliente
npm run generate-types
```
## 🧪 Testing
```bash
# Servidor
cd server
npm test
# Cliente
cd client
npm test
# E2E
npm run test:e2e
```
## 📁 Estructura del Proyecto
```
snatchgame/
├── 📁 server/ # Colyseus.io backend
│ ├── src/
│ │ ├── rooms/ # Game rooms
│ │ ├── schema/ # Data schemas
│ │ └── index.ts # Entry point
│ └── package.json
├── 📁 client/ # Vue 3 frontend
│ ├── src/
│ │ ├── components/ # Vue components
│ │ ├── services/ # Game client & logger
│ │ ├── types/ # Auto-generated types
│ │ └── main.ts
│ └── package.json
├── 📁 admin/ # Admin dashboard
├── 📁 docs/ # Documentation
├── 🐳 docker-compose.yml
├── 📋 CLAUDE.md # Development guide
└── 📖 README.md # This file
```
## 🤝 Contribuir
1. **Fork** el proyecto
2. **Crea** una feature branch (`git checkout -b feature/AmazingFeature`)
3. **Commit** tus cambios (`git commit -m 'Add AmazingFeature'`)
4. **Push** a la branch (`git push origin feature/AmazingFeature`)
5. **Abre** un Pull Request
### Guías de contribución
- Sigue las convenciones de código existentes
- Añade tests para nuevas características
- Actualiza la documentación si es necesario
- Los commits deben ser descriptivos
## 🐛 Debugging
### Logs de desarrollo
1. Ve a **Configuración** en la UI
2. Habilita **"Logs de depuración"**
3. Abre **DevTools** (F12) para ver logs detallados
### Comandos útiles
```bash
# Verificar puertos libres
lsof -i :2567,3000,3001
# Cerrar procesos
pkill -f "ts-node-dev" && pkill -f "vite"
# Regenerar tipos
cd client && npm run generate-types
```
## 🚀 Deploy
### Docker (Recomendado)
```bash
# Build y start
docker-compose up -d
# Logs
docker-compose logs -f
# Stop
docker-compose down
```
### Manual
```bash
# Build servidor
cd server && npm run build
# Build cliente
cd client && npm run build
# Start producción
npm run start
```
## 📋 Roadmap
- [ ] 🎨 Themes y customización
- [ ] 🏆 Sistema de logros
- [ ] 📊 Estadísticas detalladas
- [ ] 🔊 Efectos de sonido
- [ ] 📱 PWA support
- [ ] 🌍 Multi-idioma
- [ ] 🔒 Sistema de autenticación
## 📄 Licencia
Este proyecto está bajo la licencia **MIT**. Ver el archivo [LICENSE](LICENSE) para más detalles.
## 👥 Autores
- **NucleoServices** - *Desarrollo inicial* - [@draganel](https://github.com/draganel)
## 📞 Soporte
¿Encontraste un bug? ¿Tienes una sugerencia?
- 🐛 **Issues**: [GitHub Issues](https://github.com/username/snatchgame/issues)
- 💬 **Discusiones**: [GitHub Discussions](https://github.com/username/snatchgame/discussions)
- 📧 **Email**: support@nucleoservices.com
---
<div align="center">
**⭐ ¡Dale una estrella si te gusta el proyecto! ⭐**
Hecho con ❤️ por **NucleoServices**
</div>

25
TODO.md Normal file
View File

@@ -0,0 +1,25 @@
# TODO - SnatchGame
## Problemas Técnicos Pendientes
### TypeScript - Tipos en Game.vue
**Problema:** Se tuvo que usar `any` en Game.vue para el prop `gameClient` debido a incompatibilidades de tipos entre Vue y la clase GameClient.
**Archivo afectado:** `client/src/components/Game.vue`
```typescript
const props = defineProps<{
gameClient: any // TEMPORAL - debería ser GameClient
}>()
```
**Causa:** Vue está infiriendo mal el tipo de GameClient cuando se pasa como prop/emit, causando errores como:
```
Type 'GameClient' is missing the following properties from type 'GameClient': client, room
```
**Solución pendiente:**
- Investigar la causa raíz del problema de inferencia de tipos
- Posiblemente usar una interface en lugar de clase
- O definir tipos de props más explícitos
**Prioridad:** Media (funciona pero no es tipo-seguro)

1
client/.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_SERVER_URL=ws://localhost:2567

BIN
client/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

4
client/assets/logo.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="36" cy="36" r="36" fill="#2979ff"/>
<text x="36" y="42" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">SG</text>
</svg>

After

Width:  |  Height:  |  Size: 288 B

12
client/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snatch Game</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2917
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
client/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "snatchgame-client",
"version": "0.0.1-alpha",
"description": "SnatchGame client UI server",
"main": "server.js",
"scripts": {
"dev": "npm run generate-types && vite",
"build": "npm run generate-types && vue-tsc && vite build",
"preview": "vite preview",
"serve": "nodemon server.js",
"start": "NODE_ENV=production node server.js",
"generate-types": "cd ../server && npx schema-codegen src/rooms/GameRoom.ts --ts --output ../client/src/types/",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"express",
"vue",
"client",
"ui"
],
"author": "",
"license": "ISC",
"dependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.7.0",
"colyseus.js": "^0.16.19",
"dotenv": "^16.0.0",
"express": "^4.18.0",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vue": "^3.5.17"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/node": "^20.0.0",
"nodemon": "^3.1.10",
"vue-tsc": "^3.0.1"
}
}

45
client/server.js Normal file
View File

@@ -0,0 +1,45 @@
const express = require('express');
const path = require('path');
const dotenv = require('dotenv');
// Load environment variables
const ENV = process.env.NODE_ENV || 'development';
dotenv.config({ path: `.env.${ENV}` });
const app = express();
const PORT = process.env.PORT || 3000;
// Serve static files from current directory
app.use(express.static('.'));
// Serve main HTML file
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
service: 'snatchgame-client',
environment: ENV,
serverUrl: process.env.SERVER_URL
});
});
// API endpoint to get environment config for client
app.get('/api/config', (req, res) => {
res.json({
serverUrl: process.env.SERVER_URL,
environment: ENV
});
});
app.listen(PORT, () => {
console.log(`
🎮 SnatchGame Client Server
📱 Environment: ${ENV}
🌐 Server URL: http://localhost:${PORT}
🔗 Game Server: ${process.env.SERVER_URL}
`);
});

49
client/src/App.vue Normal file
View File

@@ -0,0 +1,49 @@
<template>
<div id="app">
<Home
v-if="currentScreen === 'home'"
@join-game="onJoinGame"
@show-settings="showSettings = true"
/>
<Game v-else-if="currentScreen === 'game'" :game-client="gameClient" />
<!-- Settings Modal -->
<Settings v-if="showSettings" @close="showSettings = false" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Home from '@/components/Home.vue'
import Game from '@/components/Game.vue'
import Settings from '@/components/Settings.vue'
import { GameClient } from '@/services/gameClient'
import { logger } from '@/services/logger'
const currentScreen = ref<'home' | 'game'>('home')
const gameClient = ref<GameClient | null>(null)
const showSettings = ref(false)
const onJoinGame = (client: any) => {
gameClient.value = client
currentScreen.value = 'game'
logger.info('Transitioning to game screen')
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
#app {
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="game-container">
<!-- Waiting Phase -->
<div v-if="gamePhase === 'waiting'" class="waiting-screen">
<div class="waiting-content">
<h2>Esperando jugadores...</h2>
<div class="player-count">
{{ playerCount }}/{{ minPlayers }} jugadores conectados
</div>
<div class="spinner"></div>
</div>
</div>
<!-- Playing Phase -->
<div v-else-if="gamePhase === 'playing'" class="game-screen">
<!-- Scoreboard -->
<div class="scoreboard">
<div
v-for="player in players"
:key="player.id"
class="player-score"
:class="{ 'current-player': player.id === currentPlayerId }"
>
<span class="player-name">{{ player.name }}</span>
<span class="score">{{ player.score }}</span>
</div>
</div>
<!-- Click Button -->
<div class="click-area">
<button
@click="handleClick"
class="click-button"
:class="{ 'clicked': isClicked }"
>
<span class="click-text">¡CLICK!</span>
<div class="click-effect" v-if="showEffect"></div>
</button>
</div>
<!-- Current Player Info -->
<div class="player-info">
<p>Tu puntaje: <strong>{{ currentPlayerScore }}</strong></p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, triggerRef } from 'vue'
import { GameClient } from '@/services/gameClient'
import { GameState, Player } from '@/types'
import type { Room } from 'colyseus.js'
import { logger } from '@/services/logger'
const props = defineProps<{
gameClient: any
}>()
const gameState = ref<GameState | null>(null)
const isClicked = ref(false)
const showEffect = ref(false)
// Computed properties
const gamePhase = computed(() => {
const phase = gameState.value?.gamePhase || 'waiting'
logger.computedProperty('gamePhase', phase)
return phase
})
const minPlayers = computed(() => gameState.value?.minPlayers || 2)
const playerCount = computed(() => {
const count = gameState.value?.players.size || 0
logger.computedProperty('playerCount', count)
return count
})
const players = computed(() => {
if (!gameState.value) return []
const playerList = Array.from(gameState.value.players.values())
logger.computedProperty('players', playerList)
return playerList
})
const currentPlayerId = computed(() => props.gameClient?.currentPlayerId || '')
const currentPlayerScore = computed(() => {
if (!gameState.value || !currentPlayerId.value) return 0
const player = gameState.value.players.get(currentPlayerId.value)
return player?.score || 0
})
const handleClick = () => {
if (!props.gameClient || gamePhase.value !== 'playing') return
// Send click through gameClient
props.gameClient.sendClick()
// Visual feedback
isClicked.value = true
showEffect.value = true
setTimeout(() => {
isClicked.value = false
}, 150)
setTimeout(() => {
showEffect.value = false
}, 400)
}
onMounted(() => {
if (!props.gameClient) return
logger.gameMounted()
// Subscribe to state changes
const unsubscribeStateChange = props.gameClient.onStateChange((state: GameState) => {
logger.gameComponentUpdate({
gamePhase: state.gamePhase,
playerCount: state.players.size,
gameStarted: state.gameStarted
})
// Force Vue reactivity by assigning new reference and triggering update
gameState.value = state
triggerRef(gameState)
logger.info('Reactive gameState updated and triggered:', gameState.value)
})
// Subscribe to phase changes for debugging
const unsubscribePhaseChange = props.gameClient.onGamePhaseChange((phase: string) => {
logger.info('Game component detected phase change:', phase)
})
// Cleanup on unmount
onUnmounted(() => {
logger.gameUnmounted()
unsubscribeStateChange()
unsubscribePhaseChange()
})
})
</script>
<style scoped>
.game-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
}
/* Waiting Screen */
.waiting-screen {
text-align: center;
}
.waiting-content h2 {
font-size: 2.5rem;
margin-bottom: 1rem;
font-weight: 700;
}
.player-count {
font-size: 1.5rem;
margin-bottom: 2rem;
opacity: 0.9;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Game Screen */
.game-screen {
width: 100%;
max-width: 800px;
}
.scoreboard {
display: flex;
justify-content: center;
gap: 2rem;
margin-bottom: 3rem;
flex-wrap: wrap;
}
.player-score {
background: rgba(255, 255, 255, 0.1);
padding: 1rem 1.5rem;
border-radius: 12px;
text-align: center;
backdrop-filter: blur(10px);
border: 2px solid transparent;
transition: all 0.3s ease;
}
.player-score.current-player {
border-color: #ffd700;
background: rgba(255, 215, 0, 0.2);
}
.player-name {
display: block;
font-size: 1rem;
margin-bottom: 0.5rem;
opacity: 0.9;
}
.score {
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;
}
.player-info strong {
color: #ffd700;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="container">
<div class="branding">
<img src="/assets/logo.svg" alt="Snatch Game Logo" class="logo" />
<h1>Snatch Game</h1>
</div>
<div class="buttons">
<button @click="onJoin" :disabled="isConnecting">
{{ isConnecting ? 'Conectando...' : 'Unirse a partida' }}
</button>
<button @click="onSettings">Configuración</button>
<button @click="onLogin">Iniciar sesión</button>
</div>
<div v-if="connectionStatus" class="connection-status">
{{ connectionStatus }}
</div>
<div class="footer">NucleoServices</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { GameClient } from '@/services/gameClient'
import { logger } from '@/services/logger'
const emit = defineEmits<{
'join-game': [client: any]
'show-settings': []
}>()
const gameClient = ref<GameClient | null>(null)
const isConnecting = ref(false)
const connectionStatus = ref('')
onMounted(() => {
gameClient.value = new GameClient()
logger.info('GameClient initialized:', gameClient.value);
})
const onJoin = async () => {
if (isConnecting.value || !gameClient.value) return
isConnecting.value = true
connectionStatus.value = 'Conectando...'
try {
await gameClient.value.joinGame('Jugador Test', 'classic')
connectionStatus.value = 'Conectado exitosamente!'
logger.info('Conectado al servidor')
// Emit the join-game event to transition to game screen
emit('join-game', gameClient.value)
} catch (error) {
connectionStatus.value = 'Error de conexión: ' + (error as Error).message
logger.error('Error connecting:', error)
} finally {
isConnecting.value = false
}
}
const onSettings = () => {
emit('show-settings')
}
const onLogin = () => {
logger.info('Iniciar sesión clicked')
// TODO: lógica para iniciar sesión
}
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
}
.branding {
text-align: center;
margin-bottom: 3rem;
}
.logo {
width: 120px;
height: 120px;
margin-bottom: 1rem;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
}
.buttons {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
}
button {
padding: 1rem 2rem;
font-size: 1.1rem;
font-weight: 600;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
color: #333;
cursor: pointer;
transition: all 0.3s ease;
min-width: 200px;
}
button:hover:not(:disabled) {
background: white;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.connection-status {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
font-weight: 500;
backdrop-filter: blur(10px);
}
.footer {
position: absolute;
bottom: 2rem;
font-size: 0.9rem;
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div class="settings-container">
<div class="settings-header">
<h2>Configuración</h2>
<button @click="$emit('close')" class="close-button">
<span></span>
</button>
</div>
<div class="settings-content">
<div class="setting-section">
<h3>Desarrollo</h3>
<div class="setting-item">
<label class="checkbox-label">
<input
type="checkbox"
:checked="debugLoggingEnabled"
@change="toggleDebugLogging"
/>
<span class="checkmark"></span>
<span class="label-text">Activar logs de depuración</span>
</label>
<p class="setting-description">
Muestra información detallada en la consola del navegador.
Útil para depuración pero puede afectar el rendimiento.
</p>
</div>
</div>
<div class="setting-section">
<h3>Información</h3>
<div class="info-item">
<span class="info-label">Versión:</span>
<span class="info-value">0.0.1-alpha</span>
</div>
<div class="info-item">
<span class="info-label">Entorno:</span>
<span class="info-value">{{ environment }}</span>
</div>
</div>
</div>
<div class="settings-footer">
<button @click="$emit('close')" class="button-primary">
Cerrar
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { logger } from '@/services/logger'
defineEmits<{
close: []
}>()
const debugLoggingEnabled = ref(false)
const environment = import.meta.env.MODE
onMounted(() => {
debugLoggingEnabled.value = logger.isDebugEnabled()
})
const toggleDebugLogging = (event: Event) => {
const target = event.target as HTMLInputElement
debugLoggingEnabled.value = target.checked
logger.setDebugEnabled(target.checked)
}
</script>
<style scoped>
.settings-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 500px;
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
z-index: 1000;
color: #333;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e0e0e0;
}
.settings-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.close-button {
width: 32px;
height: 32px;
border: none;
background: #f5f5f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s;
}
.close-button:hover {
background: #e0e0e0;
}
.settings-content {
padding: 1.5rem;
max-height: 60vh;
overflow-y: auto;
}
.setting-section {
margin-bottom: 2rem;
}
.setting-section h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
font-weight: 600;
color: #555;
}
.setting-item {
margin-bottom: 1.5rem;
}
.checkbox-label {
display: flex;
align-items: flex-start;
cursor: pointer;
gap: 0.75rem;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 20px;
height: 20px;
border: 2px solid #ddd;
border-radius: 4px;
position: relative;
flex-shrink: 0;
margin-top: 1px;
transition: all 0.2s;
}
.checkbox-label input[type="checkbox"]:checked + .checkmark {
background: #667eea;
border-color: #667eea;
}
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '';
position: absolute;
left: 6px;
top: 2px;
width: 6px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.label-text {
font-weight: 500;
line-height: 1.4;
}
.setting-description {
margin: 0.5rem 0 0 2.75rem;
font-size: 0.9rem;
color: #666;
line-height: 1.4;
}
.info-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-weight: 500;
color: #555;
}
.info-value {
color: #333;
font-family: 'Monaco', 'Consolas', monospace;
font-size: 0.9rem;
}
.settings-footer {
padding: 1.5rem;
border-top: 1px solid #e0e0e0;
text-align: right;
}
.button-primary {
padding: 0.75rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.button-primary:hover {
background: #5a6fd8;
}
/* Overlay */
.settings-container::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
</style>

4
client/src/main.ts Normal file
View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

View File

@@ -0,0 +1,145 @@
import { Client, Room } from 'colyseus.js'
import { GameState, Player } from '../types'
import type { GameRoomOptions } from '../types'
import { logger } from './logger'
export class GameClient {
public client: Client
public room: Room<GameState> | null = null
// Current state
public gameState: GameState | null = null
public currentPlayerId: string = ''
public isConnected: boolean = false
// Event callbacks
private onStateChangeCallbacks: ((state: GameState) => void)[] = []
private onGamePhaseChangeCallbacks: ((phase: string) => void)[] = []
constructor() {
const serverUrl = import.meta.env.VITE_SERVER_URL || 'ws://localhost:2567'
this.client = new Client(serverUrl)
logger.info('Game client initialized with server:', serverUrl)
}
async joinGame(playerName: string, gameMode: string = 'classic'): Promise<Room<GameState>> {
try {
logger.info('Attempting to join game room...')
const options: GameRoomOptions = {
playerName,
gameMode
}
this.room = await this.client.joinOrCreate<GameState>('game', options)
this.currentPlayerId = this.room.sessionId
this.isConnected = true
logger.info('Successfully joined room:', this.room)
logger.info('Player ID:', this.currentPlayerId)
this.room.onStateChange((state) => {
logger.gameStateChange({
gamePhase: state.gamePhase,
playerCount: state.players.size,
gameStarted: state.gameStarted
})
const previousPhase = this.gameState?.gamePhase
this.gameState = state
// Notify all state change callbacks
this.onStateChangeCallbacks.forEach(callback => callback(state))
// Notify phase change if it changed
if (previousPhase !== state.gamePhase) {
logger.gamePhaseChange(previousPhase, state.gamePhase)
this.onGamePhaseChangeCallbacks.forEach(callback => callback(state.gamePhase))
}
})
this.room.onLeave((code) => {
logger.info('Left room with code:', code)
this.isConnected = false
})
this.room.onError((code, message) => {
logger.error('Room error:', { code, message })
})
return this.room
} catch (error) {
logger.error('Failed to join room:', error)
throw error
}
}
leaveGame(): void {
if (this.room) {
this.room.leave()
this.room = null
this.isConnected = false
this.gameState = null
}
}
getRoom(): Room<GameState> | null {
return this.room
}
// Event subscription methods
onStateChange(callback: (state: GameState) => void): () => void {
this.onStateChangeCallbacks.push(callback)
// If we already have state, call immediately
if (this.gameState) {
callback(this.gameState)
}
// Return unsubscribe function
return () => {
const index = this.onStateChangeCallbacks.indexOf(callback)
if (index > -1) {
this.onStateChangeCallbacks.splice(index, 1)
}
}
}
onGamePhaseChange(callback: (phase: string) => void): () => void {
this.onGamePhaseChangeCallbacks.push(callback)
// If we already have state, call immediately
if (this.gameState) {
callback(this.gameState.gamePhase)
}
// Return unsubscribe function
return () => {
const index = this.onGamePhaseChangeCallbacks.indexOf(callback)
if (index > -1) {
this.onGamePhaseChangeCallbacks.splice(index, 1)
}
}
}
// Game actions
sendClick(): void {
if (this.room && this.gameState?.gamePhase === 'playing') {
this.room.send('click')
logger.clickSent()
} else {
logger.clickIgnored()
}
}
// Getters
getCurrentPlayer(): Player | null {
if (!this.gameState || !this.currentPlayerId) return null
return this.gameState.players.get(this.currentPlayerId) || null
}
getPlayers(): Player[] {
if (!this.gameState) return []
return Array.from(this.gameState.players.values())
}
}

View File

@@ -0,0 +1,116 @@
class Logger {
private static instance: Logger
private debugEnabled: boolean
private constructor() {
// Default based on environment
const isDevelopment = import.meta.env.MODE === 'development'
this.debugEnabled = isDevelopment
// Load from localStorage if available
const savedSetting = localStorage.getItem('debug-logging-enabled')
if (savedSetting !== null) {
this.debugEnabled = savedSetting === 'true'
}
console.log(`🐛 Debug logging ${this.debugEnabled ? 'enabled' : 'disabled'} (environment: ${import.meta.env.MODE})`)
}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger()
}
return Logger.instance
}
setDebugEnabled(enabled: boolean): void {
this.debugEnabled = enabled
localStorage.setItem('debug-logging-enabled', enabled.toString())
console.log(`🐛 Debug logging ${enabled ? 'enabled' : 'disabled'}`)
}
isDebugEnabled(): boolean {
return this.debugEnabled
}
// Game-specific logging methods
gameStateChange(data: any): void {
if (this.debugEnabled) {
console.log('🔄 State changed:', data)
}
}
gameComponentUpdate(data: any): void {
if (this.debugEnabled) {
console.log('🔄 Game component received state update:', data)
}
}
gamePhaseChange(oldPhase: string | undefined, newPhase: string): void {
if (this.debugEnabled) {
console.log(`🎮 Game phase changed: ${oldPhase}${newPhase}`)
}
}
gameMounted(): void {
if (this.debugEnabled) {
console.log('🎮 Game component mounted, setting up state subscription')
}
}
gameUnmounted(): void {
if (this.debugEnabled) {
console.log('🎮 Game component unmounting, cleaning up subscriptions')
}
}
computedProperty(name: string, value: any): void {
if (this.debugEnabled) {
console.log(`🔢 ${name} computed:`, value)
}
}
clickSent(): void {
if (this.debugEnabled) {
console.log('🖱️ Click sent to server')
}
}
clickIgnored(): void {
if (this.debugEnabled) {
console.log('⚠️ Click ignored - game not in playing phase or not connected')
}
}
// General logging methods
info(message: string, data?: any): void {
if (this.debugEnabled) {
if (data) {
console.log(message, data)
} else {
console.log(message)
}
}
}
warn(message: string, data?: any): void {
if (this.debugEnabled) {
if (data) {
console.warn(message, data)
} else {
console.warn(message)
}
}
}
error(message: string, data?: any): void {
// Errors are always logged regardless of debug setting
if (data) {
console.error(message, data)
} else {
console.error(message)
}
}
}
export const logger = Logger.getInstance()

View File

@@ -0,0 +1,9 @@
// Re-export generated schema classes
export { Player } from './Player'
export { GameState } from './GameState'
// Additional types that are not Schema classes
export interface GameRoomOptions {
gameMode?: string;
playerName?: string;
}

15
client/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"verbatimModuleSyntax": false,
"paths": {
"@/*": ["./src/*"]
}
}
}

18
client/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
host: true
},
build: {
outDir: 'dist'
},
resolve: {
alias: {
'@': '/src'
}
}
})

55
package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "snatchgame",
"version": "0.0.1-alpha",
"description": "Multiplayer real-time click battle game built with Colyseus.io and Vue 3",
"private": true,
"scripts": {
"install:all": "npm install && cd server && npm install && cd ../client && npm install",
"dev:server": "cd server && npm run dev",
"dev:client": "cd client && npm run dev",
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"build:server": "cd server && npm run build",
"build:client": "cd client && npm run build",
"build": "npm run build:server && npm run build:client",
"start:server": "cd server && npm start",
"start:client": "cd client && npm start",
"start": "concurrently \"npm run start:server\" \"npm run start:client\"",
"generate-types": "cd client && npm run generate-types",
"clean": "rm -rf node_modules server/node_modules client/node_modules server/lib client/dist",
"docker:build": "docker-compose build",
"docker:up": "docker-compose up -d",
"docker:down": "docker-compose down",
"docker:logs": "docker-compose logs -f"
},
"keywords": [
"multiplayer",
"game",
"colyseus",
"vue3",
"typescript",
"real-time",
"websockets",
"local-network"
],
"author": "NucleoServices",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/username/snatchgame.git"
},
"bugs": {
"url": "https://github.com/username/snatchgame/issues"
},
"homepage": "https://github.com/username/snatchgame#readme",
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"workspaces": [
"server",
"client"
],
"devDependencies": {
"concurrently": "^8.2.2"
}
}

1
server/.devmode.json Normal file
View File

@@ -0,0 +1 @@
{"data":{"colyseus:nodes":[]},"hash":{"roomcount":{},"roomhistory":{}},"keys":{}}

4
server/.env.development Normal file
View File

@@ -0,0 +1,4 @@
NODE_ENV=development
PORT=2567
CLIENT_URL=http://localhost:3000
ADMIN_URL=http://localhost:3001

2982
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
server/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "snatchgame-server",
"version": "0.0.1-alpha",
"description": "SnatchGame multiplayer server using Colyseus",
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"start": "node lib/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"colyseus",
"multiplayer",
"game",
"typescript"
],
"author": "",
"license": "ISC",
"dependencies": {
"@colyseus/schema": "^3.0.42",
"@colyseus/tools": "^0.16.0",
"colyseus": "^0.16.0",
"express": "^4.18.0"
},
"devDependencies": {
"@types/express": "^4.17.0",
"@types/node": "^20.0.0",
"nodemon": "^3.1.10",
"ts-node-dev": "^2.0.0",
"typescript": "^5.0.0"
}
}

30
server/src/app.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import config from "@colyseus/tools";
import { GameRoom } from "./rooms/GameRoom";
export default config({
options: {
devMode: process.env.NODE_ENV !== "production",
gracefullyShutdown: true,
},
initializeGameServer: (gameServer) => {
// Define game room handler
gameServer.define('game', GameRoom)
.filterBy(['gameMode'])
.sortBy({ clients: 1 }); // Prefer rooms with fewer clients
},
initializeExpress: (app) => {
app.get("/", (req, res) => {
res.json({
name: "SnatchGame Server",
status: "running",
version: "1.0.0"
});
});
app.get("/health", (req, res) => {
res.json({ status: "healthy" });
});
}
});

6
server/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import { listen } from "@colyseus/tools";
import app from "./app.config";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 2567;
listen(app, PORT);

View File

@@ -0,0 +1,100 @@
import { Room, Client } from "colyseus";
import { Schema, MapSchema, type } from "@colyseus/schema";
export interface GameRoomOptions {
gameMode?: string;
playerName?: string;
}
export class Player extends Schema {
@type("string") id: string;
@type("string") name: string;
@type("number") score: number = 0;
@type("boolean") ready: boolean = false;
}
export class GameState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
@type("boolean") gameStarted: boolean = false;
@type("string") gameMode: string = "classic";
@type("number") minPlayers: number = 2;
@type("string") gamePhase: string = "waiting"; // "waiting" | "playing"
}
export class GameRoom extends Room<GameState> {
maxClients = 8;
onCreate(options: GameRoomOptions) {
console.log(`GameRoom created with options:`, options);
this.setState(new GameState());
this.state.gameMode = options.gameMode || 'classic';
this.state.gamePhase = "waiting";
this.onMessage("click", (client, message) => {
this.handleClick(client);
});
this.onMessage("*", (client, type, message) => {
console.log(`Message from ${client.sessionId}:`, type, message);
});
}
private handleClick(client: Client) {
const player = this.state.players.get(client.sessionId);
if (!player) {
console.log(`Player not found for client ${client.sessionId}`);
return;
}
if (this.state.gamePhase !== "playing") {
console.log(`Click ignored - game not started (phase: ${this.state.gamePhase})`);
return;
}
player.score += 1;
console.log(`🎮 Player ${player.name} clicked! New score: ${player.score}`);
}
private checkGameStart() {
const playerCount = this.state.players.size;
if (playerCount >= this.state.minPlayers && this.state.gamePhase === "waiting") {
this.state.gamePhase = "playing";
this.state.gameStarted = true;
console.log(`🚀 Game started! ${playerCount} players ready to play`);
} else if (playerCount < this.state.minPlayers && this.state.gamePhase === "playing") {
this.state.gamePhase = "waiting";
this.state.gameStarted = false;
console.log(`⏸️ Game paused - not enough players (${playerCount}/${this.state.minPlayers})`);
}
}
onJoin(client: Client, options: any) {
console.log(`Client ${client.sessionId} joined the room`);
const player = new Player();
player.id = client.sessionId;
player.name = options.playerName || `Player ${this.state.players.size + 1}`;
player.score = 0;
player.ready = false;
this.state.players.set(client.sessionId, player);
// Check if we can start the game
this.checkGameStart();
}
onLeave(client: Client, consented: boolean) {
console.log(`Client ${client.sessionId} left the room`);
this.state.players.delete(client.sessionId);
// Check if we need to pause the game
this.checkGameStart();
}
onDispose() {
console.log(`GameRoom ${this.roomId} disposed`);
}
}

28
server/tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"outDir": "./lib",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"baseUrl": "."
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"lib"
]
}

8
tsconfig.base.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"strict": true,
"baseUrl": "."
}
}