initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-11-11 23:13:13 +01:00
padre 78613f6cbe
commit 16469bab3c
Se han modificado 22 ficheros con 3855 adiciones y 6659 borrados

143
components/DominoCanvas.tsx Archivo normal
Ver fichero

@@ -0,0 +1,143 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { DominoTile } from '@/lib/types';
interface DominoCanvasProps {
tile: DominoTile;
width?: number;
height?: number;
isSelected?: boolean;
isPlayable?: boolean;
onClick?: () => void;
className?: string;
}
export function DominoCanvas({
tile,
width = 60,
height = 30,
isSelected = false,
isPlayable = true,
onClick,
className = '',
}: DominoCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Draw tile background
ctx.fillStyle = isSelected ? '#3b82f6' : '#ffffff';
ctx.strokeStyle = isPlayable ? '#1f2937' : '#9ca3af';
ctx.lineWidth = 2;
const radius = 4;
ctx.beginPath();
ctx.moveTo(radius, 0);
ctx.lineTo(width - radius, 0);
ctx.quadraticCurveTo(width, 0, width, radius);
ctx.lineTo(width, height - radius);
ctx.quadraticCurveTo(width, height, width - radius, height);
ctx.lineTo(radius, height);
ctx.quadraticCurveTo(0, height, 0, height - radius);
ctx.lineTo(0, radius);
ctx.quadraticCurveTo(0, 0, radius, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Draw center divider
ctx.beginPath();
ctx.moveTo(width / 2, 0);
ctx.lineTo(width / 2, height);
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.stroke();
// Draw dots
const dotRadius = 2.5;
const drawDots = (value: number, x: number, y: number, size: number) => {
ctx.fillStyle = isSelected ? '#ffffff' : '#1f2937';
const positions = getDotPositions(value, size);
positions.forEach(pos => {
ctx.beginPath();
ctx.arc(x + pos.x, y + pos.y, dotRadius, 0, Math.PI * 2);
ctx.fill();
});
};
const leftX = width / 4;
const rightX = (width * 3) / 4;
const centerY = height / 2;
const dotAreaSize = width / 2 - 6;
drawDots(tile.left, leftX, centerY, dotAreaSize);
drawDots(tile.right, rightX, centerY, dotAreaSize);
}, [tile, width, height, isSelected, isPlayable]);
return (
<canvas
ref={canvasRef}
width={width}
height={height}
className={`${className} ${onClick && isPlayable ? 'cursor-pointer hover:scale-105 transition-transform' : ''} ${!isPlayable ? 'opacity-50' : ''}`}
onClick={isPlayable ? onClick : undefined}
/>
);
}
// Get dot positions for a domino value (0-6)
function getDotPositions(value: number, size: number): { x: number; y: number }[] {
const margin = size * 0.2;
const positions: { x: number; y: number }[] = [];
switch (value) {
case 0:
return [];
case 1:
positions.push({ x: 0, y: 0 });
break;
case 2:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: margin });
break;
case 3:
positions.push({ x: -margin, y: -margin });
positions.push({ x: 0, y: 0 });
positions.push({ x: margin, y: margin });
break;
case 4:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: -margin, y: margin });
positions.push({ x: margin, y: margin });
break;
case 5:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: 0, y: 0 });
positions.push({ x: -margin, y: margin });
positions.push({ x: margin, y: margin });
break;
case 6:
positions.push({ x: -margin, y: -margin });
positions.push({ x: 0, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: -margin, y: margin });
positions.push({ x: 0, y: margin });
positions.push({ x: margin, y: margin });
break;
}
return positions;
}

230
components/GameBoard.tsx Archivo normal
Ver fichero

