Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/components/TrucoCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,10 @@ function parseCarta(id: string): { num: number; palo: Palo } {
}

/** La carta como SVG suelto (para el componente y para exportar el mazo). */
export const TrucoCardSvg: React.FC<{ id?: string; className?: string }> = ({ id, className }) => {
export const TrucoCardSvg: React.FC<{ id?: string; className?: string; style?: React.CSSProperties }> = ({ id, className, style }) => {
const c = id && parseCarta(id);
return (
<svg viewBox={`0 0 ${W} ${H}`} className={className} role="img" aria-label={c ? `${c.num} de ${PALO[c.palo].nombre}` : "carta tapada"}>
<svg viewBox={`0 0 ${W} ${H}`} className={className} style={style} role="img" aria-label={c ? `${c.num} de ${PALO[c.palo].nombre}` : "carta tapada"}>
<Defs />
{c ? (
<>
Expand All @@ -226,9 +226,10 @@ const SIZES = {
xs: "w-8",
sm: "w-12",
md: "w-16",
played: "w-11 sm:w-16",
mini: "w-8 sm:w-12",
hand: "w-[clamp(72px,11vw,120px)]",
played: "w-14 sm:w-20",
played6: "w-11 sm:w-16",
mini: "w-9 sm:w-12",
hand: "w-[clamp(100px,27vw,140px)]",
pile: "w-[clamp(84px,11vw,132px)]",
} as const;

Expand Down
121 changes: 69 additions & 52 deletions src/components/TrucoTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
top: `${50 + ry * Math.sin(angle(s))}%`,
});

// Pilas: en 1v1 y 2v2 las de arriba y abajo cerca del centro, para no chocar con el asiento;
// en 3v3 van repartidas en el óvalo (con cartas más chicas)
const pilePos = (s: number) => {
const sin = Math.sin(angle(s));
if (n === 6) return pos(s, 21, 24);
return { left: `${50 + 20 * Math.cos(angle(s))}%`, top: `${50 + 20 * sin}%` };
};

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]) : "";
Expand Down Expand Up @@ -93,13 +101,15 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
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) =>
// Arriba del jugador; los de arriba de la mesa lo llevan a la izquierda (arriba está el encabezado y abajo su pila)
const bubble = (s: number, side = false) =>
bubbles[s] && (
<span
key={bubbles[s].id}
className={`animate-bubble absolute left-1/2 z-20 whitespace-nowrap px-3 py-1 rounded-2xl bg-white text-black font-display text-base sm:text-lg shadow-xl after:absolute after:left-1/2 after:-translate-x-1/2 after:border-8 after:border-transparent ${
below ? "top-full mt-2 after:bottom-full after:border-b-white" : "bottom-full mb-2 after:top-full after:border-t-white"
className={`absolute z-20 whitespace-nowrap px-3 py-1 rounded-2xl bg-white text-black font-display text-base sm:text-lg shadow-xl after:absolute after:border-8 after:border-transparent ${
side
? "animate-bubble-side right-full top-1/2 mr-2 after:left-full after:top-1/2 after:-translate-y-1/2 after:border-l-white"
: "animate-bubble left-1/2 bottom-full mb-2 after:top-full after:left-1/2 after:-translate-x-1/2 after:border-t-white"
}`}
>
{bubbleText(bubbles[s].ev)}
Expand Down Expand Up @@ -141,7 +151,7 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
</div>
</header>

<main className="relative flex-1 min-h-0 flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-2 p-3 sm:p-6">
<main className="relative flex-1 min-h-0 flex flex-col sm:flex-row items-center justify-center gap-6 sm:gap-2 p-3 sm:p-4">
{/* Tanteador: en el celu arriba de la mesa; en pantallas grandes, en la esquina libre */}
<div className="shrink-0 sm:absolute z-10 sm:top-3 sm:left-3 flex flex-col gap-1.5 px-2.5 py-2 rounded-2xl ring-2 ring-amber-950/80 shadow-[inset_0_1px_0_rgba(255,255,255,.25),0_8px_20px_rgba(0,0,0,.45)]"
style={{ backgroundImage: "repeating-linear-gradient(90deg, transparent 0 38px, rgba(0,0,0,.22) 38px 40px), linear-gradient(#b45309, #451a03)" }}
Expand All @@ -155,31 +165,35 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
if (s === me) return null;
const out = !st.order.includes(s);
const partner = st.partners[s];
// Los de arriba llevan las cartas al costado: abajo chocarían con su pila
const top = Math.sin(angle(s)) < -0.9; // solo el de enfrente
return (
<div
key={s}
data-seat={s}
className={`absolute z-20 -translate-x-1/2 -translate-y-1/2 flex flex-col items-center gap-1 transition-opacity ${out ? "opacity-40" : ""}`}
className={`absolute z-20 -translate-x-1/2 -translate-y-1/2 flex items-center transition-opacity ${top ? "flex-row gap-2" : "flex-col gap-1"} ${out ? "opacity-40" : ""}`}
style={pos(s, 40, 41)}
>
<div className="relative">
<Avatar seed={st.names[s]} size={40} isTurn={acting.includes(s)} />
{s === st.mano && (
<span title={t("truco_mano")} className="absolute -right-2 -bottom-1 px-1 rounded bg-yellow-400 text-black text-[10px] font-extrabold">
M
</span>
)}
{Math.sin(angle(s)) > -0.5 && bubble(s)}
<div className="flex flex-col items-center gap-1">
<div className="relative">
<Avatar seed={st.names[s]} size={40} isTurn={acting.includes(s)} />
{s === st.mano && (
<span title={t("truco_mano")} className="absolute -right-2 -bottom-1 px-1 rounded bg-yellow-400 text-black text-[10px] font-extrabold">
M
</span>
)}
{!top && bubble(s)}
</div>
<span className={`max-w-[7rem] truncate text-xs font-bold px-2 rounded-full ring-1 ${TEAM_STYLE[team(s) === us ? 0 : 1]}`}>
{name(s)}
</span>
</div>
<span className={`max-w-[7rem] truncate text-xs font-bold px-2 rounded-full ring-1 ${TEAM_STYLE[team(s) === us ? 0 : 1]}`}>
{name(s)}
</span>
<div className="flex -space-x-4">
{partner
? partner.map((c) => <TrucoCard key={c} id={c} size="mini" />)
: Array.from({ length: st.counts[s] }).map((_, i) => <TrucoCard key={i} size="xs" />)}
</div>
{Math.sin(angle(s)) <= -0.5 && bubble(s, true)}
{top && bubble(s, true)}
</div>
);
})}
Expand All @@ -189,7 +203,7 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
{st.order.map((s) => {
const cards = st.played.flatMap((b) => b.filter((p) => p.seat === s).map((p) => p.card));
return (
<div key={s} className="absolute -translate-x-1/2 -translate-y-1/2 flex -space-x-5" style={pos(s, 20, 23)}>
<div key={s} className="absolute -translate-x-1/2 -translate-y-1/2 flex -space-x-6 sm:-space-x-8" style={pilePos(s)}>
{cards.map((c, j) => {
const top = st.bazaTop?.[j];
const won = top && st.results[j] !== "P" && top[0] === s;
Expand All @@ -198,7 +212,7 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
<TrucoCard
key={c}
id={c}
size="played"
size={n === 6 ? "played6" : "played"}
dimmed={!!lost}
className={`animate-card-drop ${won ? "z-10 -translate-y-1 rounded-lg ring-2 ring-yellow-300 shadow-[0_0_16px_rgba(250,204,21,0.6)]" : ""}`}
/>
Expand Down Expand Up @@ -251,40 +265,43 @@ const TrucoTable: React.FC<Props> = ({ st, version, bubbles, handPoints, notice,
</div>
</div>

{st.phase === "OVER" && st.envido.result?.cards && (
<div className="flex items-center justify-center gap-2 pt-2 text-sm text-white/70">
{t("truco_envido_of", { player: name(st.envido.result.seat), n: st.envido.result.tanto })}
{st.envido.result.cards.map((c) => (
<TrucoCard key={c} id={c} size="sm" />
))}
</div>
)}
{/* Alto fijo (botonera o envido) y lugar fijo para la mano: la mesa no cambia de tamaño entre estados */}
<div className="min-h-[4.75rem] flex flex-col justify-center">
{st.phase === "OVER" && st.envido.result?.cards && (
<div className="flex items-center justify-center gap-2 pt-2 text-sm text-white/70">
{t("truco_envido_of", { player: name(st.envido.result.seat), n: st.envido.result.tanto })}
{st.envido.result.cards.map((c) => (
<TrucoCard key={c} id={c} size="sm" />
))}
</div>
)}

{(calls.length > 0 || canMazo) && (
<div className="flex flex-wrap items-center justify-center gap-2 px-4 pt-2">
{calls.map((a) => (
<button
key={a.type + ("call" in a ? a.call : "")}
onClick={() => onAct(a)}
className={`px-4 py-2 rounded-xl font-extrabold text-sm sm:text-base shadow-lg active:scale-95 transition ${ACTION_STYLE[a.type]}`}
>
{actionLabel(a)}
</button>
))}
{canMazo && (
<button
onClick={() => (confirmMazo ? onAct({ type: "mazo" }) : setConfirmMazo(true))}
className={`px-3 py-2 rounded-xl text-sm font-semibold transition ${
confirmMazo ? "bg-red-600 animate-pulse" : "bg-white/10 hover:bg-white/20 text-white/80"
}`}
>
{confirmMazo ? t("truco_mazo_confirm") : t("truco_mazo")}
</button>
)}
</div>
)}
{(calls.length > 0 || canMazo) && (
<div className="flex flex-wrap items-center justify-center gap-2 px-4 pt-2">
{calls.map((a) => (
<button
key={a.type + ("call" in a ? a.call : "")}
onClick={() => onAct(a)}
className={`px-4 py-2 rounded-xl font-extrabold text-sm sm:text-base shadow-lg active:scale-95 transition ${ACTION_STYLE[a.type]}`}
>
{actionLabel(a)}
</button>
))}
{canMazo && (
<button
onClick={() => (confirmMazo ? onAct({ type: "mazo" }) : setConfirmMazo(true))}
className={`px-3 py-2 rounded-xl text-sm font-semibold transition ${
confirmMazo ? "bg-red-600 animate-pulse" : "bg-white/10 hover:bg-white/20 text-white/80"
}`}
>
{confirmMazo ? t("truco_mazo_confirm") : t("truco_mazo")}
</button>
)}
</div>
)}
</div>

<div className="flex justify-center gap-2 sm:gap-4 pt-3 min-h-[7rem]">
<div className="flex justify-center gap-2 sm:gap-4 pt-2 min-h-[clamp(155px,40vw,215px)]">
{st.hand.map((c) => (
<TrucoCard
key={c}
Expand Down
32 changes: 24 additions & 8 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ const resources = {
common: {
title: 'CHAIN TABLE',
play_now: 'Jugar Ahora',
hero_title: 'UNO con amigos.',
hero_title: 'UNO y truco con amigos.',
hero_title_accent: 'Con premio, si te animás.',
hero_sub: 'Entrá con un alias y jugá gratis en segundos. Conectá tu wallet para mesas con pozo en cripto: un contrato guarda la plata y le paga al ganador.',
hero_sub: 'Entrá con un alias y jugá gratis en segundos: UNO de 2 a 8, o truco mano a mano y en equipos. Conectá tu wallet para mesas con pozo en cripto: un contrato guarda la plata y le paga al ganador.',
live_tables_one: '{{count}} mesa abierta',
live_tables_other: '{{count}} mesas abiertas',
live_playing_one: '{{count}} jugando',
Expand All @@ -18,19 +18,27 @@ const resources = {
step1_title: 'Elegí un alias',
step1_text: 'Sin registro ni mail. La wallet es opcional y la conectás cuando quieras.',
step2_title: 'Sentate en una mesa',
step2_text: 'Pública, privada con contraseña o paga. De 2 a 8 jugadores.',
step2_text: 'Elegí UNO o truco: pública, privada con contraseña o paga.',
step3_title: 'Ganá la partida',
step3_text: 'En las mesas pagas el premio va a tu wallet apenas termina la partida.',
paid_kicker: 'Con premio',
paid_title: 'Mesas pagas',
paid_text: 'Todos pagan la misma entrada y el pozo queda en un contrato inteligente, no en nuestras manos. El contrato solo puede pagarles a los jugadores de esa mesa, y si la partida no arranca recuperás tu entrada.',
networks_label: 'Redes',
coming_soon: 'Las mesas pagas se habilitan muy pronto.',
paid_uno_only: 'Por ahora las mesas pagas son solo de UNO; el truco con premio viene después.',
games_title: 'Dos juegos, una mesa',
game_uno_text: 'El clásico de colores: +2, +4, saltos y cambios de sentido. Gana el primero que se queda sin cartas.',
game_uno_tags: 'De 2 a 8 jugadores|Mesas pagas|Rápido',
game_truco_text: 'Truco argentino sin flor: envido, truco, retruco y vale cuatro, con los tantos en fósforos.',
game_truco_tags: '1v1 · 2v2 · 3v3|A 15 o 30|Pica-pica en 3v3',
back_home: 'Volver al inicio',
tables_back: 'Mesas',
final_cta: '¿Armamos una partida?',
made_by: 'Hecho por',
change_language: 'Cambiar idioma',
// Auth
auth_brandline: 'UNO on-chain, con amigos',
auth_brandline: 'UNO y truco on-chain, con amigos',
auth_connect_wallet: 'Conectar Wallet',
auth_wallet_prompt: 'Las mesas gratis se juegan solo con alias. Para las mesas pagas vas a necesitar una wallet: la podés conectar ahora o después.',
table_public: 'Pública',
Expand Down Expand Up @@ -212,9 +220,9 @@ const resources = {
common: {
title: 'CHAIN TABLE',
play_now: 'Play Now',
hero_title: 'UNO with friends.',
hero_title: 'UNO and truco with friends.',
hero_title_accent: 'With a prize, if you dare.',
hero_sub: 'Pick an alias and play for free in seconds. Connect your wallet for tables with a crypto pot: a smart contract holds the money and pays the winner.',
hero_sub: 'Pick an alias and play for free in seconds: UNO for 2 to 8, or Argentine truco one-on-one and in teams. Connect your wallet for tables with a crypto pot: a smart contract holds the money and pays the winner.',
live_tables_one: '{{count}} open table',
live_tables_other: '{{count}} open tables',
live_playing_one: '{{count}} playing',
Expand All @@ -223,19 +231,27 @@ const resources = {
step1_title: 'Pick an alias',
step1_text: 'No sign-up, no email. Your wallet is optional; connect it whenever you want.',
step2_title: 'Take a seat',
step2_text: 'Public, private with a password, or paid. 2 to 8 players.',
step2_text: 'Pick UNO or truco: public, private with a password, or paid.',
step3_title: 'Win the game',
step3_text: 'On paid tables the prize goes to your wallet as soon as the game ends.',
paid_kicker: 'With prizes',
paid_title: 'Paid tables',
paid_text: 'Everyone pays the same entry and the pot sits in a smart contract, not with us. The contract can only pay the players at that table, and if the game never starts you get your entry back.',
networks_label: 'Networks',
coming_soon: 'Paid tables are coming very soon.',
paid_uno_only: 'For now paid tables are UNO only; truco with prizes comes later.',
games_title: 'Two games, one table',
game_uno_text: 'The color classic: +2, +4, skips and reverses. First one out of cards wins.',
game_uno_tags: '2 to 8 players|Paid tables|Quick',
game_truco_text: 'Argentine truco without flor: envido, truco, retruco and vale cuatro, with matchsticks for the score.',
game_truco_tags: '1v1 · 2v2 · 3v3|To 15 or 30|Pica-pica in 3v3',
back_home: 'Back to home',
tables_back: 'Tables',
final_cta: 'Up for a game?',
made_by: 'Made by',
change_language: 'Change language',
// Auth
auth_brandline: 'On-chain UNO with friends',
auth_brandline: 'On-chain UNO and truco with friends',
auth_connect_wallet: 'Connect Wallet',
auth_wallet_prompt: 'Free tables only need an alias. Paid tables need a wallet: connect it now or later.',
table_public: 'Public',
Expand Down
12 changes: 12 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,15 @@ body {
transform-origin: center;
animation: fosforo 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.3) backwards;
}
@keyframes bubble-side {
0% { transform: translate(6px, -50%) scale(0.6); opacity: 0; }
8% { transform: translate(0, -50%) scale(1.08); opacity: 1; }
12%, 85% { transform: translate(0, -50%) scale(1); opacity: 1; }
100% { transform: translate(0, -50%) scale(1); opacity: 0; }
}
.animate-bubble-side {
animation: bubble-side 4s ease-out forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-bubble-side { animation-name: none !important; transform: translate(0, -50%); }
}
9 changes: 9 additions & 0 deletions src/pages/Game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ const Game: React.FC = () => {
return (
<div className={`${PAGE_BG} px-4 py-10`}>
<div className="max-w-3xl mx-auto">
<button
onClick={leaveGame}
className="-mt-4 mb-4 inline-flex items-center gap-1.5 rounded-full bg-white/5 ring-1 ring-white/10 hover:bg-white/15 pl-2.5 pr-4 py-1.5 text-sm font-semibold"
>
<svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M15 18l-6-6 6-6" />
</svg>
{t("tables_back")}
</button>
<div className="text-center mb-8">
<p className="text-emerald-300/80 text-sm uppercase tracking-widest">{t("waiting_players")}</p>
<h1 className="text-4xl sm:text-5xl font-extrabold mt-1">{gameState.name}</h1>
Expand Down
Loading
Loading