143
components/DominoCanvas.tsx
Archivo normal
143
components/DominoCanvas.tsx
Archivo normal
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { DominoTile } from '@/lib/types';
|
||||
|
||||
interface DominoCanvasProps {
|
||||
tile: DominoTile;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isSelected?: boolean;
|
||||
isPlayable?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DominoCanvas({
|
||||
tile,
|
||||
width = 60,
|
||||
height = 30,
|
||||
isSelected = false,
|
||||
isPlayable = true,
|
||||
onClick,
|
||||
className = '',
|
||||
}: DominoCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw tile background
|
||||
ctx.fillStyle = isSelected ? '#3b82f6' : '#ffffff';
|
||||
ctx.strokeStyle = isPlayable ? '#1f2937' : '#9ca3af';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const radius = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(radius, 0);
|
||||
ctx.lineTo(width - radius, 0);
|
||||
ctx.quadraticCurveTo(width, 0, width, radius);
|
||||
ctx.lineTo(width, height - radius);
|
||||
ctx.quadraticCurveTo(width, height, width - radius, height);
|
||||
ctx.lineTo(radius, height);
|
||||
ctx.quadraticCurveTo(0, height, 0, height - radius);
|
||||
ctx.lineTo(0, radius);
|
||||
ctx.quadraticCurveTo(0, 0, radius, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Draw center divider
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(width / 2, 0);
|
||||
ctx.lineTo(width / 2, height);
|
||||
ctx.strokeStyle = '#6b7280';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw dots
|
||||
const dotRadius = 2.5;
|
||||
const drawDots = (value: number, x: number, y: number, size: number) => {
|
||||
ctx.fillStyle = isSelected ? '#ffffff' : '#1f2937';
|
||||
|
||||
const positions = getDotPositions(value, size);
|
||||
positions.forEach(pos => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + pos.x, y + pos.y, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
};
|
||||
|
||||
const leftX = width / 4;
|
||||
const rightX = (width * 3) / 4;
|
||||
const centerY = height / 2;
|
||||
const dotAreaSize = width / 2 - 6;
|
||||
|
||||
drawDots(tile.left, leftX, centerY, dotAreaSize);
|
||||
drawDots(tile.right, rightX, centerY, dotAreaSize);
|
||||
|
||||
}, [tile, width, height, isSelected, isPlayable]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`${className} ${onClick && isPlayable ? 'cursor-pointer hover:scale-105 transition-transform' : ''} ${!isPlayable ? 'opacity-50' : ''}`}
|
||||
onClick={isPlayable ? onClick : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get dot positions for a domino value (0-6)
|
||||
function getDotPositions(value: number, size: number): { x: number; y: number }[] {
|
||||
const margin = size * 0.2;
|
||||
const positions: { x: number; y: number }[] = [];
|
||||
|
||||
switch (value) {
|
||||
case 0:
|
||||
return [];
|
||||
case 1:
|
||||
positions.push({ x: 0, y: 0 });
|
||||
break;
|
||||
case 2:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 3:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: 0, y: 0 });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 4:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 5:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: 0, y: 0 });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 6:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: 0, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: 0, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
230
components/GameBoard.tsx
Archivo normal
230
components/GameBoard.tsx
Archivo normal
@@ -0,0 +1,230 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { PlacedTile, Position } from '@/lib/types';
|
||||
|
||||
interface GameBoardProps {
|
||||
placedTiles: PlacedTile[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GameBoard({ placedTiles, width = 1200, height = 700, className = '' }: GameBoardProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [offset, setOffset] = useState<Position>({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState<Position>({ x: 0, y: 0 });
|
||||
|
||||
// Auto-center the board on first tile
|
||||
useEffect(() => {
|
||||
if (placedTiles.length === 1 && offset.x === 0 && offset.y === 0) {
|
||||
setOffset({ x: width / 2 - 400, y: height / 2 - 300 });
|
||||
}
|
||||
}, [placedTiles.length, width, height, offset]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw background pattern
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Draw grid pattern
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 0; x < width; x += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < height; y += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw placed tiles
|
||||
placedTiles.forEach((placedTile, index) => {
|
||||
const { tile, position, orientation } = placedTile;
|
||||
const tileWidth = orientation === 'horizontal' ? 60 : 30;
|
||||
const tileHeight = orientation === 'horizontal' ? 30 : 60;
|
||||
|
||||
const x = position.x + offset.x;
|
||||
const y = position.y + offset.y;
|
||||
|
||||
// Draw tile background with shadow
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.shadowOffsetX = 2;
|
||||
ctx.shadowOffsetY = 2;
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#1f2937';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const radius = 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + tileWidth - radius, y);
|
||||
ctx.quadraticCurveTo(x + tileWidth, y, x + tileWidth, y + radius);
|
||||
ctx.lineTo(x + tileWidth, y + tileHeight - radius);
|
||||
ctx.quadraticCurveTo(x + tileWidth, y + tileHeight, x + tileWidth - radius, y + tileHeight);
|
||||
ctx.lineTo(x + radius, y + tileHeight);
|
||||
ctx.quadraticCurveTo(x, y + tileHeight, x, y + tileHeight - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Reset shadow
|
||||
ctx.shadowColor = 'transparent';
|
||||
|
||||
// Draw center divider
|
||||
ctx.strokeStyle = '#6b7280';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (orientation === 'horizontal') {
|
||||
ctx.moveTo(x + tileWidth / 2, y);
|
||||
ctx.lineTo(x + tileWidth / 2, y + tileHeight);
|
||||
} else {
|
||||
ctx.moveTo(x, y + tileHeight / 2);
|
||||
ctx.lineTo(x + tileWidth, y + tileHeight / 2);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw dots
|
||||
const dotRadius = 2.5;
|
||||
ctx.fillStyle = '#1f2937';
|
||||
|
||||
const drawDots = (value: number, dotX: number, dotY: number, size: number) => {
|
||||
const positions = getDotPositions(value, size);
|
||||
positions.forEach(pos => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(dotX + pos.x, dotY + pos.y, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
};
|
||||
|
||||
if (orientation === 'horizontal') {
|
||||
const leftX = x + tileWidth / 4;
|
||||
const rightX = x + (tileWidth * 3) / 4;
|
||||
const centerY = y + tileHeight / 2;
|
||||
const dotAreaSize = tileWidth / 2 - 6;
|
||||
|
||||
drawDots(tile.left, leftX, centerY, dotAreaSize);
|
||||
drawDots(tile.right, rightX, centerY, dotAreaSize);
|
||||
} else {
|
||||
const topY = y + tileHeight / 4;
|
||||
const bottomY = y + (tileHeight * 3) / 4;
|
||||
const centerX = x + tileWidth / 2;
|
||||
const dotAreaSize = tileHeight / 2 - 6;
|
||||
|
||||
drawDots(tile.left, centerX, topY, dotAreaSize);
|
||||
drawDots(tile.right, centerX, bottomY, dotAreaSize);
|
||||
}
|
||||
|
||||
// Draw tile number for debugging (optional)
|
||||
if (placedTiles.length < 20) {
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(`#${index + 1}`, x + tileWidth / 2, y - 5);
|
||||
}
|
||||
});
|
||||
|
||||
}, [placedTiles, offset, width, height]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isDragging) return;
|
||||
setOffset({
|
||||
x: e.clientX - dragStart.x,
|
||||
y: e.clientY - dragStart.y,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className={`border border-gray-300 rounded-lg ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
/>
|
||||
{placedTiles.length > 0 && (
|
||||
<div className="mt-2 text-center text-sm text-gray-600">
|
||||
Drag to pan • {placedTiles.length} tiles placed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get dot positions for a domino value (0-6)
|
||||
function getDotPositions(value: number, size: number): { x: number; y: number }[] {
|
||||
const margin = size * 0.2;
|
||||
const positions: { x: number; y: number }[] = [];
|
||||
|
||||
switch (value) {
|
||||
case 0:
|
||||
return [];
|
||||
case 1:
|
||||
positions.push({ x: 0, y: 0 });
|
||||
break;
|
||||
case 2:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 3:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: 0, y: 0 });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 4:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 5:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: 0, y: 0 });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 6:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: 0, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: 0, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
110
components/GameOver.tsx
Archivo normal
110
components/GameOver.tsx
Archivo normal
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Player } from '@/lib/types';
|
||||
|
||||
interface GameOverProps {
|
||||
winner: Player | null;
|
||||
players: Player[];
|
||||
onPlayAgain: () => void;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
export function GameOver({ winner, players, onPlayAgain, onLeave }: GameOverProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, y: 50 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
|
||||
className="text-6xl mb-4"
|
||||
>
|
||||
{winner ? '🏆' : '🤝'}
|
||||
</motion.div>
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{winner ? 'Game Over!' : 'Game Blocked!'}
|
||||
</h2>
|
||||
{winner && (
|
||||
<p className="text-xl text-gray-600">
|
||||
<span className="font-semibold text-blue-600">{winner.name}</span> wins!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-3">Final Scores</h3>
|
||||
<div className="space-y-2">
|
||||
{players
|
||||
.sort((a, b) => {
|
||||
const scoreA = a.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
|
||||
const scoreB = b.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
|
||||
return scoreA - scoreB;
|
||||
})
|
||||
.map((player, index) => {
|
||||
const score = player.tiles.reduce((sum, t) => sum + t.left + t.right, 0);
|
||||
const isWinner = player.id === winner?.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={player.id}
|
||||
initial={{ x: -50, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className={`flex items-center justify-between p-3 rounded-lg ${
|
||||
isWinner ? 'bg-yellow-100 border-2 border-yellow-400' : 'bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{isWinner && <span className="text-2xl">👑</span>}
|
||||
<div>
|
||||
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{player.tiles.length} tiles remaining
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
isWinner ? 'text-yellow-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{score}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onPlayAgain}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow"
|
||||
>
|
||||
Play Again
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onLeave}
|
||||
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
Back to Menu
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
148
components/Lobby.tsx
Archivo normal
148
components/Lobby.tsx
Archivo normal
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LobbyProps {
|
||||
onCreateRoom: (playerName: string) => void;
|
||||
onJoinRoom: (roomId: string, playerName: string) => void;
|
||||
onStartAI: (playerName: string) => void;
|
||||
roomId: string | null;
|
||||
}
|
||||
|
||||
export function Lobby({ onCreateRoom, onJoinRoom, onStartAI, roomId }: LobbyProps) {
|
||||
const [playerName, setPlayerName] = useState('');
|
||||
const [joinRoomId, setJoinRoomId] = useState('');
|
||||
const [mode, setMode] = useState<'menu' | 'create' | 'join' | 'ai'>('menu');
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!playerName.trim()) return;
|
||||
onCreateRoom(playerName);
|
||||
};
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!playerName.trim() || !joinRoomId.trim()) return;
|
||||
onJoinRoom(joinRoomId.toUpperCase(), playerName);
|
||||
};
|
||||
|
||||
const handleAI = () => {
|
||||
if (!playerName.trim()) return;
|
||||
onStartAI(playerName);
|
||||
};
|
||||
|
||||
if (mode === 'menu') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
|
||||
>
|
||||
<motion.h1
|
||||
initial={{ y: -20 }}
|
||||
animate={{ y: 0 }}
|
||||
className="text-4xl font-bold text-center mb-2 bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent"
|
||||
>
|
||||
Dominoes
|
||||
</motion.h1>
|
||||
<p className="text-center text-gray-600 mb-8">Online Multiplayer Game</p>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter your name"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-blue-500 focus:outline-none transition-colors"
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleCreate}
|
||||
disabled={!playerName.trim()}
|
||||
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Create Room
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setMode('join')}
|
||||
disabled={!playerName.trim()}
|
||||
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Join Room
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleAI}
|
||||
disabled={!playerName.trim()}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Play vs AI
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-sm text-gray-500">
|
||||
<p>Built with Next.js, Canvas & Socket.IO</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'join') {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full"
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-center mb-6 text-gray-800">Join Room</h2>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter Room ID"
|
||||
value={joinRoomId}
|
||||
onChange={(e) => setJoinRoomId(e.target.value.toUpperCase())}
|
||||
className="w-full px-4 py-3 border-2 border-gray-300 rounded-lg focus:border-purple-500 focus:outline-none transition-colors uppercase"
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleJoin}
|
||||
disabled={!joinRoomId.trim()}
|
||||
className="w-full bg-gradient-to-r from-purple-500 to-purple-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Join Game
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => setMode('menu')}
|
||||
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
Back
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
187
components/PlayerHand.tsx
Archivo normal
187
components/PlayerHand.tsx
Archivo normal
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Player } from '@/lib/types';
|
||||
|
||||
interface PlayerHandProps {
|
||||
player: Player;
|
||||
isCurrentPlayer: boolean;
|
||||
selectedTileId: string | null;
|
||||
onTileClick: (tileId: string) => void;
|
||||
validTileIds: string[];
|
||||
}
|
||||
|
||||
export function PlayerHand({
|
||||
player,
|
||||
isCurrentPlayer,
|
||||
selectedTileId,
|
||||
onTileClick,
|
||||
validTileIds,
|
||||
}: PlayerHandProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-white ${
|
||||
player.isAI ? 'bg-purple-500' : 'bg-blue-500'
|
||||
}`}>
|
||||
{player.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||
<div className="text-xs text-gray-500">{player.tiles.length} tiles</div>
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentPlayer && (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||
className="bg-green-500 text-white px-3 py-1 rounded-full text-xs font-semibold"
|
||||
>
|
||||
Your Turn
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!player.isAI && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AnimatePresence>
|
||||
{player.tiles.map((tile, index) => {
|
||||
const isSelected = tile.id === selectedTileId;
|
||||
const isPlayable = validTileIds.includes(tile.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={tile.id}
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, rotate: 180 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
whileHover={isPlayable ? { y: -5 } : {}}
|
||||
onClick={() => isPlayable && isCurrentPlayer && onTileClick(tile.id)}
|
||||
className={`relative ${
|
||||
isPlayable && isCurrentPlayer ? 'cursor-pointer' : 'cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<DominoTileSVG
|
||||
tile={tile}
|
||||
isSelected={isSelected}
|
||||
isPlayable={isPlayable && isCurrentPlayer}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DominoTileSVGProps {
|
||||
tile: { left: number; right: number; id: string };
|
||||
isSelected: boolean;
|
||||
isPlayable: boolean;
|
||||
}
|
||||
|
||||
function DominoTileSVG({ tile, isSelected, isPlayable }: DominoTileSVGProps) {
|
||||
const width = 60;
|
||||
const height = 30;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={`transition-all ${
|
||||
isSelected ? 'ring-4 ring-blue-500 rounded' : ''
|
||||
} ${!isPlayable ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{/* Background */}
|
||||
<rect
|
||||
width={width}
|
||||
height={height}
|
||||
rx={4}
|
||||
fill={isSelected ? '#3b82f6' : '#ffffff'}
|
||||
stroke={isPlayable ? '#1f2937' : '#9ca3af'}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
{/* Center divider */}
|
||||
<line
|
||||
x1={width / 2}
|
||||
y1={0}
|
||||
x2={width / 2}
|
||||
y2={height}
|
||||
stroke="#6b7280"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Left dots */}
|
||||
<g transform={`translate(${width / 4}, ${height / 2})`}>
|
||||
{renderDots(tile.left, isSelected)}
|
||||
</g>
|
||||
|
||||
{/* Right dots */}
|
||||
<g transform={`translate(${(width * 3) / 4}, ${height / 2})`}>
|
||||
{renderDots(tile.right, isSelected)}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function renderDots(value: number, isSelected: boolean) {
|
||||
const dotRadius = 2.5;
|
||||
const margin = 6;
|
||||
const fill = isSelected ? '#ffffff' : '#1f2937';
|
||||
|
||||
const positions = getDotPositions(value, margin);
|
||||
|
||||
return positions.map((pos, i) => (
|
||||
<circle key={i} cx={pos.x} cy={pos.y} r={dotRadius} fill={fill} />
|
||||
));
|
||||
}
|
||||
|
||||
function getDotPositions(value: number, margin: number): { x: number; y: number }[] {
|
||||
const positions: { x: number; y: number }[] = [];
|
||||
|
||||
switch (value) {
|
||||
case 0:
|
||||
return [];
|
||||
case 1:
|
||||
positions.push({ x: 0, y: 0 });
|
||||
break;
|
||||
case 2:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 3:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: 0, y: 0 });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 4:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 5:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: 0, y: 0 });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
case 6:
|
||||
positions.push({ x: -margin, y: -margin });
|
||||
positions.push({ x: 0, y: -margin });
|
||||
positions.push({ x: margin, y: -margin });
|
||||
positions.push({ x: -margin, y: margin });
|
||||
positions.push({ x: 0, y: margin });
|
||||
positions.push({ x: margin, y: margin });
|
||||
break;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
137
components/WaitingRoom.tsx
Archivo normal
137
components/WaitingRoom.tsx
Archivo normal
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Player } from '@/lib/types';
|
||||
|
||||
interface WaitingRoomProps {
|
||||
roomId: string;
|
||||
players: Player[];
|
||||
currentPlayerId: string | null;
|
||||
onReady: () => void;
|
||||
onLeave: () => void;
|
||||
}
|
||||
|
||||
export function WaitingRoom({ roomId, players, currentPlayerId, onReady, onLeave }: WaitingRoomProps) {
|
||||
const currentPlayer = players.find(p => p.id === currentPlayerId);
|
||||
const isReady = currentPlayer?.isReady || false;
|
||||
const canStart = players.length >= 2 && players.every(p => p.isReady);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="bg-white rounded-2xl shadow-2xl p-8 max-w-2xl w-full"
|
||||
>
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-2">Waiting Room</h2>
|
||||
<div className="inline-block bg-gradient-to-r from-blue-500 to-purple-500 text-white px-6 py-2 rounded-full font-mono text-xl">
|
||||
{roomId}
|
||||
</div>
|
||||
<p className="text-gray-600 mt-2 text-sm">Share this code with your friends</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold text-gray-700 mb-4">
|
||||
Players ({players.length}/4)
|
||||
</h3>
|
||||
{players.length < 2 && (
|
||||
<div className="mb-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm text-center">
|
||||
⚠️ Minimum 2 players required to start
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{players.map((player) => (
|
||||
<motion.div
|
||||
key={player.id}
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className={`p-4 rounded-lg border-2 ${
|
||||
player.isReady
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-300 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center font-bold text-white ${
|
||||
player.isReady ? 'bg-green-500' : 'bg-gray-400'
|
||||
}`}>
|
||||
{player.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-800">{player.name}</div>
|
||||
{player.id === currentPlayerId && (
|
||||
<div className="text-xs text-blue-600 font-medium">You</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{player.isReady && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-green-500 text-2xl"
|
||||
>
|
||||
✓
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Empty slots */}
|
||||
{Array.from({ length: 4 - players.length }).map((_, i) => (
|
||||
<div
|
||||
key={`empty-${i}`}
|
||||
className="p-4 rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-gray-400 text-center">
|
||||
<div className="text-3xl mb-1">+</div>
|
||||
<div className="text-sm">Optional player...</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{!isReady ? (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onReady}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-3 rounded-lg font-semibold shadow-lg hover:shadow-xl transition-shadow"
|
||||
>
|
||||
Ready to Play
|
||||
</motion.button>
|
||||
) : (
|
||||
<div className="w-full bg-green-100 border-2 border-green-500 text-green-700 py-3 rounded-lg font-semibold text-center">
|
||||
{canStart ? 'Starting game...' : players.length < 2 ? 'Waiting for at least 1 more player...' : 'Waiting for other players...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={onLeave}
|
||||
className="w-full bg-gray-300 text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
Leave Room
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Game Rules</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>• 2-4 players can play (minimum 2 required)</li>
|
||||
<li>• Match numbers on tiles to place them on the board</li>
|
||||
<li>• Draw from the boneyard if you can't play</li>
|
||||
<li>• First player to use all tiles wins!</li>
|
||||
<li>• Game ends when someone runs out or no one can move</li>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Referencia en una nueva incidencia
Block a user