@@ -0,0 +1,230 @@
'use client';
import React, { useRef, useEffect, useState } from 'react';
import { PlacedTile, Position } from '@/lib/types';
interface GameBoardProps {
placedTiles: PlacedTile[];
width?: number;
height?: number;
className?: string;
}
export function GameBoard({ placedTiles, width = 1200, height = 700, className = '' }: GameBoardProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [offset, setOffset] = useState<Position>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Position>({ x: 0, y: 0 });
// Auto-center the board on first tile
useEffect(() => {
if (placedTiles.length === 1 && offset.x === 0 && offset.y === 0) {
setOffset({ x: width / 2 - 400, y: height / 2 - 300 });
}
}, [placedTiles.length, width, height, offset]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Draw background pattern
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(0, 0, width, height);
// Draw grid pattern
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
for (let x = 0; x < width; x += 40) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y < height; y += 40) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// Draw placed tiles
placedTiles.forEach((placedTile, index) => {
const { tile, position, orientation } = placedTile;
const tileWidth = orientation === 'horizontal' ? 60 : 30;
const tileHeight = orientation === 'horizontal' ? 30 : 60;
const x = position.x + offset.x;
const y = position.y + offset.y;
// Draw tile background with shadow
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
ctx.shadowBlur = 5;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#1f2937';
ctx.lineWidth = 2;
const radius = 4;
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + tileWidth - radius, y);
ctx.quadraticCurveTo(x + tileWidth, y, x + tileWidth, y + radius);
ctx.lineTo(x + tileWidth, y + tileHeight - radius);
ctx.quadraticCurveTo(x + tileWidth, y + tileHeight, x + tileWidth - radius, y + tileHeight);
ctx.lineTo(x + radius, y + tileHeight);
ctx.quadraticCurveTo(x, y + tileHeight, x, y + tileHeight - radius);
ctx.lineTo(x, y + radius);
ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Reset shadow
ctx.shadowColor = 'transparent';
// Draw center divider
ctx.strokeStyle = '#6b7280';
ctx.lineWidth = 1;
ctx.beginPath();
if (orientation === 'horizontal') {
ctx.moveTo(x + tileWidth / 2, y);
ctx.lineTo(x + tileWidth / 2, y + tileHeight);
} else {
ctx.moveTo(x, y + tileHeight / 2);
ctx.lineTo(x + tileWidth, y + tileHeight / 2);
}
ctx.stroke();
// Draw dots
const dotRadius = 2.5;
ctx.fillStyle = '#1f2937';
const drawDots = (value: number, dotX: number, dotY: number, size: number) => {
const positions = getDotPositions(value, size);
positions.forEach(pos => {
ctx.beginPath();
ctx.arc(dotX + pos.x, dotY + pos.y, dotRadius, 0, Math.PI * 2);
ctx.fill();
});
};
if (orientation === 'horizontal') {
const leftX = x + tileWidth / 4;
const rightX = x + (tileWidth * 3) / 4;
const centerY = y + tileHeight / 2;
const dotAreaSize = tileWidth / 2 - 6;
drawDots(tile.left, leftX, centerY, dotAreaSize);
drawDots(tile.right, rightX, centerY, dotAreaSize);
} else {
const topY = y + tileHeight / 4;
const bottomY = y + (tileHeight * 3) / 4;
const centerX = x + tileWidth / 2;
const dotAreaSize = tileHeight / 2 - 6;
drawDots(tile.left, centerX, topY, dotAreaSize);
drawDots(tile.right, centerX, bottomY, dotAreaSize);
}
// Draw tile number for debugging (optional)
if (placedTiles.length < 20) {
ctx.fillStyle = '#9ca3af';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(`#${index + 1}`, x + tileWidth / 2, y - 5);
}
});
}, [placedTiles, offset, width, height]);
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
setIsDragging(true);
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDragging) return;
setOffset({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
return (
<div className={className}>
<canvas
ref={canvasRef}
width={width}
height={height}
className={`border border-gray-300 rounded-lg ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
{placedTiles.length > 0 && (
<div className="mt-2 text-center text-sm text-gray-600">
Drag to pan {placedTiles.length} tiles placed
</div>
)}
</div>
);
}
// Get dot positions for a domino value (0-6)
function getDotPositions(value: number, size: number): { x: number; y: number }[] {
const margin = size * 0.2;
const positions: { x: number; y: number }[] = [];
switch (value) {
case 0:
return [];
case 1:
positions.push({ x: 0, y: 0 });
break;
case 2:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: margin });
break;
case 3:
positions.push({ x: -margin, y: -margin });
positions.push({ x: 0, y: 0 });
positions.push({ x: margin, y: margin });
break;
case 4:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: -margin, y: margin });
positions.push({ x: margin, y: margin });
break;
case 5:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: 0, y: 0 });
positions.push({ x: -margin, y: margin });
positions.push({ x: margin, y: margin });
break;
case 6:
positions.push({ x: -margin, y: -margin });
positions.push({ x: 0, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: -margin, y: margin });
positions.push({ x: 0, y: margin });
positions.push({ x: margin, y: margin });
break;
}
return positions;
}

110
components/GameOver.tsx Archivo normal
Ver fichero

@@ -0,0 +1,110 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Player } from '@/lib/types';
interface GameOverProps {
winner: Player | null;
players: Player[];
onPlayAgain: () => void;
onLeave: () => void;
}
export function GameOver({ winner, players, onPlayAgain, onLeave }: GameOverProps) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<motion.div
initial={{ scale: 0.8, y: 50 }}
animate={{ scale: 1, y: 0 }}
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
>
<div className="text-center mb-6">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="text-6xl mb-4"
>
{winner ? '🏆' : '🤝'}
</motion.div>
<h2 className="text-3xl font-bold text-gray-800 mb-2">
{winner ? 'Game Over!' : 'Game Blocked!'}
</h2>
{winner && (
<p className="text-xl text-gray-600">
<span className="font-semibold text-blue-600">{winner.name}</span> wins!
</p>
)}
</div>
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-700 mb-3">Final Scores</h3>
<div className="space-y-2">
{players
.sort((a, b) => {
const scoreA = a.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
const scoreB = b.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
return scoreA - scoreB;
})
.map((player, index) => {
const score = player.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
const isWinner = player.id === winner?.id;
return (
<motion.div
key={player.id}
initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: index * 0.1 }}
className={`flex items-center justify-between p-3 rounded-lg ${
isWinner ? 'bg-yellow-100 border-2 border-yellow-400' : 'bg-gray-100'
}`}
>
<div className="flex items-center gap-3">
{isWinner && <span className="text-2xl">👑</span>}
<div>
<div className="font-semibold text-gray-800">{player.name}</div>
<div className="text-xs text-gray-500">
{player.tiles.length} tiles remaining
</div>
</div>
</div>
<div className={`text-2xl font-bold ${
isWinner ? 'text-yellow-600' : 'text-gray-600'
}`}>
{score}
</div>
</motion.div>
);
})}
</div>
</div>
<div className="space-y-3">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onPlayAgain}
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow"
>
Play Again
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onLeave}
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
>
Back to Menu
</motion.button>
</div>
</motion.div>
</motion.div>
);
}

148
components/Lobby.tsx Archivo normal
Ver fichero

@@ -0,0 +1,148 @@
'use client';
import React, { useState } from 'react';
import { motion } from 'framer-motion';
interface LobbyProps {
onCreateRoom: (playerName: string) => void;
onJoinRoom: (roomId: string, playerName: string) => void;
onStartAI: (playerName: string) => void;
roomId: string | null;
}
export function Lobby({ onCreateRoom, onJoinRoom, onStartAI, roomId }: LobbyProps) {
const [playerName, setPlayerName] = useState('');
const [joinRoomId, setJoinRoomId] = useState('');
const [mode, setMode] = useState<'menu' | 'create' | 'join' | 'ai'>('menu');
const handleCreate = () => {
if (!playerName.trim()) return;
onCreateRoom(playerName);
};
const handleJoin = () => {
if (!playerName.trim() || !joinRoomId.trim()) return;
onJoinRoom(joinRoomId.toUpperCase(), playerName);
};
const handleAI = () => {
if (!playerName.trim()) return;
onStartAI(playerName);
};
if (mode === 'menu') {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
>
<motion.h1
initial={{ y: -20 }}
animate={{ y: 0 }}
className="text-4xl font-bold text-center mb-2 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"
>
Dominoes
</motion.h1>
<p className="text-center text-gray-600 mb-8">Online Multiplayer Game</p>
<div className="space-y-4 mb-6">
<input
type="text"
placeholder="Enter your name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none transition-colors"
maxLength={20}
/>
</div>
<div className="space-y-3">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleCreate}
disabled={!playerName.trim()}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
>
Create Room
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setMode('join')}
disabled={!playerName.trim()}
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
>
Join Room
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleAI}
disabled={!playerName.trim()}
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
>
Play vs AI
</motion.button>
</div>
<div className="mt-8 text-center text-sm text-gray-500">
<p>Built with Next.js, Canvas & Socket.IO</p>
</div>
</motion.div>
</div>
);
}
if (mode === 'join') {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
>
<h2 className="text-3xl font-bold text-center mb-6 text-gray-800">Join Room</h2>
<div className="space-y-4 mb-6">
<input
type="text"
placeholder="Enter Room ID"
value={joinRoomId}
onChange={(e) => setJoinRoomId(e.target.value.toUpperCase())}
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-purple-500 focus:outline-none transition-colors uppercase"
maxLength={6}
/>
</div>
<div className="space-y-3">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleJoin}
disabled={!joinRoomId.trim()}
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
>
Join Game
</motion.button>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => setMode('menu')}
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
>
Back
</motion.button>
</div>
</motion.div>
</div>
);
}
return null;
}

187
components/PlayerHand.tsx Archivo normal
Ver fichero

@@ -0,0 +1,187 @@
'use client';
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Player } from '@/lib/types';
interface PlayerHandProps {
player: Player;
isCurrentPlayer: boolean;
selectedTileId: string | null;
onTileClick: (tileId: string) => void;
validTileIds: string[];
}
export function PlayerHand({
player,
isCurrentPlayer,
selectedTileId,
onTileClick,
validTileIds,
}: PlayerHandProps) {
return (
<div className="bg-white rounded-lg shadow-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${
player.isAI ? 'bg-purple-500' : 'bg-blue-500'
}`}>
{player.name.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-800">{player.name}</div>
<div className="text-xs text-gray-500">{player.tiles.length} tiles</div>
</div>
</div>
{isCurrentPlayer && (
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold"
>
Your Turn
</motion.div>
)}
</div>
{!player.isAI && (
<div className="flex flex-wrap gap-2">
<AnimatePresence>
{player.tiles.map((tile, index) => {
const isSelected = tile.id === selectedTileId;
const isPlayable = validTileIds.includes(tile.id);
return (
<motion.div
key={tile.id}
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, rotate: 180 }}
transition={{ delay: index * 0.05 }}
whileHover={isPlayable ? { y: -5 } : {}}
onClick={() => isPlayable && isCurrentPlayer && onTileClick(tile.id)}
className={`relative ${
isPlayable && isCurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed'
}`}
>
<DominoTileSVG
tile={tile}
isSelected={isSelected}
isPlayable={isPlayable && isCurrentPlayer}
/>
</motion.div>
);
})}
</AnimatePresence>
</div>
)}
</div>
);
}
interface DominoTileSVGProps {
tile: { left: number; right: number; id: string };
isSelected: boolean;
isPlayable: boolean;
}
function DominoTileSVG({ tile, isSelected, isPlayable }: DominoTileSVGProps) {
const width = 60;
const height = 30;
return (
<svg
width={width}
height={height}
className={`transition-all ${
isSelected ? 'ring-4 ring-blue-500 rounded' : ''
} ${!isPlayable ? 'opacity-50' : ''}`}
>
{/* Background */}
<rect
width={width}
height={height}
rx={4}
fill={isSelected ? '#3b82f6' : '#ffffff'}
stroke={isPlayable ? '#1f2937' : '#9ca3af'}
strokeWidth={2}
/>
{/* Center divider */}
<line
x1={width / 2}
y1={0}
x2={width / 2}
y2={height}
stroke="#6b7280"
strokeWidth={1}
/>
{/* Left dots */}
<g transform={`translate(${width / 4}, ${height / 2})`}>
{renderDots(tile.left, isSelected)}
</g>
{/* Right dots */}
<g transform={`translate(${(width * 3) / 4}, ${height / 2})`}>
{renderDots(tile.right, isSelected)}
</g>
</svg>
);
}
function renderDots(value: number, isSelected: boolean) {
const dotRadius = 2.5;
const margin = 6;
const fill = isSelected ? '#ffffff' : '#1f2937';
const positions = getDotPositions(value, margin);
return positions.map((pos, i) => (
<circle key={i} cx={pos.x} cy={pos.y} r={dotRadius} fill={fill} />
));
}
function getDotPositions(value: number, margin: number): { x: number; y: number }[] {
const positions: { x: number; y: number }[] = [];
switch (value) {
case 0:
return [];
case 1:
positions.push({ x: 0, y: 0 });
break;
case 2:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: margin });
break;
case 3:
positions.push({ x: -margin, y: -margin });
positions.push({ x: 0, y: 0 });
positions.push({ x: margin, y: margin });
break;
case 4:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: -margin, y: margin });
positions.push({ x: margin, y: margin });
break;
case 5:
positions.push({ x: -margin, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: 0, y: 0 });
positions.push({ x: -margin, y: margin });
positions.push({ x: margin, y: margin });
break;
case 6:
positions.push({ x: -margin, y: -margin });
positions.push({ x: 0, y: -margin });
positions.push({ x: margin, y: -margin });
positions.push({ x: -margin, y: margin });
positions.push({ x: 0, y: margin });
positions.push({ x: margin, y: margin });
break;
}
return positions;
}

