From b3fc71088c6837a67a294977fccb423e0aa8a455 Mon Sep 17 00:00:00 2001 From: alva-p Date: Sun, 25 Oct 2026 00:39:53 -0300 Subject: [PATCH 1/2] Truco: mesa jugable en el frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Crear mesa: selector UNO/Truco, modo 1v1/2v2/3v3 y a 15 o 30 (sin mesas pagas) - Lista: etiqueta del juego en cada mesa - Sala de espera por equipos; arranca con la mesa llena - Mesa: cartas jugadas delante de cada uno, cantos según las jugadas legales, globos, tanteador con malas y buenas, resultado de la mano y cartel final - Levantarse en plena partida la pierde para su equipo (motor + server) - Textos en español e inglés Co-Authored-By: Claude --- server/server.js | 4 + server/test/truco.test.js | 15 ++ src/components/TrucoCard.tsx | 1 + src/components/TrucoTable.tsx | 312 ++++++++++++++++++++++++++++++++++ src/hooks/useSocket.ts | 8 +- src/hooks/useTruco.ts | 53 ++++++ src/i18n.ts | 86 ++++++++++ src/index.css | 14 ++ src/pages/Game.tsx | 99 ++++++++--- src/pages/Lobbies.tsx | 84 ++++++++- src/services/socketService.ts | 8 + src/types/game.ts | 2 + src/types/lobby.ts | 6 + src/types/truco.ts | 54 ++++++ truco/README.md | 7 +- truco/engine/index.js | 11 +- truco/engine/test.js | 7 + 17 files changed, 734 insertions(+), 37 deletions(-) create mode 100644 src/components/TrucoTable.tsx create mode 100644 src/hooks/useTruco.ts create mode 100644 src/types/truco.ts diff --git a/server/server.js b/server/server.js index 19affd2..531f0a1 100644 --- a/server/server.js +++ b/server/server.js @@ -916,7 +916,11 @@ io.on('connection', (socket) => { // Salir de lobby socket.on('lobby:leave', (data) => { + const truco = lobbyManager.trucoGames.get(data?.lobbyId); + const seat = truco ? truco.seats.findIndex((s) => s.id === currentUserId) : -1; const result = currentUserId ? lobbyManager.leaveLobby(data.lobbyId, currentUserId) : { success: false, error: 'Sesión inválida' }; + // Truco: irse en plena partida la pierde (si no, los demás esperan el reloj mano tras mano) + if (result.success && seat >= 0 && !truco.match.over) trucoStep(data.lobbyId, truco, Truco.abandon(truco.match, seat).events); socket.emit('lobby:left', result); socket.emit('lobbyLeft', result); if (result.success) { diff --git a/server/test/truco.test.js b/server/test/truco.test.js index ccf3147..17a0f1a 100644 --- a/server/test/truco.test.js +++ b/server/test/truco.test.js @@ -111,6 +111,21 @@ async function mesa(n, target = 15) { console.log(`✓ partida completa ${fin.scores.join('-')}`); [a, b].forEach((s) => s.close()); + + // Levantarse en plena partida la pierde + { + const { socks: [c, d], lobby: l2 } = await mesa(2); + let last; + c.on('truco:state', (st) => (last = st)); + c.emit('game:start', { lobbyId: l2.id }); + await sleep(300); + d.emit('lobby:leave', { lobbyId: l2.id }); + await sleep(300); + assert.ok(last.over && last.winner === last.seat % 2, 'gana el que se quedó'); + assert.ok(last.events.some((e) => e.type === 'abandono')); + [c, d].forEach((s) => s.close()); + console.log('✓ el que se va pierde'); + } console.log('TRUCO OK'); } finally { server?.kill(); diff --git a/src/components/TrucoCard.tsx b/src/components/TrucoCard.tsx index 4db8170..fe74e17 100644 --- a/src/components/TrucoCard.tsx +++ b/src/components/TrucoCard.tsx @@ -226,6 +226,7 @@ const SIZES = { xs: "w-8", sm: "w-12", md: "w-16", + played: "w-11 sm:w-16", hand: "w-[clamp(72px,11vw,120px)]", pile: "w-[clamp(84px,11vw,132px)]", } as const; diff --git a/src/components/TrucoTable.tsx b/src/components/TrucoTable.tsx new file mode 100644 index 0000000..32d070a --- /dev/null +++ b/src/components/TrucoTable.tsx @@ -0,0 +1,312 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { TrucoCard } from "./TrucoCard"; +import { Avatar, TurnClock } from "./Table"; +import { displayName } from "../utils/gameUtils"; +import type { Bubble, Puntos } from "../hooks/useTruco"; +import type { CantoTruco, TrucoAction, TrucoEvent, TrucoState } from "../types/truco"; + +const TRUCO: CantoTruco[] = ["truco", "retruco", "valeCuatro"]; +const team = (s: number) => s % 2; +// Nosotros en celeste, ellos en rosa (en la sala de espera y en la mesa) +const TEAM_STYLE = ["bg-sky-500/25 text-sky-100 ring-sky-400/50", "bg-rose-500/25 text-rose-100 ring-rose-400/50"]; +const ACTION_STYLE: Record = { + quiero: "bg-emerald-500 hover:bg-emerald-400 text-black", + noQuiero: "bg-red-600 hover:bg-red-500", + quieroSubir: "bg-gradient-to-r from-yellow-300 to-orange-500 text-black", + truco: "bg-gradient-to-r from-yellow-300 to-orange-500 text-black", + envido: "bg-sky-500 hover:bg-sky-400 text-black", +}; +const ORDER = ["quiero", "noQuiero", "quieroSubir", "truco", "envido"]; + +interface Props { + st: TrucoState; + version: number; + bubbles: Record; + handPoints: Puntos[]; + notice: { kind: "timeout" | "left"; seat: number } | null; + tableName: string; + message: string | null; + onAct: (a: TrucoAction) => void; + onLeave: () => void; +} + +const TrucoTable: React.FC = ({ st, version, bubbles, handPoints, notice, tableName, message, onAct, onLeave }) => { + const { t } = useTranslation(); + const me = st.seat; + const n = st.players; + const us = team(me); + const name = (s: number) => displayName(st.names[s]); + const call = (c: string) => t(`truco_call_${c}`); + + // Irse al mazo pide un segundo toque: tocarlo sin querer cuesta la mano + const [confirmMazo, setConfirmMazo] = useState(false); + useEffect(() => { + if (!confirmMazo) return; + const id = setTimeout(() => setConfirmMazo(false), 3000); + return () => clearTimeout(id); + }, [confirmMazo]); + + // Yo abajo; los demás en el orden de juego, hacia la derecha + const angle = (s: number) => Math.PI / 2 - (((s - me + n) % n) * 2 * Math.PI) / n; + const pos = (s: number, rx: number, ry: number) => ({ + left: `${50 + rx * Math.cos(angle(s))}%`, + top: `${50 + ry * Math.sin(angle(s))}%`, + }); + + const pendingBy = st.phase === "ENVIDO" ? st.envido.caller : st.phase === "TRUCO" ? st.truco.pendingBy : null; + const pendingCall = + st.phase === "ENVIDO" ? st.envido.chain.map(call).join(" · ") : st.phase === "TRUCO" ? call(TRUCO[st.truco.level]) : ""; + const acting = st.phase === "PLAYING" ? [st.turn] : pendingBy !== null ? st.order.filter((s) => team(s) !== pendingBy) : []; + const playable = new Set(st.legal.flatMap((a) => (a.type === "jugar" ? [a.card] : []))); + const calls = st.legal + .filter((a) => a.type !== "jugar" && a.type !== "mazo") + .sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type)); + const canMazo = st.legal.some((a) => a.type === "mazo"); + + const actionLabel = (a: TrucoAction) => { + if (a.type === "envido") return call(a.call); + if (a.type === "truco") return call(TRUCO[st.truco.level]); + if (a.type === "quieroSubir") return t("truco_quiero_subir", { call: call(TRUCO[st.truco.level + 1]).toLowerCase() }); + return t(a.type === "quiero" ? "truco_quiero" : "truco_no_quiero"); + }; + + const bubbleText = (ev: TrucoEvent) => { + if (ev.type === "canto") return `¡${call(ev.call)}!`; + if (ev.type === "envido") return t("truco_tanto", { n: ev.tanto }); + if (ev.type === "quiero") return t("truco_quiero"); + if (ev.type === "noQuiero") return t("truco_no_quiero"); + return t("truco_mazo_said"); + }; + + const prompt = + st.phase === "OVER" + ? t("truco_hand_over") + : !st.order.includes(me) + ? t("truco_sitting_out") + : pendingBy !== null + ? t(pendingBy === us ? "truco_waiting_answer" : "truco_they_called", { call: pendingCall }) + : st.turn === me + ? t("your_turn") + : t("turn_of", { player: name(st.turn) }); + + const reason = (p: Puntos) => + p.reason === "mano" ? (st.truco.level ? call(TRUCO[st.truco.level - 1]) : t("truco_reason_mano")) : t(`truco_reason_${p.reason}`); + + // Arriba del jugador, salvo los de la mitad de arriba de la mesa: abajo, para no taparse con el encabezado + const bubble = (s: number, below = false) => + bubbles[s] && ( + + {bubbleText(bubbles[s].ev)} + + ); + + const score = (tm: number) => ( +
+
{t(tm === us ? "truco_us" : "truco_them")}
+
{st.scores[tm]}
+ {st.target === 30 && ( +
{t(st.scores[tm] < 15 ? "truco_malas" : "truco_buenas")}
+ )} +
+ ); + + const toast = message ?? (notice && t(notice.kind === "left" ? "truco_left" : "truco_timeout", { player: name(notice.seat) })); + + return ( +
+
+
+

{tableName}

+
+ {t("truco_mode", { mode: `${n / 2}v${n / 2}` })} + · {t("truco_to", { n: st.target })} + {st.pica && {t("truco_pica")}} +
+
+
+ {score(us)} + {score(1 - us)} + +
+
+ +
+
+ {/* Los demás jugadores alrededor */} + {st.names.map((_, s) => { + if (s === me) return null; + const out = !st.order.includes(s); + const partner = st.partners[s]; + return ( +
+
+ + {s === st.mano && ( + + M + + )} + {Math.sin(angle(s)) > -0.5 && bubble(s)} +
+ + {name(s)} + +
+ {partner + ? partner.map((c) => ) + : Array.from({ length: st.counts[s] }).map((_, i) => )} +
+ {Math.sin(angle(s)) <= -0.5 && bubble(s, true)} +
+ ); + })} + + {/* Cartas jugadas: cada uno las deja delante suyo, una por baza */} + {st.order.map((s) => { + const cards = st.played.flatMap((b) => b.filter((p) => p.seat === s).map((p) => p.card)); + return ( +
+ {cards.map((c) => ( + + ))} +
+ ); + })} + +
+ + {toast && ( +
+ {toast} +
+ )} +
+ + {/* Mi lugar */} +
+
+
+ + {bubble(me)} +
+ {me === st.mano && {t("truco_mano")}} + {prompt} + {st.msLeft != null && st.phase !== "OVER" && } + {st.phase === "OVER" && + handPoints.map((p, i) => ( + + {t(p.team === us ? "truco_us" : "truco_them")} +{p.pts} · {reason(p)} + + ))} +
+ {st.results.map((r, i) => ( + + {r === "P" ? "=" : r === us ? "✓" : "✗"} + + ))} +
+
+ + {st.phase === "OVER" && st.envido.result?.cards && ( +
+ {t("truco_envido_of", { player: name(st.envido.result.seat), n: st.envido.result.tanto })} + {st.envido.result.cards.map((c) => ( + + ))} +
+ )} + + {(calls.length > 0 || canMazo) && ( +
+ {calls.map((a) => ( + + ))} + {canMazo && ( + + )} +
+ )} + +
+ {st.hand.map((c) => ( + onAct({ type: "jugar", card: c })} + /> + ))} +
+
+ + {st.over && st.winner !== null && ( +
+
+ {st.winner === us &&
🏆
} +
+ {t(st.winner === us ? (n === 2 ? "you_won" : "truco_we_won") : n === 2 ? "truco_you_lost" : "truco_we_lost")} +
+
+ {st.scores[us]} – {st.scores[1 - us]} +
+ {notice?.kind === "left" &&

{t("truco_left", { player: name(notice.seat) })}

} + +
+
+ )} +
+ ); +}; + +export default TrucoTable; diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index a621db5..e704af7 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -274,7 +274,9 @@ export const useSocket = (): UseSocketReturn => { maxPlayers: lobby.maxPlayers, ...(lobby.mode && { mode: lobby.mode }), ...(lobby.onchain && { onchain: lobby.onchain }), - ...(lobby.entryCost && { entryCost: lobby.entryCost }) + ...(lobby.entryCost && { entryCost: lobby.entryCost }), + ...(lobby.game && { game: lobby.game }), + ...(lobby.target && { target: lobby.target }) })), ...data.inGame.map((lobby: any) => ({ id: lobby.id, @@ -285,7 +287,9 @@ export const useSocket = (): UseSocketReturn => { maxPlayers: lobby.players, ...(lobby.mode && { mode: lobby.mode }), ...(lobby.onchain && { onchain: lobby.onchain }), - ...(lobby.entryCost && { entryCost: lobby.entryCost }) + ...(lobby.entryCost && { entryCost: lobby.entryCost }), + ...(lobby.game && { game: lobby.game }), + ...(lobby.target && { target: lobby.target }) })) ]; diff --git a/src/hooks/useTruco.ts b/src/hooks/useTruco.ts new file mode 100644 index 0000000..dcb16c0 --- /dev/null +++ b/src/hooks/useTruco.ts @@ -0,0 +1,53 @@ +import { useCallback, useEffect, useState } from 'react'; +import { socketService } from '../services/socketService'; +import { useSocketContext } from '../contexts/SocketContext'; +import type { TrucoAction, TrucoEvent, TrucoState } from '../types/truco'; + +export type Bubble = { ev: TrucoEvent; id: number }; +export type Puntos = Extract; + +let seq = 0; + +// Estado de la mesa de truco. Se monta al tope de la página: el server manda truco:state apenas empieza +export function useTruco(lobbyId: string) { + const { isConnected } = useSocketContext(); + const [state, setState] = useState(null); + const [bubbles, setBubbles] = useState>({}); + const [handPoints, setHandPoints] = useState([]); + const [notice, setNotice] = useState<{ kind: 'timeout' | 'left'; seat: number } | null>(null); + const [version, setVersion] = useState(0); // reinicia el reloj en cada estado nuevo + + useEffect(() => { + const onState = (st: TrucoState) => { + if (st.lobbyId !== lobbyId) return; + setState(st); + setVersion((v) => v + 1); + if (st.events.some((e) => e.type === 'nuevaMano')) { + setBubbles({}); + setHandPoints([]); + setNotice(null); + } + const said = st.events.filter((e) => ['canto', 'quiero', 'noQuiero', 'mazo', 'envido'].includes(e.type)); + if (said.length) { + setBubbles((b) => { + const next = { ...b }; + for (const ev of said) next[(ev as { seat: number }).seat] = { ev, id: ++seq }; + return next; + }); + } + const pts = st.events.filter((e): e is Puntos => e.type === 'puntos'); + if (pts.length) setHandPoints((p) => [...p, ...pts]); + const auto = st.events.find((e) => e.auto === 'timeout' && 'seat' in e) as { seat: number } | undefined; + if (auto) setNotice({ kind: 'timeout', seat: auto.seat }); + const left = st.events.find((e) => e.type === 'abandono') as { seat: number } | undefined; + if (left) setNotice({ kind: 'left', seat: left.seat }); + }; + socketService.on('truco:state', onState); + return () => socketService.off('truco:state', onState); + // isConnected: en una recarga el socket se crea después del primer render + }, [lobbyId, isConnected]); + + const act = useCallback((action: TrucoAction) => socketService.trucoAct(lobbyId, action).catch(() => {}), [lobbyId]); + + return { state, version, bubbles, handPoints, notice, act }; +} diff --git a/src/i18n.ts b/src/i18n.ts index 08dd295..ff3c441 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -165,6 +165,47 @@ const resources = { msg_lobby_closed: 'El creador cerró la mesa', msg_recovering: 'Recuperando tu entrada on-chain…', msg_timeout: '⏱ Se acabó tu tiempo', + truco_name: 'Truco', + truco_mode: '{{mode}}', + truco_to: 'a {{n}}', + truco_points: 'Puntos', + truco_team: 'Equipo {{n}}', + truco_need_full_one: 'El truco se juega con la mesa llena: falta {{count}}', + truco_need_full_other: 'El truco se juega con la mesa llena: faltan {{count}}', + truco_call_E: 'Envido', + truco_call_R: 'Real envido', + truco_call_F: 'Falta envido', + truco_call_truco: 'Truco', + truco_call_retruco: 'Retruco', + truco_call_valeCuatro: 'Vale cuatro', + truco_quiero: 'Quiero', + truco_no_quiero: 'No quiero', + truco_quiero_subir: 'Quiero {{call}}', + truco_tanto: '{{n}} de envido', + truco_mazo_said: 'Me voy al mazo', + truco_mazo: 'Irse al mazo', + truco_mazo_confirm: '¿Seguro? Tocá de nuevo', + truco_hand_over: 'Terminó la mano', + truco_sitting_out: 'Pica-pica: mirás este duelo', + truco_waiting_answer: 'Cantaron {{call}}: esperando respuesta', + truco_they_called: 'Te cantaron {{call}}', + truco_reason_mano: 'la mano', + truco_reason_envido: 'envido', + truco_reason_noQuiero: 'no quiso', + truco_reason_mazo: 'se fue al mazo', + truco_us: 'Nosotros', + truco_them: 'Ellos', + truco_malas: 'malas', + truco_buenas: 'buenas', + truco_timeout: '⏱ A {{player}} se le acabó el tiempo', + truco_left: '🚪 {{player}} se levantó de la mesa', + truco_pica: 'Pica-pica', + truco_mano: 'Mano', + truco_bazas: 'Bazas', + truco_envido_of: 'Envido de {{player}}: {{n}}', + truco_we_won: '¡Ganamos!', + truco_we_lost: 'Perdimos', + truco_you_lost: 'Perdiste', }, }, en: { @@ -329,6 +370,47 @@ const resources = { msg_lobby_closed: 'The host closed the table', msg_recovering: 'Recovering your entry on-chain…', msg_timeout: "⏱ Time's up", + truco_name: 'Truco', + truco_mode: '{{mode}}', + truco_to: 'to {{n}}', + truco_points: 'Points', + truco_team: 'Team {{n}}', + truco_need_full_one: 'Truco needs a full table: {{count}} seat left', + truco_need_full_other: 'Truco needs a full table: {{count}} seats left', + truco_call_E: 'Envido', + truco_call_R: 'Real envido', + truco_call_F: 'Falta envido', + truco_call_truco: 'Truco', + truco_call_retruco: 'Retruco', + truco_call_valeCuatro: 'Vale cuatro', + truco_quiero: 'Accept', + truco_no_quiero: 'Decline', + truco_quiero_subir: 'Accept + {{call}}', + truco_tanto: '{{n}} envido', + truco_mazo_said: 'I fold', + truco_mazo: 'Fold', + truco_mazo_confirm: 'Sure? Tap again', + truco_hand_over: 'Hand over', + truco_sitting_out: 'Pica-pica: watching this duel', + truco_waiting_answer: '{{call}} called: waiting for an answer', + truco_they_called: 'They called {{call}}', + truco_reason_mano: 'the hand', + truco_reason_envido: 'envido', + truco_reason_noQuiero: 'declined', + truco_reason_mazo: 'folded', + truco_us: 'Us', + truco_them: 'Them', + truco_malas: 'bad', + truco_buenas: 'good', + truco_timeout: "⏱ {{player}} ran out of time", + truco_left: '🚪 {{player}} left the table', + truco_pica: 'Pica-pica', + truco_mano: 'Lead', + truco_bazas: 'Tricks', + truco_envido_of: "{{player}}'s envido: {{n}}", + truco_we_won: 'We won!', + truco_we_lost: 'We lost', + truco_you_lost: 'You lost', }, }, } @@ -399,6 +481,10 @@ const ERRORS_EN: Record = { 'Tipo de mesa inválido': 'Invalid table type', 'Ponele un nombre a la mesa': 'Give the table a name', 'La partida ya empezó': 'The game already started', + 'No estás jugando en esta mesa': "You're not playing at this table", + 'El truco todavía no tiene mesas pagas': 'Truco has no paid tables yet', + 'El truco se juega de a 2, 4 o 6': 'Truco is played with 2, 4 or 6', + 'El truco se juega a 15 o a 30': 'Truco is played to 15 or 30', // prefijos 'No se pudo cancelar on-chain: ': "Couldn't cancel on-chain: ", 'Red no soportada: ': 'Unsupported network: ', diff --git a/src/index.css b/src/index.css index 31f0378..70826a7 100644 --- a/src/index.css +++ b/src/index.css @@ -348,3 +348,17 @@ body { transition-duration: 0.01ms !important; } } + +/* Globo de canto del truco: aparece, se lee y se va */ +@keyframes bubble { + 0% { transform: translate(-50%, 6px) scale(0.6); opacity: 0; } + 8% { transform: translate(-50%, 0) scale(1.08); opacity: 1; } + 12%, 85% { transform: translate(-50%, 0) scale(1); opacity: 1; } + 100% { transform: translate(-50%, -6px) scale(1); opacity: 0; } +} +.animate-bubble { + animation: bubble 4s ease-out forwards; +} +@media (prefers-reduced-motion: reduce) { + .animate-bubble { animation-name: none !important; transform: translate(-50%, 0); } +} diff --git a/src/pages/Game.tsx b/src/pages/Game.tsx index d52810a..f1fcf5d 100644 --- a/src/pages/Game.tsx +++ b/src/pages/Game.tsx @@ -3,6 +3,8 @@ import { useParams, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useGame } from "../hooks/useGame"; import { useSocket } from "../hooks/useSocket"; +import { useTruco } from "../hooks/useTruco"; +import TrucoTable from "../components/TrucoTable"; import { getUserSession } from "../utils/userSession"; import { displayName, isValidPlay, playOrigin } from "../utils/gameUtils"; import ColorPicker from "../components/ColorPicker"; @@ -60,6 +62,7 @@ const Game: React.FC = () => { leaveGame, } = useGame(lobbyId || ""); const { cancelLobby } = useSocket(); + const truco = useTruco(lobbyId || ""); // Al terminar, la mesa queda unos segundos con el cartel del ganador antes del podio const [showResults, setShowResults] = useState(false); @@ -86,6 +89,22 @@ const Game: React.FC = () => { return null; // Se redirigirá en useEffect } + if (truco.state) { + return ( + + ); + } + const formatPlayerForLog = (playerName?: string) => { if (!playerName) return "-"; return displayName(playerName); @@ -221,13 +240,40 @@ const Game: React.FC = () => { if (gameState.status === "waiting") { const max = gameState.maxPlayers ?? 8; const empty = Math.max(0, max - gameState.players.length); + const isTruco = gameState.game === "truco"; + const canStart = isTruco ? gameState.players.length === max : gameState.players.length >= 2; + const seatCard = (player: GameState["players"][number]) => ( +
+ + {displayName(player.username)} +
+ {player.isHost && 👑 Host} + {player.id === session.id && {t("you_label")}} +
+
+ ); + const emptySeat = (key: string) => ( +
+
+ {t("empty_seat")} +
+ ); return (

{t("waiting_players")}

{gameState.name}

-

{t("seats_count", { count: gameState.players.length, max })}

+

+ {isTruco && `${t("truco_name")} · ${t("truco_mode", { mode: `${max / 2}v${max / 2}` })} · ${t("truco_to", { n: gameState.target })} · `} + {t("seats_count", { count: gameState.players.length, max })} +

{isPaid && ( @@ -246,36 +292,33 @@ const Game: React.FC = () => {
)} -
- {gameState.players.map((player) => ( -
- - {displayName(player.username)} -
- {player.isHost && 👑 Host} - {player.id === session.id && {t("you_label")}} + {isTruco ? ( + // Equipos por orden de llegada: asientos pares contra impares +
+ {[0, 1].map((tm) => ( +
+

+ {t("truco_team", { n: tm + 1 })} +

+ {Array.from({ length: max / 2 }).map((_, k) => { + const player = gameState.players[2 * k + tm]; + return player ? seatCard(player) : emptySeat(`empty-${tm}-${k}`); + })}
-
- ))} - {Array.from({ length: Math.min(empty, 8) }).map((_, i) => ( -
-
- {t("empty_seat")} -
- ))} -
+ ))} +
+ ) : ( +
+ {gameState.players.map(seatCard)} + {Array.from({ length: Math.min(empty, 8) }).map((_, i) => emptySeat(`empty-${i}`))} +
+ )}
{isHost ? (
- {gameState.players.length < 2 && ( -

⏳ {t("game_need_two", { count: 2 })}

+ {!canStart && ( +

+ ⏳ {isTruco ? t("truco_need_full", { count: empty }) : t("game_need_two", { count: 2 })} +

)} {error &&

❌ {error}

}
diff --git a/src/pages/Lobbies.tsx b/src/pages/Lobbies.tsx index 1e3efc8..31e2404 100644 --- a/src/pages/Lobbies.tsx +++ b/src/pages/Lobbies.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useAccount, useDisconnect } from "wagmi"; import { ConnectButton, useConnectModal } from "@rainbow-me/rainbowkit"; import { useTranslation } from "react-i18next"; -import type { LobbySummary, LobbyType, SupportedNetwork } from "../types/lobby"; +import type { GameKind, LobbySummary, LobbyType, SupportedNetwork } from "../types/lobby"; import { ENABLED_NETWORKS, NETWORKS, fmtAmount, symbolOf } from "../config/chains"; import { createPaidLobby, joinPaidLobby, leavePaidLobby } from "../utils/chainTable"; import { useSocket } from "../hooks/useSocket"; @@ -92,6 +92,7 @@ const Lobbies: React.FC = () => { name: form.name.trim(), ...(form.type === "privado" && { password: form.password }), ...(form.type === "pago" && { entryCost: form.entry, mode: form.mode, network: form.network, onchain }), + ...(form.game === "truco" && { game: "truco" as const, players: form.players, target: form.target }), }, getUserId(), userName, @@ -330,6 +331,15 @@ const TableCard: React.FC<{ lobby: LobbySummary; busy?: boolean; disabled?: bool {style.icon} {t(style.key)}
+
+ {lobby.game === "truco" ? ( + + 🗡️ {t("truco_name")} · {lobby.maxPlayers / 2}v{lobby.maxPlayers / 2} · {t("truco_to", { n: lobby.target })} + + ) : ( + 🃏 UNO + )} +
{price && (
@@ -414,6 +424,9 @@ const INPUT = "w-full bg-white/5 ring-1 ring-white/15 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-yellow-400"; interface CreateForm { + game: GameKind; + players: 2 | 4 | 6; + target: 15 | 30; type: LobbyType; name: string; password: string; @@ -429,6 +442,9 @@ const CreateModal: React.FC<{ busy: boolean; onClose: () => void; onSubmit: (f: }) => { const { t } = useTranslation(); const [f, setF] = useState({ + game: "uno", + players: 2, + target: 30, type: "publico", name: "", password: "", @@ -440,8 +456,10 @@ const CreateModal: React.FC<{ busy: boolean; onClose: () => void; onSubmit: (f: const paidAvailable = ENABLED_NETWORKS.length > 0; const entry = f.entry.replace(",", "."); const entryOk = parseFloat(entry) > 0; + const truco = f.game === "truco"; const valid = f.name.trim().length >= 3 && + !(truco && f.type === "pago") && (f.type !== "privado" || f.password.length > 0) && (f.type !== "pago" || (paidAvailable && entryOk)); @@ -454,9 +472,26 @@ const CreateModal: React.FC<{ busy: boolean; onClose: () => void; onSubmit: (f: }} className="space-y-5" > - {/* Tipo */} -
- {(Object.keys(TYPE_STYLE) as LobbyType[]).map((type) => ( + {/* Juego */} +
+ {(["uno", "truco"] as GameKind[]).map((g) => ( + + ))} +
+ + {/* Tipo (el truco todavía no tiene mesas pagas) */} +
+ {(Object.keys(TYPE_STYLE) as LobbyType[]).filter((type) => !truco || type !== "pago").map((type) => ( + ))} +
+
+
+ {t("truco_points")} +
+ {([15, 30] as const).map((n) => ( + + ))} +
+
+
+ )} + {f.type === "privado" && (
- {score(us)} - {score(1 - us)}
-
-
+
+ {/* Tanteador: en el celu arriba de la mesa; en pantallas grandes, en la esquina libre */} +
+ {score(us)} + {score(1 - us)} +
+
{/* Los demás jugadores alrededor */} {st.names.map((_, s) => { if (s === me) return null; @@ -309,4 +315,48 @@ const TrucoTable: React.FC = ({ st, version, bubbles, handPoints, notice, ); }; +// Cada fósforo: palito y cabeza roja. Orden de un cuadradito: arriba, derecha, abajo, izquierda, diagonal +const PALITOS: [number, number, number, number][] = [ + [6, 5, 30, 5], + [31, 6, 31, 30], + [30, 31, 6, 31], + [5, 30, 5, 6], + [10, 26, 26, 10], +]; +const Fosforo: React.FC<{ p: [number, number, number, number] }> = ({ p: [x1, y1, x2, y2] }) => { + const len = Math.hypot(x2 - x1, y2 - y1); + const [ux, uy] = [(x2 - x1) / len, (y2 - y1) / len]; + return ( + + + + + + ); +}; +const Fosforos: React.FC<{ points: number; target: number }> = ({ points, target }) => { + const groups = target / 5; + const W = 36; + const GAP = 6; + const SEP = target === 30 ? 8 : 0; // raya entre malas y buenas + const width = groups * W + (groups - 1) * GAP + SEP; + return ( + + {SEP > 0 && } + {Array.from({ length: groups }).map((_, g) => { + const x = g * (W + GAP) + (g >= 3 ? SEP : 0); + const n = Math.max(0, Math.min(5, points - g * 5)); + return ( + + + {PALITOS.slice(0, n).map((p, i) => ( + + ))} + + ); + })} + + ); +}; + export default TrucoTable; diff --git a/src/index.css b/src/index.css index 70826a7..aa02982 100644 --- a/src/index.css +++ b/src/index.css @@ -362,3 +362,14 @@ body { @media (prefers-reduced-motion: reduce) { .animate-bubble { animation-name: none !important; transform: translate(-50%, 0); } } + +/* Fósforo que se suma al tanteador */ +@keyframes fosforo { + from { opacity: 0; transform: scale(0.4); } + to { opacity: 1; transform: scale(1); } +} +.animate-fosforo { + transform-box: fill-box; + transform-origin: center; + animation: fosforo 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.3) backwards; +} diff --git a/truco/engine/test.js b/truco/engine/test.js index 5584c48..b9298ba 100644 --- a/truco/engine/test.js +++ b/truco/engine/test.js @@ -248,6 +248,9 @@ test('cada uno ve su mano y la del compañero, nunca la del rival', () => { assert.deepEqual(v.hand, ['3-espada', '4-oro', '5-oro']); assert.deepEqual(Object.keys(v.partners), ['2']); assert.ok(!JSON.stringify(v).includes('4-basto'), 'no filtra cartas del rival'); + const uno = T.view(partida(2, ['3e 4o 5o', '4b 5b 6b']), 0); + assert.deepEqual(uno.partners, {}, '1v1: no hay compañero'); + assert.ok(!JSON.stringify(uno).includes('-basto'), '1v1: no se ve la mano del rival'); const sin = partida(4, ['3e 4o 5o', '4b 5b 6b', '3o 4c 5c', '6e 6o 6c'], { verCompanero: false }); assert.deepEqual(T.view(sin, 0).partners, {}); });