137
components/WaitingRoom.tsx Archivo normal
Ver fichero

@@ -0,0 +1,137 @@
'use client';
import React from 'react';
import { motion } from 'framer-motion';
import { Player } from '@/lib/types';
interface WaitingRoomProps {
roomId: string;
players: Player[];
currentPlayerId: string | null;
onReady: () => void;
onLeave: () => void;
}
export function WaitingRoom({ roomId, players, currentPlayerId, onReady, onLeave }: WaitingRoomProps) {
const currentPlayer = players.find(p => p.id === currentPlayerId);
const isReady = currentPlayer?.isReady || false;
const canStart = players.length >= 2 && players.every(p => p.isReady);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-white rounded-2xl shadow-2xl p-8 max-w-2xl w-full"
>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-800 mb-2">Waiting Room</h2>
<div className="inline-block bg-gradient-to-r from-blue-500 to-purple-500 text-white px-6 py-2 rounded-full font-mono text-xl">
{roomId}
</div>
<p className="text-gray-600 mt-2 text-sm">Share this code with your friends</p>
</div>
<div className="mb-8">
<h3 className="text-xl font-semibold text-gray-700 mb-4">
Players ({players.length}/4)
</h3>
{players.length < 2 && (
<div className="mb-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm text-center">
Minimum 2 players required to start
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{players.map((player) => (
<motion.div
key={player.id}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className={`p-4 rounded-lg border-2 ${
player.isReady
? 'border-green-500 bg-green-50'
: 'border-gray-300 bg-gray-50'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-full flex items-center justify-center font-bold text-white ${
player.isReady ? 'bg-green-500' : 'bg-gray-400'
}`}>
{player.name.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-800">{player.name}</div>
{player.id === currentPlayerId && (
<div className="text-xs text-blue-600 font-medium">You</div>
)}
</div>
</div>
{player.isReady && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="text-green-500 text-2xl"
>
</motion.div>
)}
</div>
</motion.div>
))}
{/* Empty slots */}
{Array.from({ length: 4 - players.length }).map((_, i) => (
<div
key={`empty-${i}`}
className="p-4 rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 flex items-center justify-center"
>
<div className="text-gray-400 text-center">
<div className="text-3xl mb-1">+</div>
<div className="text-sm">Optional player...</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-3">
{!isReady ? (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onReady}
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow"
>
Ready to Play
</motion.button>
) : (
<div className="w-full bg-green-100 border-2 border-green-500 text-green-700 py-3 rounded-lg font-semibold text-center">
{canStart ? 'Starting game...' : players.length < 2 ? 'Waiting for at least 1 more player...' : 'Waiting for other players...'}
</div>
)}
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onLeave}
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
>
Leave Room
</motion.button>
</div>
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="font-semibold text-blue-900 mb-2">Game Rules</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> 2-4 players can play (minimum 2 required)</li>
<li> Match numbers on tiles to place them on the board</li>
<li> Draw from the boneyard if you can&apos;t play</li>
<li> First player to use all tiles wins!</li>
<li> Game ends when someone runs out or no one can move</li>
</ul>
</div>
</motion.div>
</div>
);
}